Fix hangs and precision loss in the Jacobi theta functions - #1465
Merged
Merged
Conversation
Three numerical problems in jacobi_theta.hpp: 1. Non-finite arguments hung forever. The convergence predicate `delta == 0 || delta < eps*last` is false for NaN, so a NaN tau or q (which also passed the `q <= 0 || q >= 1` check) spun every series loop indefinitely, and a NaN or infinite z did the same whenever tau < 1 because fmod() produced NaN before the Gaussian sums. All twelve functions now reject non-finite z, non-positive or NaN tau, and q outside (0, 1) with a domain error, and the convergence predicate is written as !(delta > eps*last) so NaN terminates. The "m1" tau functions also now report their own name in domain errors instead of forwarding to theta3tau/theta4tau. 2. theta1 with tau < 1 lost precision catastrophically as z -> 0. The imaginary-transform branch summed differences of nearly equal Gaussians, giving relative error ~ eps*tau/z: about 1e3 ulps at z = 1e-4 and 1e9 ulps at z = 1e-10 for tau = 0.5. The sum is now arranged as pairs G(z - c) - G(z + c) evaluated through the exact identity -G(z - c) * expm1(-2 tau' z (2n+1)), after reducing z to [0, pi/2] using theta1(z + pi) = -theta1(z) and oddness. Errors are now around 1 ulp all the way down. 3. The q-parameterized functions amplified rounding by |log q|. Every q version converted to tau = -log(q)/pi and then evaluated exp(-tau pi n^2), so the exponent's rounding scaled with |log q|: 8 ulps at q = 1e-8, 50 at 1e-100, 100 at 1e-200 for theta3m1 and theta4m1, the functions meant for small q. When q < exp(-pi) the direct series is now evaluated in q with pow(), which is accurate to about an ulp regardless of magnitude. The four direct series are factored into shared helpers parameterized on how the nome power is computed. Regression tests cover the non-finite arguments, small-z theta1 with tau < 1, and small-q values of the q-parameterized functions against 40-digit references. The Watson's identity tolerance is relaxed from 101 to 200 eps: those identities subtract products of order 1 to leave a small remainder, so single-ulp changes in individual theta values are amplified by up to a couple of orders of magnitude, and the old tolerance was only passing by chance on the previous bit patterns. The Kolmogorov-Smirnov distribution only calls theta4tau and theta4m1tau at z = 0 with finite validated inputs, and its output is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Collaborator
|
@evanmiller : Could you generate an ulps_plot from this an post it here? We have code for it-though you may need to pick a slice along some line. The ulps plot will of course bitrot over time-so maybe we test using the exp-sinh quadrature? Should be something like boost::math::quadrature::exp_sinh<Real> integrator;
auto f = [&](Real tau) {
return exp(-a * tau) * boost::math::jacobi_theta1tau(z, tau);
};
Real Q = integrator.integrate(f);The build failures . . . are mine lol. Will try to green it up later. Other than that. . . .LGTM! |
Contributor
Author
|
@NAThompson Here are some plots courtesy of my friend Claude
|
Collaborator
|
@evanmiller : Major improvement. Can your friend also do the quadrature tests? |
Integrate exp(-a tau) theta(z | i tau) over tau with exp_sinh and compare with closed forms obtained by integrating the Fourier series term by term: theta1: sqrt(pi/a) sinh(2 c z) sech(s) |z| < pi/2 theta2: sqrt(pi/a) sinh(c (pi - 2 z)) sech(s) 0 < z < pi theta3m1: sqrt(pi/a) cosh(c (pi - 2 z)) csch(s) - 1/a 0 <= z <= pi theta4m1: sqrt(pi/a) cosh(2 c z) csch(s) - 1/a |z| <= pi/2 with c = sqrt(a/pi) and s = sqrt(a pi). The integrals pass through both the tau < 1 and tau >= 1 branches, and the small z values exercise the regime where theta1 with tau < 1 previously lost precision: against the old implementation the theta1 checks at z = 1e-6 and 1e-3 are off by up to three percent. The theta2 and theta3 forms also resolve the TODO in test_laplace_transforms. The tolerance is relative to the L1 norm of the integrand, which is what exp_sinh controls, plus 1/a for the two "m1" forms whose closed form subtracts that quantity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
Author
|
"We" have added quadrature tests in a second commit: dcec40d |
Collaborator
|
@evanmiller : Thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




Three numerical problems in
jacobi_theta.hpp, each verified against an 80-digit direct-series reference (mpmath).1. Non-finite arguments hung forever
The convergence predicate
delta == 0 || delta < eps*lastis false for NaN, so a NaNtauorq(which also slipped pastq <= 0 || q >= 1) spun every series loop indefinitely. A NaN or infinitezdid the same whenevertau < 1, becausefmod()produced NaN before the Gaussian sums. Eight of the twelve entry points could be hung this way.All twelve functions now reject non-finite
z, non-positive or NaNtau, andqoutside (0, 1) with a domain error, and the predicate is written as!(delta > eps*last)so a NaN can never loop. The "m1" tau functions also now report their own name in domain errors instead of the name of the function they forward to.2.
theta1withtau < 1lost precision catastrophically asz -> 0The imaginary-transform branch summed differences of nearly equal Gaussians, so relative error grew like
eps * tau / z:The sum is now arranged as pairs
G(z - c) - G(z + c), evaluated through the exact identity-G(z - c) * expm1(-2 tau' z (2n+1)), after reducingzto[0, pi/2]usingtheta1(z + pi) = -theta1(z)and oddness. Thetau >= 1branch was already fine and is unchanged.3. The
q-parameterized functions amplified rounding by|log q|Every
qversion converted totau = -log(q)/piand then evaluatedexp(-tau pi n^2), so the exponent's rounding scaled with|log q|. This hit the "m1" functions hardest, and smallqis exactly their stated purpose:When
q < exp(-pi)the direct series is now evaluated inqwithpow(). The four direct series are factored into shared helpers parameterized on how the nome power is computed, so thetauversions use the same loops.Tests and docs
Regression tests cover the non-finite arguments, small-
ztheta1withtau < 1, and small-qvalues of theqfunctions against 40-digit references. The Watson's-identity tolerance is relaxed from 101 to 200 eps: those identities subtract products of order 1 to leave a small remainder, amplifying single-ulp differences in the individual theta values by up to two orders of magnitude, and the old tolerance was passing only by chance on the previous bit patterns.test_jacobi_thetapasses, the existing accuracy tables are unchanged or improved (theta1tau<double>max error 11.1 -> 8.3 ulps), the reverse-mode autodiff compile tests for all twelve functions still compile, and the docs' accuracy and domain notes are updated.The Kolmogorov-Smirnov distribution only calls
theta4tauandtheta4m1tauatz = 0with validated finite inputs; its output is bit-identical before and after.Not addressed here: for
tau < 1and very large|z|(above ~1e3), the argument reduction is a single-constantfmodand loses accuracy linearly in|z|. That needs a proper Cody-Waite or Payne-Hanek reduction and is left for a separate change.🤖 Generated with Claude Code