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.
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")
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
|
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))
|
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))
|
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)
|
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
|
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
|
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
| @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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
| 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
|