Skip to content

API Reference

This page describes the FiniteFree 0.4 APIs, with executable examples and reference sections generated from their docstrings. Version 0.4.1 changes metadata and documentation only; library behavior matches 0.4.0. Install with python -m pip install finitefree==0.4.1; consult PyPI for published versions. See the development guide, migration notes, and release process.

For repeated numerical work, use prepared kernels or pencils, batch evaluation and streaming summaries where appropriate. These APIs expose reuse and storage choices while preserving existing defaults. Integer/rational algebra and floating-point evaluation have different precision contracts: an exact coefficient representation or an exact=True option does not make numerical outputs exact or certify their error. Performance measurements apply to the recorded inputs and environments; see the benchmark commands.

Optional numerical workflows

OrthogonalPolynomialKernel.prepare_numeric() returns an owned PreparedOrthogonalKernel. basis(points) evaluates normalized basis rows; matrix(xs, ys=None) computes a batch kernel matrix. Verified shifted/scaled Hermite bases use their normalized recurrence. Other families use normalized coefficients with Horner evaluation, retaining the conditioning limits of that representation. Exact scalar calls and BaseKernel.matrix keep their existing behavior. These explicit float64 APIs reject complex/nonfinite coordinates and raise for nonfinite results. Finite results can still lose information through underflow or ill-conditioning.

Prepared call Input Output
basis(points) One-dimensional coordinates of length m float64 array (m, rank)
matrix(xs) One-dimensional coordinates of length m float64 array (m, m)
matrix(xs, ys) One-dimensional coordinates of lengths m, k float64 array (m, k)

These methods accept real array-like coordinates, including empty arrays. They differ from the existing single-argument BaseKernel.matrix(xs), which returns nested scalar evaluations.

prepared.quadrature(a, b, n_points=50, weight_func=None) retains nodes, measure weights and a rank-by-rank restricted Gram matrix for repeated gap/count queries. Supply the reference measure density explicitly: the default is 1, including for Hermite kernels. Storage is O(n_points * rank + rank**2).

DPPCountDistribution(restricted_matrix) gives the finite-rank Bernoulli-sum count law from a real symmetric restriction. pmf() returns probabilities on 0..rank, pmf(k) returns one probability, cdf(k) is P(N<=k), and sf(k) is P(N>k). Thresholds are integers. Upper tails are summed directly. mean and variance are population moments. Convenience functions count_distribution_discrete(kernel, points) and count_distribution_continuous(kernel, a, b, n_points=50, weight_func=None) construct the corresponding laws.

pmf() has one more entry than the restriction matrix dimension, including any zero-mass entries. Outside that support, pmf(k) is zero; cdf(k) is zero below zero and one at or above the upper endpoint, and sf(k) has the complementary endpoint values. The discrete convenience helper expects a BaseKernel and distinct states; wrap an array in DiscreteFiniteKernel first.

Continuous results have estimated=True; estimated=False does not certify a float64 count law. Restriction symmetry is checked with absolute tolerance 1e-12. Eigenvalues outside [0,1] by more than 1e-12 are rejected; smaller roundoff excursions are clipped and reported as spectral_correction. probability_mass_error records the PMF mass error before normalization. These diagnostics and quadrature refinements are not certified error bounds. Float64 cannot resolve every rare tail, and a spectrum in [0,1] alone does not establish quadrature accuracy. Returned arrays are owned copies. See the GUE workflow for measure conventions and the distinction between expected characteristic roots and mean spectra.

import numpy as np
import sympy as sp
from finitefree import OrthogonalPolynomialKernel, RealRootedPolynomial

# Rank-two Legendre kernel for Lebesgue measure on [-1,1].
kernel = OrthogonalPolynomialKernel(
    [RealRootedPolynomial([1]), RealRootedPolynomial([1, 0]),
     RealRootedPolynomial([1, 0, -sp.Rational(1, 3)])],
    [2, sp.Rational(2, 3)],
)
prepared = kernel.prepare_numeric()
assert prepared.matrix([0, 0.5], [0.25]).shape == (2, 1)
restriction = prepared.quadrature(0, 1, n_points=16)
law = restriction.count_distribution()
np.testing.assert_allclose(law.pmf(), [1/16, 7/8, 1/16], atol=1e-14)
assert abs(restriction.gap_probability() - law.pmf(0)) < 1e-14
assert law.estimated

StreamingEmpiricalComparison(polynomial, intervals=()) accumulates coefficient moments and count histograms through update(matrix) or extend(generator, samples). Intervals are closed [a,b]. A doubled matrix must have paired eigenvalues, with one eigenvalue counted per pair. The object retains no samples or eigenvalues. Its O(d + m*d) storage depends on polynomial degree d and the number of configured intervals m. coefficient_mean, coefficient_std, count_mean, count_variance, and count_histograms return owned summaries; standard deviations and variances use ddof=1. At least two samples are needed for variances or coefficient verification. An invalid update leaves statistics intact; a failed extend retains its prior successful updates.

verify_coefficients uses the same Bonferroni-adjusted t bands and tolerances as EmpiricalComparison: independent samples are required, with exact bands for normal coefficient observations and a large-sample approximation otherwise. Dynamic scaling protects variance calculations at extreme magnitudes, but online roundoff can differ from a retained two-pass calculation near a test threshold. The original EmpiricalComparison continues to retain samples and support plots.

import numpy as np
from finitefree import RealRootedPolynomial, StreamingEmpiricalComparison

comparison = StreamingEmpiricalComparison(
    RealRootedPolynomial.from_roots([1, 3]), intervals=[(1, 3), (1, 1)]
)
comparison.extend(lambda: np.diag([1.0, 3.0]), samples=3)
assert comparison.verify_coefficients()
np.testing.assert_array_equal(comparison.count_mean, [2, 1])
np.testing.assert_array_equal(comparison.count_variance, [0, 0])

Root evaluation and conditioning

RealRootedPolynomial.evaluate_roots_float64(parallel=False, exact=True) returns sorted NumPy float64 roots, with multiplicities. It validates real-rootedness before extraction unless the constructor or a proven family has already marked the polynomial verified.

Exact rational operations preserve the stored binary value of supplied Python and NumPy floating scalars, including longdouble when it provides extra precision on the platform. This applies to polynomial inputs, affine parameters, and exact matrix-pencil entries/parameters. Precision lost before supplying a value cannot be recovered. Numerical outputs and matrix views still use float64 and can round values that remain distinct in the exact representation.

  • exact=False first uses a symmetric tridiagonal Jacobi matrix when recurrence metadata is available. It scales the matrix without centering, computes parameter differences before float conversion, and accumulates affine parameters rationally before converting the final roots.
  • exact=True requests python-flint/Arb isolation and remains the default. Use PrecisionContext(degree=d, prec=192) to request a working precision. This option selects the high-precision path; the returned floats are neither exact algebraic roots nor interval error bounds. SymPy/general numerical fallbacks may still be used if isolation fails.
  • An exact=True request bypasses a cache produced with exact=False, or a high-precision cache recorded at a lower working precision. An exact=False request can reuse any existing root result. Returned arrays are read-only and repeated calls may reuse the same array; call .copy() for editable values.
  • When real-rootedness validation uses Arb, it retains the isolated roots and working precision in that same read-only cache. Generic root calls can return this result directly, without another isolation or a companion-matrix pass. Without a suitable cache, dispatch follows the solver options below.
  • Numerical extraction raises RuntimeError if the final roots contain NaN or infinity; those values never enter a root cache. Exact rational construction, from_roots, and real-rootedness certification remain available when roots exceed the finite float64 range. Tiny nonzero roots can still round to zero, and distinct roots can round to the same float.
  • If no usable recurrence is available, exact=False uses the existing balanced companion-matrix path, or the Aberth–Ehrlich path when parallel=True, with Arb/general numerical fallback. Generic monomial coefficients can be ill-conditioned. Fast recurrence metadata is preserved through affine transforms and proven Hermite additive convolutions, not through arbitrary coefficient operations.
  • Generic numerical solvers divide out the leading coefficient in rational arithmetic before float conversion. A polynomial stored with monic=False therefore has the same numerical roots as its monic representative, including when a large or tiny common scalar would overflow or underflow during direct coefficient conversion. Stored coefficients and ordinary polynomial evaluation retain that scalar; conditioning and final-output rounding limits still apply.

The recurrence path is available for degree at least two in these domains:

Factory or operation Numerical recurrence coverage
hermite_polynomial Physicist and probabilist conventions
laguerre_polynomial(n, alpha) alpha > -1; the boundary alpha == -1 uses the general path
jacobi_polynomial(n, alpha, beta) alpha > -1 and beta > -1
legendre_polynomial, chebyshev_t_polynomial, chebyshev_u_polynomial All supported degrees
gue_expected_poly(d) Normalized Hermite recurrence
wishart_expected_poly(d, n) Positive integer dimensions with n >= d
dilation, shift Inherit an available recurrence; negative dilation is allowed
symmetric_additive(p, q, d) Full-degree shifted/dilated Hermite inputs with exact family provenance

Nonfinite or underflowed recurrence entries and eigensolver failures fall back to the general path. Near-zero positive Laguerre roots require scaling without centering. Extreme scales or shifts can still overflow, underflow or erase root separations that float64 cannot represent; exact=True cannot overcome final-output rounding. No uniform error guarantee is claimed for arbitrary polynomials. Compound-Wishart/lognormal convolutions retain the general solver.

from finitefree import RealRootedPolynomial

p = RealRootedPolynomial.from_roots([10**400, 10**400])
assert p.verify_real_rootedness()  # Exact construction/certification still work.
try:
    p.evaluate_roots_float64()
except RuntimeError:
    pass
else:
    raise AssertionError("Out-of-range numerical roots were accepted")
import numpy as np
from finitefree import PrecisionContext, gue_expected_poly
from finitefree.convolutions import symmetric_additive

d = 32
p = gue_expected_poly(d)
result = symmetric_additive(p, p, d)
numerical = result.evaluate_roots_float64(exact=False)
with PrecisionContext(degree=d, prec=192):
    reference = result.evaluate_roots_float64(exact=True)
np.testing.assert_allclose(numerical, reference, atol=1e-13, rtol=1e-13)
assert len(numerical) == d

The reproducible benchmarks compare six families against independently isolated Arb roots. Timings exclude polynomial construction and cached results; construction is separate. These cases do not provide performance or accuracy guarantees for other inputs or machines.

For generic compound-Wishart convolutions, PYTHONPATH=. python scripts/benchmark_compound_roots.py --output compound-roots.json measures first public root calls with domain validation included, at degrees 10, 30, 60, 100 and 150. It compares 192-bit working precision with an independent 384-bit reference and counts isolation calls. Construction, numerical-library warm-up and repeat-cache latency are excluded from first-call timings and reported separately where applicable. These polynomials retain the general solver rather than inheriting an orthogonal-family recurrence.

Validation and mathematical contracts

Exact and numerical evaluation

Coefficient sequences are descending; FLINT polynomials use ascending storage. Construction defaults to monic normalization. On a rational polynomial, integer/rational evaluate(x) arguments use exact FLINT evaluation, while Python/NumPy floating arguments select float64 Horner arithmetic. That numerical path can overflow or underflow even when exact coefficients are valid. to_numpy_poly1d() directly casts stored coefficients and warns above degree 20; it does not rescale or certify them.

PrecisionContext changes FLINT's process-wide precision to the requested bits, defaulting to max(53, int(2.5*degree)), and restores the old value on exit. It does not provide thread-local isolation or a memory pool. Concurrent code that changes FLINT precision needs coordination.

Positive integer power(c) uses exact rational operations and requires non-negative roots. Noninteger root powers reconstruct from numerical roots and inherit their float64 limits. additive_power(t) scales cumulants for positive rational t; fractional powers are formal coefficient constructions with lazy geometry validation, not an unconditional real-root preservation guarantee.

Convolution dimensions and domains

The three coefficient convolutions accept an ambient integer dimension d covering both polynomial degrees. They divide out leading scalars, pad normalized sequences with zeros, and return monic degree-d results. Writing e_k for the normalized coefficients:

\[ e_k(p\boxplus_d q)=\sum_{i=0}^k\binom{k}{i}e_i(p)e_{k-i}(q), \qquad e_k(p\boxtimes_d q)=e_k(p)e_k(q). \]

The asymmetric operation uses

\[ e_k(p\uplus_d q)=\frac{k!}{d!(d-k)!} \sum_{i=0}^k\frac{(d-i)!(d-k+i)!}{i!(k-i)!}e_i(p)e_{k-i}(q). \]

Optional asymmetric weights=[a,b] dilate the input roots before this calculation. They do not represent rank or alter dimensions. Real-root preservation uses real-rooted inputs for symmetric addition, non-negative-root inputs for asymmetric addition, and real-rooted inputs with one non-negative-root input for multiplication. The implementation permits formal coefficients without checking those domains eagerly; root requests validate reconstructed outputs.

Coefficient ownership and cached arrays

Construction snapshots caller-supplied coefficient sequences and FLINT polynomials. from_roots and from_normalized_coeffs also copy their inputs. Changing those inputs later cannot change the polynomial, its verification state or cached results. The coeffs property returns an independent editable array on both rational and symbolic backends.

normalized_coeffs and real/unitary root extraction return read-only NumPy arrays. Ordinary in-place edits raise ValueError; use .copy() to obtain editable values. Caching still avoids repeated extraction. Internal attributes and explicit re-enabling of array write flags are outside this mutation contract. A T-transform owns its coefficient list rather than sharing the polynomial's cache.

import flint
import numpy as np
from finitefree import RealRootedPolynomial

source = flint.fmpq_poly([2, -3, 1])
p = RealRootedPolynomial(source)
source[0] = 100
assert list(p.coeffs) == [1, -3, 2]
roots = p.evaluate_roots_float64()
assert not roots.flags.writeable
editable = roots.copy()
editable[0] = 100
np.testing.assert_allclose(p.evaluate_roots_float64(), [1, 2])

Real-rootedness

from_normalized_coeffs requires a nonempty sequence starting with e_0 == 1 and reconstructs the formal polynomial without certifying its roots. Root extraction and positive-root domain properties validate it lazily. Coefficient sign alternation is used only after real-rootedness is verified. The zero polynomial is rejected; nonzero constants have no roots. FiniteCauchyTransform requires positive degree.

Verification uses square-free factorization and exact Sturm sequences for factors below degree 15. Factors of degree at least 15 first request certified Arb isolation of the polynomial; degrees above 30 use Arb directly. Degrees through 30 retain exact Sturm/subresultant PRS as a fallback if Arb cannot obtain a certificate. Repeated small factors keep the inexpensive exact path. An imaginary ball merely containing zero is not accepted as a real-root certificate. Complex-rooted inputs raise ValueError; inability to certify raises RuntimeError. Passing assume_real_rooted=True explicitly trusts the caller's claim and bypasses this verification. Jacobi/Laguerre construction outside a known orthogonality domain remains available but validates lazily.

scripts/benchmark_compound_roots.py measures fresh public calls, including validation, against Arb references at twice the requested precision. Construction and warm-up are excluded; cache latency is separate. See the benchmark commands.

Degree-one polynomials use the coefficient ratio -a_1/a_0. A symbolic nonreal root is rejected; unknown realness raises RuntimeError. Known real roots can be certified without a numerical value. Their positive/nonnegative domain checks also require a known sign. Numerical extraction requires a numeric root in the finite float64 range; otherwise it raises RuntimeError without filling the root cache. Rational and numeric symbolic linear roots use direct coefficient division rather than a general eigensolver or Arb isolation. Higher-degree symbolic certification retains its existing limitations.

import numpy as np
import sympy as sp
from finitefree import RealRootedPolynomial

p = RealRootedPolynomial([1, -sp.sqrt(2)])
assert p.verify_real_rootedness()
assert p.has_strictly_positive_roots
np.testing.assert_allclose(p.evaluate_roots_float64(), [np.sqrt(2)])
try:
    RealRootedPolynomial([1, sp.I]).verify_real_rootedness()
except ValueError:
    pass
else:
    raise AssertionError("Nonreal linear root was certified")
from finitefree import RealRootedPolynomial

formal = RealRootedPolynomial.from_normalized_coeffs([1, 0, 1])  # x^2 + 1
try:
    formal.evaluate_roots_float64(exact=False)
except ValueError:
    pass
else:
    raise AssertionError("Complex-rooted reconstruction was accepted")

Polynomial projections

For a rational polynomial of degree d, p.projection(j) computes the derivative of order d-j, monic-normalized when j<d. j must be an integer index in [0,d], including NumPy integers. Projection to d returns p itself, preserving the existing identity boundary and any nonmonic leading coefficient. Proper projection to zero gives the unit constant polynomial.

Writing \(p(x)=\sum_{k=0}^{d}a_k x^{d-k}\), a proper projection uses

\[ \partial^{j\mid d}p(x)=\sum_{k=0}^{j}\frac{a_k}{a_0} \frac{(j)_k}{(d)_k}x^{j-k}, \]

where \((m)_k=m(m-1)\cdots(m-k+1)\) and \((m)_0=1\). The implementation reads only the leading j+1 coefficients and accumulates the ratios in exact rational arithmetic. It avoids constructing intermediate derivatives and does not populate the full normalized-coefficient cache. The number of coefficient updates is linear in j; bit-arithmetic costs still depend on coefficient sizes. For monic input, the normalized coefficient prefix is unchanged, so for 1<=n<=j the finite-cumulant normalization gives \(\kappa_n^{(j)}(\partial^{j\mid d}p)=(j/d)^{n-1}\kappa_n^{(d)}(p)\).

Projections preserve the source's verification flag. Known shifted/dilated Hermite inputs also preserve exact variance and center. The Hermite derivative identity permits copying the existing Jacobi-matrix prefix and its exact affine factors, including cases where the variance underflows in float64 while its square root remains usable. Family membership is not inferred from generic coefficient arrays. Proper projections of other families retain the general root-evaluation path; the numerical recurrence remains subject to the root conditioning limits above.

import sympy as sp
from finitefree import RealRootedPolynomial, gue_expected_poly

p = RealRootedPolynomial.from_roots([1, 2, 3, 4])
assert list(p.projection(2).coeffs) == [1, -5, sp.Rational(35, 6)]
assert p.projection(4) is p
projected = gue_expected_poly(100).projection(20)
assert len(projected.evaluate_roots_float64(exact=False)) == 20

PYTHONPATH=. python scripts/benchmark_projection.py --output projection-benchmark.json times projections to degree 20 from GUE/Wishart degrees 100, 400 and 900. Exact Hermite/Laguerre derivative laws supply independent coefficient references. Source construction is reported separately; projection timings are best of five and exclude construction and reference generation. Results cover those families and degrees, not arbitrary coefficient sizes.

Finite free cumulants

For \(p(x)=\sum_{k=0}^{m}a_k x^{m-k}\) and ambient dimension \(d\ge m\), normalized_coeffs(d) returns \(\tilde e_k=(-1)^k a_k/(a_0\binom{d}{k})\) for k<=m and zero above m. The leading coefficient is divided out even for monic=False, so e_0=1 and the sequence depends only on the roots, with zero padding when d>m. Rational normalization is exact; the SymPy path simplifies symbolic common factors. Stored coefficients, leading coefficient and polynomial evaluations are preserved, and normalization does not certify real-rootedness.

Finite cumulants, additive powers and coefficient convolutions use this root-normalized sequence. Reconstructed/convolved outputs are monic. Ratio-based S/T transforms are unchanged by this scalar normalization. In particular, a nonmonic derivative from derivative(monic=False) has the same finite cumulants as its monic form.

Truncated R-transform calls extract only the needed coefficient prefix. The largest requested native-dimension prefix is cached separately, bounded by the polynomial degree; complete coefficient requests still return the complete sequence. Ambient dimensions use their exact binomial factors without accumulating persistent cache entries. Numerical centering remains exact before the Arb recurrence.

import sympy as sp
from finitefree import FiniteRTransform, RealRootedPolynomial
from finitefree.convolutions import symmetric_additive

p = RealRootedPolynomial([2, -6, 4], monic=False)  # 2*(x-1)*(x-2)
assert list(p.coeffs) == [2, -6, 4]
assert list(p.normalized_coeffs()) == [1, sp.Rational(3, 2), 2]
assert FiniteRTransform(p, order=2) == [sp.Rational(3, 2), sp.Rational(1, 2)]
assert list(symmetric_additive(p, p, 2).coeffs) == [1, -6, sp.Rational(17, 2)]

FiniteRTransform uses \(\kappa_n^{(d)}=(-d)^{n-1}c_n/(n-1)!\), where \(c_n\) is the classical cumulant of the normalized coefficient sequence, following Definition 2.14. additive_power uses the matching inverse: its polynomial coefficients agree with repeated symmetric additive convolution for positive integer powers. Requested orders above the ambient dimension return zero as an API convention, outside the finite cumulant definition.

from finitefree import FiniteRTransform, RealRootedPolynomial
from finitefree.convolutions import symmetric_additive

p = RealRootedPolynomial.from_roots([0, 0, 3])
assert FiniteRTransform(p, order=3) == [1, 3, 9]
assert list(p.additive_power(2).coeffs) == list(symmetric_additive(p, p, 3).coeffs)

numerical=True uses Arb at prec bits (default 256) after centering the requested normalized-coefficient prefix exactly. The first cumulant is restored from the original mean; higher cumulants are invariant under translation. This avoids cancellation from large offsets while keeping the cumulant recurrence numerical. Centering uses the specified ambient dimension, including any zero padding. The precision setting is restored on return or failure. Results are Python float approximations rather than arbitrary-precision values or error bounds; increasing the order or using badly conditioned coefficients may require increasing prec. Final float conversion can still overflow or underflow, and this transform does not have the root API's final-finiteness check.

import numpy as np
from finitefree import FiniteRTransform, gue_expected_poly

p = gue_expected_poly(8).shift(10**50)
values = FiniteRTransform(p, order=4, numerical=True, prec=128)
assert values[0] == 1e50
np.testing.assert_allclose(values[1:], [1, 0, 0], rtol=0, atol=1e-28)

For repeatable accuracy and timing checks, run PYTHONPATH=. python scripts/benchmark_transforms.py --output transform-benchmark.json. The default cases use degrees 60, 150 and 300, order 12, a shift of \(10^{50}\) and 128-bit working precision. Hermite and Wishart analytic cumulants supply the reference. Transform times are best of three with normalized coefficients already cached; construction is reported separately. These cases test stability under translation, not arbitrary high-order inputs.

Finite T-transform

FiniteTTransform(p) requires positive degree and non-negative roots. Its input must be a finite real value in \((0,1)\). It uses the right-continuous intervals in Definition 6.3 and Remark 6.4, including the boundary of an atom at zero. Rational inputs and stored binary float values select intervals exactly, without a float multiplication that could round onto a neighboring grid boundary. Real symbolic constants are also supported. A float approximation to \(k/d\) can lie on either side of that rational number; supply the rational value to select the exact boundary.

import sympy as sp
from finitefree import FiniteTTransform, RealRootedPolynomial

T = FiniteTTransform(RealRootedPolynomial.from_roots([1, 2, 3, 4, 5]))
assert T(sp.Rational(1, 10**400)) == sp.Rational(300, 137)
assert T(1 - sp.Rational(1, 10**20)) == 3
assert T(0.6) == sp.Rational(45, 17)  # stored float is below 3/5
assert T(sp.Rational(3, 5)) == sp.Rational(17, 6)

Symmetric finite S-output convention

FiniteSTransform(p) returns e_{k-1}/e_k at node -k/d for k=1,...,d and ordinarily requires strictly positive roots. exact=True returns SymPy rationals; exact=False converts to float64. The coefficient path requires rational-convertible inputs; the unit-circle positivity exemption does not supply general symbolic support.

For an even rational polynomial of degree 2d with zero multiplicity 2r, SymmetricFiniteSTransform(p, convention="ratio") returns an array of length d-r with entry e_{2(k-1)}/e_{2k} at -k/d. This remains the default and checks even parity without certifying real-rootedness. It is the square of the complex transform in Definition 8.1.

Use convention="standard" for that definition's positive-imaginary square root. This requires positive degree and real-rootedness certification, honoring the existing explicit assume_real_rooted=True trust contract. Odd-degree, non-even, nonrational or complex-rooted inputs are rejected. A positive-degree polynomial whose roots are all zero has an empty node domain. Exact outputs are SymPy expressions; exact=False returns complex128 values and raises RuntimeError if a nonzero result cannot fit that range. The square root is taken before float conversion, allowing representable results even when the squared ratio overflows or underflows float64.

import sympy as sp
from finitefree import RealRootedPolynomial, SymmetricFiniteSTransform

p = RealRootedPolynomial.from_roots([-1, 1])
ratio = SymmetricFiniteSTransform(p)[0]  # compatible default
standard = SymmetricFiniteSTransform(p, convention="standard")[0]
assert ratio == -1
assert standard == sp.I
assert standard**2 == ratio
assert SymmetricFiniteSTransform(p, exact=False, convention="standard")[0] == 1j

Unitary and interpolated root distributions

UnitaryPolynomial trusts the intended unit-circle domain instead of certifying it. Its root method returns complex128 values ordered by angle, with optional CuPy acceleration; parallel does not change this implementation. It has no Arb precision path or real-root API final-finiteness check.

to_scipy_dist() constructs a continuous piecewise-linear CDF through numerical roots and requires at least two root entries. It is an interpolation, not the atomic empirical root measure; repeated roots remain present.

Ensemble random streams

sample_gue, sample_goe, sample_gse, sample_wishart and all three sample_haar_* functions accept keyword-only rng=None or a numpy.random.Generator, matching the discrete DPP sampling convention. Omitting rng or passing None preserves the legacy global NumPy RNG, draw order, ensemble normalization and positional arguments. An explicit generator is advanced in place without reading or changing global RNG state. Integer seeds and RandomState objects are rejected with TypeError; construct a generator explicitly. Reproducibility refers to a fixed NumPy/build environment, not a cross-version or cross-platform bitwise guarantee.

import numpy as np
from finitefree import sample_gue

matrix = sample_gue(3, rng=np.random.default_rng(2026))
np.testing.assert_array_equal(
    matrix, sample_gue(3, rng=np.random.default_rng(2026))
)
np.testing.assert_allclose(matrix, matrix.conj().T)

Empirical coefficient comparison

EmpiricalComparison(p, samples, generator) stores eigenvalues and characteristic coefficients from at least two samples. Each sample must be a finite Hermitian matrix of shape (d, d), where d=p.degree. A (2d, 2d) representation is accepted when its sorted eigenvalues occur in pairs; one eigenvalue from each pair supplies the degree-d characteristic polynomial. Hermitian and pairing checks permit absolute roundoff up to 1e-10 times the largest entry magnitude or eigenvalue magnitude, respectively. Matrix eigenvalues, sampled coefficients, and analytical coefficients must fit finite float64 values.

verify_coefficients(alpha=0.05, *, rtol=1e-10, atol=0) compares every coefficient mean to the analytical target with half-width

\[ t_{1-\alpha/(2(d+1)),\,N-1}\frac{s}{\sqrt{N}} +\mathrm{atol}+\mathrm{rtol}\,|\mathrm{target}|. \]

The Student-t mean band follows the NIST definition; Bonferroni adjustment allocates alpha across the d+1 coefficients without assuming independence between coefficients. Smaller alpha gives wider bands. Samples must be independent; Student-t coverage is exact for normal coefficient observations and is a large-sample approximation for other distributions. Characteristic coefficients of random matrices need not be normal. A return value of True reports no detected coefficient mismatch under this diagnostic, not proof of a sampler's distribution.

Zero sample variance still requires agreement within the explicit numerical tolerances. The calculation divides each coefficient column and its target by their maximum magnitude before computing the mean and standard deviation, avoiding variance overflow or underflow across different coefficient scales. It cannot recover precision already lost in eigenvalues or characteristic coefficients. Nonfinite/invalid alpha, unrepresentable critical values, and negative/nonfinite tolerances raise ValueError.

import numpy as np
from finitefree import EmpiricalComparison, RealRootedPolynomial

p = RealRootedPolynomial.from_roots([1, 2])
matching = EmpiricalComparison(p, 3, lambda: np.diag([1, 2]))
assert matching.verify_coefficients()
mismatch = EmpiricalComparison(p, 3, lambda: np.zeros((2, 2)))
assert not mismatch.verify_coefficients()

Orthogonal polynomial kernel evaluation

OrthogonalPolynomialKernel(polys, norms) represents the unweighted kernel \(K(x,y)=\sum_{j=0}^{n-1}p_j(x)p_j(y)/h_j\), with n=len(norms) and polynomials through \(p_n\). Construction snapshots the polynomials, norms and leading coefficients; their public properties return independent copies. Reconstruct a kernel to change its basis. The supplied values must form a consistent orthogonal family; construction does not certify general orthogonality. Exact evaluation uses the Christoffel–Darboux identity and its confluent form, with exact equality selecting the diagonal and preserving distinct integers beyond the float64 range. An empty basis (norms=[], with \(p_0\) supplied) returns zero.

If either argument is a floating scalar, both coordinates must fit finite float64 values. For exactly verified scaled/shifted Hermite bases, every floating call uses the normalized three-term recurrence and a finite basis sum with math.fsum. Verification checks actual degrees, leading coefficients, the monic recurrence with common rational center and positive variance, and monic norms H_j = H_0 * j! * variance**j through the supplied basis. Positive measure mass H_0 and signed basis rescalings are retained. Exact ratios precede numerical conversion, avoiding separate factorial conversion. A name or stale root provenance cannot select this path or determine the measure mass. Nonfinite recurrence arithmetic raises RuntimeError. The GUE workflow composes projected expected polynomials, the compatible kernel and matrix samples.

Other families retain the derivative formula for equal stored coordinates and the Christoffel–Darboux quotient for separated points. Distinct points with separation at most sqrt(float64_eps) * max(1, abs(x), abs(y)) use the finite basis sum with math.fsum, preserving both coordinates. Generic polynomial evaluation retains its conditioning, overflow, underflow and output-rounding limits. The Hermite recurrence improves the tested cases but is not a uniform accuracy certificate.

from finitefree import OrthogonalPolynomialKernel, hermite_polynomial

polys = [hermite_polynomial(j, physicist=False) for j in range(3)]
K = OrthogonalPolynomialKernel(polys, [1, 1])  # K(x,y) = 1+x*y
x, y = 10**16, 10**16 + 1
assert K(x, y) == 1 + x*y
assert K(20.0, 20.000000001) == 1 + 20.0*20.000000001
assert K(20.000000001, 20.0) == K(20.0, 20.000000001)

PYTHONPATH=. python scripts/benchmark_kernels.py --output kernel-benchmark.json compares Hermite kernels at degrees 20, 40 and 60 with independent exact rational basis sums, for diagonal, nearby and separated coordinates. Timings are best of five with warmed coefficient caches and exclude construction and reference evaluation. These cases do not provide a uniform accuracy guarantee for other bases, degrees or coordinates.

Discrete DPP sampling

sample_discrete uses floating-point spectral HKPV sampling for real symmetric correlation kernels. Nonprojection kernels first select eigenvectors with independent Bernoulli draws. It requires a finite square matrix matching the state-space size, symmetry, and spectrum in [0, 1]. Absolute roundoff up to 1e-10 is allowed; small asymmetry is symmetrized and eigenvalues are clipped only within that tolerance. Invalid kernels raise ValueError before sampling; loss of the projection basis raises RuntimeError.

An ndarray is indexed in the supplied state-space order and may use arbitrary distinct hashable labels. A BaseKernel is evaluated on the actual states, so subsets and permutations select the corresponding principal matrix. DiscreteFiniteKernel accepts integer indices, including NumPy integers, and rejects fractional indices. Empty state spaces return an empty sample; an ndarray must then have shape (0, 0).

import numpy as np
from finitefree import DiscreteFiniteKernel, sample_discrete

kernel = DiscreteFiniteKernel(np.diag([0.0, 1.0, 1.0]))
assert sample_discrete(kernel, [2, 0]) == [2]
assert sample_discrete(np.diag([0.0, 1.0]), ["excluded", "included"]) == ["included"]
try:
    sample_discrete(2 * np.eye(2), [0, 1])
except ValueError:
    pass
else:
    raise AssertionError("Invalid correlation spectrum was accepted")

For repeated samples from the same kernel, PreparedDiscreteDPP(kernel, state_space) validates and decomposes it once. It owns immutable array storage and a tuple of the supplied hashable labels; mutable label objects themselves are not deep-copied. kernel_matrix and eigenvalues return independent read-only copies. Preparation consumes no random numbers. Each sample() owns its working projection basis.

Both prepared.sample(rng=generator) and sample_discrete(kernel, states, rng=generator) accept a NumPy Generator. Omitting rng preserves the legacy global NumPy RNG. sample_discrete(prepared) reuses the prepared state; an explicitly supplied state space must exactly match its stored order. Construct a new prepared object when the kernel or state space changes.

import numpy as np
from finitefree import PreparedDiscreteDPP, sample_discrete

prepared = PreparedDiscreteDPP(np.diag([0.0, 1.0]), ["excluded", "included"])
rng = np.random.default_rng(73)
assert prepared.sample(rng=rng) == ["included"]
assert sample_discrete(prepared, rng=rng) == ["included"]
assert prepared.state_space == ("excluded", "included")

Matrix-pencil input contract

SymmetricMatrixPencil and MultiplicativeMatrixPencil accept nonempty sequences of equally sized square matrices containing real rational values, including Python/NumPy integers, Python floats, Fraction, SymPy rationals and Flint rationals. Constructors copy the entries into rational matrices before creating their separate float64 views. Supplied floats represent their actual binary values, not a recovered decimal intent. Entries must fit a finite float64 numerical view; tiny entries may underflow in that view while their rational values remain preserved.

characteristic_polynomial, rational SLP evaluation/gradient/Hessian, diagonal specialization and direct/interpolated/sparse multivariate determinant construction use the preserved rational values. evaluate and numerical SLP operations use the float64 views. Construct a new pencil when changing matrix entries rather than mutating its stored views.

Diagonal specialization also preserves rational weights and offsets, including the stored binary ratios of Python/NumPy floats. Its result retains monic normalization and lazy root validation. Modular reconstruction continues until the modulus exceeds twice a proved bound on every integer coefficient; repeated agreement alone does not establish correctness. Nonfinite, complex and irrational parameters raise ValueError before reconstruction.

Invalid shape, inconsistent dimensions, empty matrix sequences, nonfinite/complex entries or nonsymmetric supplied entries in SymmetricMatrixPencil raise ValueError. Complex entries are rejected even when their imaginary part is zero. Symmetry uses the rational entries without an approximate tolerance, so float rounding cannot hide a difference between large integers. Coordinate sequences must match the number of matrices in numerical and rational evaluation. Modular paths reduce integer entries modulo each prime before int64 conversion for Cython calculations.

Generic straight-line gradients use reverse-mode differentiation of scalar operations. Determinant-pencil gradients and Hessians instead use determinant/inverse trace identities. Exact derivatives reject singular evaluated matrices. Numerical inverse/pseudoinverse formulas do not reliably give derivatives at singular points; use symbolic polynomial differentiation when singular-point derivatives are needed. verify_hyperbolicity(e) checks the sufficient positive-definite-pencil condition; numerical eigenvalues must exceed 1e-14, while exact=True uses exact rational positivity.

MultivariatePolynomial stores rational sparse coefficients and supports exact evaluation through FLINT's positional callable interface, including python-flint 0.9.0. Native inputs must match the ordered variable names and are copied. Variable lists, sparse coefficient maps and to_fmpq_mpoly() exports are caller-owned. Rational algebra, exact polynomial gradient/Hessian and line restriction are described in multivariate workflows. Construction does not enforce homogeneity; normalized_coefficients() requires it. Algebra and line restriction do not infer stability, hyperbolicity or real-rootedness.

from_symmetric_matrix_pencil_sparse(pencil, *, max_verification_bits=1_000_000) uses randomized discovery followed by deterministic coefficient/support verification and exact fallback. The positive integer limit bounds the conservative encoded integer size before discovery starts; exceeding it raises ValueError rather than returning an unverified candidate. It is not a memory or runtime quota. See the proof and construction limits.

reorder_variables(variables) preserves the expression in an explicit permutation of the same SymPy symbols, including their assumptions. It rejects renaming and implicit coordinate relabeling. substitute(mapping, *, variables=None) performs simultaneous rational scalar/polynomial substitution. Its default target retains the original ordered variables, including after scalar replacement; an explicit nonempty target supports renaming and dimension changes. Unreplaced symbols must be present, and polynomial replacements must match the target order exactly. Full scalar substitution returns a constant polynomial. Irrational/complex coefficients, rational functions, unknown symbols and nonpolynomial expressions are rejected. See context examples and exact scalar semantics.

evaluate_float64(points) handles real batches with shape (..., m) and returns a float for one point or the leading batch shape otherwise. gradient_float64(points) returns (..., m) and hessian_float64(points) returns (..., m, m); one point gives a vector and matrix. Empty batches and packed broadcast arrays are valid. Axes follow stored variable order. Derivative coefficients are formed exactly before conversion, cached separately and evaluated without inverses or division by coordinates. This permits derivatives at singular determinant points. Outputs are owned float64 arrays; supplied extended-precision coordinates are narrowed. Nonfinite/complex inputs or shape errors raise ValueError; unrepresentable requested coefficients and nonfinite intermediates/results raise RuntimeError, even when the true mathematical result could be finite. Finite cancellation, underflow and rounding remain possible. See numerical shapes and limits.

import sympy as sp
from finitefree.multivariate import MultivariatePolynomial

x, y = sp.symbols("x y")
p = MultivariatePolynomial(x + y, [x, y])
assert p.evaluate([1, 2]) == 3
assert p.expr.subs({x: 1, y: 2}) == 3
assert p.to_fmpq_mpoly()(1, 2) == 3
from finitefree.hyperbolic import SymmetricMatrixPencil

large = 2**53 + 1
pencil = SymmetricMatrixPencil([[[large]]])
assert pencil.characteristic_polynomial([1])[0] == -large
assert pencil.evaluate([1])[0, 0] == float(large)

Numerical evaluation processes bounded point blocks and reuses monomial buffers. Derivative coordinate powers are cached only within each block. Strided and broadcast inputs retain their logical point order without copying the entire input to flatten it. The returned array is still fully allocated: Hessians require 8 * batch_size * m**2 bytes for their float64 output alone. Input conversion/validation, lazy exact derivative caches and native arithmetic have additional costs; bounded power storage is not a total memory limit. Context permutations use native sparse projection after validating symbol identity, while general substitutions retain exact native composition.

Prepared numerical pencils

PreparedMatrixPencil and PencilFactorization are exported from finitefree.hyperbolic. pencil.prepare_numeric() creates an owned float64 coefficient snapshot; evaluate(points) assembles matrices, and factor(points) retains pivoted LU factors for slogdet(), logabsdet_gradient() and logabsdet_hvp(directions). Inputs have shape (..., variable_count) and outputs preserve the leading batch shape. This opt-in path leaves exact methods intact.

import numpy as np
from finitefree.hyperbolic import SymmetricMatrixPencil

pencil = SymmetricMatrixPencil([np.diag([2.0, 3.0]), np.eye(2)])
prepared = pencil.prepare_numeric()
points = np.array([[1.0, 0.0], [0.0, 2.0]], dtype=np.float64)
factors = prepared.factor(points)
sign, logabsdet = factors.slogdet()
np.testing.assert_array_equal(sign, [1.0, 1.0])
np.testing.assert_allclose(logabsdet, np.log([6.0, 4.0]))
np.testing.assert_allclose(factors.logabsdet_gradient()[0], [2.0, 5.0/6.0])

backend="cupy" explicitly selects resident float64 GPU arrays and requires compatible installed CuPy/CUDA. Device/stream ordering, singular-point derivative limits and retained factor/output memory remain caller responsibilities; point and coefficient blocking bound scratch space, not total output storage. See the full contract and benchmark protocol. Local GPU correctness validation does not establish a speed or memory advantage.

Core Operations

finitefree.core

Classes

RealRootedPolynomial

Bases: Polynomial

Source code in finitefree/core.py
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
class RealRootedPolynomial(Polynomial):
    def __init__(
        self,
        coeffs: Union[Sequence[Any], NDArray[Any], flint.fmpq_poly],
        assume_real_rooted: bool = False,
        monic: bool = True,
    ) -> None:
        r"""
        Construct from descending coefficients or a native ascending FLINT polynomial.
        Inputs are copied; monic=True divides out the leading coefficient, while
        monic=False retains it. Rational values use FLINT, including the exact stored
        ratio of Python/NumPy floats. Other symbolic coefficients use SymPy.
        Geometry is checked lazily unless assume_real_rooted=True trusts the caller.
        The rational zero polynomial is rejected; nonzero constants have no roots.
        """
        import flint

        if not isinstance(coeffs, flint.fmpq_poly):
            if len(coeffs) == 0:
                raise ValueError("Coefficients sequence cannot be empty")
        else:
            if coeffs.degree() < 0:
                raise ValueError("Zero polynomial is not supported")

        self._is_flint = False
        if isinstance(coeffs, flint.fmpq_poly):
            poly = flint.fmpq_poly(coeffs)
            self._is_flint = True
        else:
            try:
                q_coeffs = [sympy_to_fmpq(c) for c in reversed(coeffs)]
                poly = flint.fmpq_poly(q_coeffs)
                self._is_flint = True
            except Exception:
                # Fallback to SymPy object array for non-rational/transcendental coefficients (e.g. UnitaryPolynomial)
                self.coeffs_sympy = np.array(coeffs, dtype=object)
                leading_coeff = self.coeffs_sympy[0]
                if monic and leading_coeff != 1:
                    coeffs_list = []
                    for c in self.coeffs_sympy:
                        coeffs_list.append(c / leading_coeff)
                    self.coeffs_sympy = np.array(coeffs_list, dtype=object)
                self._is_flint = False

        if self._is_flint:
            if poly.degree() < 0:
                raise ValueError("Zero polynomial is not supported")
            try:
                leading_coeff = poly.leading_coefficient()
            except AttributeError:
                # Fallback for older python-flint versions where leading_coefficient() is missing
                leading_coeff = poly.coeffs()[-1] if poly.coeffs() else flint.fmpq(0, 1)
            if monic and leading_coeff != 0 and leading_coeff != 1:
                poly = poly * (1 / leading_coeff)
            self._fmpq_poly = poly
            self.degree: int = poly.degree() if poly.degree() >= 0 else 0
        else:
            self.degree = len(self.coeffs_sympy) - 1
            self.coeffs_sympy.setflags(write=False)

        self._is_verified: bool = assume_real_rooted
        self._is_monic = monic
        self._normalized_coeffs_flint_cached: Union[List[Any], None] = None
        self._normalized_coeffs_flint_prefix_cached: Union[List[Any], None] = None
        self._normalized_coeffs_sympy_cached: Union[NDArray[np.object_], None] = None
        self._roots_cached: Union[NDArray[Any], None] = None
        self._roots_cached_exact = False
        self._roots_cached_prec = 0
        self._root_recurrence: Union[
            tuple[NDArray[np.float64], NDArray[np.float64]], None
        ] = None
        self._root_scale = 1.0
        self._root_dilation: Any = flint.fmpq(1)
        self._root_shift: Any = flint.fmpq(0)
        self._root_recurrence_driver = "stemr"
        self._root_recurrence_positive = False
        self._hermite_variance: Any = None
        self._hermite_center: Any = 0
        self._has_non_negative_roots_cached: Union[bool, None] = None
        self._has_strictly_positive_roots_cached: Union[bool, None] = None
        self._float_coeffs_cached: Union[list[float], None] = None

    @property
    def coeffs(self) -> NDArray[np.object_]:
        """Return a caller-owned copy of the descending coefficient array."""
        if self._is_flint:
            return np.array(fmpq_poly_to_sympy_coeffs(self._fmpq_poly), dtype=object)
        return self.coeffs_sympy.copy()

    @property
    def variables(self) -> list[sp.Symbol]:
        """Returns the symbolic variables of the polynomial (a single list [x])."""
        return [sp.Symbol("x")]

    def evaluate(self, x: Any) -> Any:
        r"""
        Evaluate at x, retaining the polynomial's stored scalar.
        Rational-backend integer/rational arguments use exact FLINT evaluation.
        Floating arguments use float64 Horner evaluation and may overflow, underflow
        or lose precision. Symbolic-backend evaluation uses SymPy substitution.
        """
        if self._is_flint:
            if isinstance(x, (float, np.floating)):
                if self._float_coeffs_cached is None:
                    from finitefree.utils.conversion import flint_to_float

                    self._float_coeffs_cached = [
                        flint_to_float(c) for c in self._fmpq_poly.coeffs()
                    ]
                val = 0.0
                x_val = float(x)
                for c in reversed(self._float_coeffs_cached):
                    val = val * x_val + c
                return val
            x_fmpq = sympy_to_fmpq(x)
            return self._fmpq_poly(x_fmpq)
        sym_x = sp.sympify(x)
        val_sym = sp.Symbol("x")
        expr = sum(
            c * val_sym ** (self.degree - i) for i, c in enumerate(self.coeffs_sympy)
        )
        return expr.subs(val_sym, sym_x)

    def _to_fmpq_poly(self) -> Any:
        """Converts the polynomial's coefficients to a flint.fmpq_poly in ascending degree order."""
        if self._is_flint:
            return self._fmpq_poly
        import flint

        q_coeffs = [sympy_to_fmpq(c) for c in reversed(self.coeffs_sympy)]
        return flint.fmpq_poly(q_coeffs)

    def _linear_root(self) -> Any:
        """Return the exact coefficient ratio for a degree-one polynomial."""
        coefficients = self.coeffs
        return sp.cancel(-sp.sympify(coefficients[1]) / sp.sympify(coefficients[0]))

    def verify_real_rootedness(self) -> bool:
        """
        Lazily verify real-rootedness using Arb isolation above degree 30 or
        for squarefree factors of degree at least 15. Smaller factors use exact
        Sturm sequences; degrees through 30 also retain Sturm as a fallback
        when Arb cannot obtain a certificate. Complex roots raise
        ValueError; failure to obtain a certificate raises RuntimeError.
        An explicit assume_real_rooted=True bypasses verification.
        """
        if self._is_verified:
            return True

        if self.degree <= 1:
            if self.degree == 1 and not self._is_flint:
                real = self._linear_root().is_real
                if real is False:
                    raise ValueError(
                        "Polynomial is not real-rooted: nonreal linear root"
                    )
                if real is None:
                    raise RuntimeError("Unable to certify that the linear root is real")
            self._is_verified = True
            return True

        if self.degree > 30:
            return self._verify_real_rootedness_arb()

        import flint

        try:
            f_poly = self._to_fmpq_poly()
            _, factors = f_poly.factor_squarefree()

            if any(factor.degree() >= 15 for factor, _ in factors):
                try:
                    return self._verify_real_rootedness_arb()
                except RuntimeError:
                    pass  # Keep exact Sturm certification if isolation is unavailable.

            total_real_roots = 0
            for factor, multiplicity in factors:
                if factor.degree() >= 15:
                    from .utils.prs import sturm_subresultant_prs

                    sturm_seq = sturm_subresultant_prs(factor, factor.derivative())
                else:
                    # Generate Sturm sequence using compiled division modulo in C
                    seq = [factor, factor.derivative()]
                    while not seq[-1].is_zero():
                        remainder = seq[-2] % seq[-1]
                        seq.append(-remainder)
                    sturm_seq = seq[:-1]

                # Evaluate sturm sequence at -inf and +inf
                def sign_changes_flint(
                    seq_list: List[flint.fmpq_poly], val: str
                ) -> int:
                    signs: List[int] = []
                    for p in seq_list:
                        if p.is_zero():
                            continue
                        try:
                            lead_coeff = p.leading_coefficient()
                        except AttributeError:
                            lead_coeff = (
                                p.coeffs()[-1] if p.coeffs() else flint.fmpq(0, 1)
                            )
                        s = 1 if lead_coeff > 0 else -1
                        deg = p.degree()

                        if val == "-inf":
                            if deg % 2 != 0:
                                s = -s
                        signs.append(s)

                    changes = 0
                    for i in range(len(signs) - 1):
                        if signs[i] * signs[i + 1] < 0:
                            changes += 1
                    return changes

                v_minus_inf = sign_changes_flint(sturm_seq, "-inf")
                v_plus_inf = sign_changes_flint(sturm_seq, "inf")

                real_roots_factor = v_minus_inf - v_plus_inf
                if real_roots_factor < factor.degree():
                    raise ValueError(
                        "Polynomial is not real-rooted. "
                        f"Missing real roots in factor {factor}"
                    )

                total_real_roots += real_roots_factor * multiplicity

            if total_real_roots < self.degree:
                raise ValueError("Polynomial is not real-rooted.")

            self._is_verified = True
            return True

        except ValueError:
            raise
        except Exception:
            return self._verify_real_rootedness_arb()

    def _verify_real_rootedness_arb(self) -> bool:
        """
        Verify real-rootedness using Flint's certified root isolation (Arb).
        Numerical proximity to the real axis is not a certificate.
        """
        try:
            acb_roots: Any = self._to_fmpq_poly().complex_roots()

            # A ball merely containing zero does not prove a real root.
            for r_pair in acb_roots:
                r = r_pair[0]
                if not r.imag.is_zero():
                    raise ValueError(
                        "Polynomial is not real-rooted: complex root detected."
                    )

        except ValueError:
            raise
        except Exception as inner_e:
            raise RuntimeError("Could not certify real-rootedness.") from inner_e

        self._is_verified = True
        # A real-root certificate does not require float64 representability.
        # Cache isolated roots only when the final numerical values are finite.
        try:
            roots = [
                flint_to_float(root.real)
                for root, multiplicity in acb_roots
                for _ in range(int(multiplicity))
            ]
            cached = self._finite_float64_root_array(np.sort(roots))
        except (TypeError, ValueError, OverflowError, RuntimeError):
            return True
        self._roots_cached = cached
        self._roots_cached_exact = True
        self._roots_cached_prec = flint.ctx.prec
        return True

    @staticmethod
    def _finite_float64_root_array(roots: Any) -> NDArray[np.float64]:
        """Validate numerical representability before storing a root result."""
        try:
            result = np.asarray(roots, dtype=np.float64)
        except (TypeError, ValueError, OverflowError) as error:
            raise RuntimeError("Roots cannot be converted to float64") from error
        if not np.all(np.isfinite(result)):
            raise RuntimeError("Roots are outside the finite float64 range")
        result.setflags(write=False)
        return result

    def verify_root_interlacing(self, strict: bool = False) -> bool:
        r"""
        Verifies that the roots of the derivative $p'(x)$ interlace the roots of $p(x)$.
        For univariate polynomials, if $p(x)$ is real-rooted, Rolle's Theorem
        mathematically guarantees that the roots of $p'(x)$ interlace the roots of $p(x)$.
        If strict=True, they strictly interlace if and only if all roots of $p(x)$
        are simple (which corresponds to the polynomial being square-free).
        """
        self.verify_real_rootedness()

        if not strict:
            return True

        if self.degree <= 1:
            return True

        # Check if the polynomial is square-free (no multiple roots)
        g = self._fmpq_poly.gcd(self._fmpq_poly.derivative())
        if g.degree() > 0:
            raise ValueError("Strict root interlacing failed: multiple roots detected.")

        return True

    def _normalized_coeffs_flint(self, d: Union[int, None] = None) -> list[Any]:
        """Extract root-normalized elementary coefficients as flint.fmpq.

        The leading coefficient is divided out, so e_0=1 even for a nonmonic
        representation. Ambient dimensions above the degree pad roots with zero.
        """
        if d is None:
            d = self.degree

        if d < self.degree:
            raise ValueError(
                f"Ambient dimension d ({d}) cannot be less than polynomial "
                f"degree ({self.degree})."
            )

        if d == self.degree and self._normalized_coeffs_flint_cached is not None:
            return self._normalized_coeffs_flint_cached

        import flint

        if self._is_flint:
            e_k = []
            leading = self._fmpq_poly[self.degree]
            curr_binom = flint.fmpz(1)
            for k in range(d + 1):
                if k > 0:
                    curr_binom = (curr_binom * (d - k + 1)) // k
                if k <= self.degree:
                    c_k = self._fmpq_poly[self.degree - k]
                    if leading != 1:
                        c_k /= leading
                    sign = (-1) ** k
                    val = c_k * flint.fmpq(flint.fmpz(sign), curr_binom)
                    e_k.append(val)
                else:
                    e_k.append(flint.fmpq(0))
        else:
            e_k = [sympy_to_fmpq(c) for c in self.normalized_coeffs(d)]

        if d == self.degree:
            self._normalized_coeffs_flint_cached = e_k
            self._normalized_coeffs_flint_prefix_cached = None
        return e_k

    def _normalized_coeffs_flint_prefix(
        self, count: int, d: Union[int, None] = None
    ) -> list[Any]:
        """Extract e_0 through e_count without normalizing unused coefficients.

        A single largest requested prefix is cached only for the native dimension.
        It is never exposed as a complete normalized sequence. Ambient dimensions
        use uncached prefixes; a complete cache takes precedence when available.
        """
        if d is None:
            d = self.degree
        if d < self.degree:
            raise ValueError("Ambient dimension cannot be less than polynomial degree.")
        if count < 0:
            raise ValueError("Prefix index must be nonnegative.")
        count = min(count, d)
        if not self._is_flint or count == d:
            return self._normalized_coeffs_flint(d)[: count + 1]
        if d == self.degree and self._normalized_coeffs_flint_cached is not None:
            return self._normalized_coeffs_flint_cached[: count + 1]

        prefix = (
            self._normalized_coeffs_flint_prefix_cached if d == self.degree else None
        )
        if prefix is not None and len(prefix) > count:
            return prefix[: count + 1]
        values = list(prefix) if prefix is not None else []
        binomial = (
            flint.fmpz(math.comb(d, len(values) - 1)) if values else flint.fmpz(1)
        )
        leading = self._fmpq_poly[self.degree]
        for k in range(len(values), count + 1):
            if k:
                binomial = binomial * (d - k + 1) // k
            coefficient = (
                self._fmpq_poly[self.degree - k] if k <= self.degree else flint.fmpq(0)
            )
            values.append(coefficient / leading * flint.fmpq((-1) ** k, binomial))
        if d == self.degree:
            self._normalized_coeffs_flint_prefix_cached = values
        return values[: count + 1]

    def normalized_coeffs(self, d: Union[int, None] = None) -> NDArray[np.object_]:
        r"""
        Extracts the normalized elementary symmetric polynomial sequence
        $\tilde{e}_k^{(d)}(p)$ with respect to ambient dimension $d$ as an object
        array. Rational coefficients remain exact; symbolic coefficients use SymPy.
        Divides out the leading coefficient, preserving the polynomial's stored
        coefficients while making the sequence depend only on its roots.
        Returned arrays are read-only; use .copy() to obtain editable values.
        """
        if d is None:
            d = self.degree

        if d < self.degree:
            raise ValueError(
                f"Ambient dimension d ({d}) cannot be less than polynomial "
                f"degree ({self.degree})."
            )

        if d == self.degree and self._normalized_coeffs_sympy_cached is not None:
            return self._normalized_coeffs_sympy_cached

        if self._is_flint:
            raw = self._normalized_coeffs_flint(d)
            e_k = [sp.Rational(int(val.p), int(val.q)) for val in raw]
        else:
            e_k = []
            leading = self.coeffs_sympy[0]
            for k in range(d + 1):
                if k <= self.degree:
                    binom = math.comb(d, k)
                    sign = (-1) ** k
                    c_k = self.coeffs_sympy[k]
                    if leading != 1:
                        c_k = sp.cancel(sp.sympify(c_k) / sp.sympify(leading))
                    val = sign * binom
                    if isinstance(c_k, (int, np.integer)):
                        if c_k % val == 0:
                            e_k.append(c_k // val)
                        else:
                            e_k.append(sp.Rational(c_k, val))
                    else:
                        e_k.append(c_k / val)
                else:
                    e_k.append(0)

        res_array = np.array(e_k, dtype=object)
        res_array.setflags(write=False)
        if d == self.degree:
            self._normalized_coeffs_sympy_cached = res_array
        return res_array

    @classmethod
    def from_normalized_coeffs(
        cls, e_k: Union[Sequence[Any], NDArray[Any]]
    ) -> "RealRootedPolynomial":
        r"""
        Reconstructs the polynomial from the normalized sequence
        $\tilde{e}_k^{(d)}(p)$.
        Real-rootedness is verified lazily; arbitrary sequences need not
        represent real-rooted polynomials.
        """
        import flint

        if len(e_k) == 0 or e_k[0] != 1:
            raise ValueError("Normalized coefficients must start with e_0 = 1.")
        d = len(e_k) - 1
        try:
            q_ek = [sympy_to_fmpq(x) for x in e_k]
            # Precompute binomial coefficients via C-level running product
            binoms = [flint.fmpz(1)]
            curr = flint.fmpz(1)
            for k in range(1, d + 1):
                curr = (curr * (d - k + 1)) // k
                binoms.append(curr)

            c_asc = []
            for j in range(d + 1):
                k = d - j
                sign = (-1) ** k
                val = q_ek[k] * flint.fmpq(flint.fmpz(sign) * binoms[k])
                c_asc.append(val)
            poly = flint.fmpq_poly(c_asc)
            inst = cls(poly)
            inst._normalized_coeffs_flint_cached = q_ek
            return inst
        except Exception:
            c = []
            curr_binom = 1
            for k in range(d + 1):
                if k > 0:
                    curr_binom = (curr_binom * (d - k + 1)) // k
                sign = (-1) ** k
                c.append(e_k[k] * sign * curr_binom)
            inst = cls(c)
            return inst

    @classmethod
    def from_roots(
        cls, roots: Union[Sequence[Any], NDArray[Any]]
    ) -> "RealRootedPolynomial":
        r"""
        Construct a monic polynomial from supplied real rational root values.
        Python/NumPy floating inputs are interpreted by their own stored binary ratios.
        A balanced product tree multiplies FLINT linear factors; cost depends on
        multiplication and coefficient bit sizes. Empty input gives the unit constant.
        Finite approximate roots may be cached read-only. Exact construction remains
        available when supplied roots cannot fit float64.
        """
        from collections import deque

        import flint

        if len(roots) == 0:
            return cls(flint.fmpq_poly([1]), assume_real_rooted=True)

        factors = []
        for r in roots:
            val = sympy_to_fmpq(r)
            factors.append(flint.fmpq_poly([-val, 1]))

        queue = deque(factors)
        while len(queue) > 1:
            left = queue.popleft()
            right = queue.popleft()
            queue.append(left * right)

        poly_flint = queue[0]
        inst = cls(poly_flint, assume_real_rooted=True)
        try:
            float_roots = [flint_to_float(r) for r in roots]
            inst._roots_cached = cls._finite_float64_root_array(np.sort(float_roots))
        except (TypeError, ValueError, OverflowError, RuntimeError):
            pass  # Exact construction is valid even beyond the float64 range.
        return inst

    def __str__(self) -> str:
        import sympy as sp

        x = sp.Symbol("x")
        expr = sum(c * x ** (self.degree - i) for i, c in enumerate(self.coeffs))
        return f"{self.__class__.__name__}({expr})"

    def __repr__(self) -> str:
        return self.__str__()

    def to_numpy_poly1d(self) -> np.poly1d:
        r"""
        Export stored coefficients directly to NumPy float64 and return poly1d.
        This method warns above degree 20 but does not rescale coefficients or certify
        finiteness/accuracy. Conversion may overflow and root finding on monomial
        coefficients may be ill-conditioned. Prefer evaluate_roots_float64().
        """
        if self.degree > 20:
            warnings.warn(
                "Directly exporting high-degree coefficients to float64 can result in "
                "extreme numerical instability due to Wilkinson's phenomenon. "
                "Consider using evaluate_roots_float64() which performs high-precision "
                "root isolation first.",
                RuntimeWarning,
                stacklevel=2,
            )
        float_coeffs = np.array(self.coeffs, dtype=float)
        return np.poly1d(float_coeffs)

    def to_scipy_dist(self) -> Any:
        r"""
        Return a SciPy continuous distribution with an interpolated root CDF.
        The CDF linearly interpolates evenly spaced values from 0 to 1 at sorted
        numerical roots. It is an approximation, not the atomic empirical root law.
        At least two returned root entries are required; multiplicities are retained.
        """
        import scipy.stats

        roots = self.evaluate_roots_float64()
        if len(roots) < 2:
            raise ValueError(
                "SciPy continuous distribution requires at least 2 distinct roots."
            )

        class PolynomialRootDist(scipy.stats.rv_continuous):  # type: ignore[misc]
            def __init__(self, roots_arr: NDArray[np.float64]) -> None:
                self.roots_arr = np.sort(roots_arr)
                self.d_val = len(roots_arr)
                super().__init__(
                    a=float(self.roots_arr[0]), b=float(self.roots_arr[-1])
                )

            def _cdf(self, x: Any) -> Any:
                return np.interp(x, self.roots_arr, np.linspace(0.0, 1.0, self.d_val))

        return PolynomialRootDist(roots)

    def evaluate_roots_float64(
        self, parallel: bool = False, exact: bool = True
    ) -> NDArray[Any]:
        """
        Return sorted float64 roots with multiplicities after domain validation.
        Returned arrays are read-only; use .copy() to obtain editable values.
        With exact=True (the default), first request Arb isolation; PrecisionContext
        controls the working precision. Numerical fallbacks can still be used if
        isolation fails, and returned floats are not interval error bounds.
        With exact=False, first try a scaled symmetric tridiagonal solver for
        supported orthogonal families and proven Hermite additive convolutions.
        Otherwise use a balanced companion matrix, or parallelized Aberth-Ehrlich
        when parallel=True, with high-precision/general numerical fallback.
        Arb validation retains isolated roots. An exact=True request bypasses
        an approximate cache or one recorded at a lower working precision.
        Nonfinite numerical results raise RuntimeError without entering the cache;
        exact construction and real-rootedness certification remain available.
        Nonfinite/underflowed recurrences fall back; extreme affine scales or
        shifts remain limited by float64 representability and conditioning.
        """
        self.verify_real_rootedness()
        if self._roots_cached is not None and (
            not exact
            or (self._roots_cached_exact and self._roots_cached_prec >= flint.ctx.prec)
        ):
            return self._roots_cached
        res = self._evaluate_roots_float64_uncached(parallel=parallel, exact=exact)
        res = self._finite_float64_root_array(res)
        self._roots_cached = res
        self._roots_cached_exact = exact
        self._roots_cached_prec = flint.ctx.prec if exact else 0
        return res

    def _evaluate_roots_float64_uncached(
        self, parallel: bool = False, exact: bool = True
    ) -> NDArray[np.float64]:
        # Estimate root scale S to prevent float64 overflow/underflow
        d = self.degree
        if d == 0:
            return np.empty(0, dtype=np.float64)
        if d == 1:
            root = (
                -self._fmpq_poly[0] / self._fmpq_poly[1]
                if self._is_flint
                else self._linear_root()
            )
            try:
                value = flint_to_float(root)
            except (TypeError, ValueError, OverflowError) as error:
                raise RuntimeError(
                    "Linear root cannot be converted to float64"
                ) from error
            if not np.isfinite(value):
                raise RuntimeError("Linear root is outside the finite float64 range")
            return np.array([value], dtype=np.float64)

        if not exact and self._root_recurrence is not None:
            from scipy.linalg import eigvalsh_tridiagonal

            diagonal, off_diagonal = self._root_recurrence
            # Scaling preserves tiny positive eigenvalues at a hard edge.
            # Centering and then adding back a large offset can erase them.
            scale = max(
                float(np.max(np.abs(diagonal))), float(np.max(np.abs(off_diagonal)))
            )
            if scale == 0:
                scale = 1.0
            try:
                scaled_off = off_diagonal / scale
                if np.any(scaled_off == 0):
                    raise ValueError("Recurrence scaling underflows float64")
                roots = eigvalsh_tridiagonal(
                    diagonal / scale,
                    scaled_off,
                    lapack_driver=self._root_recurrence_driver,
                )
                roots = (roots * scale) * self._root_scale * flint_to_float(
                    self._root_dilation
                ) + flint_to_float(self._root_shift)
                if np.all(np.isfinite(roots)) and (
                    not self._root_recurrence_positive or np.all(roots > 0)
                ):
                    return np.sort(np.asarray(roots, dtype=np.float64))
            except (ValueError, np.linalg.LinAlgError):
                pass  # Retain the general polynomial / high-precision fallback.

        # --- Certified path: Arb-based root isolation (default) ---
        if exact:
            try:
                acb_roots: Any = self._fmpq_poly.complex_roots()
                float_roots = []
                for r_pair in acb_roots:
                    r = r_pair[0]
                    mult = int(r_pair[1])
                    if hasattr(r, "real"):
                        real_attr = "real"
                        val = flint_to_float(getattr(r, real_attr))
                    else:
                        val = flint_to_float(r)
                    for _ in range(mult):
                        float_roots.append(val)
                return np.sort(np.array(float_roots, dtype=np.float64))
            except Exception:
                try:
                    # Fallback to sympy.nroots
                    x = sp.Symbol("x")
                    expr = sum(
                        sp.Rational(c) * x ** (self.degree - i)
                        for i, c in enumerate(self.coeffs)
                    )
                    roots_complex = sp.nroots(expr, maxsteps=1000)
                    float_roots = [float(r.as_real_imag()[0]) for r in roots_complex]
                    return np.sort(np.array(float_roots, dtype=np.float64))
                except Exception:
                    # Fall through to numerical solvers if exact solver fails
                    pass

        # Companion/Aberth coefficients have leading term one. Normalize in
        # rational arithmetic before float conversion, including extreme scalar
        # multiples; keep the caller's stored coefficient representation intact.
        numerical_polynomial = self._fmpq_poly
        leading_coefficient = numerical_polynomial[d]
        if leading_coefficient != 1:
            numerical_polynomial = numerical_polynomial / leading_coefficient

        non_zero_scales = []

        for k in range(1, d + 1):
            val = abs(flint_to_float(numerical_polynomial[d - k]))
            if val > 0:
                non_zero_scales.append(val ** (1.0 / k))

        if non_zero_scales:
            S = float(np.median(non_zero_scales))
            min_S = 10 ** (-250.0 / d) if d > 0 else 1.0
            max_S = 10 ** (250.0 / d) if d > 0 else 1.0
            S = float(np.clip(S, min_S, max_S))
            if S <= 0 or not np.isfinite(S):
                S = 1.0
        else:
            S = 1.0

        # Construct scaled float coefficients: c'_k = c_k / S**k
        scaled_coeffs = np.zeros(d + 1, dtype=np.float64)
        scaled_coeffs[0] = 1.0
        for k in range(1, d + 1):
            scaled_coeffs[k] = flint_to_float(numerical_polynomial[d - k]) / (S**k)

        # --- Parallel path: Vectorized Aberth-Ehrlich Candidate Seeker ---
        if parallel:
            try:
                if np.all(np.isfinite(scaled_coeffs)):
                    try:
                        import cupy as cp  # type: ignore[import-not-found]

                        # GPU Seeker via CuPy
                        c_0 = scaled_coeffs[0]
                        center = -scaled_coeffs[1] / (d * c_0)
                        r_vals = [
                            abs(scaled_coeffs[k] / c_0) ** (1.0 / k)
                            for k in range(1, d + 1)
                        ]
                        R = max(r_vals) * 1.5
                        theta = (2.0 * cp.arange(d) + 0.5) * cp.pi / d
                        z = center + R * (cp.cos(theta) + 1j * cp.sin(theta))
                        dp_coeffs = [scaled_coeffs[i] * (d - i) for i in range(d)]

                        for _ in range(50):
                            P_z = cp.zeros_like(z, dtype=complex)
                            for c in scaled_coeffs:
                                P_z = P_z * z + c
                            Dp_z = cp.zeros_like(z, dtype=complex)
                            for c in dp_coeffs:
                                Dp_z = Dp_z * z + c
                            w = P_z / Dp_z
                            diff = z[:, None] - z[None, :]
                            cp.fill_diagonal(diff, 1.0)
                            inv_diff = 1.0 / diff
                            cp.fill_diagonal(inv_diff, 0.0)
                            sum_diff = cp.sum(inv_diff, axis=1)
                            update = w / (1.0 - w * sum_diff)
                            z = z - update
                            if cp.max(cp.abs(update)) < 1e-12:
                                break
                        gpu_roots = np.sort(np.real(cp.asnumpy(z)))
                        return np.asarray(gpu_roots * S, dtype=np.float64)
                    except Exception:
                        # CPU Seeker via NumPy (Vectorized)
                        c_0 = scaled_coeffs[0]
                        center = -scaled_coeffs[1] / (d * c_0)
                        r_vals = [
                            abs(scaled_coeffs[k] / c_0) ** (1.0 / k)
                            for k in range(1, d + 1)
                        ]
                        R = max(r_vals) * 1.5
                        theta = (2.0 * np.arange(d) + 0.5) * np.pi / d
                        z = center + R * (np.cos(theta) + 1j * np.sin(theta))
                        dp_coeffs = [scaled_coeffs[i] * (d - i) for i in range(d)]

                        for _ in range(50):
                            P_z = np.zeros_like(z, dtype=complex)
                            for c in scaled_coeffs:
                                P_z = P_z * z + c
                            Dp_z = np.zeros_like(z, dtype=complex)
                            for c in dp_coeffs:
                                Dp_z = Dp_z * z + c
                            w = P_z / Dp_z
                            diff = z[:, None] - z[None, :]
                            np.fill_diagonal(diff, 1.0)
                            inv_diff = 1.0 / diff
                            np.fill_diagonal(inv_diff, 0.0)
                            sum_diff = np.sum(inv_diff, axis=1)
                            update = w / (1.0 - w * sum_diff)
                            z = z - update
                            if np.max(np.abs(update)) < 1e-12:
                                break
                        cpu_roots = np.sort(np.real(z))
                        return np.asarray(cpu_roots * S, dtype=np.float64)
            except (OverflowError, ValueError):
                pass  # Fall through to companion matrix/certified solver

        # --- Fast path: NumPy companion-matrix eigensolver with SciPy balancing ---
        try:
            if not np.all(np.isfinite(scaled_coeffs)):
                raise OverflowError("Coefficients overflow float64")

            # Construct companion matrix
            companion = np.zeros((d, d), dtype=np.float64)
            if d > 1:
                companion[1:, :-1] = np.eye(d - 1)
            companion[0, :] = -scaled_coeffs[1:] / scaled_coeffs[0]

            # Balance companion matrix to minimize floating-point errors
            from scipy.linalg import matrix_balance

            balanced_companion, _ = matrix_balance(companion, permute=False)

            raw_roots = np.linalg.eigvals(balanced_companion)
            # Check if all roots are real (imaginary parts negligible)
            if np.all(np.abs(raw_roots.imag) < 1e-10 * (np.abs(raw_roots.real) + 1)):
                return np.sort(raw_roots.real) * S
            # Complex roots detected — fall through to certified solver
        except (OverflowError, ValueError, FloatingPointError):
            pass  # Fall through to certified solver

        # --- Certified path: Arb-based root isolation fallback (when exact is False but numerical fails) ---
        try:
            acb_roots = self._fmpq_poly.complex_roots()
            float_roots = []
            for r_pair in acb_roots:
                r = r_pair[0]
                mult = int(r_pair[1])
                if hasattr(r, "real"):
                    real_attr = "real"
                    val = flint_to_float(getattr(r, real_attr))
                else:
                    val = flint_to_float(r)
                for _ in range(mult):
                    float_roots.append(val)
            return np.sort(np.array(float_roots, dtype=np.float64))
        except Exception:
            try:
                # Fallback to sympy.nroots
                x = sp.Symbol("x")
                expr = sum(
                    sp.Rational(c) * x ** (self.degree - i)
                    for i, c in enumerate(self.coeffs)
                )
                roots_complex = sp.nroots(expr, maxsteps=1000)
                float_roots = [float(r.as_real_imag()[0]) for r in roots_complex]
                return np.sort(np.array(float_roots, dtype=np.float64))
            except Exception:
                warnings.warn(
                    "High-precision root isolation failed. Falling back to "
                    "numpy.roots. Roots may exhibit Wilkinson's phenomenon.",
                    RuntimeWarning,
                    stacklevel=2,
                )
                float_coeffs = [
                    flint_to_float(c) for c in reversed(numerical_polynomial.coeffs())
                ]
                return np.sort(np.real(np.roots(float_coeffs)))

    def dilation(self, c: Any) -> "RealRootedPolynomial":
        r"""
        Computes the dilated polynomial $[\text{Dil}_c p](x) = c^d p(x/c)$.
        """
        if c == 0:
            raise ValueError("Dilation factor c cannot be zero.")

        import flint

        d = self.degree
        c_fmpq = sympy_to_fmpq(c)
        new_asc = []
        for j in range(d + 1):
            new_asc.append(self._fmpq_poly[j] * (c_fmpq ** (d - j)))

        result = RealRootedPolynomial(
            flint.fmpq_poly(new_asc), assume_real_rooted=self._is_verified
        )
        if self._root_recurrence is not None:
            result._root_recurrence = self._root_recurrence
            result._root_scale = self._root_scale
            result._root_dilation = self._root_dilation * c_fmpq
            result._root_shift = self._root_shift * c_fmpq
            result._root_recurrence_driver = self._root_recurrence_driver
            result._root_recurrence_positive = (
                self._root_recurrence_positive and c_fmpq > 0
            )
        if self._hermite_variance is not None:
            result._hermite_variance = self._hermite_variance * c_fmpq**2
            result._hermite_center = self._hermite_center * c_fmpq
        return result

    def shift(self, c: Any) -> "RealRootedPolynomial":
        r"""
        Computes the shifted polynomial $[\text{Shi}_c p](x) = p(x-c)$.
        """
        c_fmpq = sympy_to_fmpq(c)
        shift_poly = _get_shift_poly(c_fmpq)
        res_poly = self._fmpq_poly(shift_poly)

        result = RealRootedPolynomial(res_poly, assume_real_rooted=self._is_verified)
        if self._root_recurrence is not None:
            result._root_recurrence = self._root_recurrence
            result._root_scale = self._root_scale
            result._root_dilation = self._root_dilation
            result._root_shift = self._root_shift + c_fmpq
            result._root_recurrence_driver = self._root_recurrence_driver
            result._root_recurrence_positive = (
                self._root_recurrence_positive and c_fmpq >= 0
            )
        if self._hermite_variance is not None:
            result._hermite_variance = self._hermite_variance
            result._hermite_center = self._hermite_center + c_fmpq
        return result

    def power(self, c: Any) -> "RealRootedPolynomial":
        r"""
        Construct a polynomial with roots $\lambda_i^c$.
        Requires non-negative roots and c>0. Positive integer powers use exact rational
        operations: squaring uses p(x)p(-x), and other powers use a companion-matrix
        power and characteristic polynomial. Noninteger powers use float64 roots and
        reconstruction from their numerical powers, inheriting range/rounding limits.
        """
        if not self.has_non_negative_roots:
            raise ValueError(
                "Power operation is only defined for polynomials with "
                "non-negative roots."
            )
        if c <= 0:
            raise ValueError("Power factor c must be strictly positive.")

        c_exact = sympy_to_fmpq(c)
        if c_exact.q == 1:
            exponent = int(c_exact.p)
            d = self.degree
            if d == 0 or exponent == 1:
                result = self._fmpq_poly
            elif exponent == 2:
                reflected = flint.fmpq_poly(
                    [self._fmpq_poly[j] * (-1) ** j for j in range(d + 1)]
                )
                product = self._fmpq_poly * reflected * (-1) ** d
                result = flint.fmpq_poly([product[2 * j] for j in range(d + 1)])
            else:
                companion = flint.fmpq_mat(d, d)
                for j in range(d):
                    companion[0, j] = -self._fmpq_poly[d - j - 1] / self._fmpq_poly[d]
                for j in range(1, d):
                    companion[j, j - 1] = 1
                result = (companion**exponent).charpoly()
            return RealRootedPolynomial(result, assume_real_rooted=True)

        roots = self.evaluate_roots_float64()
        new_roots = [r**c for r in roots]
        return RealRootedPolynomial.from_roots(new_roots)

    def reversed_polynomial(self) -> "RealRootedPolynomial":
        r"""
        Computes the reversed polynomial $p^{(-1)}$ with roots $1/\lambda_i(p)$.
        """
        d = self.degree
        e_k = self.normalized_coeffs()

        # e_d must be non-zero (otherwise we have a root at 0 and cannot invert)
        e_d = e_k[d]
        if e_d == 0:
            raise ValueError(
                "Reversed polynomial is only defined for polynomials with "
                "strictly non-zero roots."
            )

        new_e = []
        for k in range(d + 1):
            val_num = e_k[d - k]
            val_den = e_d
            # Maintain exact representation
            if isinstance(val_num, (int, np.integer)) and isinstance(
                val_den, (int, np.integer)
            ):
                if val_num % val_den == 0:
                    new_e.append(val_num // val_den)
                else:
                    new_e.append(sp.Rational(val_num, val_den))
            else:
                new_e.append(val_num / val_den)

        return RealRootedPolynomial.from_normalized_coeffs(new_e)

    def phi_d(self) -> "RealRootedPolynomial":
        r"""
        Construct the multiplicative limit polynomial $\Phi_d(p)$.
        For non-negative roots and zero multiplicity r, its nonzero roots are
        $\tilde e_k/\tilde e_{k-1}$ for 1<=k<=d-r, with r additional zero roots.
        See equation (2.12) in Arizmendi et al., arXiv:2408.09337v2.
        Construction from rational coefficient ratios is exact.
        """
        # Verify that all roots are non-negative
        if not self.has_non_negative_roots:
            raise ValueError(
                "phi_d is only defined for polynomials with non-negative roots "
                "(p in P_d(R_>=0))."
            )

        d = self.degree

        if self._is_flint:
            import flint

            e_k = self._normalized_coeffs_flint()
            r = 0
            while r < d and self._fmpq_poly[r] == 0:
                r += 1

            new_roots = []
            for k in range(1, d + 1):
                if k <= d - r:
                    val_num = e_k[k]
                    val_den = e_k[k - 1]
                    if val_den == 0:
                        raise ValueError(
                            f"Zero division encountered: e_tilde_{k - 1} is zero."
                        )
                    new_roots.append(val_num / val_den)
                else:
                    new_roots.append(flint.fmpq(0))
            return RealRootedPolynomial.from_roots(new_roots)

        e_k_sympy = self.normalized_coeffs()

        # Multiplicity r of the root at 0 is the number of trailing zero coefficients
        r = 0
        coeffs = self.coeffs_sympy
        while r < d and coeffs[d - r] == 0:
            r += 1

        new_roots = []
        for k in range(1, d + 1):
            if k <= d - r:
                val_num = e_k_sympy[k]
                val_den = e_k_sympy[k - 1]
                if val_den == 0:
                    raise ValueError(
                        f"Zero division encountered: e_tilde_{k - 1} is zero."
                    )
                if isinstance(val_num, (int, np.integer)) and isinstance(
                    val_den, (int, np.integer)
                ):
                    if val_num % val_den == 0:
                        new_roots.append(val_num // val_den)
                    else:
                        new_roots.append(sp.Rational(val_num, val_den))
                else:
                    new_roots.append(val_num / val_den)
            else:
                new_roots.append(0)

        return RealRootedPolynomial.from_roots(new_roots)

    def derivative(self, monic: bool = True) -> "RealRootedPolynomial":
        """
        Computes the derivative p'(x) of the polynomial.
        If monic=True, returns the monic-normalized derivative.
        """
        if self.degree == 0:
            raise ValueError("Cannot take derivative of a constant polynomial.")
        res_poly = self._fmpq_poly.derivative()
        return RealRootedPolynomial(
            res_poly, assume_real_rooted=self._is_verified, monic=monic
        )

    def projection(self, j: SupportsIndex) -> "RealRootedPolynomial":
        r"""
        Computes the projection $\partial^{j|d} p(x)$ which is the derivative
        of order $d-j$, monic-normalized. Proper projections update only the
        leading j+1 coefficients, without constructing intermediate derivatives.
        Projection to the original degree returns self. Known Hermite provenance
        preserves its variance, center and numerical root recurrence.
        """
        try:
            dimension = operator.index(j)
        except TypeError as error:
            raise ValueError(
                "Projection dimension j must be an integer between 0 and degree."
            ) from error
        if dimension < 0 or dimension > self.degree:
            raise ValueError(
                "Projection dimension j must be an integer between 0 and degree."
            )
        if dimension == self.degree:
            return self

        # For coefficient a_k of x^(d-k), monic differentiation multiplies
        # a_k/a_0 by (j)_k/(d)_k. Accumulate that ratio exactly in O(j) updates.
        multiplier = 1 / self._fmpq_poly[self.degree]
        descending = []
        for k in range(dimension + 1):
            descending.append(self._fmpq_poly[self.degree - k] * multiplier)
            if k < dimension:
                multiplier *= flint.fmpq(dimension - k, self.degree - k)

        result = RealRootedPolynomial(
            flint.fmpq_poly(list(reversed(descending))),
            assume_real_rooted=self._is_verified,
        )
        if self._hermite_variance is not None:
            result._hermite_variance = self._hermite_variance
            result._hermite_center = self._hermite_center
            if self._root_recurrence is not None and dimension >= 2:
                # Hermite derivatives use the leading principal Jacobi matrix.
                # Keep the existing affine factors: variance itself may underflow
                # in float64 while its square root and the roots remain usable.
                diagonal, off_diagonal = self._root_recurrence
                result._root_recurrence = (
                    diagonal[:dimension].copy(),
                    off_diagonal[: dimension - 1].copy(),
                )
                result._root_scale = self._root_scale
                result._root_dilation = self._root_dilation
                result._root_shift = self._root_shift
                result._root_recurrence_driver = self._root_recurrence_driver
                result._root_recurrence_positive = self._root_recurrence_positive
        return result

    def additive_power(self, t: Any) -> "RealRootedPolynomial":
        r"""
        Scale finite cumulants by a positive rational t and reconstruct.
        Uses $\kappa_n^{(d)}(p^{\boxplus_d t})=t\kappa_n^{(d)}(p)$ with the matching
        coefficient inverse. Integer t agrees with repeated symmetric convolution.
        Formal positive fractional powers need not be real-rooted; the result validates
        geometry lazily when root/domain properties are requested.
        """
        if t <= 0:
            raise ValueError(
                "Fractional convolution power t must be strictly positive."
            )

        d = self.degree
        import flint

        from .transforms import FiniteRTransform

        kappas = FiniteRTransform(self, order=d)
        t_fmpq = sympy_to_fmpq(t)
        kappas_fmpq = [sympy_to_fmpq(k) for k in kappas]
        kappas_scaled = [k * t_fmpq for k in kappas_fmpq]

        c_cumulants = []
        for n in range(1, d + 1):
            den = flint.fmpq((-d) ** (n - 1), math.factorial(n - 1))
            val_num = kappas_scaled[n - 1]
            c_cumulants.append(val_num / den)

        e_k = [flint.fmpq(1)]
        for n in range(1, d + 1):
            en = c_cumulants[n - 1]
            for k in range(1, n):
                en += math.comb(n - 1, k - 1) * c_cumulants[k - 1] * e_k[n - k]
            e_k.append(en)

        return RealRootedPolynomial.from_normalized_coeffs(e_k)

    def is_symmetric(self) -> bool:
        """
        A polynomial of degree 2d is symmetric if its degree is even and
        all coefficients for odd powers of x are strictly zero (i.e. p(x) = p(-x)).
        """
        d = self.degree
        if d % 2 != 0:
            return False

        for k in range(d + 1):
            if (d - k) % 2 != 0:
                if self._fmpq_poly[k] != 0:
                    return False
        return True

    def square_roots_map(self) -> "RealRootedPolynomial":
        r"""
        The $\mathbf{Sq}(p)$ mapping.
        Given a symmetric polynomial $p(x) = \sum c_{2k} x^{2k}$, constructs
        and returns the transformed polynomial $\mathbf{Sq}(p)(x) = \sum c_{2k} x^k$.
        This satisfies the identity $\mathbf{Sq}(p)(x^2) = p(x)$.
        """
        if not self.is_symmetric():
            raise ValueError(
                "square_roots_map is only defined for symmetric polynomials."
            )

        d = self.degree
        d_new = d // 2
        new_asc = []
        for j in range(d_new + 1):
            new_asc.append(self._fmpq_poly[2 * j])

        return RealRootedPolynomial(
            flint.fmpq_poly(new_asc), assume_real_rooted=self._is_verified
        )

    @property
    def has_non_negative_roots(self) -> bool:
        """
        Check if all roots of the polynomial are non-negative in O(d) time
        using sign alternation after certifying real-rootedness.
        """
        if self._has_non_negative_roots_cached is None:
            if not self.verify_real_rootedness():
                return False
            if self.degree <= 1 and not self._is_flint:
                answer = (
                    True if self.degree == 0 else self._linear_root().is_nonnegative
                )
                if answer is None:
                    raise RuntimeError(
                        "Unable to certify that the linear root is nonnegative"
                    )
                self._has_non_negative_roots_cached = bool(answer)
                return self._has_non_negative_roots_cached
            signs = []
            for j in range(self.degree + 1):
                c = self._fmpq_poly[self.degree - j]
                if c != 0:
                    val = c * (-1) ** (self.degree - j)
                    signs.append(1 if val > 0 else -1)
            self._has_non_negative_roots_cached = len(set(signs)) <= 1
        return self._has_non_negative_roots_cached

    @property
    def has_strictly_positive_roots(self) -> bool:
        """
        Check if all roots of the polynomial are strictly positive in O(d) time
        using sign alternation after certifying real-rootedness.
        """
        if self._has_strictly_positive_roots_cached is None:
            if not self.verify_real_rootedness():
                return False
            if self.degree <= 1 and not self._is_flint:
                answer = True if self.degree == 0 else self._linear_root().is_positive
                if answer is None:
                    raise RuntimeError(
                        "Unable to certify that the linear root is positive"
                    )
                self._has_strictly_positive_roots_cached = bool(answer)
                return self._has_strictly_positive_roots_cached
            ans = True
            for k in range(self.degree + 1):
                if self._fmpq_poly[k] == 0:
                    ans = False
                    break
            if ans:
                for i in range(1, self.degree + 1):
                    c_i = self._fmpq_poly[self.degree - i]
                    c_prev = self._fmpq_poly[self.degree - (i - 1)]
                    if c_i * c_prev >= 0:
                        ans = False
                        break
            self._has_strictly_positive_roots_cached = ans
        return self._has_strictly_positive_roots_cached
Attributes
coeffs property

Return a caller-owned copy of the descending coefficient array.

has_non_negative_roots property

Check if all roots of the polynomial are non-negative in O(d) time using sign alternation after certifying real-rootedness.

has_strictly_positive_roots property

Check if all roots of the polynomial are strictly positive in O(d) time using sign alternation after certifying real-rootedness.

variables property

Returns the symbolic variables of the polynomial (a single list [x]).

Methods:
__init__(coeffs, assume_real_rooted=False, monic=True)

Construct from descending coefficients or a native ascending FLINT polynomial. Inputs are copied; monic=True divides out the leading coefficient, while monic=False retains it. Rational values use FLINT, including the exact stored ratio of Python/NumPy floats. Other symbolic coefficients use SymPy. Geometry is checked lazily unless assume_real_rooted=True trusts the caller. The rational zero polynomial is rejected; nonzero constants have no roots.

Source code in finitefree/core.py
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def __init__(
    self,
    coeffs: Union[Sequence[Any], NDArray[Any], flint.fmpq_poly],
    assume_real_rooted: bool = False,
    monic: bool = True,
) -> None:
    r"""
    Construct from descending coefficients or a native ascending FLINT polynomial.
    Inputs are copied; monic=True divides out the leading coefficient, while
    monic=False retains it. Rational values use FLINT, including the exact stored
    ratio of Python/NumPy floats. Other symbolic coefficients use SymPy.
    Geometry is checked lazily unless assume_real_rooted=True trusts the caller.
    The rational zero polynomial is rejected; nonzero constants have no roots.
    """
    import flint

    if not isinstance(coeffs, flint.fmpq_poly):
        if len(coeffs) == 0:
            raise ValueError("Coefficients sequence cannot be empty")
    else:
        if coeffs.degree() < 0:
            raise ValueError("Zero polynomial is not supported")

    self._is_flint = False
    if isinstance(coeffs, flint.fmpq_poly):
        poly = flint.fmpq_poly(coeffs)
        self._is_flint = True
    else:
        try:
            q_coeffs = [sympy_to_fmpq(c) for c in reversed(coeffs)]
            poly = flint.fmpq_poly(q_coeffs)
            self._is_flint = True
        except Exception:
            # Fallback to SymPy object array for non-rational/transcendental coefficients (e.g. UnitaryPolynomial)
            self.coeffs_sympy = np.array(coeffs, dtype=object)
            leading_coeff = self.coeffs_sympy[0]
            if monic and leading_coeff != 1:
                coeffs_list = []
                for c in self.coeffs_sympy:
                    coeffs_list.append(c / leading_coeff)
                self.coeffs_sympy = np.array(coeffs_list, dtype=object)
            self._is_flint = False

    if self._is_flint:
        if poly.degree() < 0:
            raise ValueError("Zero polynomial is not supported")
        try:
            leading_coeff = poly.leading_coefficient()
        except AttributeError:
            # Fallback for older python-flint versions where leading_coefficient() is missing
            leading_coeff = poly.coeffs()[-1] if poly.coeffs() else flint.fmpq(0, 1)
        if monic and leading_coeff != 0 and leading_coeff != 1:
            poly = poly * (1 / leading_coeff)
        self._fmpq_poly = poly
        self.degree: int = poly.degree() if poly.degree() >= 0 else 0
    else:
        self.degree = len(self.coeffs_sympy) - 1
        self.coeffs_sympy.setflags(write=False)

    self._is_verified: bool = assume_real_rooted
    self._is_monic = monic
    self._normalized_coeffs_flint_cached: Union[List[Any], None] = None
    self._normalized_coeffs_flint_prefix_cached: Union[List[Any], None] = None
    self._normalized_coeffs_sympy_cached: Union[NDArray[np.object_], None] = None
    self._roots_cached: Union[NDArray[Any], None] = None
    self._roots_cached_exact = False
    self._roots_cached_prec = 0
    self._root_recurrence: Union[
        tuple[NDArray[np.float64], NDArray[np.float64]], None
    ] = None
    self._root_scale = 1.0
    self._root_dilation: Any = flint.fmpq(1)
    self._root_shift: Any = flint.fmpq(0)
    self._root_recurrence_driver = "stemr"
    self._root_recurrence_positive = False
    self._hermite_variance: Any = None
    self._hermite_center: Any = 0
    self._has_non_negative_roots_cached: Union[bool, None] = None
    self._has_strictly_positive_roots_cached: Union[bool, None] = None
    self._float_coeffs_cached: Union[list[float], None] = None
additive_power(t)

Scale finite cumulants by a positive rational t and reconstruct. Uses \(\kappa_n^{(d)}(p^{\boxplus_d t})=t\kappa_n^{(d)}(p)\) with the matching coefficient inverse. Integer t agrees with repeated symmetric convolution. Formal positive fractional powers need not be real-rooted; the result validates geometry lazily when root/domain properties are requested.

Source code in finitefree/core.py
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
def additive_power(self, t: Any) -> "RealRootedPolynomial":
    r"""
    Scale finite cumulants by a positive rational t and reconstruct.
    Uses $\kappa_n^{(d)}(p^{\boxplus_d t})=t\kappa_n^{(d)}(p)$ with the matching
    coefficient inverse. Integer t agrees with repeated symmetric convolution.
    Formal positive fractional powers need not be real-rooted; the result validates
    geometry lazily when root/domain properties are requested.
    """
    if t <= 0:
        raise ValueError(
            "Fractional convolution power t must be strictly positive."
        )

    d = self.degree
    import flint

    from .transforms import FiniteRTransform

    kappas = FiniteRTransform(self, order=d)
    t_fmpq = sympy_to_fmpq(t)
    kappas_fmpq = [sympy_to_fmpq(k) for k in kappas]
    kappas_scaled = [k * t_fmpq for k in kappas_fmpq]

    c_cumulants = []
    for n in range(1, d + 1):
        den = flint.fmpq((-d) ** (n - 1), math.factorial(n - 1))
        val_num = kappas_scaled[n - 1]
        c_cumulants.append(val_num / den)

    e_k = [flint.fmpq(1)]
    for n in range(1, d + 1):
        en = c_cumulants[n - 1]
        for k in range(1, n):
            en += math.comb(n - 1, k - 1) * c_cumulants[k - 1] * e_k[n - k]
        e_k.append(en)

    return RealRootedPolynomial.from_normalized_coeffs(e_k)
derivative(monic=True)

Computes the derivative p'(x) of the polynomial. If monic=True, returns the monic-normalized derivative.

Source code in finitefree/core.py
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
def derivative(self, monic: bool = True) -> "RealRootedPolynomial":
    """
    Computes the derivative p'(x) of the polynomial.
    If monic=True, returns the monic-normalized derivative.
    """
    if self.degree == 0:
        raise ValueError("Cannot take derivative of a constant polynomial.")
    res_poly = self._fmpq_poly.derivative()
    return RealRootedPolynomial(
        res_poly, assume_real_rooted=self._is_verified, monic=monic
    )
dilation(c)

Computes the dilated polynomial \([\text{Dil}_c p](x) = c^d p(x/c)\).

Source code in finitefree/core.py
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
def dilation(self, c: Any) -> "RealRootedPolynomial":
    r"""
    Computes the dilated polynomial $[\text{Dil}_c p](x) = c^d p(x/c)$.
    """
    if c == 0:
        raise ValueError("Dilation factor c cannot be zero.")

    import flint

    d = self.degree
    c_fmpq = sympy_to_fmpq(c)
    new_asc = []
    for j in range(d + 1):
        new_asc.append(self._fmpq_poly[j] * (c_fmpq ** (d - j)))

    result = RealRootedPolynomial(
        flint.fmpq_poly(new_asc), assume_real_rooted=self._is_verified
    )
    if self._root_recurrence is not None:
        result._root_recurrence = self._root_recurrence
        result._root_scale = self._root_scale
        result._root_dilation = self._root_dilation * c_fmpq
        result._root_shift = self._root_shift * c_fmpq
        result._root_recurrence_driver = self._root_recurrence_driver
        result._root_recurrence_positive = (
            self._root_recurrence_positive and c_fmpq > 0
        )
    if self._hermite_variance is not None:
        result._hermite_variance = self._hermite_variance * c_fmpq**2
        result._hermite_center = self._hermite_center * c_fmpq
    return result
evaluate(x)

Evaluate at x, retaining the polynomial's stored scalar. Rational-backend integer/rational arguments use exact FLINT evaluation. Floating arguments use float64 Horner evaluation and may overflow, underflow or lose precision. Symbolic-backend evaluation uses SymPy substitution.

Source code in finitefree/core.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def evaluate(self, x: Any) -> Any:
    r"""
    Evaluate at x, retaining the polynomial's stored scalar.
    Rational-backend integer/rational arguments use exact FLINT evaluation.
    Floating arguments use float64 Horner evaluation and may overflow, underflow
    or lose precision. Symbolic-backend evaluation uses SymPy substitution.
    """
    if self._is_flint:
        if isinstance(x, (float, np.floating)):
            if self._float_coeffs_cached is None:
                from finitefree.utils.conversion import flint_to_float

                self._float_coeffs_cached = [
                    flint_to_float(c) for c in self._fmpq_poly.coeffs()
                ]
            val = 0.0
            x_val = float(x)
            for c in reversed(self._float_coeffs_cached):
                val = val * x_val + c
            return val
        x_fmpq = sympy_to_fmpq(x)
        return self._fmpq_poly(x_fmpq)
    sym_x = sp.sympify(x)
    val_sym = sp.Symbol("x")
    expr = sum(
        c * val_sym ** (self.degree - i) for i, c in enumerate(self.coeffs_sympy)
    )
    return expr.subs(val_sym, sym_x)
evaluate_roots_float64(parallel=False, exact=True)

Return sorted float64 roots with multiplicities after domain validation. Returned arrays are read-only; use .copy() to obtain editable values. With exact=True (the default), first request Arb isolation; PrecisionContext controls the working precision. Numerical fallbacks can still be used if isolation fails, and returned floats are not interval error bounds. With exact=False, first try a scaled symmetric tridiagonal solver for supported orthogonal families and proven Hermite additive convolutions. Otherwise use a balanced companion matrix, or parallelized Aberth-Ehrlich when parallel=True, with high-precision/general numerical fallback. Arb validation retains isolated roots. An exact=True request bypasses an approximate cache or one recorded at a lower working precision. Nonfinite numerical results raise RuntimeError without entering the cache; exact construction and real-rootedness certification remain available. Nonfinite/underflowed recurrences fall back; extreme affine scales or shifts remain limited by float64 representability and conditioning.

Source code in finitefree/core.py
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
def evaluate_roots_float64(
    self, parallel: bool = False, exact: bool = True
) -> NDArray[Any]:
    """
    Return sorted float64 roots with multiplicities after domain validation.
    Returned arrays are read-only; use .copy() to obtain editable values.
    With exact=True (the default), first request Arb isolation; PrecisionContext
    controls the working precision. Numerical fallbacks can still be used if
    isolation fails, and returned floats are not interval error bounds.
    With exact=False, first try a scaled symmetric tridiagonal solver for
    supported orthogonal families and proven Hermite additive convolutions.
    Otherwise use a balanced companion matrix, or parallelized Aberth-Ehrlich
    when parallel=True, with high-precision/general numerical fallback.
    Arb validation retains isolated roots. An exact=True request bypasses
    an approximate cache or one recorded at a lower working precision.
    Nonfinite numerical results raise RuntimeError without entering the cache;
    exact construction and real-rootedness certification remain available.
    Nonfinite/underflowed recurrences fall back; extreme affine scales or
    shifts remain limited by float64 representability and conditioning.
    """
    self.verify_real_rootedness()
    if self._roots_cached is not None and (
        not exact
        or (self._roots_cached_exact and self._roots_cached_prec >= flint.ctx.prec)
    ):
        return self._roots_cached
    res = self._evaluate_roots_float64_uncached(parallel=parallel, exact=exact)
    res = self._finite_float64_root_array(res)
    self._roots_cached = res
    self._roots_cached_exact = exact
    self._roots_cached_prec = flint.ctx.prec if exact else 0
    return res
from_normalized_coeffs(e_k) classmethod

Reconstructs the polynomial from the normalized sequence \(\tilde{e}_k^{(d)}(p)\). Real-rootedness is verified lazily; arbitrary sequences need not represent real-rooted polynomials.

Source code in finitefree/core.py
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
@classmethod
def from_normalized_coeffs(
    cls, e_k: Union[Sequence[Any], NDArray[Any]]
) -> "RealRootedPolynomial":
    r"""
    Reconstructs the polynomial from the normalized sequence
    $\tilde{e}_k^{(d)}(p)$.
    Real-rootedness is verified lazily; arbitrary sequences need not
    represent real-rooted polynomials.
    """
    import flint

    if len(e_k) == 0 or e_k[0] != 1:
        raise ValueError("Normalized coefficients must start with e_0 = 1.")
    d = len(e_k) - 1
    try:
        q_ek = [sympy_to_fmpq(x) for x in e_k]
        # Precompute binomial coefficients via C-level running product
        binoms = [flint.fmpz(1)]
        curr = flint.fmpz(1)
        for k in range(1, d + 1):
            curr = (curr * (d - k + 1)) // k
            binoms.append(curr)

        c_asc = []
        for j in range(d + 1):
            k = d - j
            sign = (-1) ** k
            val = q_ek[k] * flint.fmpq(flint.fmpz(sign) * binoms[k])
            c_asc.append(val)
        poly = flint.fmpq_poly(c_asc)
        inst = cls(poly)
        inst._normalized_coeffs_flint_cached = q_ek
        return inst
    except Exception:
        c = []
        curr_binom = 1
        for k in range(d + 1):
            if k > 0:
                curr_binom = (curr_binom * (d - k + 1)) // k
            sign = (-1) ** k
            c.append(e_k[k] * sign * curr_binom)
        inst = cls(c)
        return inst
from_roots(roots) classmethod

Construct a monic polynomial from supplied real rational root values. Python/NumPy floating inputs are interpreted by their own stored binary ratios. A balanced product tree multiplies FLINT linear factors; cost depends on multiplication and coefficient bit sizes. Empty input gives the unit constant. Finite approximate roots may be cached read-only. Exact construction remains available when supplied roots cannot fit float64.

Source code in finitefree/core.py
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
@classmethod
def from_roots(
    cls, roots: Union[Sequence[Any], NDArray[Any]]
) -> "RealRootedPolynomial":
    r"""
    Construct a monic polynomial from supplied real rational root values.
    Python/NumPy floating inputs are interpreted by their own stored binary ratios.
    A balanced product tree multiplies FLINT linear factors; cost depends on
    multiplication and coefficient bit sizes. Empty input gives the unit constant.
    Finite approximate roots may be cached read-only. Exact construction remains
    available when supplied roots cannot fit float64.
    """
    from collections import deque

    import flint

    if len(roots) == 0:
        return cls(flint.fmpq_poly([1]), assume_real_rooted=True)

    factors = []
    for r in roots:
        val = sympy_to_fmpq(r)
        factors.append(flint.fmpq_poly([-val, 1]))

    queue = deque(factors)
    while len(queue) > 1:
        left = queue.popleft()
        right = queue.popleft()
        queue.append(left * right)

    poly_flint = queue[0]
    inst = cls(poly_flint, assume_real_rooted=True)
    try:
        float_roots = [flint_to_float(r) for r in roots]
        inst._roots_cached = cls._finite_float64_root_array(np.sort(float_roots))
    except (TypeError, ValueError, OverflowError, RuntimeError):
        pass  # Exact construction is valid even beyond the float64 range.
    return inst
is_symmetric()

A polynomial of degree 2d is symmetric if its degree is even and all coefficients for odd powers of x are strictly zero (i.e. p(x) = p(-x)).

Source code in finitefree/core.py
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
def is_symmetric(self) -> bool:
    """
    A polynomial of degree 2d is symmetric if its degree is even and
    all coefficients for odd powers of x are strictly zero (i.e. p(x) = p(-x)).
    """
    d = self.degree
    if d % 2 != 0:
        return False

    for k in range(d + 1):
        if (d - k) % 2 != 0:
            if self._fmpq_poly[k] != 0:
                return False
    return True
normalized_coeffs(d=None)

Extracts the normalized elementary symmetric polynomial sequence \(\tilde{e}_k^{(d)}(p)\) with respect to ambient dimension \(d\) as an object array. Rational coefficients remain exact; symbolic coefficients use SymPy. Divides out the leading coefficient, preserving the polynomial's stored coefficients while making the sequence depend only on its roots. Returned arrays are read-only; use .copy() to obtain editable values.

Source code in finitefree/core.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
def normalized_coeffs(self, d: Union[int, None] = None) -> NDArray[np.object_]:
    r"""
    Extracts the normalized elementary symmetric polynomial sequence
    $\tilde{e}_k^{(d)}(p)$ with respect to ambient dimension $d$ as an object
    array. Rational coefficients remain exact; symbolic coefficients use SymPy.
    Divides out the leading coefficient, preserving the polynomial's stored
    coefficients while making the sequence depend only on its roots.
    Returned arrays are read-only; use .copy() to obtain editable values.
    """
    if d is None:
        d = self.degree

    if d < self.degree:
        raise ValueError(
            f"Ambient dimension d ({d}) cannot be less than polynomial "
            f"degree ({self.degree})."
        )

    if d == self.degree and self._normalized_coeffs_sympy_cached is not None:
        return self._normalized_coeffs_sympy_cached

    if self._is_flint:
        raw = self._normalized_coeffs_flint(d)
        e_k = [sp.Rational(int(val.p), int(val.q)) for val in raw]
    else:
        e_k = []
        leading = self.coeffs_sympy[0]
        for k in range(d + 1):
            if k <= self.degree:
                binom = math.comb(d, k)
                sign = (-1) ** k
                c_k = self.coeffs_sympy[k]
                if leading != 1:
                    c_k = sp.cancel(sp.sympify(c_k) / sp.sympify(leading))
                val = sign * binom
                if isinstance(c_k, (int, np.integer)):
                    if c_k % val == 0:
                        e_k.append(c_k // val)
                    else:
                        e_k.append(sp.Rational(c_k, val))
                else:
                    e_k.append(c_k / val)
            else:
                e_k.append(0)

    res_array = np.array(e_k, dtype=object)
    res_array.setflags(write=False)
    if d == self.degree:
        self._normalized_coeffs_sympy_cached = res_array
    return res_array
phi_d()

Construct the multiplicative limit polynomial \(\Phi_d(p)\). For non-negative roots and zero multiplicity r, its nonzero roots are \(\tilde e_k/\tilde e_{k-1}\) for 1<=k<=d-r, with r additional zero roots. See equation (2.12) in Arizmendi et al., arXiv:2408.09337v2. Construction from rational coefficient ratios is exact.

Source code in finitefree/core.py
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
def phi_d(self) -> "RealRootedPolynomial":
    r"""
    Construct the multiplicative limit polynomial $\Phi_d(p)$.
    For non-negative roots and zero multiplicity r, its nonzero roots are
    $\tilde e_k/\tilde e_{k-1}$ for 1<=k<=d-r, with r additional zero roots.
    See equation (2.12) in Arizmendi et al., arXiv:2408.09337v2.
    Construction from rational coefficient ratios is exact.
    """
    # Verify that all roots are non-negative
    if not self.has_non_negative_roots:
        raise ValueError(
            "phi_d is only defined for polynomials with non-negative roots "
            "(p in P_d(R_>=0))."
        )

    d = self.degree

    if self._is_flint:
        import flint

        e_k = self._normalized_coeffs_flint()
        r = 0
        while r < d and self._fmpq_poly[r] == 0:
            r += 1

        new_roots = []
        for k in range(1, d + 1):
            if k <= d - r:
                val_num = e_k[k]
                val_den = e_k[k - 1]
                if val_den == 0:
                    raise ValueError(
                        f"Zero division encountered: e_tilde_{k - 1} is zero."
                    )
                new_roots.append(val_num / val_den)
            else:
                new_roots.append(flint.fmpq(0))
        return RealRootedPolynomial.from_roots(new_roots)

    e_k_sympy = self.normalized_coeffs()

    # Multiplicity r of the root at 0 is the number of trailing zero coefficients
    r = 0
    coeffs = self.coeffs_sympy
    while r < d and coeffs[d - r] == 0:
        r += 1

    new_roots = []
    for k in range(1, d + 1):
        if k <= d - r:
            val_num = e_k_sympy[k]
            val_den = e_k_sympy[k - 1]
            if val_den == 0:
                raise ValueError(
                    f"Zero division encountered: e_tilde_{k - 1} is zero."
                )
            if isinstance(val_num, (int, np.integer)) and isinstance(
                val_den, (int, np.integer)
            ):
                if val_num % val_den == 0:
                    new_roots.append(val_num // val_den)
                else:
                    new_roots.append(sp.Rational(val_num, val_den))
            else:
                new_roots.append(val_num / val_den)
        else:
            new_roots.append(0)

    return RealRootedPolynomial.from_roots(new_roots)
power(c)

Construct a polynomial with roots \(\lambda_i^c\). Requires non-negative roots and c>0. Positive integer powers use exact rational operations: squaring uses p(x)p(-x), and other powers use a companion-matrix power and characteristic polynomial. Noninteger powers use float64 roots and reconstruction from their numerical powers, inheriting range/rounding limits.

Source code in finitefree/core.py
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
def power(self, c: Any) -> "RealRootedPolynomial":
    r"""
    Construct a polynomial with roots $\lambda_i^c$.
    Requires non-negative roots and c>0. Positive integer powers use exact rational
    operations: squaring uses p(x)p(-x), and other powers use a companion-matrix
    power and characteristic polynomial. Noninteger powers use float64 roots and
    reconstruction from their numerical powers, inheriting range/rounding limits.
    """
    if not self.has_non_negative_roots:
        raise ValueError(
            "Power operation is only defined for polynomials with "
            "non-negative roots."
        )
    if c <= 0:
        raise ValueError("Power factor c must be strictly positive.")

    c_exact = sympy_to_fmpq(c)
    if c_exact.q == 1:
        exponent = int(c_exact.p)
        d = self.degree
        if d == 0 or exponent == 1:
            result = self._fmpq_poly
        elif exponent == 2:
            reflected = flint.fmpq_poly(
                [self._fmpq_poly[j] * (-1) ** j for j in range(d + 1)]
            )
            product = self._fmpq_poly * reflected * (-1) ** d
            result = flint.fmpq_poly([product[2 * j] for j in range(d + 1)])
        else:
            companion = flint.fmpq_mat(d, d)
            for j in range(d):
                companion[0, j] = -self._fmpq_poly[d - j - 1] / self._fmpq_poly[d]
            for j in range(1, d):
                companion[j, j - 1] = 1
            result = (companion**exponent).charpoly()
        return RealRootedPolynomial(result, assume_real_rooted=True)

    roots = self.evaluate_roots_float64()
    new_roots = [r**c for r in roots]
    return RealRootedPolynomial.from_roots(new_roots)
projection(j)

Computes the projection \(\partial^{j|d} p(x)\) which is the derivative of order \(d-j\), monic-normalized. Proper projections update only the leading j+1 coefficients, without constructing intermediate derivatives. Projection to the original degree returns self. Known Hermite provenance preserves its variance, center and numerical root recurrence.

Source code in finitefree/core.py
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
def projection(self, j: SupportsIndex) -> "RealRootedPolynomial":
    r"""
    Computes the projection $\partial^{j|d} p(x)$ which is the derivative
    of order $d-j$, monic-normalized. Proper projections update only the
    leading j+1 coefficients, without constructing intermediate derivatives.
    Projection to the original degree returns self. Known Hermite provenance
    preserves its variance, center and numerical root recurrence.
    """
    try:
        dimension = operator.index(j)
    except TypeError as error:
        raise ValueError(
            "Projection dimension j must be an integer between 0 and degree."
        ) from error
    if dimension < 0 or dimension > self.degree:
        raise ValueError(
            "Projection dimension j must be an integer between 0 and degree."
        )
    if dimension == self.degree:
        return self

    # For coefficient a_k of x^(d-k), monic differentiation multiplies
    # a_k/a_0 by (j)_k/(d)_k. Accumulate that ratio exactly in O(j) updates.
    multiplier = 1 / self._fmpq_poly[self.degree]
    descending = []
    for k in range(dimension + 1):
        descending.append(self._fmpq_poly[self.degree - k] * multiplier)
        if k < dimension:
            multiplier *= flint.fmpq(dimension - k, self.degree - k)

    result = RealRootedPolynomial(
        flint.fmpq_poly(list(reversed(descending))),
        assume_real_rooted=self._is_verified,
    )
    if self._hermite_variance is not None:
        result._hermite_variance = self._hermite_variance
        result._hermite_center = self._hermite_center
        if self._root_recurrence is not None and dimension >= 2:
            # Hermite derivatives use the leading principal Jacobi matrix.
            # Keep the existing affine factors: variance itself may underflow
            # in float64 while its square root and the roots remain usable.
            diagonal, off_diagonal = self._root_recurrence
            result._root_recurrence = (
                diagonal[:dimension].copy(),
                off_diagonal[: dimension - 1].copy(),
            )
            result._root_scale = self._root_scale
            result._root_dilation = self._root_dilation
            result._root_shift = self._root_shift
            result._root_recurrence_driver = self._root_recurrence_driver
            result._root_recurrence_positive = self._root_recurrence_positive
    return result
reversed_polynomial()

Computes the reversed polynomial \(p^{(-1)}\) with roots \(1/\lambda_i(p)\).

Source code in finitefree/core.py
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
def reversed_polynomial(self) -> "RealRootedPolynomial":
    r"""
    Computes the reversed polynomial $p^{(-1)}$ with roots $1/\lambda_i(p)$.
    """
    d = self.degree
    e_k = self.normalized_coeffs()

    # e_d must be non-zero (otherwise we have a root at 0 and cannot invert)
    e_d = e_k[d]
    if e_d == 0:
        raise ValueError(
            "Reversed polynomial is only defined for polynomials with "
            "strictly non-zero roots."
        )

    new_e = []
    for k in range(d + 1):
        val_num = e_k[d - k]
        val_den = e_d
        # Maintain exact representation
        if isinstance(val_num, (int, np.integer)) and isinstance(
            val_den, (int, np.integer)
        ):
            if val_num % val_den == 0:
                new_e.append(val_num // val_den)
            else:
                new_e.append(sp.Rational(val_num, val_den))
        else:
            new_e.append(val_num / val_den)

    return RealRootedPolynomial.from_normalized_coeffs(new_e)
shift(c)

Computes the shifted polynomial \([\text{Shi}_c p](x) = p(x-c)\).

Source code in finitefree/core.py
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
def shift(self, c: Any) -> "RealRootedPolynomial":
    r"""
    Computes the shifted polynomial $[\text{Shi}_c p](x) = p(x-c)$.
    """
    c_fmpq = sympy_to_fmpq(c)
    shift_poly = _get_shift_poly(c_fmpq)
    res_poly = self._fmpq_poly(shift_poly)

    result = RealRootedPolynomial(res_poly, assume_real_rooted=self._is_verified)
    if self._root_recurrence is not None:
        result._root_recurrence = self._root_recurrence
        result._root_scale = self._root_scale
        result._root_dilation = self._root_dilation
        result._root_shift = self._root_shift + c_fmpq
        result._root_recurrence_driver = self._root_recurrence_driver
        result._root_recurrence_positive = (
            self._root_recurrence_positive and c_fmpq >= 0
        )
    if self._hermite_variance is not None:
        result._hermite_variance = self._hermite_variance
        result._hermite_center = self._hermite_center + c_fmpq
    return result
square_roots_map()

The \(\mathbf{Sq}(p)\) mapping. Given a symmetric polynomial \(p(x) = \sum c_{2k} x^{2k}\), constructs and returns the transformed polynomial \(\mathbf{Sq}(p)(x) = \sum c_{2k} x^k\). This satisfies the identity \(\mathbf{Sq}(p)(x^2) = p(x)\).

Source code in finitefree/core.py
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
def square_roots_map(self) -> "RealRootedPolynomial":
    r"""
    The $\mathbf{Sq}(p)$ mapping.
    Given a symmetric polynomial $p(x) = \sum c_{2k} x^{2k}$, constructs
    and returns the transformed polynomial $\mathbf{Sq}(p)(x) = \sum c_{2k} x^k$.
    This satisfies the identity $\mathbf{Sq}(p)(x^2) = p(x)$.
    """
    if not self.is_symmetric():
        raise ValueError(
            "square_roots_map is only defined for symmetric polynomials."
        )

    d = self.degree
    d_new = d // 2
    new_asc = []
    for j in range(d_new + 1):
        new_asc.append(self._fmpq_poly[2 * j])

    return RealRootedPolynomial(
        flint.fmpq_poly(new_asc), assume_real_rooted=self._is_verified
    )
to_numpy_poly1d()

Export stored coefficients directly to NumPy float64 and return poly1d. This method warns above degree 20 but does not rescale coefficients or certify finiteness/accuracy. Conversion may overflow and root finding on monomial coefficients may be ill-conditioned. Prefer evaluate_roots_float64().

Source code in finitefree/core.py
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
def to_numpy_poly1d(self) -> np.poly1d:
    r"""
    Export stored coefficients directly to NumPy float64 and return poly1d.
    This method warns above degree 20 but does not rescale coefficients or certify
    finiteness/accuracy. Conversion may overflow and root finding on monomial
    coefficients may be ill-conditioned. Prefer evaluate_roots_float64().
    """
    if self.degree > 20:
        warnings.warn(
            "Directly exporting high-degree coefficients to float64 can result in "
            "extreme numerical instability due to Wilkinson's phenomenon. "
            "Consider using evaluate_roots_float64() which performs high-precision "
            "root isolation first.",
            RuntimeWarning,
            stacklevel=2,
        )
    float_coeffs = np.array(self.coeffs, dtype=float)
    return np.poly1d(float_coeffs)
to_scipy_dist()

Return a SciPy continuous distribution with an interpolated root CDF. The CDF linearly interpolates evenly spaced values from 0 to 1 at sorted numerical roots. It is an approximation, not the atomic empirical root law. At least two returned root entries are required; multiplicities are retained.

Source code in finitefree/core.py
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
def to_scipy_dist(self) -> Any:
    r"""
    Return a SciPy continuous distribution with an interpolated root CDF.
    The CDF linearly interpolates evenly spaced values from 0 to 1 at sorted
    numerical roots. It is an approximation, not the atomic empirical root law.
    At least two returned root entries are required; multiplicities are retained.
    """
    import scipy.stats

    roots = self.evaluate_roots_float64()
    if len(roots) < 2:
        raise ValueError(
            "SciPy continuous distribution requires at least 2 distinct roots."
        )

    class PolynomialRootDist(scipy.stats.rv_continuous):  # type: ignore[misc]
        def __init__(self, roots_arr: NDArray[np.float64]) -> None:
            self.roots_arr = np.sort(roots_arr)
            self.d_val = len(roots_arr)
            super().__init__(
                a=float(self.roots_arr[0]), b=float(self.roots_arr[-1])
            )

        def _cdf(self, x: Any) -> Any:
            return np.interp(x, self.roots_arr, np.linspace(0.0, 1.0, self.d_val))

    return PolynomialRootDist(roots)
verify_real_rootedness()

Lazily verify real-rootedness using Arb isolation above degree 30 or for squarefree factors of degree at least 15. Smaller factors use exact Sturm sequences; degrees through 30 also retain Sturm as a fallback when Arb cannot obtain a certificate. Complex roots raise ValueError; failure to obtain a certificate raises RuntimeError. An explicit assume_real_rooted=True bypasses verification.

Source code in finitefree/core.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def verify_real_rootedness(self) -> bool:
    """
    Lazily verify real-rootedness using Arb isolation above degree 30 or
    for squarefree factors of degree at least 15. Smaller factors use exact
    Sturm sequences; degrees through 30 also retain Sturm as a fallback
    when Arb cannot obtain a certificate. Complex roots raise
    ValueError; failure to obtain a certificate raises RuntimeError.
    An explicit assume_real_rooted=True bypasses verification.
    """
    if self._is_verified:
        return True

    if self.degree <= 1:
        if self.degree == 1 and not self._is_flint:
            real = self._linear_root().is_real
            if real is False:
                raise ValueError(
                    "Polynomial is not real-rooted: nonreal linear root"
                )
            if real is None:
                raise RuntimeError("Unable to certify that the linear root is real")
        self._is_verified = True
        return True

    if self.degree > 30:
        return self._verify_real_rootedness_arb()

    import flint

    try:
        f_poly = self._to_fmpq_poly()
        _, factors = f_poly.factor_squarefree()

        if any(factor.degree() >= 15 for factor, _ in factors):
            try:
                return self._verify_real_rootedness_arb()
            except RuntimeError:
                pass  # Keep exact Sturm certification if isolation is unavailable.

        total_real_roots = 0
        for factor, multiplicity in factors:
            if factor.degree() >= 15:
                from .utils.prs import sturm_subresultant_prs

                sturm_seq = sturm_subresultant_prs(factor, factor.derivative())
            else:
                # Generate Sturm sequence using compiled division modulo in C
                seq = [factor, factor.derivative()]
                while not seq[-1].is_zero():
                    remainder = seq[-2] % seq[-1]
                    seq.append(-remainder)
                sturm_seq = seq[:-1]

            # Evaluate sturm sequence at -inf and +inf
            def sign_changes_flint(
                seq_list: List[flint.fmpq_poly], val: str
            ) -> int:
                signs: List[int] = []
                for p in seq_list:
                    if p.is_zero():
                        continue
                    try:
                        lead_coeff = p.leading_coefficient()
                    except AttributeError:
                        lead_coeff = (
                            p.coeffs()[-1] if p.coeffs() else flint.fmpq(0, 1)
                        )
                    s = 1 if lead_coeff > 0 else -1
                    deg = p.degree()

                    if val == "-inf":
                        if deg % 2 != 0:
                            s = -s
                    signs.append(s)

                changes = 0
                for i in range(len(signs) - 1):
                    if signs[i] * signs[i + 1] < 0:
                        changes += 1
                return changes

            v_minus_inf = sign_changes_flint(sturm_seq, "-inf")
            v_plus_inf = sign_changes_flint(sturm_seq, "inf")

            real_roots_factor = v_minus_inf - v_plus_inf
            if real_roots_factor < factor.degree():
                raise ValueError(
                    "Polynomial is not real-rooted. "
                    f"Missing real roots in factor {factor}"
                )

            total_real_roots += real_roots_factor * multiplicity

        if total_real_roots < self.degree:
            raise ValueError("Polynomial is not real-rooted.")

        self._is_verified = True
        return True

    except ValueError:
        raise
    except Exception:
        return self._verify_real_rootedness_arb()
verify_root_interlacing(strict=False)

Verifies that the roots of the derivative \(p'(x)\) interlace the roots of \(p(x)\). For univariate polynomials, if \(p(x)\) is real-rooted, Rolle's Theorem mathematically guarantees that the roots of \(p'(x)\) interlace the roots of \(p(x)\). If strict=True, they strictly interlace if and only if all roots of \(p(x)\) are simple (which corresponds to the polynomial being square-free).

Source code in finitefree/core.py
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
def verify_root_interlacing(self, strict: bool = False) -> bool:
    r"""
    Verifies that the roots of the derivative $p'(x)$ interlace the roots of $p(x)$.
    For univariate polynomials, if $p(x)$ is real-rooted, Rolle's Theorem
    mathematically guarantees that the roots of $p'(x)$ interlace the roots of $p(x)$.
    If strict=True, they strictly interlace if and only if all roots of $p(x)$
    are simple (which corresponds to the polynomial being square-free).
    """
    self.verify_real_rootedness()

    if not strict:
        return True

    if self.degree <= 1:
        return True

    # Check if the polynomial is square-free (no multiple roots)
    g = self._fmpq_poly.gcd(self._fmpq_poly.derivative())
    if g.degree() > 0:
        raise ValueError("Strict root interlacing failed: multiple roots detected.")

    return True

UnitaryPolynomial

Bases: RealRootedPolynomial

Represent a polynomial intended to have roots on the unit circle. Construction trusts that domain and bypasses real-rootedness checks; no unit-circle certification is performed. Numerical extraction returns complex roots rather than the real-root API's sorted real values.

Source code in finitefree/core.py
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
class UnitaryPolynomial(RealRootedPolynomial):
    r"""
    Represent a polynomial intended to have roots on the unit circle.
    Construction trusts that domain and bypasses real-rootedness checks; no
    unit-circle certification is performed. Numerical extraction returns complex
    roots rather than the real-root API's sorted real values.
    """

    def __init__(
        self,
        coeffs: Union[Sequence[Any], NDArray[Any]],
    ) -> None:
        # Bypasses real-rootedness verification
        super().__init__(coeffs, assume_real_rooted=True)

    def verify_real_rootedness(self) -> bool:
        return False

    def evaluate_roots_float64(
        self, parallel: bool = False, gpu: bool = False
    ) -> NDArray[Any]:
        r"""
        Return read-only complex128 roots ordered by angle in [-pi, pi].
        Coefficients are numerically evaluated using SymPy and passed to a companion
        eigensolver. gpu=True attempts CuPy and falls back to NumPy on failure.
        parallel is retained in the signature but does not change this implementation.
        No Arb working-precision path, modulus certification or final-finiteness check
        is provided here. Results retain complex128 conditioning and range limits.
        """
        if self._roots_cached is not None:
            return self._roots_cached

        float_coeffs = []
        for c in self.coeffs:
            # Safely evaluate transcendental SymPy terms to complex float
            float_coeffs.append(complex(sp.N(c)))

        d = len(float_coeffs) - 1
        if d <= 0:
            raw_roots = np.array([], dtype=complex)
        elif gpu:
            try:
                import cupy as cp

                a = np.array(float_coeffs, dtype=complex)
                companion = cp.zeros((d, d), dtype=complex)
                if d > 1:
                    companion[1:, :-1] = cp.eye(d - 1)
                companion[0, :] = -cp.array(a[1:] / a[0])
                raw_roots = cp.asnumpy(cp.linalg.eigvals(companion))
            except Exception:
                raw_roots = np.roots(float_coeffs)
        else:
            raw_roots = np.roots(float_coeffs)

        # Sort roots by their argument (angle) in [-pi, pi]
        angles = np.angle(raw_roots)
        sorted_idx = np.argsort(angles)
        res = raw_roots[sorted_idx]
        res.setflags(write=False)
        self._roots_cached = res
        return res
Methods:
evaluate_roots_float64(parallel=False, gpu=False)

Return read-only complex128 roots ordered by angle in [-pi, pi]. Coefficients are numerically evaluated using SymPy and passed to a companion eigensolver. gpu=True attempts CuPy and falls back to NumPy on failure. parallel is retained in the signature but does not change this implementation. No Arb working-precision path, modulus certification or final-finiteness check is provided here. Results retain complex128 conditioning and range limits.

Source code in finitefree/core.py
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
def evaluate_roots_float64(
    self, parallel: bool = False, gpu: bool = False
) -> NDArray[Any]:
    r"""
    Return read-only complex128 roots ordered by angle in [-pi, pi].
    Coefficients are numerically evaluated using SymPy and passed to a companion
    eigensolver. gpu=True attempts CuPy and falls back to NumPy on failure.
    parallel is retained in the signature but does not change this implementation.
    No Arb working-precision path, modulus certification or final-finiteness check
    is provided here. Results retain complex128 conditioning and range limits.
    """
    if self._roots_cached is not None:
        return self._roots_cached

    float_coeffs = []
    for c in self.coeffs:
        # Safely evaluate transcendental SymPy terms to complex float
        float_coeffs.append(complex(sp.N(c)))

    d = len(float_coeffs) - 1
    if d <= 0:
        raw_roots = np.array([], dtype=complex)
    elif gpu:
        try:
            import cupy as cp

            a = np.array(float_coeffs, dtype=complex)
            companion = cp.zeros((d, d), dtype=complex)
            if d > 1:
                companion[1:, :-1] = cp.eye(d - 1)
            companion[0, :] = -cp.array(a[1:] / a[0])
            raw_roots = cp.asnumpy(cp.linalg.eigvals(companion))
        except Exception:
            raw_roots = np.roots(float_coeffs)
    else:
        raw_roots = np.roots(float_coeffs)

    # Sort roots by their argument (angle) in [-pi, pi]
    angles = np.angle(raw_roots)
    sorted_idx = np.argsort(angles)
    res = raw_roots[sorted_idx]
    res.setflags(write=False)
    self._roots_cached = res
    return res

Functions:

Convolutions

finitefree.convolutions

Classes

Functions:

asymmetric_additive(p, q, d, weights=None)

Compute the factorial-weighted asymmetric additive convolution exactly. Ambient d must cover both input degrees. A scaled FLINT polynomial Cauchy product constructs the monic output; bit cost depends on coefficient sizes. When weights is supplied, weights[0] and weights[1] dilate the respective input roots before convolution; they are not rank or dimension parameters. The real-root preservation theorem uses non-negative-root inputs; this formal coefficient implementation does not enforce that theorem's domain eagerly.

Source code in finitefree/convolutions.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def asymmetric_additive(
    p: RealRootedPolynomial,
    q: RealRootedPolynomial,
    d: int,
    weights: Optional[Sequence[Any]] = None,
) -> RealRootedPolynomial:
    r"""
    Compute the factorial-weighted asymmetric additive convolution exactly.
    Ambient d must cover both input degrees. A scaled FLINT polynomial Cauchy
    product constructs the monic output; bit cost depends on coefficient sizes.
    When weights is supplied, weights[0] and weights[1] dilate the respective
    input roots before convolution; they are not rank or dimension parameters.
    The real-root preservation theorem uses non-negative-root inputs; this formal
    coefficient implementation does not enforce that theorem's domain eagerly.
    """
    if p.degree > d or q.degree > d:
        raise ValueError("Polynomial degrees cannot exceed dimension d.")

    if weights is not None:
        p = p.dilation(weights[0])
        q = q.dilation(weights[1])

    import flint

    e_p = p._normalized_coeffs_flint(d)
    e_q = q._normalized_coeffs_flint(d)

    # Compute W[i] = (d - i)! / i! sequentially in O(d)
    W: list[Any] = [None] * (d + 1)
    W[0] = flint.fmpq(math.factorial(d))
    for i in range(1, d + 1):
        W[i] = W[i - 1] / (i * (d - i + 1))

    A_coeffs = []
    B_coeffs = []
    for i in range(d + 1):
        A_coeffs.append(e_p[i] * W[i])
        B_coeffs.append(e_q[i] * W[i])

    # Cauchy product (polynomial multiplication) of scaled sequences in O(d log d)
    A_poly = flint.fmpq_poly(A_coeffs)
    B_poly = flint.fmpq_poly(B_coeffs)
    C_poly = A_poly * B_poly

    e_res = []
    W_d = W[d]
    for k in range(d + 1):
        val_res = C_poly[k] * (W_d / W[k])
        e_res.append(val_res)

    return RealRootedPolynomial.from_normalized_coeffs(e_res)

multiplicative(p, q, d)

Compute the monic finite multiplicative coefficient convolution. Ambient d must cover both degrees; normalized sequences are zero padded and \(e_k(r)=e_k(p)e_k(q)\). Rational arithmetic is exact and scale-invariant. Real-root preservation requires real-rooted inputs with at least one having non-negative roots. The coefficient API does not enforce that domain and its formal result is validated lazily.

Source code in finitefree/convolutions.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def multiplicative(
    p: RealRootedPolynomial, q: RealRootedPolynomial, d: int
) -> RealRootedPolynomial:
    r"""
    Compute the monic finite multiplicative coefficient convolution.
    Ambient d must cover both degrees; normalized sequences are zero padded and
    $e_k(r)=e_k(p)e_k(q)$. Rational arithmetic is exact and scale-invariant.
    Real-root preservation requires real-rooted inputs with at least one having
    non-negative roots. The coefficient API does not enforce that domain and its
    formal result is validated lazily.
    """
    if p.degree > d or q.degree > d:
        raise ValueError("Polynomial degrees cannot exceed dimension d.")

    e_p = p._normalized_coeffs_flint(d)
    e_q = q._normalized_coeffs_flint(d)

    e_res = []
    for k in range(d + 1):
        e_res.append(e_p[k] * e_q[k])

    return RealRootedPolynomial.from_normalized_coeffs(e_res)

symmetric_additive(p, q, d)

Compute the symmetric finite additive coefficient convolution. For ambient d>=max(p.degree,q.degree), use monic normalized coefficients with zero padding: \(e_k(r)=\sum_{i=0}^k\binom{k}{i}e_i(p)e_{k-i}(q)\). An EGF polynomial product and O(d) scaling updates use exact FLINT rationals. Arithmetic/bit cost depends on polynomial multiplication and coefficient sizes. Real-rooted inputs give a real-rooted result, but formal construction does not eagerly verify input/output geometry.

Source code in finitefree/convolutions.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def symmetric_additive(
    p: RealRootedPolynomial, q: RealRootedPolynomial, d: int
) -> RealRootedPolynomial:
    r"""
    Compute the symmetric finite additive coefficient convolution.
    For ambient d>=max(p.degree,q.degree), use monic normalized coefficients
    with zero padding: $e_k(r)=\sum_{i=0}^k\binom{k}{i}e_i(p)e_{k-i}(q)$.
    An EGF polynomial product and O(d) scaling updates use exact FLINT rationals.
    Arithmetic/bit cost depends on polynomial multiplication and coefficient sizes.
    Real-rooted inputs give a real-rooted result, but formal construction does not
    eagerly verify input/output geometry.
    """
    if p.degree > d or q.degree > d:
        raise ValueError("Polynomial degrees cannot exceed dimension d.")

    import flint

    e_p = p._normalized_coeffs_flint(d)
    e_q = q._normalized_coeffs_flint(d)

    # Compute U[k] = 1 / k! sequentially in O(d)
    U: list[Any] = [None] * (d + 1)
    U[0] = flint.fmpq(1)
    for k in range(1, d + 1):
        U[k] = U[k - 1] / k

    A_coeffs = []
    B_coeffs = []
    for k in range(d + 1):
        A_coeffs.append(e_p[k] * U[k])
        B_coeffs.append(e_q[k] * U[k])

    A_poly = flint.fmpq_poly(A_coeffs)
    B_poly = flint.fmpq_poly(B_coeffs)
    C_poly = A_poly * B_poly

    e_res = []
    curr_fact = flint.fmpz(1)
    for k in range(d + 1):
        if k > 0:
            curr_fact *= k
        val_res = C_poly[k] * curr_fact
        e_res.append(val_res)

    result = RealRootedPolynomial.from_normalized_coeffs(e_res)
    if (
        p.degree == q.degree == d
        and p._hermite_variance is not None
        and q._hermite_variance is not None
    ):
        # Shifted/dilated Hermite polynomials are closed under boxplus_d.
        # Keep exact provenance rather than inferring a family from rounded coefficients.
        from .utils.roots import _gaussian_roots

        result = _gaussian_roots(
            result,
            p._hermite_variance + q._hermite_variance,
            p._hermite_center + q._hermite_center,
        )
        result._is_verified = True
    return result

Transforms

finitefree.transforms

Classes

FiniteTTransform

Definition 6.3 (Finite T-transform).

Given a polynomial \(p \in P_d(\mathbb{R}_{\ge 0})\), the finite T-transform \(T_d(p)(t)\) is the right-continuous step function on \((0, 1)\) defined in terms of the coefficients of \(p\).

Source code in finitefree/transforms.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
class FiniteTTransform:
    r"""
    Definition 6.3 (Finite T-transform).

    Given a polynomial $p \in P_d(\mathbb{R}_{\ge 0})$, the finite T-transform $T_d(p)(t)$
    is the right-continuous step function on $(0, 1)$ defined in terms of
    the coefficients of $p$.
    """

    def __init__(self, p: RealRootedPolynomial) -> None:
        if p.degree == 0:
            raise ValueError("Finite T-transform requires a positive degree.")
        if not p.has_non_negative_roots:
            raise ValueError(
                "Finite T-transform is only defined for polynomials with "
                "non-negative roots."
            )

        self.p = p
        self.d = p.degree
        self.e_k = list(p._normalized_coeffs_flint())

        # Multiplicity r of the root 0 of p is trailing zeros in coeffs
        self.r = 0
        if p._is_flint:
            while self.r < self.d and p._fmpq_poly[self.r] == 0:
                self.r += 1
        else:
            while self.r < self.d and p.coeffs[self.d - self.r] == 0:
                self.r += 1

    def __call__(self, t: Any) -> Any:
        """
        Evaluate the right-continuous step function at a finite real t in (0, 1).
        Rational values and stored binary float values select intervals exactly;
        a float near a rational boundary may lie on either side of that boundary.
        """
        message = "t must be in the open interval (0, 1) and be a finite real value."
        try:
            if isinstance(t, (float, np.floating)):
                # Retain NumPy extended precision instead of narrowing to float64.
                t_rat = sp.Rational(*t.as_integer_ratio())
            else:
                t_rat = sympy_to_fmpq(t)
        except (TypeError, ValueError, OverflowError):
            # Preserve evaluation at real symbolic constants such as sqrt(2)/2.
            try:
                t_sym = sp.sympify(t)
                if not isinstance(t_sym, sp.Expr) or t_sym.is_real is not True:
                    raise ValueError(message)
                if not (0 < t_sym < 1):
                    raise ValueError(message)
                k = int(sp.floor(t_sym * self.d)) + 1
            except (TypeError, ValueError, sp.SympifyError) as error:
                raise ValueError(message) from error
        else:
            if not (0 < t_rat < 1):
                raise ValueError(message)
            k = (int(t_rat.p) * self.d) // int(t_rat.q) + 1

        if k <= self.r:
            return 0

        val_num = self.e_k[self.d - k + 1]
        val_den = self.e_k[self.d - k]

        if val_den == 0:
            raise ValueError(
                f"Zero division encountered: e_tilde_{self.d - k} is zero."
            )

        res = val_num / val_den
        return sp.Rational(int(res.p), int(res.q))
Methods:
__call__(t)

Evaluate the right-continuous step function at a finite real t in (0, 1). Rational values and stored binary float values select intervals exactly; a float near a rational boundary may lie on either side of that boundary.

Source code in finitefree/transforms.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def __call__(self, t: Any) -> Any:
    """
    Evaluate the right-continuous step function at a finite real t in (0, 1).
    Rational values and stored binary float values select intervals exactly;
    a float near a rational boundary may lie on either side of that boundary.
    """
    message = "t must be in the open interval (0, 1) and be a finite real value."
    try:
        if isinstance(t, (float, np.floating)):
            # Retain NumPy extended precision instead of narrowing to float64.
            t_rat = sp.Rational(*t.as_integer_ratio())
        else:
            t_rat = sympy_to_fmpq(t)
    except (TypeError, ValueError, OverflowError):
        # Preserve evaluation at real symbolic constants such as sqrt(2)/2.
        try:
            t_sym = sp.sympify(t)
            if not isinstance(t_sym, sp.Expr) or t_sym.is_real is not True:
                raise ValueError(message)
            if not (0 < t_sym < 1):
                raise ValueError(message)
            k = int(sp.floor(t_sym * self.d)) + 1
        except (TypeError, ValueError, sp.SympifyError) as error:
            raise ValueError(message) from error
    else:
        if not (0 < t_rat < 1):
            raise ValueError(message)
        k = (int(t_rat.p) * self.d) // int(t_rat.q) + 1

    if k <= self.r:
        return 0

    val_num = self.e_k[self.d - k + 1]
    val_den = self.e_k[self.d - k]

    if val_den == 0:
        raise ValueError(
            f"Zero division encountered: e_tilde_{self.d - k} is zero."
        )

    res = val_num / val_den
    return sp.Rational(int(res.p), int(res.q))

Functions:

FiniteCauchyTransform(p)

Computes the Finite Cauchy Transform \(G_p^{(d)}(z) = \frac{1}{d} \frac{p'(z)}{p(z)}\) Returns a SymPy expression.

Source code in finitefree/transforms.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def FiniteCauchyTransform(p: RealRootedPolynomial) -> sp.Expr:
    r"""
    Computes the Finite Cauchy Transform $G_p^{(d)}(z) = \frac{1}{d} \frac{p'(z)}{p(z)}$
    Returns a SymPy expression.
    """
    z = sp.Symbol("z")
    d = p.degree
    if d == 0:
        raise ValueError("Finite Cauchy transform requires a positive degree.")

    poly = sp.Poly(list(p.coeffs), z)
    expr = poly.as_expr()

    dp_coeffs = [p.coeffs[i] * (d - i) for i in range(d)]
    dp_poly = sp.Poly(dp_coeffs, z)
    p_prime = dp_poly.as_expr()

    return sp.Rational(1, d) * (p_prime / expr)

FiniteRTransform(p, order=5, d=None, numerical=False, prec=256)

Extracts finite free cumulants \(\kappa_n^{(d)}(p)\) exactly using the classical cumulant-moment recurrence (\(O(n^2)\)), which is equivalent to Möbius inversion over the partition lattice but avoids exponential partition enumeration. Returns the first order finite free cumulants (which strictly linearize \(\boxplus_d\)). Uses Definition 2.14 of Arizmendi et al., arXiv:2408.09337: the classical cumulant of the normalized coefficients is scaled by \((-d)^{n-1}/(n-1)!\). Orders above d are returned as zero. The numerical path centers the requested coefficient prefix exactly before using Arb at prec bits, then restores the first cumulant (the mean). Higher cumulants are invariant under translation. Numerical results remain precision-dependent approximations, particularly at high orders. Only the requested normalized-coefficient prefix is extracted when possible. A bounded per-polynomial prefix cache is separate from complete coefficients; ambient dimensions retain their own exact binomial normalization.

Source code in finitefree/transforms.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def FiniteRTransform(
    p: RealRootedPolynomial,
    order: int = 5,
    d: Optional[int] = None,
    numerical: bool = False,
    prec: int = 256,
) -> List[Any]:
    r"""
    Extracts finite free cumulants $\kappa_n^{(d)}(p)$ exactly using the classical
    cumulant-moment recurrence ($O(n^2)$), which is equivalent to Möbius
    inversion over the partition lattice but avoids exponential partition
    enumeration.
    Returns the first `order` finite free cumulants (which strictly
    linearize $\boxplus_d$).
    Uses Definition 2.14 of Arizmendi et al., arXiv:2408.09337:
    the classical cumulant of the normalized coefficients is scaled by
    $(-d)^{n-1}/(n-1)!$. Orders above d are returned as zero.
    The numerical path centers the requested coefficient prefix exactly before
    using Arb at `prec` bits, then restores the first cumulant (the mean).
    Higher cumulants are invariant under translation. Numerical results remain
    precision-dependent approximations, particularly at high orders.
    Only the requested normalized-coefficient prefix is extracted when possible.
    A bounded per-polynomial prefix cache is separate from complete coefficients;
    ambient dimensions retain their own exact binomial normalization.
    """
    import math

    import flint

    if d is None:
        d = p.degree
    count = max(0, min(order, d))
    e_k = p._normalized_coeffs_flint_prefix(count, d)

    if numerical:
        mean = e_k[1] if count else flint.fmpq(0)
        if mean != 0:
            # For a shift by -mean, normalized coefficients obey a binomial
            # transform. Only the prefix used by the recurrence is needed;
            # shifting the entire polynomial would do unnecessary exact work.
            powers = [(-mean) ** n for n in range(count + 1)]
            e_k = [
                sum(
                    (math.comb(n, k) * e_k[k] * powers[n - k] for k in range(n + 1)),
                    flint.fmpq(0),
                )
                for n in range(count + 1)
            ]
        else:
            e_k = e_k[: count + 1]
        # Use controlled high-precision Arb floats to avoid rational arithmetic blowup
        old_prec = flint.ctx.prec
        flint.ctx.prec = prec
        try:
            arb_e = []
            for v in e_k:
                val = flint.arb(v)
                arb_e.append(val)

            c_arb = []
            cumulants = []
            for n in range(1, order + 1):
                if n > d:
                    cumulants.append(0.0)
                    c_arb.append(flint.arb(0))
                    continue

                cn = arb_e[n]
                for k in range(1, n):
                    cn -= math.comb(n - 1, k - 1) * c_arb[k - 1] * arb_e[n - k]
                c_arb.append(cn)

                # Use exact operations on the arb/fmpz before floating to avoid loss of precision
                kappa_n = cn * ((-d) ** (n - 1)) / math.factorial(n - 1)
                cumulants.append(float(kappa_n))

            if count:
                cumulants[0] = float(flint.arb(mean))
            return cumulants
        finally:
            flint.ctx.prec = old_prec

    # c_n = classical cumulant of the sequence (e_1, e_2, ..., e_n)
    # Using the recurrence: c_n = e_n - sum_{k=1}^{n-1} C(n-1, k-1) * c_k * e_{n-k}
    # Then: kappa_n^{(d)} = c_n * (-d)^{n-1} / (n-1)!
    c = []  # c[0] = c_1, c[1] = c_2, etc.
    cumulants = []
    for n in range(1, order + 1):
        if n > d:
            cumulants.append(0)
            c.append(flint.fmpq(0))
            continue

        cn = e_k[n]
        for k in range(1, n):
            cn -= math.comb(n - 1, k - 1) * c[k - 1] * e_k[n - k]
        c.append(cn)

        # Maintain exact representation
        kappa_n = cn * ((-d) ** (n - 1)) / math.factorial(n - 1)
        cumulants.append(sp.Rational(int(kappa_n.p), int(kappa_n.q)))

    return cumulants

FiniteSTransform(p, exact=True)

Return coefficient-ratio S values at the nodes -k/d, k=1,...,d. Index k-1 contains e_{k-1}/e_k. The ordinary real-root API requires strictly positive roots; zero roots raise ValueError. Unit-circle objects bypass that positivity gate, but this coefficient path still requires rational inputs and nonzero neighboring coefficients. exact=True returns SymPy rationals; exact=False returns float64 values without a final-finiteness guarantee.

Source code in finitefree/transforms.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def FiniteSTransform(p: RealRootedPolynomial, exact: bool = True) -> NDArray[Any]:
    r"""
    Return coefficient-ratio S values at the nodes -k/d, k=1,...,d.
    Index k-1 contains e_{k-1}/e_k. The ordinary real-root API requires strictly
    positive roots; zero roots raise ValueError. Unit-circle objects bypass that
    positivity gate, but this coefficient path still requires rational inputs and
    nonzero neighboring coefficients. exact=True returns SymPy rationals;
    exact=False returns float64 values without a final-finiteness guarantee.
    """
    if not isinstance(p, UnitaryPolynomial) and not p.has_strictly_positive_roots:
        raise ValueError(
            "Strict positivity constraint violated: roots must be strictly positive."
        )

    d = p.degree
    e_k = p._normalized_coeffs_flint()

    s_transform = np.zeros(d, dtype=object if exact else np.float64)

    for k in range(1, d + 1):
        val_num = e_k[k - 1]
        val_den = e_k[k]
        if val_num == 0 or val_den == 0:
            raise ValueError(
                "Strict positivity constraint violated: a zero coefficient "
                f"was encountered at index {k - 1} or {k}."
            )

        res = val_num / val_den
        if exact:
            s_transform[k - 1] = sp.Rational(int(res.p), int(res.q))
        else:
            s_transform[k - 1] = flint_to_float(res)

    return s_transform

SymmetricFiniteSTransform(p, exact=True, *, convention='ratio')

Return the legacy ratio or standard symmetric finite S-transform. For degree 2d and zero multiplicity 2r, the array has length d-r; index k-1 corresponds to -k/d and contains e_{2(k-1)} / e_{2k}. The default convention="ratio" preserves the existing squared-transform output and parity-only validation. convention="standard" implements the positive-imaginary square root in Definition 8.1 of Arizmendi et al., arXiv:2408.09337v2. It requires positive degree and checks real-rootedness using the polynomial's validation contract, including explicit assumptions. Both paths require rational coefficients. exact=True returns SymPy values; exact=False returns float64 ratios or finite nonzero complex128 values.

Source code in finitefree/transforms.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def SymmetricFiniteSTransform(
    p: RealRootedPolynomial,
    exact: bool = True,
    *,
    convention: Literal["ratio", "standard"] = "ratio",
) -> NDArray[Any]:
    r"""
    Return the legacy ratio or standard symmetric finite S-transform.
    For degree 2d and zero multiplicity 2r, the array has length d-r; index k-1
    corresponds to -k/d and contains e_{2(k-1)} / e_{2k}.
    The default convention="ratio" preserves the existing squared-transform
    output and parity-only validation. convention="standard" implements the
    positive-imaginary square root in Definition 8.1 of Arizmendi et al.,
    arXiv:2408.09337v2. It requires positive degree and checks real-rootedness
    using the polynomial's validation contract, including explicit assumptions.
    Both paths require rational coefficients. exact=True returns SymPy values;
    exact=False returns float64 ratios or finite nonzero complex128 values.
    """
    if convention not in ("ratio", "standard"):
        raise ValueError("convention must be 'ratio' or 'standard'")
    if not p._is_flint:
        raise ValueError("Symmetric finite S-transform requires rational coefficients")
    if convention == "standard" and p.degree == 0:
        raise ValueError("Standard symmetric S-transform requires positive degree")
    if p.degree % 2 != 0:
        raise ValueError(
            "Polynomial degree must be even (2d) for symmetric S-transform."
        )
    if not p.is_symmetric():
        raise ValueError("Polynomial must be symmetric.")
    if convention == "standard":
        p.verify_real_rootedness()

    d = p.degree // 2
    e_k = p._normalized_coeffs_flint()

    # Multiplicity 2r of the root 0
    zero_mult = 0
    if p._is_flint:
        while zero_mult < p.degree and p._fmpq_poly[zero_mult] == 0:
            zero_mult += 1
    else:
        while zero_mult < p.degree and p.coeffs[p.degree - zero_mult] == 0:
            zero_mult += 1
    r = zero_mult // 2

    dtype = (
        object if exact else (np.complex128 if convention == "standard" else np.float64)
    )
    s_transform = np.zeros(d - r, dtype=dtype)

    for k in range(1, d - r + 1):
        val_num = e_k[2 * (k - 1)]
        val_den = e_k[2 * k]

        if val_den == 0:
            raise ValueError(f"Zero division encountered: e_tilde_{2 * k} is zero.")

        res = val_num / val_den
        if convention == "standard":
            if res >= 0:
                raise ValueError(
                    "Standard symmetric S-transform requires real symmetric roots"
                )
            magnitude = sp.sqrt(-sp.Rational(int(res.p), int(res.q)))
            if exact:
                s_transform[k - 1] = sp.I * magnitude
            else:
                # Take the square root before narrowing: its square may overflow
                # or underflow float64 while the transform remains representable.
                value = float(magnitude)
                if not np.isfinite(value) or value == 0:
                    raise RuntimeError(
                        "Standard symmetric S-transform exceeds finite complex128 range"
                    )
                s_transform[k - 1] = complex(0, value)
        elif exact:
            s_transform[k - 1] = sp.Rational(int(res.p), int(res.q))
        else:
            s_transform[k - 1] = flint_to_float(res)

    return s_transform

Orthogonal Polynomials

finitefree.orthogonal

Classes

Functions:

chebyshev_t_polynomial(n)

Computes the Chebyshev polynomial of the first kind \(T_n(x)\) of degree \(n\) exactly using the three-term recurrence relation over \(\mathbb{Q}\) with flint.fmpq_poly.

Source code in finitefree/orthogonal.py
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
def chebyshev_t_polynomial(n: int) -> RealRootedPolynomial:
    r"""
    Computes the Chebyshev polynomial of the first kind $T_n(x)$ of degree $n$
    exactly using the three-term recurrence relation over $\mathbb{Q}$ with flint.fmpq_poly.
    """
    if n < 0:
        raise ValueError("n must be non-negative")

    p0 = flint.fmpq_poly([1])
    if n == 0:
        return RealRootedPolynomial([1], assume_real_rooted=True)

    p1 = flint.fmpq_poly([0, 1])
    if n == 1:
        return RealRootedPolynomial(
            fmpq_poly_to_sympy_coeffs(p1), assume_real_rooted=True
        )

    p_prev = p0
    p_curr = p1

    for _ in range(1, n):
        factor = flint.fmpq_poly([0, 2])
        p_next = factor * p_curr - p_prev
        p_prev = p_curr
        p_curr = p_next

    return _chebyshev_roots(RealRootedPolynomial(p_curr, assume_real_rooted=True), True)

chebyshev_u_polynomial(n)

Computes the Chebyshev polynomial of the second kind \(U_n(x)\) of degree \(n\) exactly using the three-term recurrence relation over \(\mathbb{Q}\) with flint.fmpq_poly.

Source code in finitefree/orthogonal.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def chebyshev_u_polynomial(n: int) -> RealRootedPolynomial:
    r"""
    Computes the Chebyshev polynomial of the second kind $U_n(x)$ of degree $n$
    exactly using the three-term recurrence relation over $\mathbb{Q}$ with flint.fmpq_poly.
    """
    if n < 0:
        raise ValueError("n must be non-negative")

    p0 = flint.fmpq_poly([1])
    if n == 0:
        return RealRootedPolynomial([1], assume_real_rooted=True)

    p1 = flint.fmpq_poly([0, 2])
    if n == 1:
        return RealRootedPolynomial(
            fmpq_poly_to_sympy_coeffs(p1), assume_real_rooted=True
        )

    p_prev = p0
    p_curr = p1

    for _ in range(1, n):
        factor = flint.fmpq_poly([0, 2])
        p_next = factor * p_curr - p_prev
        p_prev = p_curr
        p_curr = p_next

    return _chebyshev_roots(
        RealRootedPolynomial(p_curr, assume_real_rooted=True), False
    )

compute_beta(kappa, mu, alpha)

Computes the beta_kappa_mu coefficient for the Jack recurrence with caching.

Source code in finitefree/orthogonal.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def compute_beta(kappa: tuple[int, ...], mu: tuple[int, ...], alpha: Any) -> Any:
    """Computes the beta_kappa_mu coefficient for the Jack recurrence with caching."""
    if isinstance(alpha, sp.Rational):
        alpha_sym = alpha
    else:
        alpha_sym = sp.Rational(sp.sympify(alpha))
    cache_key = (kappa, mu, alpha_sym)
    if cache_key in _BETA_CACHE:
        return _BETA_CACHE[cache_key]

    kappa_conj = get_conjugate(kappa)
    mu_conj = get_conjugate(mu)

    def get_product(nu: tuple[int, ...], nu_conj: tuple[int, ...]) -> Any:
        prod = sp.Integer(1)
        for r_idx, row_len in enumerate(nu):
            r = r_idx + 1
            for c in range(1, row_len + 1):
                k_conj_c = kappa_conj[c - 1] if c - 1 < len(kappa_conj) else 0
                m_conj_c = mu_conj[c - 1] if c - 1 < len(mu_conj) else 0

                nu_conj_c = nu_conj[c - 1] if c - 1 < len(nu_conj) else 0
                nu_r = nu[r - 1] if r - 1 < len(nu) else 0

                if k_conj_c == m_conj_c:
                    val = nu_conj_c - r + alpha_sym * (nu_r - c + 1)
                else:
                    val = nu_conj_c - r + 1 + alpha_sym * (nu_r - c)
                prod *= val
        return prod

    num = get_product(kappa, kappa_conj)
    den = get_product(mu, mu_conj)
    res = num / den
    _BETA_CACHE[cache_key] = res
    return res

get_conjugate(part)

Computes the conjugate of a partition shape with caching.

Source code in finitefree/orthogonal.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def get_conjugate(part: tuple[int, ...]) -> tuple[int, ...]:
    """Computes the conjugate of a partition shape with caching."""
    if not part:
        return ()
    if part in _CONJUGATE_CACHE:
        return _CONJUGATE_CACHE[part]
    max_val = part[0]
    conj = [0] * max_val
    for val in part:
        for idx in range(val):
            conj[idx] += 1
    res = tuple(conj)
    _CONJUGATE_CACHE[part] = res
    return res

get_horizontal_strips(kappa)

Generates all partitions mu such that kappa/mu is a horizontal strip.

Source code in finitefree/orthogonal.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def get_horizontal_strips(kappa: tuple[int, ...]) -> list[tuple[int, ...]]:
    """Generates all partitions mu such that kappa/mu is a horizontal strip."""
    m = len(kappa)
    mu_choices = []

    def backtrack(idx: int, current_mu: list[int]) -> None:
        if idx == m:
            normalized_mu = list(current_mu)
            while normalized_mu and normalized_mu[-1] == 0:
                normalized_mu.pop()
            mu_choices.append(tuple(normalized_mu))
            return

        lower_bound = kappa[idx + 1] if idx + 1 < m else 0
        upper_bound = kappa[idx]
        for val in range(lower_bound, upper_bound + 1):
            current_mu.append(val)
            backtrack(idx + 1, current_mu)
            current_mu.pop()

    backtrack(0, [])
    return mu_choices

hahn_polynomial(n, alpha, beta, N)

Computes the Hahn polynomial \(Q_n(x; \alpha, \beta, N)\) of degree \(n\) exactly using sequential \(O(n)\) running products for coefficients.

Source code in finitefree/orthogonal.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def hahn_polynomial(n: int, alpha: Any, beta: Any, N: int) -> RealRootedPolynomial:
    r"""
    Computes the Hahn polynomial $Q_n(x; \alpha, \beta, N)$ of degree $n$
    exactly using sequential $O(n)$ running products for coefficients.
    """
    if n < 0 or n > N:
        raise ValueError("n must satisfy 0 <= n <= N")

    alpha_sym = sp.Rational(sp.sympify(alpha))
    beta_sym = sp.Rational(sp.sympify(beta))
    N_sym = sp.Integer(N)

    # Initial term for k = 0
    coeff = sp.Integer(1)
    total_poly = flint.fmpq_poly([sympy_to_fmpq(coeff)])
    neg_x_k = flint.fmpq_poly([1])

    for k in range(1, n + 1):
        neg_x_k *= flint.fmpq_poly([k - 1, -1])

        # Recurrence relation for Pochhammer coefficients:
        # coeff_k = coeff_{k-1} * (-n + k - 1) * (n + alpha + beta + k) /
        #                         ((alpha + k) * (-N + k - 1) * k)
        num = (-n + k - 1) * (n + alpha_sym + beta_sym + k)
        den = (alpha_sym + k) * (-N_sym + k - 1) * k

        coeff *= sp.Rational(num, den)
        coeff_fmpq = sympy_to_fmpq(coeff)

        total_poly += coeff_fmpq * neg_x_k

    return RealRootedPolynomial(total_poly, assume_real_rooted=True)

hermite_polynomial(n, physicist=True)

Computes the Hermite polynomial (Physicist's \(H_n(x)\) or Probabilist's \(He_n(x)\)) exactly using the three-term recurrence relation over \(\mathbb{Q}\) with flint.fmpq_poly.

Source code in finitefree/orthogonal.py
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def hermite_polynomial(n: int, physicist: bool = True) -> RealRootedPolynomial:
    r"""
    Computes the Hermite polynomial (Physicist's $H_n(x)$ or Probabilist's $He_n(x)$)
    exactly using the three-term recurrence relation over $\mathbb{Q}$ with flint.fmpq_poly.
    """
    if n < 0:
        raise ValueError("n must be non-negative")

    p0 = flint.fmpq_poly([1])
    if n == 0:
        return RealRootedPolynomial([1], assume_real_rooted=True)

    if physicist:
        p1 = flint.fmpq_poly([0, 2])
    else:
        p1 = flint.fmpq_poly([0, 1])

    if n == 1:
        return RealRootedPolynomial(
            fmpq_poly_to_sympy_coeffs(p1), assume_real_rooted=True
        )

    p_prev = p0
    p_curr = p1

    for k in range(1, n):
        if physicist:
            factor = flint.fmpq_poly([0, 2])
            term_prev = sympy_to_fmpq(2 * k) * p_prev
            p_next = factor * p_curr - term_prev
        else:
            factor = flint.fmpq_poly([0, 1])
            term_prev = sympy_to_fmpq(k) * p_prev
            p_next = factor * p_curr - term_prev

        p_prev = p_curr
        p_curr = p_next

    return _hermite_roots(
        RealRootedPolynomial(p_curr, assume_real_rooted=True), physicist
    )

jack_polynomial(m, partition, alpha)

Computes the symmetric Jack polynomial \(J_\lambda^{(\alpha)}(x_1, \dots, x_m)\) recursively in \(m\) variables with parameter \(\alpha\) using transition coefficients.

Source code in finitefree/orthogonal.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def jack_polynomial(
    m: int, partition: Sequence[int], alpha: Any
) -> MultivariatePolynomial:
    r"""
    Computes the symmetric Jack polynomial $J_\lambda^{(\alpha)}(x_1, \dots, x_m)$
    recursively in $m$ variables with parameter $\alpha$ using transition coefficients.
    """
    kappa = list(partition)
    while kappa and kappa[-1] == 0:
        kappa.pop()
    kappa_tup = tuple(kappa)

    alpha_sym = sp.Rational(sp.sympify(alpha))
    poly_dict = _jack_recursive(m, kappa_tup, alpha_sym)
    variables = [sp.Symbol(f"x{i}") for i in range(1, m + 1)]

    names = tuple(x.name for x in variables)
    ctx = flint.fmpq_mpoly_ctx.get(names=names)
    flint_dict = {exp: sympy_to_fmpq(coeff) for exp, coeff in poly_dict.items()}
    mpoly = ctx.from_dict(flint_dict)

    return MultivariatePolynomial(mpoly, variables)

jacobi_polynomial(n, alpha, beta)

Computes the Jacobi polynomial \(P_n^{(\alpha, \beta)}(x)\) of degree \(n\) exactly using the three-term recurrence relation over \(\mathbb{Q}\) with flint.fmpq_poly. For alpha, beta > -1, real-rootedness is known and exact=False root evaluation uses a symmetric tridiagonal matrix. Other parameter values produce formal polynomials with lazy real-rootedness validation.

Source code in finitefree/orthogonal.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def jacobi_polynomial(n: int, alpha: Any, beta: Any) -> RealRootedPolynomial:
    r"""
    Computes the Jacobi polynomial $P_n^{(\alpha, \beta)}(x)$ of degree $n$
    exactly using the three-term recurrence relation over $\mathbb{Q}$ with flint.fmpq_poly.
    For alpha, beta > -1, real-rootedness is known and exact=False root
    evaluation uses a symmetric tridiagonal matrix. Other parameter values
    produce formal polynomials with lazy real-rootedness validation.
    """
    if n < 0:
        raise ValueError("n must be non-negative")

    alpha_sym = sp.Rational(sp.sympify(alpha))
    beta_sym = sp.Rational(sp.sympify(beta))

    p0 = flint.fmpq_poly([1])
    if n == 0:
        return RealRootedPolynomial([1], assume_real_rooted=True)

    c0 = sympy_to_fmpq((alpha_sym - beta_sym) / 2)
    c1 = sympy_to_fmpq((alpha_sym + beta_sym + 2) / 2)
    p1 = flint.fmpq_poly([c0, c1])
    if n == 1:
        return RealRootedPolynomial(
            fmpq_poly_to_sympy_coeffs(p1), assume_real_rooted=True
        )

    alpha_fmpq = sympy_to_fmpq(alpha_sym)
    beta_fmpq = sympy_to_fmpq(beta_sym)

    # 3-term recurrence: A_k P_{k+1} = (B_k x + C_k) P_k - D_k P_{k-1}
    p_prev = p0
    p_curr = p1
    for k in range(1, n):
        ak = (
            2
            * (k + 1)
            * (k + 1 + alpha_fmpq + beta_fmpq)
            * (2 * k + alpha_fmpq + beta_fmpq)
        )
        bk = (
            (2 * k + alpha_fmpq + beta_fmpq + 1)
            * (2 * k + alpha_fmpq + beta_fmpq + 2)
            * (2 * k + alpha_fmpq + beta_fmpq)
        )
        ck = (2 * k + alpha_fmpq + beta_fmpq + 1) * (alpha_fmpq**2 - beta_fmpq**2)
        dk = (
            2
            * (k + alpha_fmpq)
            * (k + beta_fmpq)
            * (2 * k + alpha_fmpq + beta_fmpq + 2)
        )

        factor = flint.fmpq_poly([ck, bk])
        p_next = (factor * p_curr - dk * p_prev) * (1 / ak)
        p_prev = p_curr
        p_curr = p_next

    return _jacobi_roots(
        RealRootedPolynomial(
            p_curr, assume_real_rooted=bool(alpha_sym > -1 and beta_sym > -1)
        ),
        alpha_sym,
        beta_sym,
    )

krawtchouk_polynomial(n, p, N)

Computes the Krawtchouk polynomial \(K_n(x; p, N)\) exactly using the three-term recurrence relation over \(\mathbb{Q}\) with flint.fmpq_poly.

Source code in finitefree/orthogonal.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
def krawtchouk_polynomial(n: int, p: Any, N: int) -> RealRootedPolynomial:
    r"""
    Computes the Krawtchouk polynomial $K_n(x; p, N)$
    exactly using the three-term recurrence relation over $\mathbb{Q}$ with flint.fmpq_poly.
    """
    if n < 0 or n > N:
        raise ValueError("n must satisfy 0 <= n <= N")
    if N <= 0:
        raise ValueError("N must be a positive integer")

    p_sym = sp.Rational(sp.sympify(p))
    if p_sym <= 0 or p_sym >= 1:
        raise ValueError("p must be in the open interval (0, 1)")

    p0 = flint.fmpq_poly([1])
    if n == 0:
        return RealRootedPolynomial([1], assume_real_rooted=True)

    p1 = flint.fmpq_poly([sympy_to_fmpq(1), sympy_to_fmpq(sp.Rational(-1, N * p_sym))])
    if n == 1:
        return RealRootedPolynomial(
            fmpq_poly_to_sympy_coeffs(p1), assume_real_rooted=True
        )

    p_fmpq = sympy_to_fmpq(p_sym)

    p_prev = p0
    p_curr = p1

    for k in range(1, n):
        scale = 1 / ((N - k) * p_fmpq)
        factor = flint.fmpq_poly([p_fmpq * (N - 2 * k) + k, -1])
        term_prev = (k * (1 - p_fmpq)) * p_prev

        p_next = (factor * p_curr - term_prev) * scale
        p_prev = p_curr
        p_curr = p_next

    return RealRootedPolynomial(
        fmpq_poly_to_sympy_coeffs(p_curr), assume_real_rooted=True
    )

laguerre_polynomial(n, alpha)

Computes the generalized Laguerre polynomial \(L_n^{(\alpha)}(x)\) exactly using the three-term recurrence relation over \(\mathbb{Q}\) with flint.fmpq_poly. Real-rootedness is known for alpha >= -1. The exact=False tridiagonal root path requires alpha > -1; other parameters use the general path and values below -1 require lazy real-rootedness validation.

Source code in finitefree/orthogonal.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
def laguerre_polynomial(n: int, alpha: Any) -> RealRootedPolynomial:
    r"""
    Computes the generalized Laguerre polynomial $L_n^{(\alpha)}(x)$
    exactly using the three-term recurrence relation over $\mathbb{Q}$ with flint.fmpq_poly.
    Real-rootedness is known for alpha >= -1. The exact=False tridiagonal
    root path requires alpha > -1; other parameters use the general path
    and values below -1 require lazy real-rootedness validation.
    """
    if n < 0:
        raise ValueError("n must be non-negative")

    alpha_sym = sp.Rational(sp.sympify(alpha))

    p0 = flint.fmpq_poly([1])
    if n == 0:
        return RealRootedPolynomial([1], assume_real_rooted=True)

    p1 = flint.fmpq_poly([sympy_to_fmpq(1 + alpha_sym), sympy_to_fmpq(-1)])
    if n == 1:
        return RealRootedPolynomial(
            fmpq_poly_to_sympy_coeffs(p1), assume_real_rooted=True
        )

    alpha_fmpq = sympy_to_fmpq(alpha_sym)

    p_prev = p0
    p_curr = p1

    for k in range(1, n):
        ak = flint.fmpq(1, k + 1)
        factor = flint.fmpq_poly([2 * k + 1 + alpha_fmpq, -1])
        term_prev = (k + alpha_fmpq) * p_prev

        p_next = (factor * p_curr - term_prev) * ak
        p_prev = p_curr
        p_curr = p_next

    return _laguerre_roots(
        RealRootedPolynomial(p_curr, assume_real_rooted=bool(alpha_sym >= -1)),
        alpha_sym,
    )

legendre_polynomial(n)

Computes the Legendre polynomial \(P_n(x)\) of degree \(n\) exactly using the three-term recurrence relation over \(\mathbb{Q}\) with flint.fmpq_poly.

Source code in finitefree/orthogonal.py
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def legendre_polynomial(n: int) -> RealRootedPolynomial:
    r"""
    Computes the Legendre polynomial $P_n(x)$ of degree $n$
    exactly using the three-term recurrence relation over $\mathbb{Q}$ with flint.fmpq_poly.
    """
    if n < 0:
        raise ValueError("n must be non-negative")

    p0 = flint.fmpq_poly([1])
    if n == 0:
        return RealRootedPolynomial([1], assume_real_rooted=True)

    p1 = flint.fmpq_poly([0, 1])
    if n == 1:
        return RealRootedPolynomial(
            fmpq_poly_to_sympy_coeffs(p1), assume_real_rooted=True
        )

    p_prev = p0
    p_curr = p1

    for k in range(1, n):
        # (k+1) P_{k+1}(x) = (2k+1) x P_k(x) - k P_{k-1}(x)
        ak = flint.fmpq(1, k + 1)
        factor = flint.fmpq_poly([0, flint.fmpq(2 * k + 1, 1)])
        p_next = (factor * p_curr - flint.fmpq(k, 1) * p_prev) * ak
        p_prev = p_curr
        p_curr = p_next

    return _legendre_roots(RealRootedPolynomial(p_curr, assume_real_rooted=True))

unitary_hermite_polynomial(d, t)

Computes the Unitary Hermite polynomial \(H_d(z; t)\) of degree \(d\) defined on the unit circle. Uses SymPy exp and binomial coefficients exactly.

Source code in finitefree/orthogonal.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
def unitary_hermite_polynomial(d: int, t: Any) -> UnitaryPolynomial:
    r"""
    Computes the Unitary Hermite polynomial $H_d(z; t)$ of degree $d$
    defined on the unit circle. Uses SymPy exp and binomial coefficients exactly.
    """
    if d < 0:
        raise ValueError("d must be non-negative")

    t_sym = sp.sympify(t)

    coeffs = []
    for k in range(d, -1, -1):
        if d > 0:
            num = -t_sym * k * (d - k)
            den = 2 * d
            exp_term = sp.exp(sp.Rational(num, den))
        else:
            exp_term = sp.Integer(1)
        coeff = ((-1) ** k) * sp.binomial(d, k) * exp_term
        coeffs.append(coeff)

    return UnitaryPolynomial(coeffs)

Random Matrix Ensembles

finitefree.ensembles

Classes

EmpiricalComparison

Compare a polynomial with characteristic coefficients from matrix samples. Samples must be finite Hermitian matrices of size d, or size 2d with paired eigenvalues. Coefficient agreement is a statistical diagnostic, not a proof of the generator's distribution.

Source code in finitefree/ensembles.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
class EmpiricalComparison:
    """
    Compare a polynomial with characteristic coefficients from matrix samples.
    Samples must be finite Hermitian matrices of size d, or size 2d with paired
    eigenvalues. Coefficient agreement is a statistical diagnostic, not a proof
    of the generator's distribution.
    """

    def __init__(
        self,
        analytical_poly: RealRootedPolynomial,
        samples: SupportsIndex,
        generator: Callable[[], NDArray[Any]],
    ) -> None:
        try:
            sample_count = operator.index(samples)
        except TypeError as error:
            raise ValueError("samples must be an integer of at least 2.") from error
        if sample_count < 2:
            raise ValueError("samples must be an integer of at least 2.")
        self.analytical_poly = analytical_poly
        self.samples = sample_count
        self.generator = generator
        self.d = analytical_poly.degree

        eigs_list = []
        coeffs_list = []
        for _ in range(sample_count):
            eigs, coefficients = _sample_observations(generator(), self.d)

            eigs_list.append(eigs)
            coeffs_list.append(coefficients)

        self.eigenvalues = np.array(eigs_list)
        self.char_poly_coeffs = np.array(coeffs_list)

    def verify_coefficients(
        self, alpha: float = 0.05, *, rtol: float = 1e-10, atol: float = 0.0
    ) -> bool:
        """
        Check every coefficient against a Bonferroni-adjusted two-sided t band.
        The critical value is t.isf(alpha / (2*(d+1)), samples-1). Zero sample
        variance still requires agreement within atol + rtol*abs(target).
        Per-coefficient scaling avoids overflow/underflow in the sample variance.
        The bands assume independent samples and are exact for normal coefficient
        observations; other distributions require a large-sample approximation.
        """
        from scipy.stats import t

        if not np.isfinite(alpha) or not 0 < alpha < 1:
            raise ValueError("alpha must be finite and in the open interval (0, 1).")
        if not np.isfinite(rtol) or not np.isfinite(atol) or rtol < 0 or atol < 0:
            raise ValueError("rtol and atol must be finite and non-negative.")
        try:
            analytical_coeffs = np.array(self.analytical_poly.coeffs, dtype=float)
        except (TypeError, ValueError, OverflowError) as error:
            raise ValueError(
                "Analytical coefficients must be finite in float64."
            ) from error
        if not np.all(np.isfinite(analytical_coeffs)):
            raise ValueError("Analytical coefficients must be finite in float64.")
        critical = t.isf(alpha / (2 * (self.d + 1)), self.samples - 1)
        if not np.isfinite(critical):
            raise ValueError("alpha is too small for a finite float64 critical value.")

        scale = np.maximum(
            np.max(np.abs(self.char_poly_coeffs), axis=0), np.abs(analytical_coeffs)
        )
        scale = np.where(scale == 0, 1.0, scale)
        observations = self.char_poly_coeffs / scale
        target = analytical_coeffs / scale
        mean_coeffs = np.mean(observations, axis=0)
        std_coeffs = np.std(observations, axis=0, ddof=1)
        sem = std_coeffs / np.sqrt(self.samples)
        with np.errstate(over="ignore"):
            tolerance = atol / scale + rtol * np.abs(target)
        return bool(np.all(np.abs(mean_coeffs - target) <= critical * sem + tolerance))

    def plot(self, show: bool = True) -> Any:
        """
        Plots empirical eigenvalue distribution alongside theoretical polynomial roots.
        """
        try:
            import matplotlib.pyplot as plt
        except ImportError:
            import warnings

            warnings.warn(
                "matplotlib is required for plotting. Skipping plot.", stacklevel=2
            )
            return None

        fig, ax = plt.subplots(figsize=(8, 5))
        flat_eigs = self.eigenvalues.flatten()

        ax.hist(
            flat_eigs,
            bins=50,
            density=True,
            alpha=0.6,
            color="#1f77b4",
            edgecolor="none",
            label="Empirical Eigenvalues",
        )

        roots = self.analytical_poly.evaluate_roots_float64()
        ax.vlines(
            roots,
            ymin=0,
            ymax=ax.get_ylim()[1] * 0.1,
            colors="#d62728",
            linewidth=1.5,
            label="Analytical Roots",
        )

        ax.set_title(
            f"Empirical Spectral Density vs. Analytical Roots (d={self.d}, samples={self.samples})"
        )
        ax.set_xlabel("Eigenvalue")
        ax.set_ylabel("Density")
        ax.legend()
        ax.grid(True, linestyle=":", alpha=0.6)

        if show:
            plt.show()
        return fig
Methods:
plot(show=True)

Plots empirical eigenvalue distribution alongside theoretical polynomial roots.

Source code in finitefree/ensembles.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
def plot(self, show: bool = True) -> Any:
    """
    Plots empirical eigenvalue distribution alongside theoretical polynomial roots.
    """
    try:
        import matplotlib.pyplot as plt
    except ImportError:
        import warnings

        warnings.warn(
            "matplotlib is required for plotting. Skipping plot.", stacklevel=2
        )
        return None

    fig, ax = plt.subplots(figsize=(8, 5))
    flat_eigs = self.eigenvalues.flatten()

    ax.hist(
        flat_eigs,
        bins=50,
        density=True,
        alpha=0.6,
        color="#1f77b4",
        edgecolor="none",
        label="Empirical Eigenvalues",
    )

    roots = self.analytical_poly.evaluate_roots_float64()
    ax.vlines(
        roots,
        ymin=0,
        ymax=ax.get_ylim()[1] * 0.1,
        colors="#d62728",
        linewidth=1.5,
        label="Analytical Roots",
    )

    ax.set_title(
        f"Empirical Spectral Density vs. Analytical Roots (d={self.d}, samples={self.samples})"
    )
    ax.set_xlabel("Eigenvalue")
    ax.set_ylabel("Density")
    ax.legend()
    ax.grid(True, linestyle=":", alpha=0.6)

    if show:
        plt.show()
    return fig
verify_coefficients(alpha=0.05, *, rtol=1e-10, atol=0.0)

Check every coefficient against a Bonferroni-adjusted two-sided t band. The critical value is t.isf(alpha / (2*(d+1)), samples-1). Zero sample variance still requires agreement within atol + rtol*abs(target). Per-coefficient scaling avoids overflow/underflow in the sample variance. The bands assume independent samples and are exact for normal coefficient observations; other distributions require a large-sample approximation.

Source code in finitefree/ensembles.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def verify_coefficients(
    self, alpha: float = 0.05, *, rtol: float = 1e-10, atol: float = 0.0
) -> bool:
    """
    Check every coefficient against a Bonferroni-adjusted two-sided t band.
    The critical value is t.isf(alpha / (2*(d+1)), samples-1). Zero sample
    variance still requires agreement within atol + rtol*abs(target).
    Per-coefficient scaling avoids overflow/underflow in the sample variance.
    The bands assume independent samples and are exact for normal coefficient
    observations; other distributions require a large-sample approximation.
    """
    from scipy.stats import t

    if not np.isfinite(alpha) or not 0 < alpha < 1:
        raise ValueError("alpha must be finite and in the open interval (0, 1).")
    if not np.isfinite(rtol) or not np.isfinite(atol) or rtol < 0 or atol < 0:
        raise ValueError("rtol and atol must be finite and non-negative.")
    try:
        analytical_coeffs = np.array(self.analytical_poly.coeffs, dtype=float)
    except (TypeError, ValueError, OverflowError) as error:
        raise ValueError(
            "Analytical coefficients must be finite in float64."
        ) from error
    if not np.all(np.isfinite(analytical_coeffs)):
        raise ValueError("Analytical coefficients must be finite in float64.")
    critical = t.isf(alpha / (2 * (self.d + 1)), self.samples - 1)
    if not np.isfinite(critical):
        raise ValueError("alpha is too small for a finite float64 critical value.")

    scale = np.maximum(
        np.max(np.abs(self.char_poly_coeffs), axis=0), np.abs(analytical_coeffs)
    )
    scale = np.where(scale == 0, 1.0, scale)
    observations = self.char_poly_coeffs / scale
    target = analytical_coeffs / scale
    mean_coeffs = np.mean(observations, axis=0)
    std_coeffs = np.std(observations, axis=0, ddof=1)
    sem = std_coeffs / np.sqrt(self.samples)
    with np.errstate(over="ignore"):
        tolerance = atol / scale + rtol * np.abs(target)
    return bool(np.all(np.abs(mean_coeffs - target) <= critical * sem + tolerance))

Functions:

gue_expected_poly(d)

Computes the exact expected characteristic polynomial of a \(d \times d\) GUE matrix: \(\mathbb{E}[\det(xI - M)] = d^{-d/2} He_d(\sqrt{d} x)\) using the probabilist's Hermite polynomial from orthogonal.py.

Source code in finitefree/ensembles.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def gue_expected_poly(d: int) -> RealRootedPolynomial:
    r"""
    Computes the exact expected characteristic polynomial of a $d \times d$ GUE matrix:
    $\mathbb{E}[\det(xI - M)] = d^{-d/2} He_d(\sqrt{d} x)$
    using the probabilist's Hermite polynomial from orthogonal.py.
    """
    import flint

    from .orthogonal import hermite_polynomial

    he = hermite_polynomial(d, physicist=False)
    f_poly = he._fmpq_poly
    coeffs_list = list(f_poly)
    scaled_coeffs = []
    for i, coeff in enumerate(coeffs_list):
        if coeff == 0:
            scaled_coeffs.append(flint.fmpq(0))
        else:
            power = (i - d) // 2
            factor = flint.fmpq(d) ** power
            scaled_coeffs.append(coeff * factor)

    new_poly = flint.fmpq_poly(scaled_coeffs)
    result = RealRootedPolynomial(new_poly, assume_real_rooted=True)
    return _gaussian_roots(result, flint.fmpq(1, d)) if d else result

sample_goe(d, scale=1.0, *, rng=None)

Generates a sample GOE matrix (Symmetric, Gaussian entries) with diagonal variance \(2/d\) and off-diagonal variance \(1/d\).

Source code in finitefree/ensembles.py
37
38
39
40
41
42
43
44
45
46
47
def sample_goe(
    d: int, scale: float = 1.0, *, rng: Optional[np.random.Generator] = None
) -> Any:
    r"""
    Generates a sample GOE matrix (Symmetric, Gaussian entries)
    with diagonal variance $2/d$ and off-diagonal variance $1/d$.
    """
    normal = _normal_draw(rng)
    X = normal(d, d)
    H = (X + X.T) / (np.sqrt(2.0) * np.sqrt(d))
    return H * scale

sample_gse(d, scale=1.0, *, rng=None)

Generates a sample GSE matrix of dimension \(2d \times 2d\) (Self-dual, Gaussian entries) with Kramers degeneracy, scaled to match the \(d\)-dimensional expected characteristic polynomial.

Source code in finitefree/ensembles.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def sample_gse(
    d: int, scale: float = 1.0, *, rng: Optional[np.random.Generator] = None
) -> Any:
    r"""
    Generates a sample GSE matrix of dimension $2d \times 2d$ (Self-dual, Gaussian entries)
    with Kramers degeneracy, scaled to match the $d$-dimensional expected characteristic polynomial.
    """
    normal = _normal_draw(rng)
    X = (normal(2 * d, 2 * d) + 1j * normal(2 * d, 2 * d)) / np.sqrt(2.0)
    H = (X + X.conj().T) / 2.0
    J = np.zeros((2 * d, 2 * d))
    J[:d, d:] = np.eye(d)
    J[d:, :d] = -np.eye(d)
    H = (H + J @ H.conj() @ J.T) / 2.0
    return H * np.sqrt(2.0 / d) * scale

sample_gue(d, scale=1.0, *, rng=None)

Generates a sample GUE matrix (Hermitian, Gaussian entries) with diagonal and off-diagonal variance of \(1/d\).

Source code in finitefree/ensembles.py
24
25
26
27
28
29
30
31
32
33
34
def sample_gue(
    d: int, scale: float = 1.0, *, rng: Optional[np.random.Generator] = None
) -> Any:
    r"""
    Generates a sample GUE matrix (Hermitian, Gaussian entries)
    with diagonal and off-diagonal variance of $1/d$.
    """
    normal = _normal_draw(rng)
    X = (normal(d, d) + 1j * normal(d, d)) / np.sqrt(2.0)
    H = (X + X.conj().T) / (np.sqrt(2.0) * np.sqrt(d))
    return H * scale

sample_haar_orthogonal(d, *, rng=None)

Generates a Haar-distributed random orthogonal matrix.

Source code in finitefree/ensembles.py
108
109
110
111
112
113
114
115
def sample_haar_orthogonal(d: int, *, rng: Optional[np.random.Generator] = None) -> Any:
    """Generates a Haar-distributed random orthogonal matrix."""
    normal = _normal_draw(rng)
    X = normal(d, d)
    Q, R = np.linalg.qr(X)
    d_r = np.diagonal(R)
    ph = d_r / np.abs(d_r)
    return Q * ph

sample_haar_symplectic(d, *, rng=None)

Generates a Haar-distributed random symplectic matrix in \(\text{USp}(2d)\).

Source code in finitefree/ensembles.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def sample_haar_symplectic(
    d: int, *, rng: Optional[np.random.Generator] = None
) -> NDArray[np.complex128]:
    r"""Generates a Haar-distributed random symplectic matrix in $\text{USp}(2d)$."""
    normal = _normal_draw(rng)
    X = (normal(d, d) + 1j * normal(d, d)) / np.sqrt(2)
    Y = (normal(d, d) + 1j * normal(d, d)) / np.sqrt(2)
    Z = np.block([[X, Y], [-np.conj(Y), np.conj(X)]])
    Q = np.zeros((2 * d, 2 * d), dtype=complex)
    J = np.block([[np.zeros((d, d)), np.eye(d)], [-np.eye(d), np.zeros((d, d))]])
    for j in range(d):
        v = Z[:, j]
        if j > 0:
            # Vectorized Gram-Schmidt projection step using NumPy matrix-vector products
            U = Q[:, :j]
            W = Q[:, d : d + j]
            v = v - U @ (U.conj().T @ v) - W @ (W.conj().T @ v)
        u = v / np.linalg.norm(v)
        w = -J @ np.conj(u)
        Q[:, j] = u
        Q[:, j + d] = w
    return Q

sample_haar_unitary(d, *, rng=None)

Generates a Haar-distributed random unitary matrix.

Source code in finitefree/ensembles.py
 98
 99
100
101
102
103
104
105
def sample_haar_unitary(d: int, *, rng: Optional[np.random.Generator] = None) -> Any:
    """Generates a Haar-distributed random unitary matrix."""
    normal = _normal_draw(rng)
    X = (normal(d, d) + 1j * normal(d, d)) / np.sqrt(2.0)
    Q, R = np.linalg.qr(X)
    d_r = np.diagonal(R)
    ph = d_r / np.abs(d_r)
    return Q * ph

sample_wishart(d, n, beta=2, scale=1.0, *, rng=None)

Sample a real/complex/quaternionic Wishart representation. For beta=1 or 2, return a d-by-d matrix XX^H/n. For beta=4, the complex representation has shape (2d,2d), paired eigenvalues, and normalization XX^H/(2n). scale multiplies the matrix. beta must be 1, 2 or 4.

Source code in finitefree/ensembles.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def sample_wishart(
    d: int,
    n: int,
    beta: int = 2,
    scale: float = 1.0,
    *,
    rng: Optional[np.random.Generator] = None,
) -> NDArray[Any]:
    r"""
    Sample a real/complex/quaternionic Wishart representation.
    For beta=1 or 2, return a d-by-d matrix XX^H/n. For beta=4, the complex
    representation has shape (2d,2d), paired eigenvalues, and normalization
    XX^H/(2n). scale multiplies the matrix. beta must be 1, 2 or 4.
    """
    normal = _normal_draw(rng)
    if beta == 1:
        X = normal(d, n)
        W = (X @ X.T) / n
    elif beta == 2:
        X = (normal(d, n) + 1j * normal(d, n)) / np.sqrt(2.0)
        W = (X @ X.conj().T) / n
    elif beta == 4:
        A = (normal(d, n) + 1j * normal(d, n)) / np.sqrt(2.0)
        B = (normal(d, n) + 1j * normal(d, n)) / np.sqrt(2.0)
        X = np.block([[A, B], [-np.conj(B), np.conj(A)]])
        W = (X @ X.conj().T) / (2 * n)
    else:
        raise ValueError("beta must be 1, 2, or 4")
    return W * scale

wishart_expected_poly(d, n, beta=2)

Construct the monic rational Wishart expectation polynomial. For positive integer dimensions with n>=d, coefficients are those of \(n^{-d}d!(-1)^d L_d^{(n-d)}(nx)\). They match the d eigenvalues used by the normalized sampler, including one eigenvalue per pair for beta=4. The beta argument is retained but does not change this coefficient formula. Known Laguerre recurrence metadata is used only in its supported domain.

Source code in finitefree/ensembles.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def wishart_expected_poly(d: int, n: int, beta: int = 2) -> RealRootedPolynomial:
    r"""
    Construct the monic rational Wishart expectation polynomial.
    For positive integer dimensions with n>=d, coefficients are those of
    $n^{-d}d!(-1)^d L_d^{(n-d)}(nx)$. They match the d eigenvalues used by the
    normalized sampler, including one eigenvalue per pair for beta=4.
    The beta argument is retained but does not change this coefficient formula.
    Known Laguerre recurrence metadata is used only in its supported domain.
    """
    import flint

    from .orthogonal import laguerre_polynomial

    lag = laguerre_polynomial(d, n - d)
    f_poly = lag._fmpq_poly
    coeffs_list = list(f_poly)
    scaled_coeffs = []
    factor_base = flint.fmpq(math.factorial(d) * (-1) ** d)

    for i, coeff in enumerate(coeffs_list):
        if coeff == 0:
            scaled_coeffs.append(flint.fmpq(0))
        else:
            power = i - d
            factor = factor_base * (flint.fmpq(n) ** power)
            scaled_coeffs.append(coeff * factor)

    new_poly = flint.fmpq_poly(scaled_coeffs)
    result = _laguerre_roots(
        RealRootedPolynomial(new_poly, assume_real_rooted=True), n - d
    )
    if result._root_recurrence is not None:
        result._root_dilation = flint.fmpq(1, n)
    return result

finitefree.streaming

Bounded-storage empirical coefficient and interval-count diagnostics.

Classes

StreamingEmpiricalComparison

Accumulate coefficient moments and counts without retaining samples.

update(matrix) consumes one Hermitian sample; extend(generator, samples) consumes a batch. Intervals are closed [a,b]. Storage is O(d + m*d) for degree d and m intervals, independent of the sample count. Dynamically scaled Welford moments avoid squaring large/tiny raw coefficients.

Source code in finitefree/streaming.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
class StreamingEmpiricalComparison:
    """Accumulate coefficient moments and counts without retaining samples.

    update(matrix) consumes one Hermitian sample; extend(generator, samples)
    consumes a batch. Intervals are closed [a,b]. Storage is O(d + m*d) for
    degree d and m intervals, independent of the sample count. Dynamically
    scaled Welford moments avoid squaring large/tiny raw coefficients.
    """

    def __init__(
        self,
        analytical_poly: RealRootedPolynomial,
        intervals: Sequence[tuple[float, float]] = (),
    ) -> None:
        self._polynomial = RealRootedPolynomial(
            analytical_poly.coeffs,
            monic=False,
            assume_real_rooted=analytical_poly._is_verified,
        )
        self.d = analytical_poly.degree
        limits = np.asarray(intervals)
        if np.iscomplexobj(limits):
            raise ValueError("Intervals must be finite real pairs with a <= b")
        try:
            limits = np.array(limits, dtype=float, copy=True).reshape((-1, 2))
        except (TypeError, ValueError, OverflowError) as error:
            raise ValueError(
                "Intervals must be finite real pairs with a <= b"
            ) from error
        if (
            (len(intervals) and np.asarray(intervals).shape != (len(intervals), 2))
            or not np.all(np.isfinite(limits))
            or np.any(limits[:, 0] > limits[:, 1])
        ):
            raise ValueError("Intervals must be finite real pairs with a <= b")
        self._intervals = limits
        self._samples = 0
        self._scale = np.zeros(self.d + 1)
        self._mean = np.zeros(self.d + 1)
        self._m2 = np.zeros(self.d + 1)
        self._counts = np.zeros((len(limits), self.d + 1), dtype=np.int64)

    @property
    def samples(self) -> int:
        return self._samples

    @property
    def intervals(self) -> NDArray[np.float64]:
        return self._intervals.copy()

    @property
    def count_histograms(self) -> NDArray[np.int64]:
        """One integer histogram on 0..d for each configured interval."""
        return self._counts.copy()

    @property
    def coefficient_mean(self) -> NDArray[np.float64]:
        if not self.samples:
            raise ValueError("At least one sample is required")
        return self._mean * self._scale

    @property
    def coefficient_std(self) -> NDArray[np.float64]:
        """Sample standard deviation (ddof=1), if representable in float64."""
        if self.samples < 2:
            raise ValueError("At least two samples are required")
        with np.errstate(over="ignore"):
            result = np.sqrt(self._m2 / (self.samples - 1)) * self._scale
        if not np.all(np.isfinite(result)):
            raise RuntimeError("Coefficient standard deviation exceeds float64 range")
        return result

    @property
    def count_mean(self) -> NDArray[np.float64]:
        if not self.samples:
            raise ValueError("At least one sample is required")
        mean: NDArray[np.float64] = (self._counts / self.samples) @ np.arange(
            self.d + 1, dtype=float
        )
        return mean

    @property
    def count_variance(self) -> NDArray[np.float64]:
        """Sample variance (ddof=1) of interval counts."""
        if self.samples < 2:
            raise ValueError("At least two samples are required")
        offsets = np.arange(self.d + 1) - self.count_mean[:, None]
        variance: NDArray[np.float64] = np.sum(self._counts * offsets**2, axis=1) / (
            self.samples - 1
        )
        return variance

    def update(self, matrix: Any) -> None:
        """Add one sample atomically; invalid samples leave all statistics intact."""
        eigenvalues, coefficients = _sample_observations(matrix, self.d)
        counts = np.sum(
            (eigenvalues >= self._intervals[:, :1])
            & (eigenvalues <= self._intervals[:, 1:]),
            axis=1,
        )
        new_scale = np.maximum(self._scale, np.abs(coefficients))
        safe_scale = np.where(new_scale == 0, 1.0, new_scale)
        ratio = self._scale / safe_scale
        mean = self._mean * ratio
        m2 = self._m2 * ratio**2
        observation = coefficients / safe_scale
        delta = observation - mean
        mean = mean + delta / (self.samples + 1)
        m2 = m2 + delta * (observation - mean)
        self._scale, self._mean, self._m2 = new_scale, mean, np.maximum(m2, 0)
        self._samples += 1
        self._counts[np.arange(len(counts)), counts] += 1

    def extend(
        self, generator: Callable[[], NDArray[Any]], samples: SupportsIndex
    ) -> None:
        """Add nonnegative integer samples; prior successful updates are retained."""
        try:
            count = operator.index(samples)
        except TypeError as error:
            raise ValueError("samples must be a non-negative integer") from error
        if isinstance(samples, (bool, np.bool_)) or count < 0:
            raise ValueError("samples must be a non-negative integer")
        for _ in range(count):
            self.update(generator())

    def verify_coefficients(
        self, alpha: float = 0.05, *, rtol: float = 1e-10, atol: float = 0.0
    ) -> bool:
        """Use the retained comparison's Bonferroni t-band assumptions.

        Independent samples are required. Bands are exact for normal
        coefficient observations and otherwise use a large-sample approximation.
        This diagnostic is not a proof of the generator's distribution.
        """
        from scipy.stats import t

        if self.samples < 2:
            raise ValueError("At least two samples are required")
        if not np.isfinite(alpha) or not 0 < alpha < 1:
            raise ValueError("alpha must be finite and in the open interval (0, 1).")
        if not np.isfinite(rtol) or not np.isfinite(atol) or rtol < 0 or atol < 0:
            raise ValueError("rtol and atol must be finite and non-negative.")
        try:
            target = np.array(self._polynomial.coeffs, dtype=float)
        except (TypeError, ValueError, OverflowError) as error:
            raise ValueError(
                "Analytical coefficients must be finite in float64."
            ) from error
        if not np.all(np.isfinite(target)):
            raise ValueError("Analytical coefficients must be finite in float64.")
        critical = t.isf(alpha / (2 * (self.d + 1)), self.samples - 1)
        if not np.isfinite(critical):
            raise ValueError("alpha is too small for a finite float64 critical value.")
        scale = np.maximum(self._scale, np.abs(target))
        scale = np.where(scale == 0, 1.0, scale)
        mean = self._mean * (self._scale / scale)
        sem = np.sqrt(self._m2 / (self.samples - 1) / self.samples) * (
            self._scale / scale
        )
        target = target / scale
        with np.errstate(over="ignore"):
            tolerance = atol / scale + rtol * np.abs(target)
        return bool(np.all(np.abs(mean - target) <= critical * sem + tolerance))
Attributes
coefficient_std property

Sample standard deviation (ddof=1), if representable in float64.

count_histograms property

One integer histogram on 0..d for each configured interval.

count_variance property

Sample variance (ddof=1) of interval counts.

Methods:
extend(generator, samples)

Add nonnegative integer samples; prior successful updates are retained.

Source code in finitefree/streaming.py
160
161
162
163
164
165
166
167
168
169
170
171
def extend(
    self, generator: Callable[[], NDArray[Any]], samples: SupportsIndex
) -> None:
    """Add nonnegative integer samples; prior successful updates are retained."""
    try:
        count = operator.index(samples)
    except TypeError as error:
        raise ValueError("samples must be a non-negative integer") from error
    if isinstance(samples, (bool, np.bool_)) or count < 0:
        raise ValueError("samples must be a non-negative integer")
    for _ in range(count):
        self.update(generator())
update(matrix)

Add one sample atomically; invalid samples leave all statistics intact.

Source code in finitefree/streaming.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def update(self, matrix: Any) -> None:
    """Add one sample atomically; invalid samples leave all statistics intact."""
    eigenvalues, coefficients = _sample_observations(matrix, self.d)
    counts = np.sum(
        (eigenvalues >= self._intervals[:, :1])
        & (eigenvalues <= self._intervals[:, 1:]),
        axis=1,
    )
    new_scale = np.maximum(self._scale, np.abs(coefficients))
    safe_scale = np.where(new_scale == 0, 1.0, new_scale)
    ratio = self._scale / safe_scale
    mean = self._mean * ratio
    m2 = self._m2 * ratio**2
    observation = coefficients / safe_scale
    delta = observation - mean
    mean = mean + delta / (self.samples + 1)
    m2 = m2 + delta * (observation - mean)
    self._scale, self._mean, self._m2 = new_scale, mean, np.maximum(m2, 0)
    self._samples += 1
    self._counts[np.arange(len(counts)), counts] += 1
verify_coefficients(alpha=0.05, *, rtol=1e-10, atol=0.0)

Use the retained comparison's Bonferroni t-band assumptions.

Independent samples are required. Bands are exact for normal coefficient observations and otherwise use a large-sample approximation. This diagnostic is not a proof of the generator's distribution.

Source code in finitefree/streaming.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def verify_coefficients(
    self, alpha: float = 0.05, *, rtol: float = 1e-10, atol: float = 0.0
) -> bool:
    """Use the retained comparison's Bonferroni t-band assumptions.

    Independent samples are required. Bands are exact for normal
    coefficient observations and otherwise use a large-sample approximation.
    This diagnostic is not a proof of the generator's distribution.
    """
    from scipy.stats import t

    if self.samples < 2:
        raise ValueError("At least two samples are required")
    if not np.isfinite(alpha) or not 0 < alpha < 1:
        raise ValueError("alpha must be finite and in the open interval (0, 1).")
    if not np.isfinite(rtol) or not np.isfinite(atol) or rtol < 0 or atol < 0:
        raise ValueError("rtol and atol must be finite and non-negative.")
    try:
        target = np.array(self._polynomial.coeffs, dtype=float)
    except (TypeError, ValueError, OverflowError) as error:
        raise ValueError(
            "Analytical coefficients must be finite in float64."
        ) from error
    if not np.all(np.isfinite(target)):
        raise ValueError("Analytical coefficients must be finite in float64.")
    critical = t.isf(alpha / (2 * (self.d + 1)), self.samples - 1)
    if not np.isfinite(critical):
        raise ValueError("alpha is too small for a finite float64 critical value.")
    scale = np.maximum(self._scale, np.abs(target))
    scale = np.where(scale == 0, 1.0, scale)
    mean = self._mean * (self._scale / scale)
    sem = np.sqrt(self._m2 / (self.samples - 1) / self.samples) * (
        self._scale / scale
    )
    target = target / scale
    with np.errstate(over="ignore"):
        tolerance = atol / scale + rtol * np.abs(target)
    return bool(np.all(np.abs(mean - target) <= critical * sem + tolerance))

Determinantal Point Processes (DPP)

finitefree.dpp

Classes

BaseKernel

Bases: ABC

Abstract base class for determinantal point process (DPP) correlation kernels.

Source code in finitefree/dpp.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class BaseKernel(abc.ABC):
    """Abstract base class for determinantal point process (DPP) correlation kernels."""

    @abc.abstractmethod
    def __call__(self, x: Any, y: Any) -> Any:
        """Evaluate the kernel at x and y."""
        pass

    def matrix(self, xs: Sequence[Any]) -> List[List[Any]]:
        """Compute the exact kernel matrix for points xs."""
        return [[self(x, y) for y in xs] for x in xs]

    def k_point_correlation(self, xs: Sequence[Any]) -> Any:
        r"""Evaluate the k-point correlation function exactly: $\det(K(x_i, x_j))$."""
        M = self.matrix(xs)
        return _exact_det(M)
Methods:
__call__(x, y) abstractmethod

Evaluate the kernel at x and y.

Source code in finitefree/dpp.py
38
39
40
41
@abc.abstractmethod
def __call__(self, x: Any, y: Any) -> Any:
    """Evaluate the kernel at x and y."""
    pass
k_point_correlation(xs)

Evaluate the k-point correlation function exactly: \(\det(K(x_i, x_j))\).

Source code in finitefree/dpp.py
47
48
49
50
def k_point_correlation(self, xs: Sequence[Any]) -> Any:
    r"""Evaluate the k-point correlation function exactly: $\det(K(x_i, x_j))$."""
    M = self.matrix(xs)
    return _exact_det(M)
matrix(xs)

Compute the exact kernel matrix for points xs.

Source code in finitefree/dpp.py
43
44
45
def matrix(self, xs: Sequence[Any]) -> List[List[Any]]:
    """Compute the exact kernel matrix for points xs."""
    return [[self(x, y) for y in xs] for x in xs]

DiscreteFiniteKernel

Bases: BaseKernel

Discrete state space kernel defined by a symmetric matrix representation.

Source code in finitefree/dpp.py
53
54
55
56
57
58
59
60
61
62
63
64
class DiscreteFiniteKernel(BaseKernel):
    """Discrete state space kernel defined by a symmetric matrix representation."""

    def __init__(self, K: Any) -> None:
        """K can be a list of lists, a numpy array, or a flint.fmpq_mat."""
        self._K = K

    def __call__(self, i: Any, j: Any) -> Any:
        i, j = operator.index(i), operator.index(j)
        if isinstance(self._K, flint.fmpq_mat):
            return self._K[i, j]
        return self._K[i][j]
Methods:
__init__(K)

K can be a list of lists, a numpy array, or a flint.fmpq_mat.

Source code in finitefree/dpp.py
56
57
58
def __init__(self, K: Any) -> None:
    """K can be a list of lists, a numpy array, or a flint.fmpq_mat."""
    self._K = K

OrthogonalPolynomialKernel

Bases: BaseKernel

Orthogonal-polynomial kernel with exact Christoffel-Darboux evaluation.

Owns its basis and exact norms. A scaled/shifted Hermite basis is verified by exact monic recurrence and norm ratios before numerical evaluation by a normalized three-term sum. Basis scaling and measure mass are preserved. Other families use Christoffel-Darboux, with a finite basis sum at nearby distinct floats. Consistent generic polynomials and norms are assumed.

Source code in finitefree/dpp.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
class OrthogonalPolynomialKernel(BaseKernel):
    """Orthogonal-polynomial kernel with exact Christoffel-Darboux evaluation.

    Owns its basis and exact norms. A scaled/shifted Hermite basis is verified
    by exact monic recurrence and norm ratios before numerical evaluation by a
    normalized three-term sum. Basis scaling and measure mass are preserved.
    Other families use Christoffel-Darboux, with a finite basis sum at nearby
    distinct floats. Consistent generic polynomials and norms are assumed.
    """

    def __init__(
        self,
        polys: List[RealRootedPolynomial],
        norms: List[Any],
        leading_coeffs: Union[List[Any], None] = None,
    ) -> None:
        r"""
        polys: List of RealRootedPolynomial, from $p_0$ to $p_n$ (length $n + 1$)
        norms: List of norm constants, from $h_0$ to $h_{n-1}$ (length $n$)
        leading_coeffs: Optional list of leading coefficients, from $k_0$ to $k_n$ (length $n + 1$)
        """
        self._polys = tuple(self._copy_polynomial(p) for p in polys)
        self._norms = tuple(sympy_to_exact(h) for h in norms)
        self.n = len(norms)

        if len(polys) < self.n + 1:
            raise ValueError(
                f"polys must contain at least {self.n + 1} polynomials (p_0 to p_n)"
            )

        if leading_coeffs is None:
            coefficients = []
            for p in self._polys:
                if p._is_flint:
                    coefficients.append(p._fmpq_poly.coeffs()[-1])
                else:
                    coefficients.append(p.coeffs[0])
        else:
            coefficients = [sympy_to_exact(k) for k in leading_coeffs]
        if len(coefficients) < self.n + 1:
            raise ValueError("leading_coeffs must contain k_0 through k_n")
        self._leading_coeffs = tuple(coefficients)
        self._hermite_parameters = self._verify_hermite_basis()
        self._hermite_steps: tuple[tuple[float, float], ...] = ()
        self._hermite_center = 0.0
        self._hermite_initial = 1.0
        self._hermite_range_error = False
        if self._hermite_parameters is not None:
            center, variance, mass = self._hermite_parameters
            from .utils.conversion import flint_to_float

            try:
                self._hermite_center = flint_to_float(center)
                self._hermite_initial = self._positive_sqrt_float64(1 / mass)
                self._hermite_steps = tuple(
                    (
                        self._positive_sqrt_float64(1 / ((j + 1) * variance)),
                        math.sqrt(j / (j + 1)),
                    )
                    for j in range(self.n - 1)
                )
                self._hermite_range_error = not math.isfinite(self._hermite_center)
            except (OverflowError, ValueError):
                self._hermite_range_error = True

        # Precompute derivative objects to avoid dynamic instantiation overhead
        self._pn = self._polys[self.n]
        self._pn_minus = self._polys[self.n - 1]
        self._pn_deriv = (
            self._pn.derivative(monic=False) if self._pn.degree > 0 else None
        )
        self._pn_minus_deriv = (
            self._pn_minus.derivative(monic=False)
            if self._pn_minus.degree > 0
            else None
        )

    @staticmethod
    def _copy_polynomial(p: RealRootedPolynomial) -> RealRootedPolynomial:
        return RealRootedPolynomial(
            p._fmpq_poly if p._is_flint else p.coeffs,
            monic=False,
            assume_real_rooted=p._is_verified,
        )

    @property
    def polys(self) -> List[RealRootedPolynomial]:
        """Return caller-owned polynomial copies of the stored basis."""
        return [self._copy_polynomial(p) for p in self._polys]

    @property
    def norms(self) -> List[Any]:
        """Return a caller-owned list of the exact norms."""
        return list(self._norms)

    @property
    def leading_coeffs(self) -> List[Any]:
        """Return a caller-owned list of the leading coefficients."""
        return list(self._leading_coeffs)

    def prepare_numeric(self) -> "PreparedOrthogonalKernel":
        """Prepare an owned float64 basis for repeated batch/quadrature work."""
        from .numerical import PreparedOrthogonalKernel

        return PreparedOrthogonalKernel(self)

    @staticmethod
    def _positive_sqrt_float64(value: Any) -> float:
        # Convert AFTER square root: tiny/huge rational ratios can have a
        # representable root even when the ratio itself over/underflows.
        with localcontext() as context:
            context.prec = 40
            result = float((Decimal(int(value.p)) / Decimal(int(value.q))).sqrt())
        if not math.isfinite(result) or result <= 0:
            raise ValueError("Hermite normalization exceeds float64 range")
        return result

    def _verify_hermite_basis(self) -> Any:
        """Infer and verify (center, variance, measure mass) from owned data.

        No provenance/root metadata is trusted: it does not encode measure mass
        and caller-supplied polynomials, norms or leading coefficients may differ.
        All normalization and recurrence comparisons precede float conversion.
        """
        if self.n == 0:
            return None
        monic = []
        norms = []
        for j, p in enumerate(self._polys[: self.n + 1]):
            if not p._is_flint or p.degree != j:
                return None
            leading = p._fmpq_poly[j]
            if self._leading_coeffs[j] != leading:
                return None
            monic.append(p._fmpq_poly / leading)
            if j < self.n:
                norm = self._norms[j]
                if not isinstance(norm, flint.fmpq) or norm <= 0:
                    return None
                norms.append(norm / leading**2)
        center = -monic[1][0]
        # A rank-one constant kernel needs no variance parameter.
        variance = norms[1] / norms[0] if self.n > 1 else flint.fmpq(1)
        shifted_x = flint.fmpq_poly([-center, 1])
        previous = flint.fmpq_poly([])
        expected = flint.fmpq_poly([1])
        expected_norm = norms[0]
        for j, polynomial in enumerate(monic):
            if polynomial != expected:
                return None
            if j < self.n:
                if norms[j] != expected_norm:
                    return None
                expected_norm *= (j + 1) * variance
            previous, expected = (
                expected,
                shifted_x * expected - j * variance * previous,
            )
        return center, variance, norms[0]

    def _hermite_sum_float64(self, x: float, y: float) -> float:
        if self._hermite_range_error:
            raise RuntimeError("Hermite kernel exceeds finite float64 range")
        x -= self._hermite_center
        y -= self._hermite_center

        def terms() -> Any:
            previous_x = previous_y = 0.0
            current_x = current_y = self._hermite_initial
            yield current_x * current_y
            for inverse_sqrt, ratio in self._hermite_steps:
                previous_x, current_x = (
                    current_x,
                    x * inverse_sqrt * current_x - ratio * previous_x,
                )
                previous_y, current_y = (
                    current_y,
                    y * inverse_sqrt * current_y - ratio * previous_y,
                )
                yield current_x * current_y

        try:
            result = math.fsum(terms())
        except (OverflowError, ValueError) as error:
            raise RuntimeError("Hermite kernel exceeds finite float64 range") from error
        if not math.isfinite(result):
            raise RuntimeError("Hermite kernel exceeds finite float64 range")
        return result

    def __call__(self, x: Any, y: Any) -> Any:
        if self.n == 0:
            return 0

        if isinstance(x, (float, np.floating)) or isinstance(y, (float, np.floating)):
            from .utils.conversion import flint_to_float

            x_f = float(x)
            y_f = float(y)
            if not math.isfinite(x_f) or not math.isfinite(y_f):
                raise ValueError("Numerical kernel coordinates must be finite")
            if self._hermite_parameters is not None:
                return self._hermite_sum_float64(x_f, y_f)
            separation = abs(x_f - y_f)
            close_scale = max(1.0, abs(x_f), abs(y_f))
            if 0 < separation <= math.sqrt(np.finfo(float).eps) * close_scale:
                # This is K(x,y), not the confluent approximation K(x,x).
                # Summing the basis is slower, but avoids the CD quotient's
                # cancellation for this small subset of floating evaluations.
                return math.fsum(
                    flint_to_float(self._polys[j].evaluate(x_f))
                    * flint_to_float(self._polys[j].evaluate(y_f))
                    / flint_to_float(self._norms[j])
                    for j in range(self.n)
                )
            kn_f = flint_to_float(self._leading_coeffs[self.n])
            kn_minus_f = flint_to_float(self._leading_coeffs[self.n - 1])
            hn_minus_f = flint_to_float(self._norms[self.n - 1])
            factor_f = kn_minus_f / (kn_f * hn_minus_f)
            if x_f == y_f:
                pn_val = self._pn.evaluate(x_f)
                pn_minus_val = self._pn_minus.evaluate(x_f)
                pn_deriv_val = self._pn_deriv.evaluate(x_f) if self._pn_deriv else 0.0
                pn_minus_deriv_val = (
                    self._pn_minus_deriv.evaluate(x_f) if self._pn_minus_deriv else 0.0
                )
                return factor_f * (
                    pn_deriv_val * pn_minus_val - pn_minus_deriv_val * pn_val
                )
            else:
                pn_x = self._pn.evaluate(x_f)
                pn_minus_y = self._pn_minus.evaluate(y_f)
                pn_minus_x = self._pn_minus.evaluate(x_f)
                pn_y = self._pn.evaluate(y_f)
                numerator = pn_x * pn_minus_y - pn_minus_x * pn_y
                return (factor_f * numerator) / (x_f - y_f)

        # Convert inputs to exact types first
        x = sympy_to_exact(x)
        y = sympy_to_exact(y)

        kn = self._leading_coeffs[self.n]
        kn_minus = self._leading_coeffs[self.n - 1]
        hn_minus = self._norms[self.n - 1]

        factor = kn_minus / (kn * hn_minus)

        if x == y:
            pn_val = self._pn.evaluate(x)
            pn_minus_val = self._pn_minus.evaluate(x)
            pn_deriv_val = self._pn_deriv.evaluate(x) if self._pn_deriv else 0
            pn_minus_deriv_val = (
                self._pn_minus_deriv.evaluate(x) if self._pn_minus_deriv else 0
            )
            return factor * (pn_deriv_val * pn_minus_val - pn_minus_deriv_val * pn_val)
        else:
            pn_x = self._pn.evaluate(x)
            pn_minus_y = self._pn_minus.evaluate(y)
            pn_minus_x = self._pn_minus.evaluate(x)
            pn_y = self._pn.evaluate(y)
            numerator = pn_x * pn_minus_y - pn_minus_x * pn_y
            return (factor * numerator) / (x - y)
Attributes
leading_coeffs property

Return a caller-owned list of the leading coefficients.

norms property

Return a caller-owned list of the exact norms.

polys property

Return caller-owned polynomial copies of the stored basis.

Methods:
__init__(polys, norms, leading_coeffs=None)

polys: List of RealRootedPolynomial, from \(p_0\) to \(p_n\) (length \(n + 1\)) norms: List of norm constants, from \(h_0\) to \(h_{n-1}\) (length \(n\)) leading_coeffs: Optional list of leading coefficients, from \(k_0\) to \(k_n\) (length \(n + 1\))

Source code in finitefree/dpp.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def __init__(
    self,
    polys: List[RealRootedPolynomial],
    norms: List[Any],
    leading_coeffs: Union[List[Any], None] = None,
) -> None:
    r"""
    polys: List of RealRootedPolynomial, from $p_0$ to $p_n$ (length $n + 1$)
    norms: List of norm constants, from $h_0$ to $h_{n-1}$ (length $n$)
    leading_coeffs: Optional list of leading coefficients, from $k_0$ to $k_n$ (length $n + 1$)
    """
    self._polys = tuple(self._copy_polynomial(p) for p in polys)
    self._norms = tuple(sympy_to_exact(h) for h in norms)
    self.n = len(norms)

    if len(polys) < self.n + 1:
        raise ValueError(
            f"polys must contain at least {self.n + 1} polynomials (p_0 to p_n)"
        )

    if leading_coeffs is None:
        coefficients = []
        for p in self._polys:
            if p._is_flint:
                coefficients.append(p._fmpq_poly.coeffs()[-1])
            else:
                coefficients.append(p.coeffs[0])
    else:
        coefficients = [sympy_to_exact(k) for k in leading_coeffs]
    if len(coefficients) < self.n + 1:
        raise ValueError("leading_coeffs must contain k_0 through k_n")
    self._leading_coeffs = tuple(coefficients)
    self._hermite_parameters = self._verify_hermite_basis()
    self._hermite_steps: tuple[tuple[float, float], ...] = ()
    self._hermite_center = 0.0
    self._hermite_initial = 1.0
    self._hermite_range_error = False
    if self._hermite_parameters is not None:
        center, variance, mass = self._hermite_parameters
        from .utils.conversion import flint_to_float

        try:
            self._hermite_center = flint_to_float(center)
            self._hermite_initial = self._positive_sqrt_float64(1 / mass)
            self._hermite_steps = tuple(
                (
                    self._positive_sqrt_float64(1 / ((j + 1) * variance)),
                    math.sqrt(j / (j + 1)),
                )
                for j in range(self.n - 1)
            )
            self._hermite_range_error = not math.isfinite(self._hermite_center)
        except (OverflowError, ValueError):
            self._hermite_range_error = True

    # Precompute derivative objects to avoid dynamic instantiation overhead
    self._pn = self._polys[self.n]
    self._pn_minus = self._polys[self.n - 1]
    self._pn_deriv = (
        self._pn.derivative(monic=False) if self._pn.degree > 0 else None
    )
    self._pn_minus_deriv = (
        self._pn_minus.derivative(monic=False)
        if self._pn_minus.degree > 0
        else None
    )
prepare_numeric()

Prepare an owned float64 basis for repeated batch/quadrature work.

Source code in finitefree/dpp.py
167
168
169
170
171
def prepare_numeric(self) -> "PreparedOrthogonalKernel":
    """Prepare an owned float64 basis for repeated batch/quadrature work."""
    from .numerical import PreparedOrthogonalKernel

    return PreparedOrthogonalKernel(self)

PreparedDiscreteDPP

Owned, validated kernel state for repeated real symmetric DPP sampling.

Preparation evaluates/copies the kernel and computes its eigensystem once. Stored arrays have immutable backing; public arrays are read-only copies. The state container is copied to a tuple, retaining the supplied hashable labels. Mutating the original matrix or state list cannot change sampling. There is no cache keyed by mutable array identity. Preparation draws no random numbers; sample() uses the ordinary Bernoulli and QR-based HKPV law.

Source code in finitefree/dpp.py
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
class PreparedDiscreteDPP:
    """Owned, validated kernel state for repeated real symmetric DPP sampling.

    Preparation evaluates/copies the kernel and computes its eigensystem once.
    Stored arrays have immutable backing; public arrays are read-only copies.
    The state container is copied to a tuple, retaining the supplied hashable
    labels. Mutating the original matrix or state list cannot change sampling.
    There is no cache keyed by mutable array identity. Preparation draws no
    random numbers; sample() uses the ordinary Bernoulli and QR-based HKPV law.
    """

    __slots__ = ("_matrix", "_eigenvalues", "_eigenvectors", "_states")

    def __init__(
        self, kernel: Union[BaseKernel, NDArray[Any]], state_space: Sequence[Any]
    ) -> None:
        states = tuple(state_space)
        matrix, eigenvalues, eigenvectors = _validated_discrete_eigensystem(
            kernel, states
        )

        def freeze(array: NDArray[np.float64]) -> NDArray[np.float64]:
            return np.frombuffer(array.tobytes(), dtype=np.float64).reshape(array.shape)

        self._matrix = freeze(matrix)
        self._eigenvalues = freeze(eigenvalues)
        self._eigenvectors = freeze(eigenvectors)
        self._states = states

    @property
    def state_space(self) -> tuple[Any, ...]:
        """The owned state order; individual labels retain their original identity."""
        return self._states

    @property
    def kernel_matrix(self) -> NDArray[np.float64]:
        """Return a caller-owned read-only copy of the symmetrized kernel."""
        result = self._matrix.copy()
        result.setflags(write=False)
        return result

    @property
    def eigenvalues(self) -> NDArray[np.float64]:
        """Return a caller-owned read-only copy of the clipped eigenvalues."""
        result = self._eigenvalues.copy()
        result.setflags(write=False)
        return result

    def sample(self, *, rng: Optional[np.random.Generator] = None) -> List[Any]:
        """Draw one sample; None preserves the legacy global NumPy RNG behavior."""
        return _sample_discrete_eigensystem(
            self._eigenvalues, self._eigenvectors, self._states, rng
        )
Attributes
eigenvalues property

Return a caller-owned read-only copy of the clipped eigenvalues.

kernel_matrix property

Return a caller-owned read-only copy of the symmetrized kernel.

state_space property

The owned state order; individual labels retain their original identity.

Methods:
sample(*, rng=None)

Draw one sample; None preserves the legacy global NumPy RNG behavior.

Source code in finitefree/dpp.py
541
542
543
544
545
def sample(self, *, rng: Optional[np.random.Generator] = None) -> List[Any]:
    """Draw one sample; None preserves the legacy global NumPy RNG behavior."""
    return _sample_discrete_eigensystem(
        self._eigenvalues, self._eigenvectors, self._states, rng
    )

Functions:

gap_probability_continuous(kernel, a, b, n_points=50, weight_func=None)

Approximates the continuous Fredholm determinant gap probability over \([a, b]\) using Nyström discretization.

Source code in finitefree/dpp.py
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def gap_probability_continuous(
    kernel: BaseKernel, a: float, b: float, n_points: int = 50, weight_func: Any = None
) -> float:
    r"""Approximates the continuous Fredholm determinant gap probability over $[a, b]$ using Nyström discretization."""
    import scipy.special

    from .utils.conversion import flint_to_float

    pts, w = scipy.special.roots_legendre(n_points)
    # Map points and weights from [-1, 1] to [a, b]
    pts_mapped = 0.5 * (b - a) * pts + 0.5 * (a + b)
    w_mapped = 0.5 * (b - a) * w

    D = np.zeros((n_points, n_points))
    for i in range(n_points):
        for j in range(n_points):
            val = kernel(pts_mapped[i], pts_mapped[j])
            weight = 1.0
            if weight_func is not None:
                weight = np.sqrt(
                    weight_func(pts_mapped[i]) * weight_func(pts_mapped[j])
                )
            D[i, j] = (
                np.sqrt(w_mapped[i])
                * flint_to_float(val)
                * weight
                * np.sqrt(w_mapped[j])
            )

    matrix = np.eye(n_points) - D
    return float(np.linalg.det(matrix))

gap_probability_discrete(kernel, points_in_gap)

Computes exact gap probability over a discrete state space subset: \(\det(I - K_I)\).

Source code in finitefree/dpp.py
347
348
349
350
351
352
353
354
355
356
357
358
359
def gap_probability_discrete(kernel: BaseKernel, points_in_gap: Sequence[Any]) -> Any:
    r"""Computes exact gap probability over a discrete state space subset: $\det(I - K_I)$."""
    n = len(points_in_gap)
    if n == 0:
        return 1
    K_mat = kernel.matrix(points_in_gap)

    # We construct exact I - K_I
    I_minus_K = [[-K_mat[i][j] for j in range(n)] for i in range(n)]
    for i in range(n):
        I_minus_K[i][i] = 1 + I_minus_K[i][i]

    return _exact_det(I_minus_K)

sample_discrete(kernel, state_space=None, *, rng=None)

Sample a real symmetric DPP correlation kernel by spectral HKPV.

Raw kernels are validated and decomposed on every call. PreparedDiscreteDPP owns a validated snapshot for repeated sampling; its state_space can be omitted or must match its stored order. Nonprojection kernels use independent Bernoulli eigenvector selection, followed by the same QR-based projection sampler. None uses global NumPy randomness; rng accepts a Generator. An ndarray is indexed in state_space order; a BaseKernel is evaluated on those states. Kernels must be finite, symmetric, with spectrum in [0, 1], up to 1e-10 absolute roundoff. States must be distinct and hashable.

Source code in finitefree/dpp.py
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
def sample_discrete(
    kernel: Union[BaseKernel, NDArray[Any], PreparedDiscreteDPP],
    state_space: Optional[Sequence[Any]] = None,
    *,
    rng: Optional[np.random.Generator] = None,
) -> List[Any]:
    """Sample a real symmetric DPP correlation kernel by spectral HKPV.

    Raw kernels are validated and decomposed on every call. PreparedDiscreteDPP
    owns a validated snapshot for repeated sampling; its state_space can be
    omitted or must match its stored order. Nonprojection kernels use independent
    Bernoulli eigenvector selection, followed by the same QR-based projection
    sampler. None uses global NumPy randomness; rng accepts a Generator.
    An ndarray is indexed in state_space order; a BaseKernel is evaluated on
    those states. Kernels must be finite, symmetric, with spectrum in [0, 1],
    up to 1e-10 absolute roundoff. States must be distinct and hashable.
    """
    if isinstance(kernel, PreparedDiscreteDPP):
        if state_space is not None and tuple(state_space) != kernel.state_space:
            raise ValueError("state_space must match the prepared kernel's state order")
        return kernel.sample(rng=rng)
    if state_space is None:
        raise ValueError("state_space is required for an unprepared kernel")
    states = tuple(state_space)
    _, eigenvalues, eigenvectors = _validated_discrete_eigensystem(kernel, states)
    return _sample_discrete_eigensystem(eigenvalues, eigenvectors, states, rng)

sympy_to_exact(val)

Converts a value to flint.fmpq or sympy Rational/float.

Source code in finitefree/dpp.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
def sympy_to_exact(val: Any) -> Any:
    """Converts a value to flint.fmpq or sympy Rational/float."""
    if isinstance(val, flint.fmpq):
        return val
    if isinstance(val, int):
        return flint.fmpq(val)
    if isinstance(val, float):
        num, den = val.as_integer_ratio()
        return flint.fmpq(num, den)
    try:
        from .utils.conversion import sympy_to_fmpq

        return sympy_to_fmpq(sp.sympify(val))
    except Exception:
        return val

finitefree.numerical

Optional float64 batch kernels and finite-rank DPP count laws.

These APIs do not change exact scalar kernels. Continuous restrictions use Gauss-Legendre quadrature: their probabilities are estimates, not enclosures.

Classes

DPPCountDistribution

Poisson-binomial count law from a real symmetric restricted DPP kernel.

Eigenvalues outside [0,1] by more than 1e-12 are rejected. Only roundoff within this absolute tolerance is clipped. spectral_correction and probability_mass_error expose those numerical adjustments. estimated marks quadrature input; even unmarked float64 results are not certified bounds.

Source code in finitefree/numerical.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
class DPPCountDistribution:
    """Poisson-binomial count law from a real symmetric restricted DPP kernel.

    Eigenvalues outside [0,1] by more than 1e-12 are rejected. Only roundoff
    within this absolute tolerance is clipped. spectral_correction and
    probability_mass_error expose those numerical adjustments. estimated marks
    quadrature input; even unmarked float64 results are not certified bounds.
    """

    def __init__(self, restricted_matrix: Any, *, estimated: bool = False) -> None:
        matrix = _real_array(restricted_matrix, "Restricted kernel")
        if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
            raise ValueError("Restricted kernel must be square")
        if not np.allclose(matrix, matrix.T, rtol=0, atol=1e-12):
            raise ValueError("Restricted kernel must be symmetric")
        eigenvalues = np.linalg.eigvalsh(matrix / 2 + matrix.T / 2)
        if np.any(eigenvalues < -1e-12) or np.any(eigenvalues > 1 + 1e-12):
            raise ValueError("Restricted kernel eigenvalues must lie in [0, 1]")
        self._eigenvalues: NDArray[np.float64] = np.clip(eigenvalues, 0, 1)
        self.spectral_correction = float(
            np.max(np.abs(self._eigenvalues - eigenvalues), initial=0)
        )
        self.estimated = bool(estimated)
        pmf = np.ones(1)
        for probability in self._eigenvalues:
            pmf = np.convolve(pmf, [1 - probability, probability])
        mass = math.fsum(pmf)
        self.probability_mass_error = abs(mass - 1)
        self._pmf = pmf / mass

    @property
    def eigenvalues(self) -> NDArray[np.float64]:
        return self._eigenvalues.copy()

    @property
    def mean(self) -> float:
        return math.fsum(self._eigenvalues)

    @property
    def variance(self) -> float:
        return math.fsum(self._eigenvalues * (1 - self._eigenvalues))

    def pmf(self, k: Optional[SupportsIndex] = None) -> Any:
        """P(N=k), or a caller-owned array on 0..rank when k is omitted."""
        if k is None:
            return self._pmf.copy()
        index = operator.index(k)
        return float(self._pmf[index]) if 0 <= index < len(self._pmf) else 0.0

    def cdf(self, k: SupportsIndex) -> float:
        """P(N<=k) for an integer threshold."""
        index = operator.index(k)
        if index < 0:
            return 0.0
        if index >= len(self._pmf) - 1:
            return 1.0
        return min(1.0, math.fsum(self._pmf[: index + 1]))

    def sf(self, k: SupportsIndex) -> float:
        """P(N>k), summed directly to avoid cancellation in 1-cdf(k)."""
        index = operator.index(k)
        if index < 0:
            return 1.0
        return min(1.0, math.fsum(self._pmf[index + 1 :]))
Methods:
cdf(k)

P(N<=k) for an integer threshold.

Source code in finitefree/numerical.py
249
250
251
252
253
254
255
256
def cdf(self, k: SupportsIndex) -> float:
    """P(N<=k) for an integer threshold."""
    index = operator.index(k)
    if index < 0:
        return 0.0
    if index >= len(self._pmf) - 1:
        return 1.0
    return min(1.0, math.fsum(self._pmf[: index + 1]))
pmf(k=None)

P(N=k), or a caller-owned array on 0..rank when k is omitted.

Source code in finitefree/numerical.py
242
243
244
245
246
247
def pmf(self, k: Optional[SupportsIndex] = None) -> Any:
    """P(N=k), or a caller-owned array on 0..rank when k is omitted."""
    if k is None:
        return self._pmf.copy()
    index = operator.index(k)
    return float(self._pmf[index]) if 0 <= index < len(self._pmf) else 0.0
sf(k)

P(N>k), summed directly to avoid cancellation in 1-cdf(k).

Source code in finitefree/numerical.py
258
259
260
261
262
263
def sf(self, k: SupportsIndex) -> float:
    """P(N>k), summed directly to avoid cancellation in 1-cdf(k)."""
    index = operator.index(k)
    if index < 0:
        return 1.0
    return min(1.0, math.fsum(self._pmf[index + 1 :]))

PreparedKernelQuadrature

Owned quadrature nodes, measure weights and rank-by-rank restricted Gram.

weight_func is the density of the kernel's reference measure; it defaults to Lebesgue density 1. It is called once per node. No density is inferred from a polynomial family. Storage is O(n_points * rank + rank**2).

Source code in finitefree/numerical.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
class PreparedKernelQuadrature:
    """Owned quadrature nodes, measure weights and rank-by-rank restricted Gram.

    weight_func is the density of the kernel's reference measure; it defaults
    to Lebesgue density 1. It is called once per node. No density is inferred
    from a polynomial family. Storage is O(n_points * rank + rank**2).
    """

    def __init__(
        self,
        kernel: PreparedOrthogonalKernel,
        a: float,
        b: float,
        n_points: SupportsIndex = 50,
        weight_func: Optional[Callable[[float], float]] = None,
    ) -> None:
        if not math.isfinite(a) or not math.isfinite(b) or a >= b:
            raise ValueError("Interval endpoints must be finite with a < b")
        try:
            order = operator.index(n_points)
        except TypeError as error:
            raise ValueError("n_points must be a positive integer") from error
        if isinstance(n_points, (bool, np.bool_)) or order < 1:
            raise ValueError("n_points must be a positive integer")
        points, weights = roots_legendre(order)
        # Half-endpoint arithmetic avoids overflowing b-a or a+b.
        half_width = b / 2 - a / 2
        self._nodes: NDArray[np.float64] = half_width * points + (a / 2 + b / 2)
        density = (
            np.ones(order)
            if weight_func is None
            else _real_array([weight_func(float(x)) for x in self._nodes], "Density")
        )
        if density.shape != (order,) or np.any(density < 0):
            raise ValueError("Density must return finite non-negative scalar values")
        with np.errstate(over="ignore", invalid="ignore"):
            self._weights: NDArray[np.float64] = half_width * weights * density
            self._weighted_basis = (
                kernel.basis(self._nodes) * np.sqrt(self._weights)[:, None]
            )
            self._gram: NDArray[np.float64] = (
                self._weighted_basis.T @ self._weighted_basis
            )
        if not np.all(np.isfinite(self._weights)) or not np.all(
            np.isfinite(self._gram)
        ):
            raise RuntimeError("Quadrature exceeds finite float64 range")

    @property
    def nodes(self) -> NDArray[np.float64]:
        return self._nodes.copy()

    @property
    def weights(self) -> NDArray[np.float64]:
        return self._weights.copy()

    @property
    def gram(self) -> NDArray[np.float64]:
        return self._gram.copy()

    def count_distribution(self) -> "DPPCountDistribution":
        """Compute a count law from this restriction without reevaluating it."""
        return DPPCountDistribution(self._gram, estimated=True)

    def gap_probability(self) -> float:
        """Estimated probability of no points, using the same restricted Gram."""
        return float(self.count_distribution().pmf(0))
Methods:
count_distribution()

Compute a count law from this restriction without reevaluating it.

Source code in finitefree/numerical.py
191
192
193
def count_distribution(self) -> "DPPCountDistribution":
    """Compute a count law from this restriction without reevaluating it."""
    return DPPCountDistribution(self._gram, estimated=True)
gap_probability()

Estimated probability of no points, using the same restricted Gram.

Source code in finitefree/numerical.py
195
196
197
def gap_probability(self) -> float:
    """Estimated probability of no points, using the same restricted Gram."""
    return float(self.count_distribution().pmf(0))

PreparedOrthogonalKernel

Owned numerical basis, reusable across batches and interval restrictions.

Verified Hermite bases use the normalized recurrence. Other bases use normalized polynomial coefficients and Horner evaluation. Neither path is an arbitrary-precision calculation; nonfinite results raise. Underflow and ill-conditioning can still lose information in finite float64 results.

Source code in finitefree/numerical.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class PreparedOrthogonalKernel:
    """Owned numerical basis, reusable across batches and interval restrictions.

    Verified Hermite bases use the normalized recurrence. Other bases use
    normalized polynomial coefficients and Horner evaluation. Neither path is
    an arbitrary-precision calculation; nonfinite results raise. Underflow and
    ill-conditioning can still lose information in finite float64 results.
    """

    def __init__(self, kernel: OrthogonalPolynomialKernel) -> None:
        self.n = kernel.n
        self._hermite = kernel._hermite_parameters is not None
        self._center = kernel._hermite_center
        self._initial = kernel._hermite_initial
        self._steps = kernel._hermite_steps
        self._coefficients: tuple[NDArray[np.float64], ...] = ()
        if self._hermite:
            if kernel._hermite_range_error:
                raise ValueError("Hermite normalization exceeds finite float64 range")
            return
        normalized = []
        for polynomial, norm in zip(kernel.polys[: self.n], kernel.norms):
            # Rational coefficients are normalized before narrowing, so a large
            # polynomial scaling can cancel a large norm without overflow.
            with localcontext() as context:
                context.prec = 40
                mass = _decimal_real(norm)
                if mass <= 0:
                    raise ValueError("Basis norms must be finite and positive")
                root = mass.sqrt()
                values = []
                for coefficient in polynomial.coeffs:
                    values.append(float(_decimal_real(coefficient) / root))
                normalized.append(_real_array(values, "Normalized coefficients"))
        self._coefficients = tuple(normalized)

    def basis(self, points: Any) -> NDArray[np.float64]:
        """Return normalized basis rows, shape (number of points, rank)."""
        xs = _real_array(points, "Coordinates")
        if xs.ndim != 1:
            raise ValueError("Coordinates must be one-dimensional")
        result = np.empty((len(xs), self.n))
        with np.errstate(over="ignore", invalid="ignore"):
            if self._hermite and self.n:
                previous = np.zeros(len(xs))
                current = np.full(len(xs), self._initial)
                result[:, 0] = current
                shifted = xs - self._center
                for j, (inverse_sqrt, ratio) in enumerate(self._steps, 1):
                    previous, current = (
                        current,
                        shifted * inverse_sqrt * current - ratio * previous,
                    )
                    result[:, j] = current
            else:
                for j, coefficients in enumerate(self._coefficients):
                    result[:, j] = np.polyval(coefficients, xs)
        if not np.all(np.isfinite(result)):
            raise RuntimeError("Numerical basis exceeds finite float64 range")
        return result

    def matrix(self, xs: Any, ys: Any = None) -> NDArray[np.float64]:
        """Return a float64 Gram/cross-kernel matrix, including empty batches."""
        left = self.basis(xs)
        right = left if ys is None else self.basis(ys)
        with np.errstate(over="ignore", invalid="ignore"):
            result = left @ right.T
        if not np.all(np.isfinite(result)):
            raise RuntimeError("Numerical kernel exceeds finite float64 range")
        return result

    def quadrature(
        self,
        a: float,
        b: float,
        n_points: SupportsIndex = 50,
        weight_func: Optional[Callable[[float], float]] = None,
    ) -> "PreparedKernelQuadrature":
        """Prepare a finite interval restriction in the kernel's measure."""
        return PreparedKernelQuadrature(self, a, b, n_points, weight_func)
Methods:
basis(points)

Return normalized basis rows, shape (number of points, rank).

Source code in finitefree/numerical.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def basis(self, points: Any) -> NDArray[np.float64]:
    """Return normalized basis rows, shape (number of points, rank)."""
    xs = _real_array(points, "Coordinates")
    if xs.ndim != 1:
        raise ValueError("Coordinates must be one-dimensional")
    result = np.empty((len(xs), self.n))
    with np.errstate(over="ignore", invalid="ignore"):
        if self._hermite and self.n:
            previous = np.zeros(len(xs))
            current = np.full(len(xs), self._initial)
            result[:, 0] = current
            shifted = xs - self._center
            for j, (inverse_sqrt, ratio) in enumerate(self._steps, 1):
                previous, current = (
                    current,
                    shifted * inverse_sqrt * current - ratio * previous,
                )
                result[:, j] = current
        else:
            for j, coefficients in enumerate(self._coefficients):
                result[:, j] = np.polyval(coefficients, xs)
    if not np.all(np.isfinite(result)):
        raise RuntimeError("Numerical basis exceeds finite float64 range")
    return result
matrix(xs, ys=None)

Return a float64 Gram/cross-kernel matrix, including empty batches.

Source code in finitefree/numerical.py
110
111
112
113
114
115
116
117
118
def matrix(self, xs: Any, ys: Any = None) -> NDArray[np.float64]:
    """Return a float64 Gram/cross-kernel matrix, including empty batches."""
    left = self.basis(xs)
    right = left if ys is None else self.basis(ys)
    with np.errstate(over="ignore", invalid="ignore"):
        result = left @ right.T
    if not np.all(np.isfinite(result)):
        raise RuntimeError("Numerical kernel exceeds finite float64 range")
    return result
quadrature(a, b, n_points=50, weight_func=None)

Prepare a finite interval restriction in the kernel's measure.

Source code in finitefree/numerical.py
120
121
122
123
124
125
126
127
128
def quadrature(
    self,
    a: float,
    b: float,
    n_points: SupportsIndex = 50,
    weight_func: Optional[Callable[[float], float]] = None,
) -> "PreparedKernelQuadrature":
    """Prepare a finite interval restriction in the kernel's measure."""
    return PreparedKernelQuadrature(self, a, b, n_points, weight_func)

Functions:

count_distribution_continuous(kernel, a, b, n_points=50, weight_func=None)

Estimated finite-rank interval count law; pass the reference density.

Source code in finitefree/numerical.py
276
277
278
279
280
281
282
283
284
285
286
287
288
def count_distribution_continuous(
    kernel: OrthogonalPolynomialKernel,
    a: float,
    b: float,
    n_points: SupportsIndex = 50,
    weight_func: Optional[Callable[[float], float]] = None,
) -> DPPCountDistribution:
    """Estimated finite-rank interval count law; pass the reference density."""
    return (
        kernel.prepare_numeric()
        .quadrature(a, b, n_points, weight_func)
        .count_distribution()
    )

count_distribution_discrete(kernel, points)

Numerical count law on distinct states of a discrete correlation kernel.

Source code in finitefree/numerical.py
266
267
268
269
270
271
272
273
def count_distribution_discrete(
    kernel: BaseKernel, points: Sequence[Any]
) -> DPPCountDistribution:
    """Numerical count law on distinct states of a discrete correlation kernel."""
    if len(set(points)) != len(points):
        raise ValueError("points must contain distinct states")
    matrix = kernel.matrix(points) if len(points) else np.empty((0, 0))
    return DPPCountDistribution(matrix)

Multivariate Geometry & Matrix Pencils

finitefree.hyperbolic

Classes

MultiplicativeMatrixPencil

Data structures for generalized asymmetric matrix pencils representing algebraic geometries of multiplicative convolutions.

Source code in finitefree/hyperbolic/pencils.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
class MultiplicativeMatrixPencil:
    """
    Data structures for generalized asymmetric matrix pencils representing
    algebraic geometries of multiplicative convolutions.
    """

    def __init__(self, matrices: Sequence[Any]) -> None:
        """Copy integer/rational inputs before preparing the float64 numerical view."""
        self.matrices, self._matrices_exact = _prepare_matrices(matrices, False)
        self.m = len(self.matrices)
        self.n = self.matrices[0].shape[0]

    def prepare_numeric(
        self,
        *,
        backend: str = "numpy",
        point_block_size: int = 64,
        coefficient_block_size: int = 16,
    ) -> "PreparedMatrixPencil":
        """Copy the numerical view once; CuPy explicitly transfers it to the GPU.

        Retain the returned object for repeated resident float64 batch queries.
        Subsequent edits to this pencil's matrices do not change that snapshot.
        Exact coefficients and existing polynomial/SLP methods are unaffected.
        """
        from .prepared import PreparedMatrixPencil

        matrices = np.stack(self.matrices)
        if backend == "cupy":
            import importlib

            try:
                cp = importlib.import_module("cupy")
            except ImportError as error:
                raise ImportError("backend='cupy' requires CuPy") from error
            matrices = cp.asarray(matrices)
        return PreparedMatrixPencil(
            matrices,
            backend=backend,
            point_block_size=point_block_size,
            coefficient_block_size=coefficient_block_size,
        )

    def evaluate(self, x: Sequence[float]) -> NDArray[np.float64]:
        if len(x) != self.m:
            raise ValueError(f"Expected {self.m} variables, got {len(x)}")
        result = np.zeros((self.n, self.n), dtype=np.float64)
        for xi, A in zip(x, self.matrices):
            result += xi * A
        return result

    def _get_matrices_exact(self) -> List[Any]:
        return self._matrices_exact

    def _evaluate_exact(self, x: Sequence[Any]) -> Any:
        if len(x) != self.m:
            raise ValueError(f"Expected {self.m} variables, got {len(x)}")
        import flint

        from ..utils.conversion import sympy_to_fmpq

        A_exact = flint.fmpq_mat(self.n, self.n)
        exact_mats = self._get_matrices_exact()
        for xi, Ai in zip(x, exact_mats):
            A_exact += Ai * sympy_to_fmpq(xi)
        return A_exact

    def characteristic_polynomial(self, x: Sequence[Any]) -> flint.fmpq_poly:
        r"""
        Computes the characteristic polynomial $\det(t I - A(x))$ of the pencil evaluated at $x$
        exactly using rational interpolation over $\mathbb{Q}[t]$.
        """
        import flint

        A_exact = self._evaluate_exact(x)
        n = self.n

        xs = [flint.fmpq(j) for j in range(n + 1)]
        ys = []
        for j in range(n + 1):
            tj = xs[j]
            M = flint.fmpq_mat(n, n)
            for r in range(n):
                for c in range(n):
                    if r == c:
                        M[r, c] = tj - A_exact[r, c]
                    else:
                        M[r, c] = -A_exact[r, c]
            ys.append(M.det())

        # Exact Lagrange interpolation
        t_var = flint.fmpq_poly([0, 1])
        poly_sum = flint.fmpq_poly([0])
        for i in range(n + 1):
            denom = flint.fmpq(1)
            L_i = flint.fmpq_poly([1])
            for j in range(n + 1):
                if j != i:
                    L_i *= t_var - xs[j]
                    denom *= xs[i] - xs[j]
            poly_sum += L_i * (ys[i] / denom)

        return poly_sum

    def _get_matrices_sympy(self) -> List[List[List[Any]]]:
        if not hasattr(self, "_matrices_sympy"):
            import sympy as sp

            self._matrices_sympy = []
            for mat in self._get_matrices_exact():
                row_list = []
                for r in range(self.n):
                    col_list = []
                    for c in range(self.n):
                        value = mat[r, c]
                        val = sp.Rational(int(value.p), int(value.q))
                        col_list.append(val)
                    row_list.append(col_list)
                self._matrices_sympy.append(row_list)
        return self._matrices_sympy

    def characteristic_polynomial_slp(self) -> Any:
        """
        Converts the characteristic polynomial of the multiplicative matrix pencil
        into a StraightLineProgram for efficient gradient/Hessian queries.
        """
        from .slp import StraightLineProgram

        return StraightLineProgram(operations=["det"], pencil=self)
Methods:
__init__(matrices)

Copy integer/rational inputs before preparing the float64 numerical view.

Source code in finitefree/hyperbolic/pencils.py
348
349
350
351
352
def __init__(self, matrices: Sequence[Any]) -> None:
    """Copy integer/rational inputs before preparing the float64 numerical view."""
    self.matrices, self._matrices_exact = _prepare_matrices(matrices, False)
    self.m = len(self.matrices)
    self.n = self.matrices[0].shape[0]
characteristic_polynomial(x)

Computes the characteristic polynomial \(\det(t I - A(x))\) of the pencil evaluated at \(x\) exactly using rational interpolation over \(\mathbb{Q}[t]\).

Source code in finitefree/hyperbolic/pencils.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
def characteristic_polynomial(self, x: Sequence[Any]) -> flint.fmpq_poly:
    r"""
    Computes the characteristic polynomial $\det(t I - A(x))$ of the pencil evaluated at $x$
    exactly using rational interpolation over $\mathbb{Q}[t]$.
    """
    import flint

    A_exact = self._evaluate_exact(x)
    n = self.n

    xs = [flint.fmpq(j) for j in range(n + 1)]
    ys = []
    for j in range(n + 1):
        tj = xs[j]
        M = flint.fmpq_mat(n, n)
        for r in range(n):
            for c in range(n):
                if r == c:
                    M[r, c] = tj - A_exact[r, c]
                else:
                    M[r, c] = -A_exact[r, c]
        ys.append(M.det())

    # Exact Lagrange interpolation
    t_var = flint.fmpq_poly([0, 1])
    poly_sum = flint.fmpq_poly([0])
    for i in range(n + 1):
        denom = flint.fmpq(1)
        L_i = flint.fmpq_poly([1])
        for j in range(n + 1):
            if j != i:
                L_i *= t_var - xs[j]
                denom *= xs[i] - xs[j]
        poly_sum += L_i * (ys[i] / denom)

    return poly_sum
characteristic_polynomial_slp()

Converts the characteristic polynomial of the multiplicative matrix pencil into a StraightLineProgram for efficient gradient/Hessian queries.

Source code in finitefree/hyperbolic/pencils.py
463
464
465
466
467
468
469
470
def characteristic_polynomial_slp(self) -> Any:
    """
    Converts the characteristic polynomial of the multiplicative matrix pencil
    into a StraightLineProgram for efficient gradient/Hessian queries.
    """
    from .slp import StraightLineProgram

    return StraightLineProgram(operations=["det"], pencil=self)
prepare_numeric(*, backend='numpy', point_block_size=64, coefficient_block_size=16)

Copy the numerical view once; CuPy explicitly transfers it to the GPU.

Retain the returned object for repeated resident float64 batch queries. Subsequent edits to this pencil's matrices do not change that snapshot. Exact coefficients and existing polynomial/SLP methods are unaffected.

Source code in finitefree/hyperbolic/pencils.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def prepare_numeric(
    self,
    *,
    backend: str = "numpy",
    point_block_size: int = 64,
    coefficient_block_size: int = 16,
) -> "PreparedMatrixPencil":
    """Copy the numerical view once; CuPy explicitly transfers it to the GPU.

    Retain the returned object for repeated resident float64 batch queries.
    Subsequent edits to this pencil's matrices do not change that snapshot.
    Exact coefficients and existing polynomial/SLP methods are unaffected.
    """
    from .prepared import PreparedMatrixPencil

    matrices = np.stack(self.matrices)
    if backend == "cupy":
        import importlib

        try:
            cp = importlib.import_module("cupy")
        except ImportError as error:
            raise ImportError("backend='cupy' requires CuPy") from error
        matrices = cp.asarray(matrices)
    return PreparedMatrixPencil(
        matrices,
        backend=backend,
        point_block_size=point_block_size,
        coefficient_block_size=coefficient_block_size,
    )

PencilFactorization

Reusable result of :meth:PreparedMatrixPencil.factor.

Owns LU factors, not input points. Returned arrays are independent copies. Slogdet and gradient are cached; HVPs reuse factors and compute coefficient solves in bounded blocks. A combined first gradient/HVP query uses one pass of those solves. This trades recomputation on subsequent HVPs for bounded O(coefficient_blocknn) scratch rather than O(batchmn*n) retained solves.

Source code in finitefree/hyperbolic/prepared.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
class PencilFactorization:
    """Reusable result of :meth:`PreparedMatrixPencil.factor`.

    Owns LU factors, not input points. Returned arrays are independent copies.
    Slogdet and gradient are cached; HVPs reuse factors and compute coefficient
    solves in bounded blocks. A combined first gradient/HVP query uses one pass
    of those solves. This trades recomputation on subsequent HVPs for bounded
    O(coefficient_block*n*n) scratch rather than O(batch*m*n*n) retained solves.
    """

    def __init__(
        self,
        pencil: PreparedMatrixPencil,
        factors: list[tuple[Any, Any] | None],
        signs: Any,
        logs: Any,
        shape: tuple[int, ...],
    ) -> None:
        self._pencil = pencil
        self._factors = factors
        self._signs = signs
        self._logs = logs
        self._shape = shape
        self._gradient: Any = None
        self._singular = bool(pencil._xp.any(signs == 0))

    def slogdet(self) -> tuple[Any, Any]:
        """Return sign and log(abs(det)); singular entries yield (0, -inf)."""
        self._pencil._check_device()
        return (
            self._signs.reshape(self._shape).copy(),
            self._logs.reshape(self._shape).copy(),
        )

    def _solve(self, factor: tuple[Any, Any], rhs: Any) -> Any:
        p = self._pencil
        context = (
            p._cupyx.errstate(linalg="raise") if p._cupyx is not None else nullcontext()
        )
        with context:
            result = p._linalg.lu_solve(factor, rhs, check_finite=False)
        p._finite_result(result)
        return result

    def _derivatives(self, directions: Any = None) -> Any:
        p = self._pencil
        p._check_device()
        if self._singular:
            raise np.linalg.LinAlgError(
                "Logdet derivatives require nonsingular matrices"
            )
        xp = p._xp
        need_gradient = self._gradient is None
        gradient = (
            xp.empty((len(self._factors), p.m), dtype=np.float64)
            if need_gradient
            else self._gradient
        )
        hvp = xp.empty_like(gradient) if directions is not None else None
        for row, factor in enumerate(self._factors):
            assert factor is not None  # Singular batches were rejected above.
            bv = None
            if directions is not None:
                av = p._assemble(directions[row : row + 1])[0]
                bv = self._solve(factor, av)
            for start in range(0, p.m, p._coefficient_block):
                stop = min(start + p._coefficient_block, p.m)
                solved = self._solve(factor, p._rhs[:, start * p.n : stop * p.n])
                blocks = solved.reshape(p.n, stop - start, p.n).transpose(1, 0, 2)
                if need_gradient:
                    gradient[row, start:stop] = xp.trace(blocks, axis1=1, axis2=2)
                if hvp is not None:
                    hvp[row, start:stop] = -xp.einsum("kij,ji->k", blocks, bv)
        p._finite_result(gradient)
        if hvp is not None:
            p._finite_result(hvp)
        self._gradient = gradient
        return hvp

    def logabsdet_gradient(self) -> Any:
        r"""Return ``tr(A(x)^-1 A_i)`` with shape ``(..., m)``.

        Requires nonsingular matrices, including outside the positive-definite
        cone. Near-singular inputs are not regularized or pseudoinverted.
        """
        self._pencil._check_device()
        if self._gradient is None:
            self._derivatives()
        return self._gradient.reshape(*self._shape, self._pencil.m).copy()

    def logabsdet_hvp(self, directions: Any) -> Any:
        r"""Return ``-tr(A^-1 A_i A^-1 A(v))``, without a full Hessian.

        Directions must be resident finite float64 arrays of shape ``(m,)``
        (one shared direction) or exactly ``points.shape``. No other implicit
        broadcasting is accepted. The first HVP also caches the gradient.
        """
        p = self._pencil
        p._validate_array(directions, "directions")
        if directions.shape == (p.m,):
            flat = p._xp.broadcast_to(directions, (len(self._factors), p.m))
        elif directions.shape == (*self._shape, p.m):
            flat = directions.reshape(-1, p.m)
        else:
            raise ValueError("directions must have shape (m,) or the points shape")
        return self._derivatives(flat).reshape(*self._shape, p.m)
Methods:
logabsdet_gradient()

Return tr(A(x)^-1 A_i) with shape (..., m).

Requires nonsingular matrices, including outside the positive-definite cone. Near-singular inputs are not regularized or pseudoinverted.

Source code in finitefree/hyperbolic/prepared.py
285
286
287
288
289
290
291
292
293
294
def logabsdet_gradient(self) -> Any:
    r"""Return ``tr(A(x)^-1 A_i)`` with shape ``(..., m)``.

    Requires nonsingular matrices, including outside the positive-definite
    cone. Near-singular inputs are not regularized or pseudoinverted.
    """
    self._pencil._check_device()
    if self._gradient is None:
        self._derivatives()
    return self._gradient.reshape(*self._shape, self._pencil.m).copy()
logabsdet_hvp(directions)

Return -tr(A^-1 A_i A^-1 A(v)), without a full Hessian.

Directions must be resident finite float64 arrays of shape (m,) (one shared direction) or exactly points.shape. No other implicit broadcasting is accepted. The first HVP also caches the gradient.

Source code in finitefree/hyperbolic/prepared.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
def logabsdet_hvp(self, directions: Any) -> Any:
    r"""Return ``-tr(A^-1 A_i A^-1 A(v))``, without a full Hessian.

    Directions must be resident finite float64 arrays of shape ``(m,)``
    (one shared direction) or exactly ``points.shape``. No other implicit
    broadcasting is accepted. The first HVP also caches the gradient.
    """
    p = self._pencil
    p._validate_array(directions, "directions")
    if directions.shape == (p.m,):
        flat = p._xp.broadcast_to(directions, (len(self._factors), p.m))
    elif directions.shape == (*self._shape, p.m):
        flat = directions.reshape(-1, p.m)
    else:
        raise ValueError("directions must have shape (m,) or the points shape")
    return self._derivatives(flat).reshape(*self._shape, p.m)
slogdet()

Return sign and log(abs(det)); singular entries yield (0, -inf).

Source code in finitefree/hyperbolic/prepared.py
232
233
234
235
236
237
238
def slogdet(self) -> tuple[Any, Any]:
    """Return sign and log(abs(det)); singular entries yield (0, -inf)."""
    self._pencil._check_device()
    return (
        self._signs.reshape(self._shape).copy(),
        self._logs.reshape(self._shape).copy(),
    )

PreparedMatrixPencil

Own a numerical snapshot of A(x) = sum_i x_i A_i.

matrices must be a resident float64 ndarray of shape (m, n, n) with m,n > 0. NumPy is the default; backend='cupy' requires an installed CuPy and a working GPU. There is no automatic backend choice, transfer, dtype coercion, symmetry assumption, or fallback. Preparation copies the coefficients and packs solve right-hand sides once. Exact pencil methods remain separate; pencil.prepare_numeric() snapshots its numerical view.

Inputs to evaluate/factor have shape (..., m), including empty batches and strided arrays. Outputs preserve the leading shape and backend. Finite inputs are validated once per call; overflow in assembly/factorization is rejected. All operations stay on the preparation device and current stream; cross-device arrays/current-device changes are rejected. GPU validation and solver error checks can synchronize. Callers must order concurrent streams.

Point blocks bound assembly scratch; coefficient blocks bound derivative solve scratch. Neither caps returned arrays or retained factors: evaluation needs O(batchnn) output and factorization retains that many LU entries. Use smaller external batches if that storage is too large. No full inverse or variable-by-variable Hessian is constructed. No positive-definite cone membership is implied by a positive determinant.

Source code in finitefree/hyperbolic/prepared.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
class PreparedMatrixPencil:
    r"""Own a numerical snapshot of ``A(x) = sum_i x_i A_i``.

    ``matrices`` must be a resident float64 ndarray of shape ``(m, n, n)``
    with m,n > 0. NumPy is the default; ``backend='cupy'`` requires an installed
    CuPy and a working GPU. There is no automatic backend choice, transfer,
    dtype coercion, symmetry assumption, or fallback. Preparation copies the
    coefficients and packs solve right-hand sides once. Exact pencil methods
    remain separate; ``pencil.prepare_numeric()`` snapshots its numerical view.

    Inputs to evaluate/factor have shape ``(..., m)``, including empty batches
    and strided arrays. Outputs preserve the leading shape and backend. Finite
    inputs are validated once per call; overflow in assembly/factorization is
    rejected. All operations stay on the preparation device and current stream;
    cross-device arrays/current-device changes are rejected. GPU validation and
    solver error checks can synchronize. Callers must order concurrent streams.

    Point blocks bound assembly scratch; coefficient blocks bound derivative
    solve scratch. Neither caps returned arrays or retained factors: evaluation
    needs O(batch*n*n) output and factorization retains that many LU entries.
    Use smaller external batches if that storage is too large. No full inverse
    or variable-by-variable Hessian is constructed. No positive-definite cone
    membership is implied by a positive determinant.
    """

    def __init__(
        self,
        matrices: Any,
        *,
        backend: str = "numpy",
        point_block_size: int = 64,
        coefficient_block_size: int = 16,
    ) -> None:
        self._point_block = _positive_size(point_block_size, "point_block_size")
        self._coefficient_block = _positive_size(
            coefficient_block_size, "coefficient_block_size"
        )
        self._backend = backend
        self._device: int | None = None
        self._cupyx: Any = None
        if backend == "numpy":
            self._xp: Any = np
            self._linalg: Any = sla
        elif backend == "cupy":
            try:
                self._xp = importlib.import_module("cupy")
                self._cupyx = importlib.import_module("cupyx")
                self._linalg = importlib.import_module("cupyx.scipy.linalg")
            except ImportError as error:
                raise ImportError(
                    "backend='cupy' requires CuPy installed for your CUDA runtime"
                ) from error
            if self._xp.cuda.runtime.getDeviceCount() < 1:
                raise RuntimeError("backend='cupy' requires a working GPU")
            self._device = int(self._xp.cuda.runtime.getDevice())
        else:
            raise ValueError("backend must be 'numpy' or 'cupy'")
        self._validate_array(matrices, "matrices")
        if (
            matrices.ndim != 3
            or matrices.shape[0] == 0
            or matrices.shape[1] == 0
            or matrices.shape[1] != matrices.shape[2]
        ):
            raise ValueError("matrices must have shape (m, n, n) with m,n > 0")
        self._m, self._n, _ = matrices.shape
        self._coefficients = self._xp.array(matrices, copy=True, order="C")
        # Each coefficient occupies n adjacent columns of the reusable RHS.
        self._rhs = self._xp.ascontiguousarray(
            self._coefficients.transpose(1, 0, 2)
        ).reshape(self.n, self.m * self.n)

    @property
    def m(self) -> int:
        return int(self._m)

    @property
    def n(self) -> int:
        return int(self._n)

    @property
    def backend(self) -> str:
        return self._backend

    def _check_device(self) -> None:
        if self._device is not None:
            if self._xp.cuda.runtime.getDevice() != self._device:
                raise ValueError("Current device must match the preparation device")

    def _validate_array(self, array: Any, name: str) -> None:
        self._check_device()
        if not isinstance(array, self._xp.ndarray) or array.dtype != np.float64:
            raise TypeError(f"{name} must be a resident {self.backend} float64 ndarray")
        if self._device is not None and array.device.id != self._device:
            raise ValueError(f"{name} must reside on the preparation device")
        if not bool(self._xp.isfinite(array).all()):
            raise ValueError(f"{name} must contain only finite values")

    def _points(self, points: Any) -> tuple[Any, tuple[int, ...]]:
        self._validate_array(points, "points")
        if points.ndim < 1 or points.shape[-1] != self.m:
            raise ValueError(f"points must have shape (..., {self.m})")
        return points.reshape(-1, self.m), points.shape[:-1]

    def _finite_result(self, value: Any) -> None:
        if not bool(self._xp.isfinite(value).all()):
            raise FloatingPointError("Nonfinite numerical result; rescale the pencil")

    def _assemble(self, points: Any) -> Any:
        with np.errstate(over="ignore", invalid="ignore"):
            result = (points @ self._coefficients.reshape(self.m, -1)).reshape(
                -1, self.n, self.n
            )
        self._finite_result(result)
        return result

    def evaluate(self, points: Any) -> Any:
        """Return owned matrices with shape ``(..., n, n)`` on this backend."""
        flat, shape = self._points(points)
        result = self._xp.empty((len(flat), self.n, self.n), dtype=np.float64)
        for start in range(0, len(flat), self._point_block):
            stop = start + self._point_block
            result[start:stop] = self._assemble(flat[start:stop])
        return result.reshape(*shape, self.n, self.n)

    def _factor_matrix(self, matrix: Any) -> tuple[Any, Any] | None:
        # SciPy uses LinAlgWarning; CuPy uses RuntimeWarning for positive GETRF
        # info. Convert ONLY that exact diagnostic to a local exception so no
        # unusable singular LU (which CuPy may fill with NaNs) is retained or
        # inspected. This also works under caller warnings-as-errors policies.
        # All other warnings/errors retain their original behavior; nonfinite
        # LU without an explicit singular diagnostic still fails validation.
        try:
            with warnings.catch_warnings():
                for category in (sla.LinAlgWarning, RuntimeWarning):
                    warnings.filterwarnings(
                        "error", message=f"^{_SINGULAR_LU_WARNING}$", category=category
                    )
                lu, piv = self._linalg.lu_factor(matrix, check_finite=False)
        except (sla.LinAlgWarning, RuntimeWarning) as error:
            if re.fullmatch(_SINGULAR_LU_WARNING, str(error)) is None:
                raise
            return None
        self._finite_result(lu)
        return lu, piv

    def factor(self, points: Any) -> PencilFactorization:
        """Own LU factors for a point snapshot, reusable across derivative queries.

        Singular points are recorded for slogdet but all derivative queries
        reject a batch containing one. No tolerance truncates near-singular
        matrices; their derivatives remain conditioning-dependent.
        """
        flat, shape = self._points(points)
        xp = self._xp
        factors: list[tuple[Any, Any] | None] = []
        signs = xp.empty(len(flat), dtype=np.float64)
        logs = xp.empty(len(flat), dtype=np.float64)
        indices = xp.arange(self.n)
        for start in range(0, len(flat), self._point_block):
            matrices = self._assemble(flat[start : start + self._point_block])
            for offset, matrix in enumerate(matrices):
                factor = self._factor_matrix(matrix)
                factors.append(factor)
                if factor is None:
                    signs[start + offset] = 0.0
                    logs[start + offset] = -xp.inf
                    continue
                lu, piv = factor
                diagonal = xp.diagonal(lu)
                parity = xp.count_nonzero(piv != indices) % 2
                signs[start + offset] = (1 - 2 * parity) * xp.prod(xp.sign(diagonal))
                with np.errstate(divide="ignore"):
                    logs[start + offset] = xp.sum(xp.log(xp.abs(diagonal)))
        return PencilFactorization(self, factors, signs, logs, shape)
Methods:
evaluate(points)

Return owned matrices with shape (..., n, n) on this backend.

Source code in finitefree/hyperbolic/prepared.py
145
146
147
148
149
150
151
152
def evaluate(self, points: Any) -> Any:
    """Return owned matrices with shape ``(..., n, n)`` on this backend."""
    flat, shape = self._points(points)
    result = self._xp.empty((len(flat), self.n, self.n), dtype=np.float64)
    for start in range(0, len(flat), self._point_block):
        stop = start + self._point_block
        result[start:stop] = self._assemble(flat[start:stop])
    return result.reshape(*shape, self.n, self.n)
factor(points)

Own LU factors for a point snapshot, reusable across derivative queries.

Singular points are recorded for slogdet but all derivative queries reject a batch containing one. No tolerance truncates near-singular matrices; their derivatives remain conditioning-dependent.

Source code in finitefree/hyperbolic/prepared.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def factor(self, points: Any) -> PencilFactorization:
    """Own LU factors for a point snapshot, reusable across derivative queries.

    Singular points are recorded for slogdet but all derivative queries
    reject a batch containing one. No tolerance truncates near-singular
    matrices; their derivatives remain conditioning-dependent.
    """
    flat, shape = self._points(points)
    xp = self._xp
    factors: list[tuple[Any, Any] | None] = []
    signs = xp.empty(len(flat), dtype=np.float64)
    logs = xp.empty(len(flat), dtype=np.float64)
    indices = xp.arange(self.n)
    for start in range(0, len(flat), self._point_block):
        matrices = self._assemble(flat[start : start + self._point_block])
        for offset, matrix in enumerate(matrices):
            factor = self._factor_matrix(matrix)
            factors.append(factor)
            if factor is None:
                signs[start + offset] = 0.0
                logs[start + offset] = -xp.inf
                continue
            lu, piv = factor
            diagonal = xp.diagonal(lu)
            parity = xp.count_nonzero(piv != indices) % 2
            signs[start + offset] = (1 - 2 * parity) * xp.prod(xp.sign(diagonal))
            with np.errstate(divide="ignore"):
                logs[start + offset] = xp.sum(xp.log(xp.abs(diagonal)))
    return PencilFactorization(self, factors, signs, logs, shape)

StraightLineProgram

Evaluate a scalar operation sequence or a determinant matrix pencil. Generic scalar gradients use reverse-mode differentiation. The determinant shortcut uses matrix determinant/inverse trace identities for derivatives. Exact=True uses rational FLINT operations; numerical methods use float64. Exact determinant derivatives reject singular evaluated matrices.

Source code in finitefree/hyperbolic/slp.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
class StraightLineProgram:
    r"""
    Evaluate a scalar operation sequence or a determinant matrix pencil.
    Generic scalar gradients use reverse-mode differentiation. The determinant
    shortcut uses matrix determinant/inverse trace identities for derivatives.
    Exact=True uses rational FLINT operations; numerical methods use float64.
    Exact determinant derivatives reject singular evaluated matrices.
    """

    def __init__(self, operations: Sequence[Any], pencil: Any = None) -> None:
        """
        operations: A sequence of operations representing the polynomial evaluation.
        pencil: An optional SymmetricMatrixPencil.
        """
        self.operations = operations
        self.pencil = pencil

    def evaluate(self, x: NDArray[np.float64], exact: bool = False) -> Any:
        """
        Evaluates the polynomial at the point x.
        """
        if self.operations == ["det"]:
            if self.pencil is None:
                raise NotImplementedError("Pencil not provided to SLP")
            if exact:
                A_exact = self._evaluate_exact(x)  # type: ignore[arg-type]
                return A_exact.det()
            A_val = self.pencil.evaluate(x)
            return float(np.linalg.det(A_val))

        # Generic SLP evaluation
        if exact:
            v = [sympy_to_fmpq(xi) for xi in x]
        else:
            v = [float(xi) for xi in x]

        for op_info in self.operations:
            op = op_info[0]
            if op == "const":
                val = op_info[1]
                v.append(sympy_to_fmpq(val) if exact else float(val))
            elif op in ("add", "+"):
                v.append(v[op_info[1]] + v[op_info[2]])
            elif op in ("sub", "-"):
                v.append(v[op_info[1]] - v[op_info[2]])
            elif op in ("mul", "*"):
                v.append(v[op_info[1]] * v[op_info[2]])
            elif op in ("div", "/"):
                v.append(v[op_info[1]] / v[op_info[2]])
            elif op == "neg":
                v.append(-v[op_info[1]])

        return v[-1]

    def _evaluate_exact(self, x: Sequence[Any]) -> Any:
        return self.pencil._evaluate_exact(x)

    def gradient(self, x: NDArray[np.float64], exact: bool = False) -> NDArray[Any]:
        r"""
        Compute a generic reverse-mode gradient or a determinant-pencil gradient.
        Exact determinant gradients require a nonsingular evaluated matrix and raise
        ValueError at singular points. Numerical determinant gradients use an inverse
        or pseudoinverse; they are not reliable singular-point derivative formulas.
        """
        if self.operations == ["det"]:
            if self.pencil is None:
                raise NotImplementedError("Pencil not provided to SLP")
            if exact:
                import flint

                A_exact = self._evaluate_exact(x)  # type: ignore[arg-type]
                det_A = A_exact.det()
                if det_A == 0:
                    raise ValueError(
                        "Exact gradient at singular point is not supported."
                    )
                identity = flint.fmpq_mat(self.pencil.n, self.pencil.n)
                for i in range(self.pencil.n):
                    identity[i, i] = flint.fmpq(1, 1)
                inv_A = A_exact.solve(identity)

                grad = []
                for Ai in self.pencil._get_matrices_exact():
                    tr = flint.fmpq(0)
                    for r in range(self.pencil.n):
                        for c in range(self.pencil.n):
                            tr += inv_A[r, c] * sympy_to_fmpq(Ai[c, r])
                    grad.append(det_A * tr)
                return np.array(grad, dtype=object)

            A_val = self.pencil.evaluate(x)
            det_A = np.linalg.det(A_val)
            if np.abs(det_A) < 1e-15:
                inv_A = np.linalg.pinv(A_val)
            else:
                inv_A = np.linalg.inv(A_val)

            grad_num = np.zeros(self.pencil.m, dtype=np.float64)
            for i, Ai in enumerate(self.pencil.matrices):
                grad_num[i] = det_A * np.trace(inv_A @ Ai)
            return grad_num

        # Generic Reverse-Mode Automatic Differentiation
        m = len(x)
        one: Any
        zero: Any
        if exact:
            import flint

            v = [sympy_to_fmpq(xi) for xi in x]
            one = flint.fmpq(1)
            zero = flint.fmpq(0)
        else:
            v = [float(xi) for xi in x]
            one = 1.0
            zero = 0.0

        # Forward pass to build trace
        for op_info in self.operations:
            op = op_info[0]
            if op == "const":
                val = op_info[1]
                v.append(sympy_to_fmpq(val) if exact else float(val))
            elif op in ("add", "+"):
                v.append(v[op_info[1]] + v[op_info[2]])
            elif op in ("sub", "-"):
                v.append(v[op_info[1]] - v[op_info[2]])
            elif op in ("mul", "*"):
                v.append(v[op_info[1]] * v[op_info[2]])
            elif op in ("div", "/"):
                v.append(v[op_info[1]] / v[op_info[2]])
            elif op == "neg":
                v.append(-v[op_info[1]])

        # Backward pass
        n_total = len(v)
        adj = [zero] * n_total
        adj[-1] = one

        for k in range(n_total - 1, m - 1, -1):
            op_info = self.operations[k - m]
            op = op_info[0]
            a = adj[k]
            if op in ("add", "+"):
                adj[op_info[1]] += a
                adj[op_info[2]] += a
            elif op in ("sub", "-"):
                adj[op_info[1]] += a
                adj[op_info[2]] -= a
            elif op in ("mul", "*"):
                adj[op_info[1]] += a * v[op_info[2]]
                adj[op_info[2]] += a * v[op_info[1]]
            elif op in ("div", "/"):
                adj[op_info[1]] += a / v[op_info[2]]
                adj[op_info[2]] -= a * v[op_info[1]] / (v[op_info[2]] * v[op_info[2]])
            elif op == "neg":
                adj[op_info[1]] -= a

        if exact:
            return np.array(adj[:m], dtype=object)
        return np.array([flint_to_float(val) for val in adj[:m]], dtype=np.float64)

    def hessian(self, x: NDArray[np.float64], exact: bool = False) -> NDArray[Any]:
        r"""
        Compute determinant-pencil Hessians using Jacobi's trace identities.
        A pencil is required; generic scalar-operation Hessians are not implemented.
        Exact=True raises ValueError at singular matrices. Numerical inverse or
        pseudoinverse evaluation remains conditioning-dependent, including near
        singular points.
        """
        if self.pencil is None:
            raise NotImplementedError("Pencil not provided to SLP")
        if exact:
            import flint

            A_exact = self._evaluate_exact(x)  # type: ignore[arg-type]
            det_A = A_exact.det()
            if det_A == 0:
                raise ValueError("Exact Hessian at singular point is not supported.")
            identity = flint.fmpq_mat(self.pencil.n, self.pencil.n)
            for i in range(self.pencil.n):
                identity[i, i] = flint.fmpq(1, 1)
            inv_A = A_exact.solve(identity)

            m = self.pencil.m
            hess = np.zeros((m, m), dtype=object)
            B = []
            for Ai in self.pencil._get_matrices_exact():
                Bi = flint.fmpq_mat(self.pencil.n, self.pencil.n)
                for r in range(self.pencil.n):
                    for c in range(self.pencil.n):
                        val = flint.fmpq(0)
                        for k in range(self.pencil.n):
                            val += inv_A[r, k] * sympy_to_fmpq(Ai[k, c])
                        Bi[r, c] = val
                B.append(Bi)

            traces = []
            for Bi in B:
                tr = flint.fmpq(0)
                for r in range(self.pencil.n):
                    tr += Bi[r, r]
                traces.append(tr)

            for i in range(m):
                for j in range(m):
                    term1 = traces[i] * traces[j]
                    term2 = flint.fmpq(0)
                    for r in range(self.pencil.n):
                        for c in range(self.pencil.n):
                            term2 += B[i][r, c] * B[j][c, r]
                    hess[i, j] = det_A * (term1 - term2)
            return hess

        A_val = self.pencil.evaluate(x)
        det_A = np.linalg.det(A_val)
        if np.abs(det_A) < 1e-15:
            inv_A = np.linalg.pinv(A_val)
        else:
            inv_A = np.linalg.inv(A_val)

        m = self.pencil.m
        hess = np.zeros((m, m), dtype=np.float64)

        # Precompute B_i = inv_A @ A_i to avoid O(n^3) matrix multiplications
        # inside the nested loop
        B_float = [inv_A @ Ai for Ai in self.pencil.matrices]
        traces = [np.trace(Bi) for Bi in B_float]
        B_T = [Bi.T for Bi in B_float]

        for i in range(m):
            for j in range(m):
                term1 = traces[i] * traces[j]
                # tr(B_i @ B_j) = sum(B_i * B_j.T) which is O(n^2) instead of O(n^3)
                term2 = np.sum(B_float[i] * B_T[j])
                hess[i, j] = det_A * (term1 - term2)
        return hess
Methods:
__init__(operations, pencil=None)

operations: A sequence of operations representing the polynomial evaluation. pencil: An optional SymmetricMatrixPencil.

Source code in finitefree/hyperbolic/slp.py
18
19
20
21
22
23
24
def __init__(self, operations: Sequence[Any], pencil: Any = None) -> None:
    """
    operations: A sequence of operations representing the polynomial evaluation.
    pencil: An optional SymmetricMatrixPencil.
    """
    self.operations = operations
    self.pencil = pencil
evaluate(x, exact=False)

Evaluates the polynomial at the point x.

Source code in finitefree/hyperbolic/slp.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def evaluate(self, x: NDArray[np.float64], exact: bool = False) -> Any:
    """
    Evaluates the polynomial at the point x.
    """
    if self.operations == ["det"]:
        if self.pencil is None:
            raise NotImplementedError("Pencil not provided to SLP")
        if exact:
            A_exact = self._evaluate_exact(x)  # type: ignore[arg-type]
            return A_exact.det()
        A_val = self.pencil.evaluate(x)
        return float(np.linalg.det(A_val))

    # Generic SLP evaluation
    if exact:
        v = [sympy_to_fmpq(xi) for xi in x]
    else:
        v = [float(xi) for xi in x]

    for op_info in self.operations:
        op = op_info[0]
        if op == "const":
            val = op_info[1]
            v.append(sympy_to_fmpq(val) if exact else float(val))
        elif op in ("add", "+"):
            v.append(v[op_info[1]] + v[op_info[2]])
        elif op in ("sub", "-"):
            v.append(v[op_info[1]] - v[op_info[2]])
        elif op in ("mul", "*"):
            v.append(v[op_info[1]] * v[op_info[2]])
        elif op in ("div", "/"):
            v.append(v[op_info[1]] / v[op_info[2]])
        elif op == "neg":
            v.append(-v[op_info[1]])

    return v[-1]
gradient(x, exact=False)

Compute a generic reverse-mode gradient or a determinant-pencil gradient. Exact determinant gradients require a nonsingular evaluated matrix and raise ValueError at singular points. Numerical determinant gradients use an inverse or pseudoinverse; they are not reliable singular-point derivative formulas.

Source code in finitefree/hyperbolic/slp.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def gradient(self, x: NDArray[np.float64], exact: bool = False) -> NDArray[Any]:
    r"""
    Compute a generic reverse-mode gradient or a determinant-pencil gradient.
    Exact determinant gradients require a nonsingular evaluated matrix and raise
    ValueError at singular points. Numerical determinant gradients use an inverse
    or pseudoinverse; they are not reliable singular-point derivative formulas.
    """
    if self.operations == ["det"]:
        if self.pencil is None:
            raise NotImplementedError("Pencil not provided to SLP")
        if exact:
            import flint

            A_exact = self._evaluate_exact(x)  # type: ignore[arg-type]
            det_A = A_exact.det()
            if det_A == 0:
                raise ValueError(
                    "Exact gradient at singular point is not supported."
                )
            identity = flint.fmpq_mat(self.pencil.n, self.pencil.n)
            for i in range(self.pencil.n):
                identity[i, i] = flint.fmpq(1, 1)
            inv_A = A_exact.solve(identity)

            grad = []
            for Ai in self.pencil._get_matrices_exact():
                tr = flint.fmpq(0)
                for r in range(self.pencil.n):
                    for c in range(self.pencil.n):
                        tr += inv_A[r, c] * sympy_to_fmpq(Ai[c, r])
                grad.append(det_A * tr)
            return np.array(grad, dtype=object)

        A_val = self.pencil.evaluate(x)
        det_A = np.linalg.det(A_val)
        if np.abs(det_A) < 1e-15:
            inv_A = np.linalg.pinv(A_val)
        else:
            inv_A = np.linalg.inv(A_val)

        grad_num = np.zeros(self.pencil.m, dtype=np.float64)
        for i, Ai in enumerate(self.pencil.matrices):
            grad_num[i] = det_A * np.trace(inv_A @ Ai)
        return grad_num

    # Generic Reverse-Mode Automatic Differentiation
    m = len(x)
    one: Any
    zero: Any
    if exact:
        import flint

        v = [sympy_to_fmpq(xi) for xi in x]
        one = flint.fmpq(1)
        zero = flint.fmpq(0)
    else:
        v = [float(xi) for xi in x]
        one = 1.0
        zero = 0.0

    # Forward pass to build trace
    for op_info in self.operations:
        op = op_info[0]
        if op == "const":
            val = op_info[1]
            v.append(sympy_to_fmpq(val) if exact else float(val))
        elif op in ("add", "+"):
            v.append(v[op_info[1]] + v[op_info[2]])
        elif op in ("sub", "-"):
            v.append(v[op_info[1]] - v[op_info[2]])
        elif op in ("mul", "*"):
            v.append(v[op_info[1]] * v[op_info[2]])
        elif op in ("div", "/"):
            v.append(v[op_info[1]] / v[op_info[2]])
        elif op == "neg":
            v.append(-v[op_info[1]])

    # Backward pass
    n_total = len(v)
    adj = [zero] * n_total
    adj[-1] = one

    for k in range(n_total - 1, m - 1, -1):
        op_info = self.operations[k - m]
        op = op_info[0]
        a = adj[k]
        if op in ("add", "+"):
            adj[op_info[1]] += a
            adj[op_info[2]] += a
        elif op in ("sub", "-"):
            adj[op_info[1]] += a
            adj[op_info[2]] -= a
        elif op in ("mul", "*"):
            adj[op_info[1]] += a * v[op_info[2]]
            adj[op_info[2]] += a * v[op_info[1]]
        elif op in ("div", "/"):
            adj[op_info[1]] += a / v[op_info[2]]
            adj[op_info[2]] -= a * v[op_info[1]] / (v[op_info[2]] * v[op_info[2]])
        elif op == "neg":
            adj[op_info[1]] -= a

    if exact:
        return np.array(adj[:m], dtype=object)
    return np.array([flint_to_float(val) for val in adj[:m]], dtype=np.float64)
hessian(x, exact=False)

Compute determinant-pencil Hessians using Jacobi's trace identities. A pencil is required; generic scalar-operation Hessians are not implemented. Exact=True raises ValueError at singular matrices. Numerical inverse or pseudoinverse evaluation remains conditioning-dependent, including near singular points.

Source code in finitefree/hyperbolic/slp.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def hessian(self, x: NDArray[np.float64], exact: bool = False) -> NDArray[Any]:
    r"""
    Compute determinant-pencil Hessians using Jacobi's trace identities.
    A pencil is required; generic scalar-operation Hessians are not implemented.
    Exact=True raises ValueError at singular matrices. Numerical inverse or
    pseudoinverse evaluation remains conditioning-dependent, including near
    singular points.
    """
    if self.pencil is None:
        raise NotImplementedError("Pencil not provided to SLP")
    if exact:
        import flint

        A_exact = self._evaluate_exact(x)  # type: ignore[arg-type]
        det_A = A_exact.det()
        if det_A == 0:
            raise ValueError("Exact Hessian at singular point is not supported.")
        identity = flint.fmpq_mat(self.pencil.n, self.pencil.n)
        for i in range(self.pencil.n):
            identity[i, i] = flint.fmpq(1, 1)
        inv_A = A_exact.solve(identity)

        m = self.pencil.m
        hess = np.zeros((m, m), dtype=object)
        B = []
        for Ai in self.pencil._get_matrices_exact():
            Bi = flint.fmpq_mat(self.pencil.n, self.pencil.n)
            for r in range(self.pencil.n):
                for c in range(self.pencil.n):
                    val = flint.fmpq(0)
                    for k in range(self.pencil.n):
                        val += inv_A[r, k] * sympy_to_fmpq(Ai[k, c])
                    Bi[r, c] = val
            B.append(Bi)

        traces = []
        for Bi in B:
            tr = flint.fmpq(0)
            for r in range(self.pencil.n):
                tr += Bi[r, r]
            traces.append(tr)

        for i in range(m):
            for j in range(m):
                term1 = traces[i] * traces[j]
                term2 = flint.fmpq(0)
                for r in range(self.pencil.n):
                    for c in range(self.pencil.n):
                        term2 += B[i][r, c] * B[j][c, r]
                hess[i, j] = det_A * (term1 - term2)
        return hess

    A_val = self.pencil.evaluate(x)
    det_A = np.linalg.det(A_val)
    if np.abs(det_A) < 1e-15:
        inv_A = np.linalg.pinv(A_val)
    else:
        inv_A = np.linalg.inv(A_val)

    m = self.pencil.m
    hess = np.zeros((m, m), dtype=np.float64)

    # Precompute B_i = inv_A @ A_i to avoid O(n^3) matrix multiplications
    # inside the nested loop
    B_float = [inv_A @ Ai for Ai in self.pencil.matrices]
    traces = [np.trace(Bi) for Bi in B_float]
    B_T = [Bi.T for Bi in B_float]

    for i in range(m):
        for j in range(m):
            term1 = traces[i] * traces[j]
            # tr(B_i @ B_j) = sum(B_i * B_j.T) which is O(n^2) instead of O(n^3)
            term2 = np.sum(B_float[i] * B_T[j])
            hess[i, j] = det_A * (term1 - term2)
    return hess

SymmetricMatrixPencil

Data structures mapping real-rooted univariate polynomials to their hyperbolic multivariate generalizations.

Source code in finitefree/hyperbolic/pencils.py
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
class SymmetricMatrixPencil:
    """
    Data structures mapping real-rooted univariate polynomials to their
    hyperbolic multivariate generalizations.
    """

    def __init__(self, matrices: Sequence[Any]) -> None:
        r"""
        matrices: A list of symmetric matrices $A_1, A_2, \dots, A_m$
        The pencil is defined as $\sum x_i A_i$
        Integer/rational entries are copied before conversion; rational methods
        preserve their values and numerical methods use a separate float64 view.
        Symmetry is checked on the supplied values, without roundoff tolerance.
        """
        self.matrices, self._matrices_exact = _prepare_matrices(matrices, True)
        self.m = len(self.matrices)
        self.n = self.matrices[0].shape[0]

    def prepare_numeric(
        self,
        *,
        backend: str = "numpy",
        point_block_size: int = 64,
        coefficient_block_size: int = 16,
    ) -> "PreparedMatrixPencil":
        """Copy the numerical view once; CuPy explicitly transfers it to the GPU.

        Retain the returned object for repeated resident float64 batch queries.
        Subsequent edits to this pencil's matrices do not change that snapshot.
        Exact coefficients and existing polynomial/SLP methods are unaffected.
        """
        from .prepared import PreparedMatrixPencil

        matrices = np.stack(self.matrices)
        if backend == "cupy":
            import importlib

            try:
                cp = importlib.import_module("cupy")
            except ImportError as error:
                raise ImportError("backend='cupy' requires CuPy") from error
            matrices = cp.asarray(matrices)
        return PreparedMatrixPencil(
            matrices,
            backend=backend,
            point_block_size=point_block_size,
            coefficient_block_size=coefficient_block_size,
        )

    def evaluate(self, x: Sequence[float]) -> NDArray[np.float64]:
        r"""
        Evaluates the matrix pencil at point $x = (x_1, \dots, x_m)$
        Returns the matrix $\sum x_i A_i$
        """
        if len(x) != self.m:
            raise ValueError(f"Expected {self.m} variables, got {len(x)}")

        result = np.zeros((self.n, self.n), dtype=np.float64)
        for xi, A in zip(x, self.matrices):
            result += xi * A
        return result

    def _get_matrices_exact(self) -> List[Any]:
        return self._matrices_exact

    def _get_matrices_sympy(self) -> List[List[List[Any]]]:
        if not hasattr(self, "_matrices_sympy"):
            import sympy as sp

            self._matrices_sympy = []
            for mat in self._get_matrices_exact():
                row_list = []
                for r in range(self.n):
                    col_list = []
                    for c in range(self.n):
                        value = mat[r, c]
                        val = sp.Rational(int(value.p), int(value.q))
                        col_list.append(val)
                    row_list.append(col_list)
                self._matrices_sympy.append(row_list)
        return self._matrices_sympy

    def _evaluate_exact(self, x: Sequence[Any]) -> Any:
        if len(x) != self.m:
            raise ValueError(f"Expected {self.m} variables, got {len(x)}")
        A_exact = flint.fmpq_mat(self.n, self.n)
        exact_mats = self._get_matrices_exact()
        for xi, Ai in zip(x, exact_mats):
            A_exact += Ai * sympy_to_fmpq(xi)
        return A_exact

    def verify_hyperbolicity(self, e: Sequence[float], exact: bool = False) -> bool:
        r"""
        Verifies if the pencil is hyperbolic in direction $e$ using
        definite matrix programming LMI: $A(e) = \sum e_i A_i \succ 0$
        """
        if exact:
            A_exact = self._evaluate_exact(e)

            # Check symmetry
            for r in range(self.n):
                for c in range(r + 1, self.n):
                    if A_exact[r, c] != A_exact[c, r]:
                        return False

            p_poly = A_exact.charpoly()
            p_roots = RealRootedPolynomial(p_poly, assume_real_rooted=True)
            return p_roots.has_strictly_positive_roots

        A_e = self.evaluate(e)
        if not np.allclose(A_e, A_e.T):
            return False
        # strictly positive eigenvalues for positive definiteness
        eigvals = np.linalg.eigvalsh(A_e)
        return bool(np.all(eigvals > 1e-14))

    def diagonal_specialization(
        self,
        w: Sequence[Any],
        b: Sequence[Any],
        parallel: bool = False,
        backend: str = "threads",
    ) -> Any:
        r"""
        Computes the univariate polynomial
        $P(w_1 z + b_1, \dots, w_m z + b_m) = \det(z A + B)$
        exactly where $A = \sum w_i A_i$ and $B = \sum b_i A_i$.
        Rational parameters retain their supplied values, including binary floats.
        Returns a monic-normalized RealRootedPolynomial, with lazy root validation.
        """

        if len(w) != self.m or len(b) != self.m:
            raise ValueError(
                f"Weights and bias lengths must match pencil variables ({self.m})."
            )

        message = "Weights and biases must contain finite real rational values"
        # SymPy can erase a zero imaginary part while converting complex scalars.
        if any(np.iscomplexobj(value) for values in (w, b) for value in values):
            raise ValueError(message)
        try:
            exact_A_coeffs = [
                sp.Rational(int(q.p), int(q.q)) for q in map(sympy_to_fmpq, w)
            ]
            exact_b_coeffs = [
                sp.Rational(int(q.p), int(q.q)) for q in map(sympy_to_fmpq, b)
            ]
        except (TypeError, ValueError, OverflowError) as error:
            raise ValueError(message) from error

        exact_matrices = self._get_matrices_sympy()

        A_exact = [[sp.Integer(0) for _ in range(self.n)] for _ in range(self.n)]
        B_exact = [[sp.Integer(0) for _ in range(self.n)] for _ in range(self.n)]
        for i in range(self.m):
            wi = exact_A_coeffs[i]
            bi = exact_b_coeffs[i]
            exact_mat = exact_matrices[i]
            for r in range(self.n):
                for c in range(self.n):
                    A_exact[r][c] += wi * exact_mat[r][c]
                    B_exact[r][c] += bi * exact_mat[r][c]

        denominators = []
        for r in range(self.n):
            for c in range(self.n):
                val_A = A_exact[r][c]
                val_B = B_exact[r][c]
                if isinstance(val_A, sp.Rational):
                    denominators.append(val_A.q)
                if isinstance(val_B, sp.Rational):
                    denominators.append(val_B.q)

        D = 1
        for den in denominators:
            D = (D * den) // math.gcd(D, den)

        A_int = [[int(A_exact[r][c] * D) for c in range(self.n)] for r in range(self.n)]
        B_int = [[int(B_exact[r][c] * D) for c in range(self.n)] for r in range(self.n)]

        primes_gen = prime_generator(1000000007)
        coefficient_bound = _determinant_coefficient_bound([A_int, B_int])
        modulus = 1
        reconstructed = None
        primes_used = []
        coeffs_by_prime = []

        deg = self.n

        effective_backend = backend if parallel else "sequential"
        if self.n <= 3:
            effective_backend = "sequential"

        with ParallelScheduler(backend=effective_backend) as scheduler:
            batch_size = max(4, scheduler.max_workers)
            while True:
                batch_primes = [next(primes_gen) for _ in range(batch_size)]
                results = scheduler.evaluate(
                    _eval_diagonal_specialization_prime_worker,
                    batch_primes,
                    A_int,
                    B_int,
                    self.n,
                    deg,
                )
                for p_res, c_p in results:
                    if c_p is not None:
                        coeffs_by_prime.append(c_p)
                        primes_used.append(p_res)
                        modulus *= p_res

                if primes_used:
                    current_reconstruction = []
                    for i in range(deg + 1):
                        vals = [coeffs_by_prime[k][i] for k in range(len(primes_used))]
                        current_reconstruction.append(crt(vals, primes_used))

                    reconstructed = current_reconstruction
                    if modulus > 2 * coefficient_bound:
                        break

        c_coeffs = []
        denom_scale = D**self.n
        for val in reconstructed:
            if val % denom_scale == 0:
                c_coeffs.append(val // denom_scale)
            else:
                c_coeffs.append(sp.Rational(val, denom_scale))

        return RealRootedPolynomial(list(reversed(c_coeffs)))

    def characteristic_polynomial_slp(self) -> Any:
        """
        Converts the characteristic polynomial of the symmetric matrix pencil
        into a StraightLineProgram for efficient gradient/Hessian queries.
        """
        from .slp import StraightLineProgram

        return StraightLineProgram(operations=["det"], pencil=self)

    def characteristic_polynomial(self, x: Sequence[Any]) -> flint.fmpq_poly:
        r"""
        Computes the characteristic polynomial $\det(t I - A(x))$ of the pencil evaluated at $x$
        exactly using rational interpolation over $\mathbb{Q}[t]$.
        """
        import flint

        A_exact = self._evaluate_exact(x)
        n = self.n

        xs = [flint.fmpq(j) for j in range(n + 1)]
        ys = []
        for j in range(n + 1):
            tj = xs[j]
            M = flint.fmpq_mat(n, n)
            for r in range(n):
                for c in range(n):
                    if r == c:
                        M[r, c] = tj - A_exact[r, c]
                    else:
                        M[r, c] = -A_exact[r, c]
            ys.append(M.det())

        # Exact Lagrange interpolation
        t_var = flint.fmpq_poly([0, 1])
        poly_sum = flint.fmpq_poly([0])
        for i in range(n + 1):
            denom = flint.fmpq(1)
            L_i = flint.fmpq_poly([1])
            for j in range(n + 1):
                if j != i:
                    L_i *= t_var - xs[j]
                    denom *= xs[i] - xs[j]
            poly_sum += L_i * (ys[i] / denom)

        return poly_sum
Methods:
__init__(matrices)

matrices: A list of symmetric matrices \(A_1, A_2, \dots, A_m\) The pencil is defined as \(\sum x_i A_i\) Integer/rational entries are copied before conversion; rational methods preserve their values and numerical methods use a separate float64 view. Symmetry is checked on the supplied values, without roundoff tolerance.

Source code in finitefree/hyperbolic/pencils.py
70
71
72
73
74
75
76
77
78
79
80
def __init__(self, matrices: Sequence[Any]) -> None:
    r"""
    matrices: A list of symmetric matrices $A_1, A_2, \dots, A_m$
    The pencil is defined as $\sum x_i A_i$
    Integer/rational entries are copied before conversion; rational methods
    preserve their values and numerical methods use a separate float64 view.
    Symmetry is checked on the supplied values, without roundoff tolerance.
    """
    self.matrices, self._matrices_exact = _prepare_matrices(matrices, True)
    self.m = len(self.matrices)
    self.n = self.matrices[0].shape[0]
characteristic_polynomial(x)

Computes the characteristic polynomial \(\det(t I - A(x))\) of the pencil evaluated at \(x\) exactly using rational interpolation over \(\mathbb{Q}[t]\).

Source code in finitefree/hyperbolic/pencils.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def characteristic_polynomial(self, x: Sequence[Any]) -> flint.fmpq_poly:
    r"""
    Computes the characteristic polynomial $\det(t I - A(x))$ of the pencil evaluated at $x$
    exactly using rational interpolation over $\mathbb{Q}[t]$.
    """
    import flint

    A_exact = self._evaluate_exact(x)
    n = self.n

    xs = [flint.fmpq(j) for j in range(n + 1)]
    ys = []
    for j in range(n + 1):
        tj = xs[j]
        M = flint.fmpq_mat(n, n)
        for r in range(n):
            for c in range(n):
                if r == c:
                    M[r, c] = tj - A_exact[r, c]
                else:
                    M[r, c] = -A_exact[r, c]
        ys.append(M.det())

    # Exact Lagrange interpolation
    t_var = flint.fmpq_poly([0, 1])
    poly_sum = flint.fmpq_poly([0])
    for i in range(n + 1):
        denom = flint.fmpq(1)
        L_i = flint.fmpq_poly([1])
        for j in range(n + 1):
            if j != i:
                L_i *= t_var - xs[j]
                denom *= xs[i] - xs[j]
        poly_sum += L_i * (ys[i] / denom)

    return poly_sum
characteristic_polynomial_slp()

Converts the characteristic polynomial of the symmetric matrix pencil into a StraightLineProgram for efficient gradient/Hessian queries.

Source code in finitefree/hyperbolic/pencils.py
295
296
297
298
299
300
301
302
def characteristic_polynomial_slp(self) -> Any:
    """
    Converts the characteristic polynomial of the symmetric matrix pencil
    into a StraightLineProgram for efficient gradient/Hessian queries.
    """
    from .slp import StraightLineProgram

    return StraightLineProgram(operations=["det"], pencil=self)
diagonal_specialization(w, b, parallel=False, backend='threads')

Computes the univariate polynomial \(P(w_1 z + b_1, \dots, w_m z + b_m) = \det(z A + B)\) exactly where \(A = \sum w_i A_i\) and \(B = \sum b_i A_i\). Rational parameters retain their supplied values, including binary floats. Returns a monic-normalized RealRootedPolynomial, with lazy root validation.

Source code in finitefree/hyperbolic/pencils.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
def diagonal_specialization(
    self,
    w: Sequence[Any],
    b: Sequence[Any],
    parallel: bool = False,
    backend: str = "threads",
) -> Any:
    r"""
    Computes the univariate polynomial
    $P(w_1 z + b_1, \dots, w_m z + b_m) = \det(z A + B)$
    exactly where $A = \sum w_i A_i$ and $B = \sum b_i A_i$.
    Rational parameters retain their supplied values, including binary floats.
    Returns a monic-normalized RealRootedPolynomial, with lazy root validation.
    """

    if len(w) != self.m or len(b) != self.m:
        raise ValueError(
            f"Weights and bias lengths must match pencil variables ({self.m})."
        )

    message = "Weights and biases must contain finite real rational values"
    # SymPy can erase a zero imaginary part while converting complex scalars.
    if any(np.iscomplexobj(value) for values in (w, b) for value in values):
        raise ValueError(message)
    try:
        exact_A_coeffs = [
            sp.Rational(int(q.p), int(q.q)) for q in map(sympy_to_fmpq, w)
        ]
        exact_b_coeffs = [
            sp.Rational(int(q.p), int(q.q)) for q in map(sympy_to_fmpq, b)
        ]
    except (TypeError, ValueError, OverflowError) as error:
        raise ValueError(message) from error

    exact_matrices = self._get_matrices_sympy()

    A_exact = [[sp.Integer(0) for _ in range(self.n)] for _ in range(self.n)]
    B_exact = [[sp.Integer(0) for _ in range(self.n)] for _ in range(self.n)]
    for i in range(self.m):
        wi = exact_A_coeffs[i]
        bi = exact_b_coeffs[i]
        exact_mat = exact_matrices[i]
        for r in range(self.n):
            for c in range(self.n):
                A_exact[r][c] += wi * exact_mat[r][c]
                B_exact[r][c] += bi * exact_mat[r][c]

    denominators = []
    for r in range(self.n):
        for c in range(self.n):
            val_A = A_exact[r][c]
            val_B = B_exact[r][c]
            if isinstance(val_A, sp.Rational):
                denominators.append(val_A.q)
            if isinstance(val_B, sp.Rational):
                denominators.append(val_B.q)

    D = 1
    for den in denominators:
        D = (D * den) // math.gcd(D, den)

    A_int = [[int(A_exact[r][c] * D) for c in range(self.n)] for r in range(self.n)]
    B_int = [[int(B_exact[r][c] * D) for c in range(self.n)] for r in range(self.n)]

    primes_gen = prime_generator(1000000007)
    coefficient_bound = _determinant_coefficient_bound([A_int, B_int])
    modulus = 1
    reconstructed = None
    primes_used = []
    coeffs_by_prime = []

    deg = self.n

    effective_backend = backend if parallel else "sequential"
    if self.n <= 3:
        effective_backend = "sequential"

    with ParallelScheduler(backend=effective_backend) as scheduler:
        batch_size = max(4, scheduler.max_workers)
        while True:
            batch_primes = [next(primes_gen) for _ in range(batch_size)]
            results = scheduler.evaluate(
                _eval_diagonal_specialization_prime_worker,
                batch_primes,
                A_int,
                B_int,
                self.n,
                deg,
            )
            for p_res, c_p in results:
                if c_p is not None:
                    coeffs_by_prime.append(c_p)
                    primes_used.append(p_res)
                    modulus *= p_res

            if primes_used:
                current_reconstruction = []
                for i in range(deg + 1):
                    vals = [coeffs_by_prime[k][i] for k in range(len(primes_used))]
                    current_reconstruction.append(crt(vals, primes_used))

                reconstructed = current_reconstruction
                if modulus > 2 * coefficient_bound:
                    break

    c_coeffs = []
    denom_scale = D**self.n
    for val in reconstructed:
        if val % denom_scale == 0:
            c_coeffs.append(val // denom_scale)
        else:
            c_coeffs.append(sp.Rational(val, denom_scale))

    return RealRootedPolynomial(list(reversed(c_coeffs)))
evaluate(x)

Evaluates the matrix pencil at point \(x = (x_1, \dots, x_m)\) Returns the matrix \(\sum x_i A_i\)

Source code in finitefree/hyperbolic/pencils.py
113
114
115
116
117
118
119
120
121
122
123
124
def evaluate(self, x: Sequence[float]) -> NDArray[np.float64]:
    r"""
    Evaluates the matrix pencil at point $x = (x_1, \dots, x_m)$
    Returns the matrix $\sum x_i A_i$
    """
    if len(x) != self.m:
        raise ValueError(f"Expected {self.m} variables, got {len(x)}")

    result = np.zeros((self.n, self.n), dtype=np.float64)
    for xi, A in zip(x, self.matrices):
        result += xi * A
    return result
prepare_numeric(*, backend='numpy', point_block_size=64, coefficient_block_size=16)

Copy the numerical view once; CuPy explicitly transfers it to the GPU.

Retain the returned object for repeated resident float64 batch queries. Subsequent edits to this pencil's matrices do not change that snapshot. Exact coefficients and existing polynomial/SLP methods are unaffected.

Source code in finitefree/hyperbolic/pencils.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def prepare_numeric(
    self,
    *,
    backend: str = "numpy",
    point_block_size: int = 64,
    coefficient_block_size: int = 16,
) -> "PreparedMatrixPencil":
    """Copy the numerical view once; CuPy explicitly transfers it to the GPU.

    Retain the returned object for repeated resident float64 batch queries.
    Subsequent edits to this pencil's matrices do not change that snapshot.
    Exact coefficients and existing polynomial/SLP methods are unaffected.
    """
    from .prepared import PreparedMatrixPencil

    matrices = np.stack(self.matrices)
    if backend == "cupy":
        import importlib

        try:
            cp = importlib.import_module("cupy")
        except ImportError as error:
            raise ImportError("backend='cupy' requires CuPy") from error
        matrices = cp.asarray(matrices)
    return PreparedMatrixPencil(
        matrices,
        backend=backend,
        point_block_size=point_block_size,
        coefficient_block_size=coefficient_block_size,
    )
verify_hyperbolicity(e, exact=False)

Verifies if the pencil is hyperbolic in direction \(e\) using definite matrix programming LMI: \(A(e) = \sum e_i A_i \succ 0\)

Source code in finitefree/hyperbolic/pencils.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def verify_hyperbolicity(self, e: Sequence[float], exact: bool = False) -> bool:
    r"""
    Verifies if the pencil is hyperbolic in direction $e$ using
    definite matrix programming LMI: $A(e) = \sum e_i A_i \succ 0$
    """
    if exact:
        A_exact = self._evaluate_exact(e)

        # Check symmetry
        for r in range(self.n):
            for c in range(r + 1, self.n):
                if A_exact[r, c] != A_exact[c, r]:
                    return False

        p_poly = A_exact.charpoly()
        p_roots = RealRootedPolynomial(p_poly, assume_real_rooted=True)
        return p_roots.has_strictly_positive_roots

    A_e = self.evaluate(e)
    if not np.allclose(A_e, A_e.T):
        return False
    # strictly positive eigenvalues for positive definiteness
    eigvals = np.linalg.eigvalsh(A_e)
    return bool(np.all(eigvals > 1e-14))

finitefree.multivariate

Classes

MultivariatePolynomial

Bases: Polynomial

Store a rational multivariate polynomial in FLINT's sparse fmpq_mpoly. Homogeneous geometry operations are available, but construction does not require homogeneity; use is_homogeneous() when that assumption is needed. Inputs must be convertible to rational coefficients. Variable order is explicit. Coefficients and exported native objects are owned copies. Algebra does not certify stability, hyperbolicity or real-rootedness.

Source code in finitefree/multivariate.py
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
class MultivariatePolynomial(Polynomial):
    r"""
    Store a rational multivariate polynomial in FLINT's sparse fmpq_mpoly.
    Homogeneous geometry operations are available, but construction does not
    require homogeneity; use is_homogeneous() when that assumption is needed.
    Inputs must be convertible to rational coefficients.
    Variable order is explicit. Coefficients and exported native objects are owned
    copies. Algebra does not certify stability, hyperbolicity or real-rootedness.
    """

    _mpoly: Any
    _float_terms_cached: Optional[_FloatTerms]
    _gradient_float_terms_cached: Optional[Tuple[_FloatTerms, ...]]
    _hessian_float_terms_cached: Optional[Tuple[Tuple[int, int, _FloatTerms], ...]]

    def evaluate(self, x: Sequence[Any]) -> Any:
        r"""
        Evaluate at a rational point $x = (x_1, \dots, x_m)$.
        Floating coordinates retain their stored binary ratios. The result is an
        exact FLINT rational; use evaluate_float64 for approximate batch evaluation.
        """
        from .utils.conversion import sympy_to_fmpq

        if len(x) != len(self.variables):
            raise ValueError(f"Expected {len(self.variables)} values, got {len(x)}")
        x_fmpq = [sympy_to_fmpq(xi) for xi in x]
        return self._mpoly(*x_fmpq)

    def evaluate_float64(self, points: Any) -> Union[float, NDArray[np.float64]]:
        """Evaluate real points shaped (..., variable_count) with NumPy.

        A single point returns a float; batches retain their leading dimensions.
        Coefficients are converted once, and terms are evaluated across each batch.
        Bounded point blocks reuse monomial buffers; output storage is still full.
        Nonfinite inputs, coefficients or results raise errors. Finite cancellation,
        underflow and rounding remain possible; outputs have no certified error bound.
        """
        coordinates = self._float64_coordinates(points)
        if self._float_terms_cached is None:
            self._float_terms_cached = self._float64_terms(self._mpoly)
        result = np.empty(coordinates.shape[:-1], dtype=np.float64)
        if self._constant_float64_terms(self._float_terms_cached):
            result.fill(
                self._float_terms_cached[0][1] + 0.0
                if self._float_terms_cached
                else 0.0
            )
            return float(result) if result.ndim == 0 else result
        flat_result = result.reshape(-1)
        for start, stop, block in self._float64_blocks(coordinates):
            self._evaluate_float64_terms(
                block, self._float_terms_cached, None, flat_result[start:stop]
            )
        return float(result) if result.ndim == 0 else result

    def _float64_coordinates(self, points: Any) -> NDArray[np.float64]:
        if np.iscomplexobj(points):
            raise ValueError("Numerical coordinates must be real.")
        try:
            supplied = np.asarray(points)
            if supplied.dtype == object and any(
                np.iscomplexobj(value) for value in supplied.flat
            ):
                raise ValueError("Numerical coordinates must be real.")
            with np.errstate(over="ignore", invalid="ignore"):
                coordinates = np.asarray(supplied, dtype=np.float64)
        except (TypeError, ValueError, OverflowError) as error:
            raise ValueError(
                "Numerical coordinates must be finite real values."
            ) from error
        if coordinates.ndim == 0 or coordinates.shape[-1] != len(self._variables):
            raise ValueError(
                f"Expected final coordinate dimension {len(self._variables)}."
            )
        if not np.all(np.isfinite(coordinates)):
            raise ValueError("Numerical coordinates must be finite.")
        return coordinates

    @staticmethod
    def _float64_terms(polynomial: Any) -> _FloatTerms:
        from .utils.conversion import flint_to_float

        terms = tuple(
            (
                tuple((i, int(k)) for i, k in enumerate(alpha) if k),
                flint_to_float(c),
            )
            for alpha, c in polynomial.to_dict().items()
        )
        if not all(math.isfinite(c) for _, c in terms):
            raise RuntimeError("Polynomial coefficients exceed the float64 range.")
        return terms

    @staticmethod
    def _float64_blocks(
        coordinates: NDArray[np.float64],
    ) -> Iterator[Tuple[int, int, NDArray[np.float64]]]:
        """Yield bounded point blocks without copying a whole strided input."""
        m = coordinates.shape[-1]
        count = math.prod(coordinates.shape[:-1])
        flat = (
            coordinates.reshape(-1, m)
            if coordinates.flags.c_contiguous or count <= _MAX_FLOAT64_BATCH
            else None
        )
        for start in range(0, count, _MAX_FLOAT64_BATCH):
            stop = min(start + _MAX_FLOAT64_BATCH, count)
            if flat is not None:
                block = flat[start:stop]
            else:
                indices = np.unravel_index(
                    np.arange(start, stop), coordinates.shape[:-1]
                )
                block = coordinates[indices]
            # Contiguous coordinate columns keep x**1 a cheap view without
            # repeatedly multiplying by strided coordinates in dense supports.
            yield start, stop, np.ascontiguousarray(block.T).T

    @staticmethod
    def _constant_float64_terms(terms: _FloatTerms) -> bool:
        return not terms or (len(terms) == 1 and not terms[0][0])

    @staticmethod
    def _evaluate_float64_terms(
        coordinates: NDArray[np.float64],
        terms: _FloatTerms,
        powers: Optional[Dict[Tuple[int, int], NDArray[np.float64]]],
        result: NDArray[np.float64],
        term: Optional[NDArray[np.float64]] = None,
    ) -> NDArray[np.float64]:
        if MultivariatePolynomial._constant_float64_terms(terms):
            result.fill(terms[0][1] + 0.0 if terms else 0.0)
            return result
        result.fill(0)
        if term is None:
            term = np.empty(result.shape, dtype=np.float64)
        try:
            with np.errstate(over="raise", invalid="raise", divide="raise"):
                for alpha, coefficient in terms:
                    term.fill(coefficient)
                    for i, exponent in alpha:
                        if exponent == 1:
                            # A view avoids retaining a full array for x**1.
                            power = coordinates[..., i]
                        else:
                            key = (i, exponent)
                            if powers is not None and key in powers:
                                power = powers[key]
                            else:
                                power = np.asarray(
                                    coordinates[..., i] ** exponent, dtype=np.float64
                                )
                                if (
                                    powers is not None
                                    and len(powers) < _MAX_FLOAT64_POWER_CACHE
                                ):
                                    powers[key] = power
                        term *= power
                    result += term
        except FloatingPointError as error:
            raise RuntimeError(
                "Numerical polynomial evaluation is nonfinite."
            ) from error
        if not np.all(np.isfinite(result)):
            raise RuntimeError("Numerical polynomial evaluation is nonfinite.")
        return result

    def gradient_float64(self, points: Any) -> NDArray[np.float64]:
        """Evaluate the gradient at real points shaped (..., m), returning (..., m).

        Differentiate rational coefficients before float64 conversion. Sparse
        derivative terms are cached; coordinate powers are reused within bounded
        point blocks. The returned gradient is still fully allocated.
        Inputs/results have the same finite/range and rounding limits as
        evaluate_float64. Zero coordinates and empty batches are valid.
        """
        coordinates = self._float64_coordinates(points)
        m = len(self._variables)
        if self._gradient_float_terms_cached is None:
            self._gradient_float_terms_cached = tuple(
                self._float64_terms(self._mpoly.derivative(i)) for i in range(m)
            )
        result = np.empty((*coordinates.shape[:-1], m), dtype=np.float64)
        if all(
            self._constant_float64_terms(t) for t in self._gradient_float_terms_cached
        ):
            for i, terms in enumerate(self._gradient_float_terms_cached):
                result[..., i].fill(terms[0][1] + 0.0 if terms else 0.0)
            return result
        flat_result = result.reshape(-1, m)
        for start, stop, block in self._float64_blocks(coordinates):
            powers: Dict[Tuple[int, int], NDArray[np.float64]] = {}
            component = np.empty(stop - start, dtype=np.float64)
            term = np.empty_like(component)
            for i, terms in enumerate(self._gradient_float_terms_cached):
                self._evaluate_float64_terms(block, terms, powers, component, term)
                flat_result[start:stop, i] = component
        return result

    def hessian_float64(self, points: Any) -> NDArray[np.float64]:
        """Evaluate exact polynomial second derivatives as float64 (..., m, m).

        Only the upper triangle is evaluated and mirrored. Coordinates shaped
        (..., m) retain their leading batch dimensions; one point returns (m, m).
        No inverses or division by coordinates are used, including at singular
        determinant points. Outputs are owned arrays with finite/range checks;
        cancellation, underflow and float64 rounding are not certified.
        """
        coordinates = self._float64_coordinates(points)
        m = len(self._variables)
        if self._hessian_float_terms_cached is None:
            gradient = [self._mpoly.derivative(i) for i in range(m)]
            self._hessian_float_terms_cached = tuple(
                (i, j, self._float64_terms(gradient[i].derivative(j)))
                for i in range(m)
                for j in range(i, m)
            )
        result = np.empty((*coordinates.shape[:-1], m, m), dtype=np.float64)
        if all(
            self._constant_float64_terms(t)
            for _, _, t in self._hessian_float_terms_cached
        ):
            for i, j, terms in self._hessian_float_terms_cached:
                value = terms[0][1] + 0.0 if terms else 0.0
                result[..., i, j].fill(value)
                if i != j:
                    result[..., j, i].fill(value)
            return result
        flat_result = result.reshape(-1, m, m)
        for start, stop, block in self._float64_blocks(coordinates):
            powers: Dict[Tuple[int, int], NDArray[np.float64]] = {}
            component = np.empty(stop - start, dtype=np.float64)
            term = np.empty_like(component)
            for i, j, terms in self._hessian_float_terms_cached:
                self._evaluate_float64_terms(block, terms, powers, component, term)
                flat_result[start:stop, i, j] = component
                if i != j:
                    flat_result[start:stop, j, i] = component
        return result

    @property
    def variables(self) -> list[sp.Symbol]:
        """Return a caller-owned list in the stored coordinate order."""
        return list(self._variables)

    def __init__(self, expr: Any, variables: Sequence[sp.Symbol]) -> None:
        import flint

        from .utils.conversion import sympy_to_fmpq

        self._variables = tuple(variables)
        if not self._variables or not all(
            isinstance(x, sp.Symbol) for x in self._variables
        ):
            raise ValueError("Variables must be a nonempty sequence of SymPy symbols.")
        names = tuple(x.name for x in self._variables)
        if len(set(names)) != len(names):
            raise ValueError("Variable names must be distinct.")
        self._ctx = flint.fmpq_mpoly_ctx.get(names=names)
        self._float_terms_cached = None
        self._gradient_float_terms_cached = None
        self._hessian_float_terms_cached = None

        if isinstance(expr, flint.fmpq_mpoly):
            if expr.context().names() != names:
                raise ValueError("Native polynomial context must match variable order.")
            self._mpoly = (
                expr + 0
                if expr.context() == self._ctx
                else self._ctx.from_dict(expr.to_dict())
            )
        elif type(expr) is int and expr == 0:
            # Internal empty results need no symbolic polynomial reconstruction.
            # Preserve Poly's generator-domain check even for a zero expression.
            if any(not symbol.is_commutative for symbol in self._variables):
                raise sp.GeneratorsError("non-commutative generators are not supported")
            self._mpoly = self._ctx.constant(0)
        else:
            poly_sym = sp.Poly(sp.expand(sp.sympify(expr)), self._variables)
            flint_dict = {}
            for exp, c in poly_sym.as_dict().items():
                flint_dict[exp] = sympy_to_fmpq(c)
            self._mpoly = self._ctx.from_dict(flint_dict)

    @classmethod
    def from_coefficients(
        cls, coefficients: Mapping[Tuple[int, ...], Any], variables: Sequence[sp.Symbol]
    ) -> "MultivariatePolynomial":
        """Construct from sparse nonnegative exponent tuples and rational values."""
        from .utils.conversion import sympy_to_fmpq

        result = cls(0, variables)
        terms = {
            result._nonnegative_indices(alpha, "Exponent"): sympy_to_fmpq(c)
            for alpha, c in coefficients.items()
        }
        result._mpoly = result._ctx.from_dict(terms)
        return result

    def coefficients(self) -> Dict[Tuple[int, ...], sp.Rational]:
        """Return an owned sparse mapping of exponent tuples to exact coefficients."""
        return {
            tuple(int(k) for k in alpha): sp.Rational(int(c.p), int(c.q))
            for alpha, c in self._mpoly.to_dict().items()
        }

    def reorder_variables(
        self, variables: Sequence[sp.Symbol]
    ) -> "MultivariatePolynomial":
        """Copy into an explicit permutation of the same ordered SymPy symbols.

        Permute exponent coordinates, preserving the polynomial expression and
        symbol assumptions. Renaming, missing or additional symbols are rejected.
        Use substitute with an explicit target context for polynomial changes.
        """
        result = type(self)(0, variables)
        if set(result._variables) != set(self._variables):
            raise ValueError(
                "Reordered variables must be a permutation of stored symbols."
            )
        # Same-context projection returns the original mutable native object.
        # Identity reordering still promises an owned snapshot.
        result._mpoly = (
            self._mpoly + 0
            if result._ctx == self._ctx
            else self._mpoly.project_to_context(result._ctx)
        )
        return result

    def substitute(
        self,
        substitutions: Mapping[sp.Symbol, Any],
        *,
        variables: Optional[Sequence[sp.Symbol]] = None,
    ) -> "MultivariatePolynomial":
        """Simultaneously replace stored symbols with rational scalars/polynomials.

        The target context defaults to the stored ordered symbols. An explicit
        nonempty variables sequence allows renaming, dimension changes or unused
        variables. Unreplaced symbols must occur unchanged in that context.
        MultivariatePolynomial replacements must match the target order exactly;
        rational SymPy polynomial expressions may use only target symbols.
        Scalars follow the exact stored-value rational conversion, including finite
        floats. Booleans, strings, arrays, rational functions and irrational/complex
        coefficients are rejected. Even full scalar substitution returns an owned
        constant polynomial in the target context, with no inferred geometry.
        """
        from .utils.conversion import sympy_to_fmpq

        if not isinstance(substitutions, Mapping):
            raise TypeError("Substitutions must be a mapping from stored symbols.")
        if any(x not in self._variables for x in substitutions):
            raise ValueError("Substitution keys must be stored SymPy symbols.")
        result = type(self)(0, self._variables if variables is None else variables)
        target_symbols = set(result._variables)
        target_indices = {symbol: i for i, symbol in enumerate(result._variables)}
        images = []
        generator_mapping = {}
        for i, symbol in enumerate(self._variables):
            value = substitutions.get(symbol, symbol)
            if isinstance(value, MultivariatePolynomial):
                if value._variables != result._variables:
                    raise ValueError(
                        "Replacement polynomial variable sequences must match the target "
                        "exactly; reorder_variables explicitly when needed."
                    )
                images.append(value._mpoly)
            elif isinstance(value, sp.Symbol):
                if value not in target_symbols:
                    raise ValueError(
                        "Replacement and unreplaced symbols must belong to target variables."
                    )
                j = target_indices[value]
                images.append(result._ctx.gen(j))
                generator_mapping[i] = j
            elif isinstance(value, sp.Expr) and value.free_symbols:
                if not value.free_symbols <= target_symbols:
                    raise ValueError(
                        "Replacement and unreplaced symbols must belong to target variables."
                    )
                try:
                    images.append(type(self)(value, result._variables)._mpoly)
                except (TypeError, ValueError, sp.PolynomialError) as error:
                    raise ValueError(
                        "Replacements must be rational polynomial expressions."
                    ) from error
            else:
                if isinstance(
                    value,
                    (bool, np.bool_, str, np.ndarray, complex, np.complexfloating),
                ):
                    raise ValueError(
                        "Replacements must be rational scalars or polynomials."
                    )
                try:
                    images.append(result._ctx.constant(sympy_to_fmpq(value)))
                except (TypeError, ValueError, OverflowError) as error:
                    raise ValueError(
                        "Replacements must be finite rational scalars or polynomials."
                    ) from error
        # FLINT 0.9 short-circuits same-context projection, ignoring a supplied
        # generator map. Such substitutions must use composition even for swaps.
        if len(generator_mapping) == len(self._variables) and result._ctx != self._ctx:
            result._mpoly = self._mpoly.project_to_context(
                result._ctx, mapping=generator_mapping
            )
        else:
            result._mpoly = self._mpoly.compose(*images, ctx=result._ctx)
        return result

    def _nonnegative_indices(
        self, values: Sequence[SupportsIndex], label: str
    ) -> Tuple[int, ...]:
        if len(values) != len(self._variables):
            raise ValueError(f"{label} count must match variable count.")
        indices = []
        for value in values:
            if isinstance(value, (bool, np.bool_)):
                raise TypeError(f"{label} values must be integers, not booleans.")
            try:
                index = operator.index(value)
            except TypeError as error:
                raise TypeError(f"{label} values must be integers.") from error
            if index < 0:
                raise ValueError(f"{label} values must be nonnegative.")
            indices.append(index)
        return tuple(indices)

    def _coerce_operand(self, other: Any) -> Any:
        if isinstance(other, MultivariatePolynomial):
            if other._variables != self._variables:
                raise ValueError("Polynomial variable sequences must match exactly.")
            return other._mpoly
        from .utils.conversion import sympy_to_fmpq

        return sympy_to_fmpq(other)

    def __add__(self, other: Any) -> "MultivariatePolynomial":
        return type(self)(self._mpoly + self._coerce_operand(other), self._variables)

    def __radd__(self, other: Any) -> "MultivariatePolynomial":
        return self.__add__(other)

    def __sub__(self, other: Any) -> "MultivariatePolynomial":
        return type(self)(self._mpoly - self._coerce_operand(other), self._variables)

    def __rsub__(self, other: Any) -> "MultivariatePolynomial":
        return type(self)(self._coerce_operand(other) - self._mpoly, self._variables)

    def __mul__(self, other: Any) -> "MultivariatePolynomial":
        return type(self)(self._mpoly * self._coerce_operand(other), self._variables)

    def __rmul__(self, other: Any) -> "MultivariatePolynomial":
        return self.__mul__(other)

    def __neg__(self) -> "MultivariatePolynomial":
        return type(self)(-self._mpoly, self._variables)

    def __pow__(self, exponent: SupportsIndex) -> "MultivariatePolynomial":
        """Raise to a nonnegative integer power within the rational polynomial ring."""
        if isinstance(exponent, (bool, np.bool_)):
            raise TypeError("Exponent must be an integer, not a boolean.")
        power = operator.index(exponent)
        if power < 0:
            raise ValueError("Exponent must be nonnegative.")
        return type(self)(self._mpoly**power, self._variables)

    @property
    def expr(self) -> sp.Expr:
        """Returns the polynomial as a SymPy expression."""
        flint_dict = self._mpoly.to_dict()
        res_expr = sp.Integer(0)
        for exp, c in flint_dict.items():
            term = sp.Rational(int(c.p), int(c.q))
            for x_i, power in zip(self.variables, exp):
                if power > 0:
                    # SymPy 1.12 sympifies FLINT integers through float();
                    # preserve the exact exponent at this backend boundary.
                    term *= x_i ** int(power)
            res_expr += term
        return res_expr

    def __str__(self) -> str:
        return f"{self.__class__.__name__}({self.expr})"

    def __repr__(self) -> str:
        return self.__str__()

    def degree(self) -> int:
        """Returns the total degree of the multivariate polynomial."""
        return int(self._mpoly.total_degree())

    def is_homogeneous(self) -> bool:
        r"""
        Verifies if the polynomial is homogeneous.
        $P(t x_1, \dots, t x_m) = t^d P(x_1, \dots, x_m)$
        """
        d = self.degree()
        return all(sum(alpha) == d for alpha in self._mpoly.monoms())

    def directional_derivative(
        self, direction: Sequence[Any]
    ) -> "MultivariatePolynomial":
        r"""
        Computes the directional derivative of the polynomial:
        $D_e P(x) = \langle e, \nabla P(x) \rangle = \sum e_i \frac{\partial P}{\partial x_i}$
        """
        if len(direction) != len(self.variables):
            raise ValueError(
                f"Direction length ({len(direction)}) must match "
                f"variable count ({len(self.variables)})."
            )

        from .utils.conversion import sympy_to_fmpq

        deriv_poly = self._ctx.from_dict({})
        for i, e_i in enumerate(direction):
            if e_i != 0:
                deriv_poly += self._mpoly.derivative(i) * sympy_to_fmpq(e_i)

        return MultivariatePolynomial(deriv_poly, self.variables)

    def mixed_partial_derivative(
        self, orders: Sequence[SupportsIndex]
    ) -> "MultivariatePolynomial":
        """
        Computes mixed partial derivatives exactly and efficiently by
        performing C-level differentiation.
        """
        indices = self._nonnegative_indices(orders, "Derivative order")
        if sum(indices) > self.degree():
            return MultivariatePolynomial(0, self.variables)

        res = self._mpoly
        for i, ord_val in enumerate(indices):
            for _ in range(ord_val):
                res = res.derivative(i)

        return MultivariatePolynomial(res, self.variables)

    def partial_derivative(
        self, variable: sp.Symbol, order: SupportsIndex = 1
    ) -> "MultivariatePolynomial":
        """Differentiate in a stored variable with a nonnegative integer order."""
        if variable not in self._variables:
            raise ValueError("Derivative variable must be in the stored variables.")
        if isinstance(order, (bool, np.bool_)):
            raise TypeError("Derivative order must be an integer, not a boolean.")
        orders = [0] * len(self._variables)
        orders[self._variables.index(variable)] = operator.index(order)
        return self.mixed_partial_derivative(orders)

    def gradient(self) -> list["MultivariatePolynomial"]:
        """Return exact polynomial derivatives in stored variable order."""
        return [self.partial_derivative(x) for x in self._variables]

    def hessian(self) -> list[list["MultivariatePolynomial"]]:
        """Return exact second polynomial derivatives, including at singular points."""
        return [
            [p.partial_derivative(y) for y in self._variables] for p in self.gradient()
        ]

    def restrict_line(
        self, base_point: Sequence[Any], direction: Sequence[Any]
    ) -> "RealRootedPolynomial":
        """Form P(base_point + t*direction) exactly, retaining its leading scalar.

        The returned univariate object certifies real-rootedness lazily. A generic
        restriction can have complex roots; no hyperbolicity assumption is inferred.
        Identically zero restrictions raise ValueError because the univariate class
        cannot represent the zero polynomial.
        """
        import flint

        from .utils.conversion import sympy_to_fmpq

        if len(base_point) != len(self._variables) or len(direction) != len(
            self._variables
        ):
            raise ValueError("Line coordinates must match variable count.")
        factors = [
            flint.fmpq_poly([sympy_to_fmpq(a), sympy_to_fmpq(b)])
            for a, b in zip(base_point, direction)
        ]
        result = flint.fmpq_poly([])
        for alpha, coefficient in self._mpoly.to_dict().items():
            term = flint.fmpq_poly([coefficient])
            for factor, exponent in zip(factors, alpha):
                if exponent:
                    term *= factor**exponent
            result += term
        if result.degree() < 0:
            raise ValueError("Line restriction is identically zero.")
        return RealRootedPolynomial(result, monic=False)

    def normalized_coefficients(self) -> Dict[Tuple[int, ...], Any]:
        r"""
        Extracts the normalized coefficients:
        $\tilde{c}_\alpha = c_\alpha / \binom{d}{\alpha}$
        where $\binom{d}{\alpha}$ is the multinomial coefficient.
        """
        import flint

        if not self.is_homogeneous():
            raise ValueError(
                "Normalized coefficients require a homogeneous polynomial."
            )
        d = self.degree()

        def multinomial_coeff(total: int, alpha: Tuple[int, ...]) -> int:
            num = math.factorial(total)
            den = 1
            for a in alpha:
                den *= math.factorial(a)
            return num // den

        normalized = {}
        for alpha, c in self._mpoly.to_dict().items():
            weight = multinomial_coeff(d, alpha)
            val = c / flint.fmpq(weight, 1)
            normalized[tuple(int(k) for k in alpha)] = sp.Rational(
                int(val.p), int(val.q)
            )

        return normalized

    def to_fmpq_mpoly(self) -> Any:
        r"""
        Return a caller-owned copy of the FLINT sparse polynomial.
        Evaluation/substitution costs depend on monomials, degrees and coefficient
        sizes; exposing the object does not make those operations constant-time.
        """
        return self._mpoly + 0

    @classmethod
    def from_symmetric_matrix_pencil_interpolated(
        cls,
        pencil: Union[SymmetricMatrixPencil, MultiplicativeMatrixPencil],
        parallel: bool = False,
        backend: str = "threads",
    ) -> "MultivariatePolynomial":
        r"""
        Constructs the multivariate polynomial $\det(x_1 A_1 + \dots + x_m A_m)$
        by evaluating the determinants modulo prime numbers exactly using fast
        C-level modular matrix mathematics and reconstructing exact coefficients
        over $\mathbb{Q}$ using the Chinese Remainder Theorem (CRT) and Rational Reconstruction.
        """
        import math

        import flint

        n = pencil.n
        m = pencil.m
        variables = [sp.Symbol(f"x{i}") for i in range(1, m + 1)]

        if m == 1:
            exact_A = pencil._get_matrices_sympy()[0]
            det_val = sp.Matrix(exact_A).det()
            expr = det_val * (variables[0] ** n)
            return cls(expr, variables)

        exps = get_monomial_exponents(m, n)
        N = len(exps)

        grid_vals = tuple(range(n + 1))
        full_grid_pts = get_grid_points(m - 1, grid_vals)

        # Convert all matrices to exact Rational representation to clear denominators
        exact_matrices = pencil._get_matrices_sympy()

        # Find the global common denominator D
        denominators = []
        for exact_A in exact_matrices:
            for r in range(n):
                for c in range(n):
                    val = exact_A[r][c]
                    if isinstance(val, sp.Rational):
                        denominators.append(val.q)
                    else:
                        denominators.append(1)

        D = 1
        for den in denominators:
            D = (D * den) // math.gcd(D, den)

        # Construct integer matrices A_prime
        integer_matrices = []
        for exact_A in exact_matrices:
            int_A = []
            for r in range(n):
                row = []
                for c in range(n):
                    val = exact_A[r][c] * D
                    row.append(int(val))
                int_A.append(row)
            integer_matrices.append(int_A)

        primes_gen = prime_generator(1000000007)
        coefficient_bound = _determinant_coefficient_bound(integer_matrices)
        modulus = 1
        reconstructed = None
        primes_used = []
        coeffs_by_prime = []

        effective_backend = backend if parallel else "sequential"
        if pencil.n <= 3:
            effective_backend = "sequential"

        with ParallelScheduler(backend=effective_backend) as scheduler:
            batch_size = max(4, scheduler.max_workers)
            while True:
                batch_primes = [next(primes_gen) for _ in range(batch_size)]
                results = scheduler.evaluate(
                    _eval_prime_worker,
                    batch_primes,
                    integer_matrices,
                    full_grid_pts,
                    n,
                    m,
                    exps,
                )
                for p_res, c_p in results:
                    if c_p is not None:
                        coeffs_by_prime.append(c_p)
                        primes_used.append(p_res)
                        modulus *= p_res

                if primes_used:
                    current_reconstruction = []
                    for i in range(N):
                        vals = [coeffs_by_prime[k][i] for k in range(len(primes_used))]
                        current_reconstruction.append(crt(vals, primes_used))

                    reconstructed = current_reconstruction
                    if modulus > 2 * coefficient_bound:
                        break

        names = tuple(x.name for x in variables)
        ctx = flint.fmpq_mpoly_ctx.get(names=names)
        flint_dict = {}
        denom_scale = D**n
        for exp, val in zip(exps, reconstructed):
            if val % denom_scale == 0:
                flint_dict[exp] = flint.fmpq(val // denom_scale, 1)
            else:
                flint_dict[exp] = flint.fmpq(val, denom_scale)

        poly = ctx.from_dict(flint_dict)
        return cls(poly, variables)

    @classmethod
    def from_symmetric_matrix_pencil_sparse(
        cls,
        pencil: Union[SymmetricMatrixPencil, MultiplicativeMatrixPencil],
        *,
        max_verification_bits: SupportsIndex = 1_000_000,
    ) -> "MultivariatePolynomial":
        r"""
        Constructs the multivariate polynomial $\det(x_1 A_1 + \dots + x_m A_m)$
        by evaluating the determinants modulo prime numbers exactly using fast
        C-level modular matrix mathematics and reconstructing exact coefficients
        over $\mathbb{Q}$ using randomized Zippel discovery followed by exact
        deterministic coefficient/support verification. A failed candidate or
        eight failed prime fields triggers balanced-base exact reconstruction.

        ``max_verification_bits`` caps the conservative bit-size bound for the
        exact Kronecker verification before discovery starts. Exceeding it
        raises ValueError rather than returning an uncertified polynomial.
        Increasing the limit is explicit; it is not a runtime or memory quota.
        """
        import math
        import random

        import flint

        if isinstance(max_verification_bits, (bool, np.bool_)):
            raise TypeError("max_verification_bits must be a positive integer")
        verification_limit = operator.index(max_verification_bits)
        if verification_limit <= 0:
            raise ValueError("max_verification_bits must be a positive integer")

        n = pencil.n
        m = pencil.m
        variables = [sp.Symbol(f"x{i}") for i in range(1, m + 1)]

        if n == 0:
            return cls(sp.Integer(1), variables)
        if m == 1:
            exact_A = pencil._get_matrices_sympy()[0]
            det_val = sp.Matrix(exact_A).det()
            expr = det_val * (variables[0] ** n)
            return cls(expr, variables)

        exact_matrices = pencil._get_matrices_sympy()

        denominators = []
        for exact_A in exact_matrices:
            for r in range(n):
                for c in range(n):
                    val = exact_A[r][c]
                    if isinstance(val, sp.Rational):
                        denominators.append(val.q)
                    else:
                        denominators.append(1)

        D = 1
        for den in denominators:
            D = (D * den) // math.gcd(D, den)

        integer_matrices = []
        for exact_A in exact_matrices:
            int_A = []
            for r in range(n):
                row = []
                for c in range(n):
                    val = exact_A[r][c] * D
                    row.append(int(val))
                int_A.append(row)
            integer_matrices.append(int_A)

        coefficient_bound = _determinant_coefficient_bound(integer_matrices)
        if coefficient_bound == 0:
            return cls(sp.Integer(0), variables)
        verification_base, verification_weights = _determinant_encoding_parameters(
            n, m, coefficient_bound, verification_limit
        )

        def eval_point_mod_p(pt: tuple[int, ...], p: int) -> int:
            M_pt = flint.nmod_mat(n, n, p)
            for r in range(n):
                for c in range(n):
                    val = 0
                    for pt_val, int_A in zip(pt, integer_matrices):
                        val = (val + pt_val * int_A[r][c]) % p
                    M_pt[r, c] = val
            return int(M_pt.det())

        def zippel_mod_p(p: int) -> dict[tuple[int, ...], int]:
            rand_gen = random.Random(42)
            S: dict[tuple[int, ...], int] = {(): 1}

            for i in range(1, m):
                t = [rand_gen.randint(2, p - 2) for _ in range(m - 1 - i)]
                candidates = []
                for beta in S:
                    sum_beta = sum(beta)
                    for j in range(n - sum_beta + 1):
                        candidates.append(beta + (j,))

                K = len(candidates)
                if K == 0:
                    break

                solved = False
                attempts = 0
                while not solved and attempts < 5:
                    attempts += 1
                    test_pts = []
                    for _ in range(K):
                        test_pts.append(
                            tuple(rand_gen.randint(2, p - 2) for _ in range(i))
                        )

                    try:
                        import os

                        if os.environ.get("PYFFP_DISABLE_CYTHON") == "1":
                            raise ImportError(
                                "Cython explicitly disabled via environment variable"
                            )
                        import numpy as np  # noqa: I001
                        from .utils.modular_fast import (
                            construct_zippel_vandermonde_mod_p,
                        )  # type: ignore[import-not-found, import-untyped, unused-ignore]  # noqa: I001

                        test_pts_np = np.array(test_pts, dtype=np.int64)
                        candidates_np = np.array(candidates, dtype=np.int64)
                        V_memview = construct_zippel_vandermonde_mod_p(
                            test_pts_np, candidates_np, p
                        )
                        V_np = np.array(V_memview, dtype=np.int64)
                        V_flat = V_np.flatten().tolist()
                        V = flint.nmod_mat(K, K, V_flat, p)
                    except ImportError:
                        V = flint.nmod_mat(K, K, p)
                        for r_idx in range(K):
                            pt_val = test_pts[r_idx]
                            for c_idx in range(K):
                                exp = candidates[c_idx]
                                term = 1
                                for val, power in zip(pt_val, exp):
                                    term = (term * pow(val, power, p)) % p
                                V[r_idx, c_idx] = term

                    y = flint.nmod_mat(K, 1, p)
                    try:
                        import os

                        if os.environ.get("PYFFP_DISABLE_CYTHON") == "1":
                            raise ImportError(
                                "Cython explicitly disabled via environment variable"
                            )
                        import numpy as np  # noqa: I001

                        from .utils.modular_fast import eval_points_grid_mod_p  # type: ignore[import-not-found, import-untyped, unused-ignore]  # noqa: I001

                        grid_pts = [
                            test_pts[r_idx] + tuple(t) + (1,) for r_idx in range(K)
                        ]
                        grid_pts_np = np.array(grid_pts, dtype=np.int64)
                        matrices_np = _as_modular_array(integer_matrices, p)
                        dets = eval_points_grid_mod_p(matrices_np, grid_pts_np, p)
                        for r_idx in range(K):
                            y[r_idx, 0] = dets[r_idx]
                    except ImportError:
                        for r_idx in range(K):
                            full_pt = test_pts[r_idx] + tuple(t) + (1,)
                            y[r_idx, 0] = eval_point_mod_p(full_pt, p)

                    try:
                        c_flint = V.solve(y)
                        solved = True
                        C = [int(c_flint[r_idx, 0]) for r_idx in range(K)]
                    except Exception:
                        continue

                if not solved:
                    raise ValueError(f"Zippel interpolation failed mod {p}")

                S = {}
                for coeff, exp in zip(C, candidates):
                    if coeff != 0:
                        S[exp] = coeff

            return S

        primes_gen = prime_generator(1000000007)
        modulus = 1
        reconstructed: dict[tuple[int, ...], int] = {}
        primes_used = []
        coeffs_by_prime = []
        exps = []
        failed_primes = 0

        while True:
            p = next(primes_gen)
            try:
                S_p = zippel_mod_p(p)
            except ValueError:
                failed_primes += 1
                if failed_primes >= 8:
                    break
                continue

            for exp in S_p:
                if exp not in exps:
                    exps.append(exp)

            coeffs_by_prime.append(S_p)
            primes_used.append(p)
            modulus *= p

            if primes_used:
                current_reconstruction = {}
                for exp in exps:
                    vals = []
                    for k in range(len(primes_used)):
                        vals.append(coeffs_by_prime[k].get(exp, 0))
                    current_reconstruction[exp] = crt(vals, primes_used)

                reconstructed = current_reconstruction
                if modulus > 2 * coefficient_bound:
                    break

        reconstructed = _certify_sparse_determinant_coefficients(
            integer_matrices,
            reconstructed,
            coefficient_bound,
            verification_base,
            verification_weights,
        )

        names = tuple(x.name for x in variables)
        ctx = flint.fmpq_mpoly_ctx.get(names=names)
        flint_dict = {}
        denom_scale = D**n
        for exp, val in reconstructed.items():
            full_exp = list(exp) + [n - sum(exp)]
            if val % denom_scale == 0:
                flint_dict[tuple(full_exp)] = flint.fmpq(val // denom_scale, 1)
            else:
                flint_dict[tuple(full_exp)] = flint.fmpq(val, denom_scale)

        poly = ctx.from_dict(flint_dict)
        return cls(poly, variables)

    @classmethod
    def from_symmetric_matrix_pencil(
        cls, pencil: Union[SymmetricMatrixPencil, MultiplicativeMatrixPencil]
    ) -> "MultivariatePolynomial":
        r"""
        Constructs the multivariate polynomial $\det(x_1 A_1 + \dots + x_m A_m)$
        using exact arithmetic. Uses exact grid-based rational polynomial
        interpolation for large pencils ($n \ge 4$) to bypass the exponential
        symbolic determinant bottleneck, and direct Berkowitz determinant
        for small pencils.
        """
        if pencil.n >= 4:
            return cls.from_symmetric_matrix_pencil_interpolated(pencil)

        # Create symbols: x0, x1, ..., x(m-1)
        variables = [sp.Symbol(f"x{i}") for i in range(1, pencil.m + 1)]
        n = pencil.n

        # Construct symbolic matrix
        M = sp.zeros(n, n)
        for xi, Ai in zip(variables, pencil._get_matrices_sympy()):
            for r in range(n):
                for c in range(n):
                    val = Ai[r][c]
                    M[r, c] += xi * val

        expr = M.berkowitz_det()
        return cls(expr, variables)
Attributes
expr property

Returns the polynomial as a SymPy expression.

variables property

Return a caller-owned list in the stored coordinate order.

Methods:
__pow__(exponent)

Raise to a nonnegative integer power within the rational polynomial ring.

Source code in finitefree/multivariate.py
632
633
634
635
636
637
638
639
def __pow__(self, exponent: SupportsIndex) -> "MultivariatePolynomial":
    """Raise to a nonnegative integer power within the rational polynomial ring."""
    if isinstance(exponent, (bool, np.bool_)):
        raise TypeError("Exponent must be an integer, not a boolean.")
    power = operator.index(exponent)
    if power < 0:
        raise ValueError("Exponent must be nonnegative.")
    return type(self)(self._mpoly**power, self._variables)
coefficients()

Return an owned sparse mapping of exponent tuples to exact coefficients.

Source code in finitefree/multivariate.py
473
474
475
476
477
478
def coefficients(self) -> Dict[Tuple[int, ...], sp.Rational]:
    """Return an owned sparse mapping of exponent tuples to exact coefficients."""
    return {
        tuple(int(k) for k in alpha): sp.Rational(int(c.p), int(c.q))
        for alpha, c in self._mpoly.to_dict().items()
    }
degree()

Returns the total degree of the multivariate polynomial.

Source code in finitefree/multivariate.py
662
663
664
def degree(self) -> int:
    """Returns the total degree of the multivariate polynomial."""
    return int(self._mpoly.total_degree())
directional_derivative(direction)

Computes the directional derivative of the polynomial: \(D_e P(x) = \langle e, \nabla P(x) \rangle = \sum e_i \frac{\partial P}{\partial x_i}\)

Source code in finitefree/multivariate.py
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
def directional_derivative(
    self, direction: Sequence[Any]
) -> "MultivariatePolynomial":
    r"""
    Computes the directional derivative of the polynomial:
    $D_e P(x) = \langle e, \nabla P(x) \rangle = \sum e_i \frac{\partial P}{\partial x_i}$
    """
    if len(direction) != len(self.variables):
        raise ValueError(
            f"Direction length ({len(direction)}) must match "
            f"variable count ({len(self.variables)})."
        )

    from .utils.conversion import sympy_to_fmpq

    deriv_poly = self._ctx.from_dict({})
    for i, e_i in enumerate(direction):
        if e_i != 0:
            deriv_poly += self._mpoly.derivative(i) * sympy_to_fmpq(e_i)

    return MultivariatePolynomial(deriv_poly, self.variables)
evaluate(x)

Evaluate at a rational point \(x = (x_1, \dots, x_m)\). Floating coordinates retain their stored binary ratios. The result is an exact FLINT rational; use evaluate_float64 for approximate batch evaluation.

Source code in finitefree/multivariate.py
189
190
191
192
193
194
195
196
197
198
199
200
def evaluate(self, x: Sequence[Any]) -> Any:
    r"""
    Evaluate at a rational point $x = (x_1, \dots, x_m)$.
    Floating coordinates retain their stored binary ratios. The result is an
    exact FLINT rational; use evaluate_float64 for approximate batch evaluation.
    """
    from .utils.conversion import sympy_to_fmpq

    if len(x) != len(self.variables):
        raise ValueError(f"Expected {len(self.variables)} values, got {len(x)}")
    x_fmpq = [sympy_to_fmpq(xi) for xi in x]
    return self._mpoly(*x_fmpq)
evaluate_float64(points)

Evaluate real points shaped (..., variable_count) with NumPy.

A single point returns a float; batches retain their leading dimensions. Coefficients are converted once, and terms are evaluated across each batch. Bounded point blocks reuse monomial buffers; output storage is still full. Nonfinite inputs, coefficients or results raise errors. Finite cancellation, underflow and rounding remain possible; outputs have no certified error bound.

Source code in finitefree/multivariate.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def evaluate_float64(self, points: Any) -> Union[float, NDArray[np.float64]]:
    """Evaluate real points shaped (..., variable_count) with NumPy.

    A single point returns a float; batches retain their leading dimensions.
    Coefficients are converted once, and terms are evaluated across each batch.
    Bounded point blocks reuse monomial buffers; output storage is still full.
    Nonfinite inputs, coefficients or results raise errors. Finite cancellation,
    underflow and rounding remain possible; outputs have no certified error bound.
    """
    coordinates = self._float64_coordinates(points)
    if self._float_terms_cached is None:
        self._float_terms_cached = self._float64_terms(self._mpoly)
    result = np.empty(coordinates.shape[:-1], dtype=np.float64)
    if self._constant_float64_terms(self._float_terms_cached):
        result.fill(
            self._float_terms_cached[0][1] + 0.0
            if self._float_terms_cached
            else 0.0
        )
        return float(result) if result.ndim == 0 else result
    flat_result = result.reshape(-1)
    for start, stop, block in self._float64_blocks(coordinates):
        self._evaluate_float64_terms(
            block, self._float_terms_cached, None, flat_result[start:stop]
        )
    return float(result) if result.ndim == 0 else result
from_coefficients(coefficients, variables) classmethod

Construct from sparse nonnegative exponent tuples and rational values.

Source code in finitefree/multivariate.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
@classmethod
def from_coefficients(
    cls, coefficients: Mapping[Tuple[int, ...], Any], variables: Sequence[sp.Symbol]
) -> "MultivariatePolynomial":
    """Construct from sparse nonnegative exponent tuples and rational values."""
    from .utils.conversion import sympy_to_fmpq

    result = cls(0, variables)
    terms = {
        result._nonnegative_indices(alpha, "Exponent"): sympy_to_fmpq(c)
        for alpha, c in coefficients.items()
    }
    result._mpoly = result._ctx.from_dict(terms)
    return result
from_symmetric_matrix_pencil(pencil) classmethod

Constructs the multivariate polynomial \(\det(x_1 A_1 + \dots + x_m A_m)\) using exact arithmetic. Uses exact grid-based rational polynomial interpolation for large pencils (\(n \ge 4\)) to bypass the exponential symbolic determinant bottleneck, and direct Berkowitz determinant for small pencils.

Source code in finitefree/multivariate.py
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
@classmethod
def from_symmetric_matrix_pencil(
    cls, pencil: Union[SymmetricMatrixPencil, MultiplicativeMatrixPencil]
) -> "MultivariatePolynomial":
    r"""
    Constructs the multivariate polynomial $\det(x_1 A_1 + \dots + x_m A_m)$
    using exact arithmetic. Uses exact grid-based rational polynomial
    interpolation for large pencils ($n \ge 4$) to bypass the exponential
    symbolic determinant bottleneck, and direct Berkowitz determinant
    for small pencils.
    """
    if pencil.n >= 4:
        return cls.from_symmetric_matrix_pencil_interpolated(pencil)

    # Create symbols: x0, x1, ..., x(m-1)
    variables = [sp.Symbol(f"x{i}") for i in range(1, pencil.m + 1)]
    n = pencil.n

    # Construct symbolic matrix
    M = sp.zeros(n, n)
    for xi, Ai in zip(variables, pencil._get_matrices_sympy()):
        for r in range(n):
            for c in range(n):
                val = Ai[r][c]
                M[r, c] += xi * val

    expr = M.berkowitz_det()
    return cls(expr, variables)
from_symmetric_matrix_pencil_interpolated(pencil, parallel=False, backend='threads') classmethod

Constructs the multivariate polynomial \(\det(x_1 A_1 + \dots + x_m A_m)\) by evaluating the determinants modulo prime numbers exactly using fast C-level modular matrix mathematics and reconstructing exact coefficients over \(\mathbb{Q}\) using the Chinese Remainder Theorem (CRT) and Rational Reconstruction.

Source code in finitefree/multivariate.py
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
@classmethod
def from_symmetric_matrix_pencil_interpolated(
    cls,
    pencil: Union[SymmetricMatrixPencil, MultiplicativeMatrixPencil],
    parallel: bool = False,
    backend: str = "threads",
) -> "MultivariatePolynomial":
    r"""
    Constructs the multivariate polynomial $\det(x_1 A_1 + \dots + x_m A_m)$
    by evaluating the determinants modulo prime numbers exactly using fast
    C-level modular matrix mathematics and reconstructing exact coefficients
    over $\mathbb{Q}$ using the Chinese Remainder Theorem (CRT) and Rational Reconstruction.
    """
    import math

    import flint

    n = pencil.n
    m = pencil.m
    variables = [sp.Symbol(f"x{i}") for i in range(1, m + 1)]

    if m == 1:
        exact_A = pencil._get_matrices_sympy()[0]
        det_val = sp.Matrix(exact_A).det()
        expr = det_val * (variables[0] ** n)
        return cls(expr, variables)

    exps = get_monomial_exponents(m, n)
    N = len(exps)

    grid_vals = tuple(range(n + 1))
    full_grid_pts = get_grid_points(m - 1, grid_vals)

    # Convert all matrices to exact Rational representation to clear denominators
    exact_matrices = pencil._get_matrices_sympy()

    # Find the global common denominator D
    denominators = []
    for exact_A in exact_matrices:
        for r in range(n):
            for c in range(n):
                val = exact_A[r][c]
                if isinstance(val, sp.Rational):
                    denominators.append(val.q)
                else:
                    denominators.append(1)

    D = 1
    for den in denominators:
        D = (D * den) // math.gcd(D, den)

    # Construct integer matrices A_prime
    integer_matrices = []
    for exact_A in exact_matrices:
        int_A = []
        for r in range(n):
            row = []
            for c in range(n):
                val = exact_A[r][c] * D
                row.append(int(val))
            int_A.append(row)
        integer_matrices.append(int_A)

    primes_gen = prime_generator(1000000007)
    coefficient_bound = _determinant_coefficient_bound(integer_matrices)
    modulus = 1
    reconstructed = None
    primes_used = []
    coeffs_by_prime = []

    effective_backend = backend if parallel else "sequential"
    if pencil.n <= 3:
        effective_backend = "sequential"

    with ParallelScheduler(backend=effective_backend) as scheduler:
        batch_size = max(4, scheduler.max_workers)
        while True:
            batch_primes = [next(primes_gen) for _ in range(batch_size)]
            results = scheduler.evaluate(
                _eval_prime_worker,
                batch_primes,
                integer_matrices,
                full_grid_pts,
                n,
                m,
                exps,
            )
            for p_res, c_p in results:
                if c_p is not None:
                    coeffs_by_prime.append(c_p)
                    primes_used.append(p_res)
                    modulus *= p_res

            if primes_used:
                current_reconstruction = []
                for i in range(N):
                    vals = [coeffs_by_prime[k][i] for k in range(len(primes_used))]
                    current_reconstruction.append(crt(vals, primes_used))

                reconstructed = current_reconstruction
                if modulus > 2 * coefficient_bound:
                    break

    names = tuple(x.name for x in variables)
    ctx = flint.fmpq_mpoly_ctx.get(names=names)
    flint_dict = {}
    denom_scale = D**n
    for exp, val in zip(exps, reconstructed):
        if val % denom_scale == 0:
            flint_dict[exp] = flint.fmpq(val // denom_scale, 1)
        else:
            flint_dict[exp] = flint.fmpq(val, denom_scale)

    poly = ctx.from_dict(flint_dict)
    return cls(poly, variables)
from_symmetric_matrix_pencil_sparse(pencil, *, max_verification_bits=1000000) classmethod

Constructs the multivariate polynomial \(\det(x_1 A_1 + \dots + x_m A_m)\) by evaluating the determinants modulo prime numbers exactly using fast C-level modular matrix mathematics and reconstructing exact coefficients over \(\mathbb{Q}\) using randomized Zippel discovery followed by exact deterministic coefficient/support verification. A failed candidate or eight failed prime fields triggers balanced-base exact reconstruction.

max_verification_bits caps the conservative bit-size bound for the exact Kronecker verification before discovery starts. Exceeding it raises ValueError rather than returning an uncertified polynomial. Increasing the limit is explicit; it is not a runtime or memory quota.

Source code in finitefree/multivariate.py
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
@classmethod
def from_symmetric_matrix_pencil_sparse(
    cls,
    pencil: Union[SymmetricMatrixPencil, MultiplicativeMatrixPencil],
    *,
    max_verification_bits: SupportsIndex = 1_000_000,
) -> "MultivariatePolynomial":
    r"""
    Constructs the multivariate polynomial $\det(x_1 A_1 + \dots + x_m A_m)$
    by evaluating the determinants modulo prime numbers exactly using fast
    C-level modular matrix mathematics and reconstructing exact coefficients
    over $\mathbb{Q}$ using randomized Zippel discovery followed by exact
    deterministic coefficient/support verification. A failed candidate or
    eight failed prime fields triggers balanced-base exact reconstruction.

    ``max_verification_bits`` caps the conservative bit-size bound for the
    exact Kronecker verification before discovery starts. Exceeding it
    raises ValueError rather than returning an uncertified polynomial.
    Increasing the limit is explicit; it is not a runtime or memory quota.
    """
    import math
    import random

    import flint

    if isinstance(max_verification_bits, (bool, np.bool_)):
        raise TypeError("max_verification_bits must be a positive integer")
    verification_limit = operator.index(max_verification_bits)
    if verification_limit <= 0:
        raise ValueError("max_verification_bits must be a positive integer")

    n = pencil.n
    m = pencil.m
    variables = [sp.Symbol(f"x{i}") for i in range(1, m + 1)]

    if n == 0:
        return cls(sp.Integer(1), variables)
    if m == 1:
        exact_A = pencil._get_matrices_sympy()[0]
        det_val = sp.Matrix(exact_A).det()
        expr = det_val * (variables[0] ** n)
        return cls(expr, variables)

    exact_matrices = pencil._get_matrices_sympy()

    denominators = []
    for exact_A in exact_matrices:
        for r in range(n):
            for c in range(n):
                val = exact_A[r][c]
                if isinstance(val, sp.Rational):
                    denominators.append(val.q)
                else:
                    denominators.append(1)

    D = 1
    for den in denominators:
        D = (D * den) // math.gcd(D, den)

    integer_matrices = []
    for exact_A in exact_matrices:
        int_A = []
        for r in range(n):
            row = []
            for c in range(n):
                val = exact_A[r][c] * D
                row.append(int(val))
            int_A.append(row)
        integer_matrices.append(int_A)

    coefficient_bound = _determinant_coefficient_bound(integer_matrices)
    if coefficient_bound == 0:
        return cls(sp.Integer(0), variables)
    verification_base, verification_weights = _determinant_encoding_parameters(
        n, m, coefficient_bound, verification_limit
    )

    def eval_point_mod_p(pt: tuple[int, ...], p: int) -> int:
        M_pt = flint.nmod_mat(n, n, p)
        for r in range(n):
            for c in range(n):
                val = 0
                for pt_val, int_A in zip(pt, integer_matrices):
                    val = (val + pt_val * int_A[r][c]) % p
                M_pt[r, c] = val
        return int(M_pt.det())

    def zippel_mod_p(p: int) -> dict[tuple[int, ...], int]:
        rand_gen = random.Random(42)
        S: dict[tuple[int, ...], int] = {(): 1}

        for i in range(1, m):
            t = [rand_gen.randint(2, p - 2) for _ in range(m - 1 - i)]
            candidates = []
            for beta in S:
                sum_beta = sum(beta)
                for j in range(n - sum_beta + 1):
                    candidates.append(beta + (j,))

            K = len(candidates)
            if K == 0:
                break

            solved = False
            attempts = 0
            while not solved and attempts < 5:
                attempts += 1
                test_pts = []
                for _ in range(K):
                    test_pts.append(
                        tuple(rand_gen.randint(2, p - 2) for _ in range(i))
                    )

                try:
                    import os

                    if os.environ.get("PYFFP_DISABLE_CYTHON") == "1":
                        raise ImportError(
                            "Cython explicitly disabled via environment variable"
                        )
                    import numpy as np  # noqa: I001
                    from .utils.modular_fast import (
                        construct_zippel_vandermonde_mod_p,
                    )  # type: ignore[import-not-found, import-untyped, unused-ignore]  # noqa: I001

                    test_pts_np = np.array(test_pts, dtype=np.int64)
                    candidates_np = np.array(candidates, dtype=np.int64)
                    V_memview = construct_zippel_vandermonde_mod_p(
                        test_pts_np, candidates_np, p
                    )
                    V_np = np.array(V_memview, dtype=np.int64)
                    V_flat = V_np.flatten().tolist()
                    V = flint.nmod_mat(K, K, V_flat, p)
                except ImportError:
                    V = flint.nmod_mat(K, K, p)
                    for r_idx in range(K):
                        pt_val = test_pts[r_idx]
                        for c_idx in range(K):
                            exp = candidates[c_idx]
                            term = 1
                            for val, power in zip(pt_val, exp):
                                term = (term * pow(val, power, p)) % p
                            V[r_idx, c_idx] = term

                y = flint.nmod_mat(K, 1, p)
                try:
                    import os

                    if os.environ.get("PYFFP_DISABLE_CYTHON") == "1":
                        raise ImportError(
                            "Cython explicitly disabled via environment variable"
                        )
                    import numpy as np  # noqa: I001

                    from .utils.modular_fast import eval_points_grid_mod_p  # type: ignore[import-not-found, import-untyped, unused-ignore]  # noqa: I001

                    grid_pts = [
                        test_pts[r_idx] + tuple(t) + (1,) for r_idx in range(K)
                    ]
                    grid_pts_np = np.array(grid_pts, dtype=np.int64)
                    matrices_np = _as_modular_array(integer_matrices, p)
                    dets = eval_points_grid_mod_p(matrices_np, grid_pts_np, p)
                    for r_idx in range(K):
                        y[r_idx, 0] = dets[r_idx]
                except ImportError:
                    for r_idx in range(K):
                        full_pt = test_pts[r_idx] + tuple(t) + (1,)
                        y[r_idx, 0] = eval_point_mod_p(full_pt, p)

                try:
                    c_flint = V.solve(y)
                    solved = True
                    C = [int(c_flint[r_idx, 0]) for r_idx in range(K)]
                except Exception:
                    continue

            if not solved:
                raise ValueError(f"Zippel interpolation failed mod {p}")

            S = {}
            for coeff, exp in zip(C, candidates):
                if coeff != 0:
                    S[exp] = coeff

        return S

    primes_gen = prime_generator(1000000007)
    modulus = 1
    reconstructed: dict[tuple[int, ...], int] = {}
    primes_used = []
    coeffs_by_prime = []
    exps = []
    failed_primes = 0

    while True:
        p = next(primes_gen)
        try:
            S_p = zippel_mod_p(p)
        except ValueError:
            failed_primes += 1
            if failed_primes >= 8:
                break
            continue

        for exp in S_p:
            if exp not in exps:
                exps.append(exp)

        coeffs_by_prime.append(S_p)
        primes_used.append(p)
        modulus *= p

        if primes_used:
            current_reconstruction = {}
            for exp in exps:
                vals = []
                for k in range(len(primes_used)):
                    vals.append(coeffs_by_prime[k].get(exp, 0))
                current_reconstruction[exp] = crt(vals, primes_used)

            reconstructed = current_reconstruction
            if modulus > 2 * coefficient_bound:
                break

    reconstructed = _certify_sparse_determinant_coefficients(
        integer_matrices,
        reconstructed,
        coefficient_bound,
        verification_base,
        verification_weights,
    )

    names = tuple(x.name for x in variables)
    ctx = flint.fmpq_mpoly_ctx.get(names=names)
    flint_dict = {}
    denom_scale = D**n
    for exp, val in reconstructed.items():
        full_exp = list(exp) + [n - sum(exp)]
        if val % denom_scale == 0:
            flint_dict[tuple(full_exp)] = flint.fmpq(val // denom_scale, 1)
        else:
            flint_dict[tuple(full_exp)] = flint.fmpq(val, denom_scale)

    poly = ctx.from_dict(flint_dict)
    return cls(poly, variables)
gradient()

Return exact polynomial derivatives in stored variable order.

Source code in finitefree/multivariate.py
726
727
728
def gradient(self) -> list["MultivariatePolynomial"]:
    """Return exact polynomial derivatives in stored variable order."""
    return [self.partial_derivative(x) for x in self._variables]
gradient_float64(points)

Evaluate the gradient at real points shaped (..., m), returning (..., m).

Differentiate rational coefficients before float64 conversion. Sparse derivative terms are cached; coordinate powers are reused within bounded point blocks. The returned gradient is still fully allocated. Inputs/results have the same finite/range and rounding limits as evaluate_float64. Zero coordinates and empty batches are valid.

Source code in finitefree/multivariate.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
def gradient_float64(self, points: Any) -> NDArray[np.float64]:
    """Evaluate the gradient at real points shaped (..., m), returning (..., m).

    Differentiate rational coefficients before float64 conversion. Sparse
    derivative terms are cached; coordinate powers are reused within bounded
    point blocks. The returned gradient is still fully allocated.
    Inputs/results have the same finite/range and rounding limits as
    evaluate_float64. Zero coordinates and empty batches are valid.
    """
    coordinates = self._float64_coordinates(points)
    m = len(self._variables)
    if self._gradient_float_terms_cached is None:
        self._gradient_float_terms_cached = tuple(
            self._float64_terms(self._mpoly.derivative(i)) for i in range(m)
        )
    result = np.empty((*coordinates.shape[:-1], m), dtype=np.float64)
    if all(
        self._constant_float64_terms(t) for t in self._gradient_float_terms_cached
    ):
        for i, terms in enumerate(self._gradient_float_terms_cached):
            result[..., i].fill(terms[0][1] + 0.0 if terms else 0.0)
        return result
    flat_result = result.reshape(-1, m)
    for start, stop, block in self._float64_blocks(coordinates):
        powers: Dict[Tuple[int, int], NDArray[np.float64]] = {}
        component = np.empty(stop - start, dtype=np.float64)
        term = np.empty_like(component)
        for i, terms in enumerate(self._gradient_float_terms_cached):
            self._evaluate_float64_terms(block, terms, powers, component, term)
            flat_result[start:stop, i] = component
    return result
hessian()

Return exact second polynomial derivatives, including at singular points.

Source code in finitefree/multivariate.py
730
731
732
733
734
def hessian(self) -> list[list["MultivariatePolynomial"]]:
    """Return exact second polynomial derivatives, including at singular points."""
    return [
        [p.partial_derivative(y) for y in self._variables] for p in self.gradient()
    ]
hessian_float64(points)

Evaluate exact polynomial second derivatives as float64 (..., m, m).

Only the upper triangle is evaluated and mirrored. Coordinates shaped (..., m) retain their leading batch dimensions; one point returns (m, m). No inverses or division by coordinates are used, including at singular determinant points. Outputs are owned arrays with finite/range checks; cancellation, underflow and float64 rounding are not certified.

Source code in finitefree/multivariate.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
def hessian_float64(self, points: Any) -> NDArray[np.float64]:
    """Evaluate exact polynomial second derivatives as float64 (..., m, m).

    Only the upper triangle is evaluated and mirrored. Coordinates shaped
    (..., m) retain their leading batch dimensions; one point returns (m, m).
    No inverses or division by coordinates are used, including at singular
    determinant points. Outputs are owned arrays with finite/range checks;
    cancellation, underflow and float64 rounding are not certified.
    """
    coordinates = self._float64_coordinates(points)
    m = len(self._variables)
    if self._hessian_float_terms_cached is None:
        gradient = [self._mpoly.derivative(i) for i in range(m)]
        self._hessian_float_terms_cached = tuple(
            (i, j, self._float64_terms(gradient[i].derivative(j)))
            for i in range(m)
            for j in range(i, m)
        )
    result = np.empty((*coordinates.shape[:-1], m, m), dtype=np.float64)
    if all(
        self._constant_float64_terms(t)
        for _, _, t in self._hessian_float_terms_cached
    ):
        for i, j, terms in self._hessian_float_terms_cached:
            value = terms[0][1] + 0.0 if terms else 0.0
            result[..., i, j].fill(value)
            if i != j:
                result[..., j, i].fill(value)
        return result
    flat_result = result.reshape(-1, m, m)
    for start, stop, block in self._float64_blocks(coordinates):
        powers: Dict[Tuple[int, int], NDArray[np.float64]] = {}
        component = np.empty(stop - start, dtype=np.float64)
        term = np.empty_like(component)
        for i, j, terms in self._hessian_float_terms_cached:
            self._evaluate_float64_terms(block, terms, powers, component, term)
            flat_result[start:stop, i, j] = component
            if i != j:
                flat_result[start:stop, j, i] = component
    return result
is_homogeneous()

Verifies if the polynomial is homogeneous. \(P(t x_1, \dots, t x_m) = t^d P(x_1, \dots, x_m)\)

Source code in finitefree/multivariate.py
666
667
668
669
670
671
672
def is_homogeneous(self) -> bool:
    r"""
    Verifies if the polynomial is homogeneous.
    $P(t x_1, \dots, t x_m) = t^d P(x_1, \dots, x_m)$
    """
    d = self.degree()
    return all(sum(alpha) == d for alpha in self._mpoly.monoms())
mixed_partial_derivative(orders)

Computes mixed partial derivatives exactly and efficiently by performing C-level differentiation.

Source code in finitefree/multivariate.py
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
def mixed_partial_derivative(
    self, orders: Sequence[SupportsIndex]
) -> "MultivariatePolynomial":
    """
    Computes mixed partial derivatives exactly and efficiently by
    performing C-level differentiation.
    """
    indices = self._nonnegative_indices(orders, "Derivative order")
    if sum(indices) > self.degree():
        return MultivariatePolynomial(0, self.variables)

    res = self._mpoly
    for i, ord_val in enumerate(indices):
        for _ in range(ord_val):
            res = res.derivative(i)

    return MultivariatePolynomial(res, self.variables)
normalized_coefficients()

Extracts the normalized coefficients: \(\tilde{c}_\alpha = c_\alpha / \binom{d}{\alpha}\) where \(\binom{d}{\alpha}\) is the multinomial coefficient.

Source code in finitefree/multivariate.py
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
def normalized_coefficients(self) -> Dict[Tuple[int, ...], Any]:
    r"""
    Extracts the normalized coefficients:
    $\tilde{c}_\alpha = c_\alpha / \binom{d}{\alpha}$
    where $\binom{d}{\alpha}$ is the multinomial coefficient.
    """
    import flint

    if not self.is_homogeneous():
        raise ValueError(
            "Normalized coefficients require a homogeneous polynomial."
        )
    d = self.degree()

    def multinomial_coeff(total: int, alpha: Tuple[int, ...]) -> int:
        num = math.factorial(total)
        den = 1
        for a in alpha:
            den *= math.factorial(a)
        return num // den

    normalized = {}
    for alpha, c in self._mpoly.to_dict().items():
        weight = multinomial_coeff(d, alpha)
        val = c / flint.fmpq(weight, 1)
        normalized[tuple(int(k) for k in alpha)] = sp.Rational(
            int(val.p), int(val.q)
        )

    return normalized
partial_derivative(variable, order=1)

Differentiate in a stored variable with a nonnegative integer order.

Source code in finitefree/multivariate.py
714
715
716
717
718
719
720
721
722
723
724
def partial_derivative(
    self, variable: sp.Symbol, order: SupportsIndex = 1
) -> "MultivariatePolynomial":
    """Differentiate in a stored variable with a nonnegative integer order."""
    if variable not in self._variables:
        raise ValueError("Derivative variable must be in the stored variables.")
    if isinstance(order, (bool, np.bool_)):
        raise TypeError("Derivative order must be an integer, not a boolean.")
    orders = [0] * len(self._variables)
    orders[self._variables.index(variable)] = operator.index(order)
    return self.mixed_partial_derivative(orders)
reorder_variables(variables)

Copy into an explicit permutation of the same ordered SymPy symbols.

Permute exponent coordinates, preserving the polynomial expression and symbol assumptions. Renaming, missing or additional symbols are rejected. Use substitute with an explicit target context for polynomial changes.

Source code in finitefree/multivariate.py
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
def reorder_variables(
    self, variables: Sequence[sp.Symbol]
) -> "MultivariatePolynomial":
    """Copy into an explicit permutation of the same ordered SymPy symbols.

    Permute exponent coordinates, preserving the polynomial expression and
    symbol assumptions. Renaming, missing or additional symbols are rejected.
    Use substitute with an explicit target context for polynomial changes.
    """
    result = type(self)(0, variables)
    if set(result._variables) != set(self._variables):
        raise ValueError(
            "Reordered variables must be a permutation of stored symbols."
        )
    # Same-context projection returns the original mutable native object.
    # Identity reordering still promises an owned snapshot.
    result._mpoly = (
        self._mpoly + 0
        if result._ctx == self._ctx
        else self._mpoly.project_to_context(result._ctx)
    )
    return result
restrict_line(base_point, direction)

Form P(base_point + t*direction) exactly, retaining its leading scalar.

The returned univariate object certifies real-rootedness lazily. A generic restriction can have complex roots; no hyperbolicity assumption is inferred. Identically zero restrictions raise ValueError because the univariate class cannot represent the zero polynomial.

Source code in finitefree/multivariate.py
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
def restrict_line(
    self, base_point: Sequence[Any], direction: Sequence[Any]
) -> "RealRootedPolynomial":
    """Form P(base_point + t*direction) exactly, retaining its leading scalar.

    The returned univariate object certifies real-rootedness lazily. A generic
    restriction can have complex roots; no hyperbolicity assumption is inferred.
    Identically zero restrictions raise ValueError because the univariate class
    cannot represent the zero polynomial.
    """
    import flint

    from .utils.conversion import sympy_to_fmpq

    if len(base_point) != len(self._variables) or len(direction) != len(
        self._variables
    ):
        raise ValueError("Line coordinates must match variable count.")
    factors = [
        flint.fmpq_poly([sympy_to_fmpq(a), sympy_to_fmpq(b)])
        for a, b in zip(base_point, direction)
    ]
    result = flint.fmpq_poly([])
    for alpha, coefficient in self._mpoly.to_dict().items():
        term = flint.fmpq_poly([coefficient])
        for factor, exponent in zip(factors, alpha):
            if exponent:
                term *= factor**exponent
        result += term
    if result.degree() < 0:
        raise ValueError("Line restriction is identically zero.")
    return RealRootedPolynomial(result, monic=False)
substitute(substitutions, *, variables=None)

Simultaneously replace stored symbols with rational scalars/polynomials.

The target context defaults to the stored ordered symbols. An explicit nonempty variables sequence allows renaming, dimension changes or unused variables. Unreplaced symbols must occur unchanged in that context. MultivariatePolynomial replacements must match the target order exactly; rational SymPy polynomial expressions may use only target symbols. Scalars follow the exact stored-value rational conversion, including finite floats. Booleans, strings, arrays, rational functions and irrational/complex coefficients are rejected. Even full scalar substitution returns an owned constant polynomial in the target context, with no inferred geometry.

Source code in finitefree/multivariate.py
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
def substitute(
    self,
    substitutions: Mapping[sp.Symbol, Any],
    *,
    variables: Optional[Sequence[sp.Symbol]] = None,
) -> "MultivariatePolynomial":
    """Simultaneously replace stored symbols with rational scalars/polynomials.

    The target context defaults to the stored ordered symbols. An explicit
    nonempty variables sequence allows renaming, dimension changes or unused
    variables. Unreplaced symbols must occur unchanged in that context.
    MultivariatePolynomial replacements must match the target order exactly;
    rational SymPy polynomial expressions may use only target symbols.
    Scalars follow the exact stored-value rational conversion, including finite
    floats. Booleans, strings, arrays, rational functions and irrational/complex
    coefficients are rejected. Even full scalar substitution returns an owned
    constant polynomial in the target context, with no inferred geometry.
    """
    from .utils.conversion import sympy_to_fmpq

    if not isinstance(substitutions, Mapping):
        raise TypeError("Substitutions must be a mapping from stored symbols.")
    if any(x not in self._variables for x in substitutions):
        raise ValueError("Substitution keys must be stored SymPy symbols.")
    result = type(self)(0, self._variables if variables is None else variables)
    target_symbols = set(result._variables)
    target_indices = {symbol: i for i, symbol in enumerate(result._variables)}
    images = []
    generator_mapping = {}
    for i, symbol in enumerate(self._variables):
        value = substitutions.get(symbol, symbol)
        if isinstance(value, MultivariatePolynomial):
            if value._variables != result._variables:
                raise ValueError(
                    "Replacement polynomial variable sequences must match the target "
                    "exactly; reorder_variables explicitly when needed."
                )
            images.append(value._mpoly)
        elif isinstance(value, sp.Symbol):
            if value not in target_symbols:
                raise ValueError(
                    "Replacement and unreplaced symbols must belong to target variables."
                )
            j = target_indices[value]
            images.append(result._ctx.gen(j))
            generator_mapping[i] = j
        elif isinstance(value, sp.Expr) and value.free_symbols:
            if not value.free_symbols <= target_symbols:
                raise ValueError(
                    "Replacement and unreplaced symbols must belong to target variables."
                )
            try:
                images.append(type(self)(value, result._variables)._mpoly)
            except (TypeError, ValueError, sp.PolynomialError) as error:
                raise ValueError(
                    "Replacements must be rational polynomial expressions."
                ) from error
        else:
            if isinstance(
                value,
                (bool, np.bool_, str, np.ndarray, complex, np.complexfloating),
            ):
                raise ValueError(
                    "Replacements must be rational scalars or polynomials."
                )
            try:
                images.append(result._ctx.constant(sympy_to_fmpq(value)))
            except (TypeError, ValueError, OverflowError) as error:
                raise ValueError(
                    "Replacements must be finite rational scalars or polynomials."
                ) from error
    # FLINT 0.9 short-circuits same-context projection, ignoring a supplied
    # generator map. Such substitutions must use composition even for swaps.
    if len(generator_mapping) == len(self._variables) and result._ctx != self._ctx:
        result._mpoly = self._mpoly.project_to_context(
            result._ctx, mapping=generator_mapping
        )
    else:
        result._mpoly = self._mpoly.compose(*images, ctx=result._ctx)
    return result
to_fmpq_mpoly()

Return a caller-owned copy of the FLINT sparse polynomial. Evaluation/substitution costs depend on monomials, degrees and coefficient sizes; exposing the object does not make those operations constant-time.

Source code in finitefree/multivariate.py
800
801
802
803
804
805
806
def to_fmpq_mpoly(self) -> Any:
    r"""
    Return a caller-owned copy of the FLINT sparse polynomial.
    Evaluation/substitution costs depend on monomials, degrees and coefficient
    sizes; exposing the object does not make those operations constant-time.
    """
    return self._mpoly + 0

Working precision

finitefree.utils.precision

Classes

PrecisionContext

Temporarily set process-wide FLINT working precision in bits.

The default is max(53, int(2.5*degree)); prec overrides it. The previous precision is restored on exit, including exceptions. This is not an accuracy certificate, a memory manager or a thread-local context.

Source code in finitefree/utils/precision.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class PrecisionContext:
    """Temporarily set process-wide FLINT working precision in bits.

    The default is max(53, int(2.5*degree)); prec overrides it. The previous
    precision is restored on exit, including exceptions. This is not an
    accuracy certificate, a memory manager or a thread-local context.
    """

    def __init__(self, degree: int, prec: Optional[int] = None) -> None:
        self.degree = degree
        self.original_prec = flint.ctx.prec
        if prec is not None:
            self.new_prec = prec
        else:
            # Dynamically scale precision based on degree to handle combinatorial explosions
            self.new_prec = max(53, int(degree * 2.5))

    def __enter__(self) -> "PrecisionContext":
        flint.ctx.prec = self.new_prec
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        flint.ctx.prec = self.original_prec