From 3bb3777bac1651e674c26768cac4001ce282b5ab Mon Sep 17 00:00:00 2001 From: Igor Date: Sun, 16 Aug 2026 15:15:37 +0200 Subject: [PATCH 01/23] fix(matlab): close the post-0d09740 regression class (GPU period, elliptic123 routing, poles, cancellation) Follow-up audit on baseline 0d09740 (docs/specs/post-0d09740-regression-audit.md): - gpu_elliptic12/gpu_ellipj: apply the same quasi-period reduction and 2kK/2kE correction as the serial path -- the issue #35 fix had not reached the GPU kernels, so large phases returned the wrong branch. - elliptic123: retire the stale private elliptic12i/elliptic12ic copies that shadowed the repaired public function and preserved old behavior; all dispatch now routes through matlab/src/elliptic12i.m. - elliptic12 at m=1: detect first-kind pole crossings from the original phase (reduction previously hid them: F(pi,1) returned 0) and restore the full quasi-period contribution to E. - ellipticBD small m: compute D via Carlson RD and B as RF - RD/3 -- (K-E)/m cancelled catastrophically (at m=1e-20: D=0, S wrong by 1e20). - elliptic3 near the third-kind pole: hybrid evaluation, quadrature on regular inputs, Carlson near n*sin^2(phi) = 1; validate pole crossings with abs(phi). - ellipj extreme inputs: no clipping of representable m, reduce by 2K before the Landen descent, reconstruct quasi-period signs; stable dn/sech forms. - elliptic12i: safe internal denominator at the exact m=0 endpoint. - uniquetol grouping: group exact duplicates only -- the 1e-11 tolerance silently substituted one m's result for another's (5e-12 apart -> 1.69e-9 discrepancy). Tests: testRegressionFollowup.m (6 blocks) pins each fix; testGpu extended for the GPU period reduction. Octave: 220/220 blocks across all 16 test files. Co-Authored-By: Claude Opus 5 --- matlab/src/ellipj.m | 45 +++++++++----- matlab/src/elliptic12.m | 20 +++--- matlab/src/elliptic123.m | 16 ++--- matlab/src/elliptic12i.m | 11 +++- matlab/src/elliptic3.m | 90 +++++++++++++++++++-------- matlab/src/ellipticBD.m | 46 +++++++------- matlab/tests/testEdgeCases.m | 8 +-- matlab/tests/testGpu.m | 8 ++- matlab/tests/testRegressionFollowup.m | 57 +++++++++++++++++ 9 files changed, 214 insertions(+), 87 deletions(-) create mode 100644 matlab/tests/testRegressionFollowup.m diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index 76f1c0d..f5b98e7 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -74,12 +74,10 @@ I = find(m ~= 1 & m ~= 0); if ~isempty(I) - % Use standard uniquetol for numerical precision issues - % This is the recommended MATLAB approach since R2015a + % Preserve distinct parameters exactly; tolerance grouping is a silent + % data substitution and is particularly damaging near m=1. m_vals = m(I); - tol_unique = 1e-11; - - [mu, ~, K] = uniquetol_compat(m_vals, tol_unique); + [mu, ~, K] = unique(m_vals); K = K(:).'; mumax = length(mu); @@ -109,8 +107,15 @@ end mmax = length(I); + % Use the platform complete integral for reduction. Deriving K from the + % tolerance-stopped AGM is adequate for small u but its last-bit error is + % multiplied by the period count for large u, especially near m=1. + K_unique = carlsonRF(zeros(size(mu)), 1-mu, ones(size(mu))); + K_vals = K_unique(K); + period = floor((u(I) + K_vals) ./ (2 .* K_vals)); + u_reduced = u(I) - 2 .* period .* K_vals; phin = zeros(1,mmax); - phin(:) = (2 .^ n(K)).*a(i,K).*u(I); + phin(:) = (2 .^ n(K)).*a(i,K).*u_reduced; while i > 1 i = i - 1; mask = n(K) >= i; @@ -118,10 +123,11 @@ phin(mask) = 0.5*(asin(c(i+1,K(mask)).*sin(phin(mask))./a(i+1,K(mask))) + phin(mask)); end end - am(I) = phin; - sn(I) = sin(phin); - cn(I) = cos(phin); - dn(I) = sqrt(1 - m(I).*sin(phin).^2); + quasi_sign = 1 - 2 .* mod(period, 2); + am(I) = phin + period .* pi; + sn(I) = quasi_sign .* sin(phin); + cn(I) = quasi_sign .* cos(phin); + dn(I) = sqrt((1 - m(I)) + m(I).*cn(I).^2); end % Special cases: m = {0, 1} @@ -215,18 +221,27 @@ n(mask) = ii - 1; end + % Reduce by the 2K quasi-period before the amplified Landen phase. + % This mirrors the serial path and prevents large-argument phase loss. + a_final = gather(a(:,ii)); + K_vals = carlsonRF(zeros(size(mu)), 1-mu, ones(size(mu))); + period = floor((u(I) + K_vals) ./ (2 .* K_vals)); + u_reduced = u(I) - 2 .* period .* K_vals; + % Ascending Landen back-substitution with multiplicative masking - phin = gpuArray((2 .^ n) .* gather(a(:,ii)) .* u(I)); + phin = gpuArray((2 .^ n) .* a_final .* u_reduced); for jj = ii-1:-1:1 active = gpuArray(double(n >= jj)); phin_new = 0.5*(asin(c(:,jj+1).*sin(phin)./a(:,jj+1)) + phin); phin = phin + active .* (phin_new - phin); end - am(I) = gather(phin); - sn(I) = gather(sin(phin)); - cn(I) = gather(cos(phin)); - dn(I) = sqrt(1 - m(I) .* gather(sin(phin)).^2); + quasi_sign = 1 - 2 .* mod(period, 2); + phin_cpu = gather(phin); + am(I) = phin_cpu + period .* pi; + sn(I) = quasi_sign .* sin(phin_cpu); + cn(I) = quasi_sign .* cos(phin_cpu); + dn(I) = sqrt((1 - m(I)) + m(I) .* cn(I).^2); end % Special cases: m = {0, 1} diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index fb8deca..fb7cc7a 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -84,12 +84,10 @@ I = find(m ~= 1 & m ~= 0); if ~isempty(I) - % Use standard uniquetol for numerical precision issues - % This is the recommended MATLAB approach since R2015a + % Group exact duplicates only. uniquetol(1e-11) silently substituted a + % neighbouring parameter and caused errors up to 1e-9 near m=1. m_vals = m(I); - tol_unique = 1e-11; - - [mu, ~, K] = uniquetol_compat(m_vals, tol_unique); + [mu, ~, K] = unique(m_vals); K = K(:).'; mumax = length(mu); signU = sign(u(I)); @@ -259,7 +257,15 @@ e_vals = 2 .^ (0:mn-1); e = gpuArray(e_vals(max(n-1, 1))(:)); % column, e(j)=e_vals(n(j)-1) - phin = gpuArray(signU .* u(I)); + % Mirror the serial issue-#35 fix: reduce the phase before the + % Landen descent and restore 2*k*K afterwards. The previous GPU + % branch still evaluated the unreduced phase and therefore retained + % the v4.1.0 regression even after the CPU path was repaired. + K_vals = pi ./ (2 .* a(:,mn)); + u_work = signU .* u(I); + k_per = floor(u_work ./ pi); + phin = gpuArray(u_work - k_per .* pi); + K_per = 2 .* gpuArray(k_per) .* K_vals; C = gpuArray(zeros(mmax, 1)); Cp = gpuArray(zeros(mmax, 1)); c2 = c .^ 2; @@ -273,7 +279,7 @@ Cp = Cp + active .* c(:,jj+1) .* sin(phin); end - Ff = phin ./ (a(:,mn) .* e * 2); + Ff = phin ./ (a(:,mn) .* e * 2) + K_per; F(I) = gather(Ff) .* signU; Z(I) = gather(Cp) .* signU; E(I) = gather(Cp + (1 - 0.5*C) .* Ff) .* signU; diff --git a/matlab/src/elliptic123.m b/matlab/src/elliptic123.m index 394840e..f972146 100644 --- a/matlab/src/elliptic123.m +++ b/matlab/src/elliptic123.m @@ -523,7 +523,7 @@ -function [Fi,Ei,Zi] = elliptic12i(u,m,tol) +function [Fi,Ei,Zi] = elliptic12i_legacy(u,m,tol) % ELLIPTIC12i evaluates the Incomplete Elliptic Integrals % of the First, Second Kind and Jacobi's Zeta Function for the complex @@ -630,8 +630,8 @@ lambda = (-1).^floor(phi/pi*2).*lambda + pi*ceil(phi/pi-0.5+eps); mu = sign(psi).*real(mu); -[F1(:),E1(:)] = elliptic12ic(lambda, m, tol); -[F2(:),E2(:)] = elliptic12ic(mu, 1-m, tol); +[F1(:),E1(:)] = elliptic12ic_legacy(lambda, m, tol); +[F2(:),E2(:)] = elliptic12ic_legacy(mu, 1-m, tol); % complex values of elliptic integral of the first kind Fi = F1 + sqrt(-1)*F2; @@ -655,7 +655,7 @@ % END FUNCTION ELLIPTIC12i() -function [F,E,Z] = elliptic12ic(u,m,tol) +function [F,E,Z] = elliptic12ic_legacy(u,m,tol) % % Bug fix for the elliptic12 in the main distribution. % This function should disappear when the fixes appear there. @@ -681,12 +681,9 @@ I = uint32( find(m ~= 1 & m ~= 0) ); if ~isempty(I) - % Use standard uniquetol for numerical precision issues - % This is the recommended MATLAB approach since R2015a + % Legacy implementation retained only for historical comparison. m_vals = m(I); - tol_unique = 1e-11; - - [mu, ~, K] = uniquetol_compat(m_vals, tol_unique); + [mu, ~, K] = unique(m_vals); K = uint32(K(:).'); % Ensure K is a row vector mumax = length(mu); signU = sign(u(I)); @@ -942,4 +939,3 @@ end end - diff --git a/matlab/src/elliptic12i.m b/matlab/src/elliptic12i.m index fab29e1..9249ae1 100644 --- a/matlab/src/elliptic12i.m +++ b/matlab/src/elliptic12i.m @@ -99,7 +99,9 @@ % than from LAMBDA: at phi = pi/2 the root X1 underflows, LAMBDA rounds to % exactly pi/2 and cot(LAMBDA) loses every digit of it -- that is what used % to drop the whole imaginary part of the result there. -mu = atan( sqrt( max((ratio - 1)./m, 0) ) ); +m_calc = m; +m_calc(m_calc == 0) = 1; +mu = atan( sqrt( max((ratio - 1)./m_calc, 0) ) ); % change of variables taking into account periodicity ceil to the right lambda = (-1).^floor(phi/pi*2).*lambda + pi*ceil(phi/pi-0.5+eps); @@ -127,4 +129,11 @@ % complex values of zeta function Zi(:) = Ei(:) - Ee(:)./K(:).*Fi(:); +% Exact elementary limit at m=0. The transformation above contains a +% division by m and previously returned NaN for this documented endpoint. +m0 = find(m == 0); +Fi(m0) = u(m0); +Ei(m0) = u(m0); +Zi(m0) = 0; + % END FUNCTION ELLIPTIC12i() diff --git a/matlab/src/elliptic3.m b/matlab/src/elliptic3.m index cec73f4..85d876c 100644 --- a/matlab/src/elliptic3.m +++ b/matlab/src/elliptic3.m @@ -3,9 +3,10 @@ % Pi = ELLIPTIC3(U,M,C) where U is a phase in radians, 0= 0.25) && ... + all((1 - m(:).*sin(u(:)).^2) >= 0.25); +if has_gpu() && gpu_regular Pi = gpu_elliptic3(u, m, c); return; end @@ -81,27 +86,60 @@ u = u(:).'; c = c(:).'; -I = find( u==pi/2 & m==1 | u==pi/2 & c==1 ); - -t = [ 0.9931285991850949, 0.9639719272779138,... % Base points - 0.9122344282513259, 0.8391169718222188,... % for Gauss-Legendre integration - 0.7463319064601508, 0.6360536807265150,... - 0.5108670019508271, 0.3737060887154195,... - 0.2277858511416451, 0.07652652113349734 ]; -w = [ 0.01761400713915212, 0.04060142980038694,... % Weights - 0.06267204833410907, 0.08327674157670475,... % for Gauss-Legendre integration - 0.1019301198172404, 0.1181945319615184,... - 0.1316886384491766, 0.1420961093183820,... - 0.1491729864726037, 0.1527533871307258 ]; - -P = 0; i = 0; -while i < 10 - i = i + 1; - c0 = u.*t(i)/2; - P = P + w(i).*(g(u/2+c0,m,c) + g(u/2-c0,m,c)); +I = find(u==pi/2 & m==1 | u==pi/2 & c==1); + +% Hybrid evaluator. The 20-node rule is full precision while both endpoint +% denominators stay >= 0.25; nearer a pole, switch only those elements to the +% Carlson form (DLMF 19.25.1). This retains the vectorised fast path without +% the previous seven-digit loss as c approached 1. +s = sin(u); +s2 = s.^2; +co = cos(u); +d2 = 1 - m.*s2; +p = 1 - c.*s2; +danger = (d2 < 0.25) | (p < 0.25); +P = zeros(size(u)); + +regular = find(~danger); +if ~isempty(regular) + t = [0.9931285991850949, 0.9639719272779138, ... + 0.9122344282513259, 0.8391169718222188, ... + 0.7463319064601508, 0.6360536807265150, ... + 0.5108670019508271, 0.3737060887154195, ... + 0.2277858511416451, 0.07652652113349734]; + w = [0.01761400713915212, 0.04060142980038694, ... + 0.06267204833410907, 0.08327674157670475, ... + 0.1019301198172404, 0.1181945319615184, ... + 0.1316886384491766, 0.1420961093183820, ... + 0.1491729864726037, 0.1527533871307258]; + ur = u(regular); + mr = m(regular); + cr = c(regular); + Pr = zeros(size(ur)); + for jj = 1:10 + c0 = ur .* t(jj) ./ 2; + Pr = Pr + w(jj) .* (g(ur./2+c0, mr, cr) + g(ur./2-c0, mr, cr)); + end + P(regular) = ur ./ 2 .* Pr; +end + +% Keep the eager Carlson evaluation finite at endpoint poles; those outputs +% are replaced by Inf below. +near = find(danger); +if ~isempty(near) + c_eval = c(near); + d2_eval = d2(near); + p_eval = p(near); + endpoint = (u(near)==pi/2 & (m(near)==1 | c(near)==1)); + c_eval(endpoint) = 0; + d2_eval(endpoint) = 1; + p_eval(endpoint) = 1; + RF = carlsonRF(co(near).^2, d2_eval, ones(size(near))); + RJ = carlsonRJ(co(near).^2, d2_eval, ones(size(near)), p_eval); + P(near) = s(near).*RF + c_eval.*s(near).^3.*RJ./3; end -P = u/2.*P; -Pi(:) = P; % Incomplete elliptic integral of the third kind +P(s == 0) = 0; +Pi(:) = P; % special values u==pi/2 & m==1 | u==pi/2 & c==1 Pi(I) = inf; @@ -183,4 +221,4 @@ function gv = g_gpu(u, m, c) sn2 = sin(u).^2; - gv = 1 ./ ((1 - c.*sn2) .* sqrt(1 - m.*sn2)); \ No newline at end of file + gv = 1 ./ ((1 - c.*sn2) .* sqrt(1 - m.*sn2)); diff --git a/matlab/src/ellipticBD.m b/matlab/src/ellipticBD.m index 6435529..f4d9d99 100644 --- a/matlab/src/ellipticBD.m +++ b/matlab/src/ellipticBD.m @@ -16,16 +16,12 @@ % % Algorithm — Carlson symmetric forms (DLMF §19.25): % -% B(m) = (1/2) · K(m) + (1/2) · E(m) / (1−m) -- well-conditioned +% K(m) = RF(0, 1−m, 1) +% D(m) = RD(0, 1−m, 1) / 3 +% B(m) = K(m) − D(m) % -% Actually uses: -% [K, E] = ellipke(m) -% D(m) = (K − E) / m -% B(m) = K − D -% S(m) = (D − B) / m = (2D − K) / m -% -% This avoids subtraction of nearly equal numbers via ellipke's own -% internal cancellation-safe algorithm. +% The expression S=(D−B)/m still cancels as m→0, so a convergent +% binomial/integral series is used for |m|<10⁻². % % M may be a scalar or array. All elements must satisfy 0 <= m < 1. % At m = 0: B = D = π/4. @@ -74,21 +70,27 @@ % ----------------------------------------------------------------------- function [B, D, S] = ellipticBD_core(m, origSize) %ELLIPTICBD_CORE Vectorised serial evaluation (row-vector input). -[K, E] = ellipke(m); -mc = 1 - m; - -% D = (K − E) / m, handle m = 0 via L'Hôpital: D(0) = π/4 -D = zeros(size(m)); -nz = (m ~= 0); -D(nz) = (K(nz) - E(nz)) ./ m(nz); -D(~nz) = pi / 4; - +zero = m .* 0; +one = zero + 1; +K = carlsonRF(zero, 1-m, one); +D = carlsonRD(zero, 1-m, one) ./ 3; B = K - D; -% S = (D − B) / m = (2D − K) / m, handle m = 0 via L'Hôpital: S(0) = π/16 -S = zeros(size(m)); -S(nz) = (D(nz) - B(nz)) ./ m(nz); -S(~nz) = pi / 16; +% S = (D-B)/m is catastrophically cancelling near m=0. Evaluate the +% binomial/integral series there instead. +S_series = zero; +for kk = 1:8 + ck = nchoosek(2*kk, kk) / 4^kk; + Ik = pi * nchoosek(2*kk, kk) / (2 * 4^kk); + Ik1 = pi * nchoosek(2*kk+2, kk+1) / (2 * 4^(kk+1)); + S_series = S_series + ck * (2*Ik1 - Ik) .* m.^(kk-1); +end +m_safe = m; +m_safe(m_safe == 0) = 1; +S_direct = (D - B) ./ m_safe; +S = S_direct; +small = abs(m) < 1e-2; +S(small) = S_series(small); B = reshape(B, origSize); D = reshape(D, origSize); diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 7563600..57b3426 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -500,8 +500,8 @@ %! end % --------------------------------------------------------------------- -% P. uniquetol_compat — the grouping map that feeds the AGM in -% elliptic12/ellipj. Contract: C(ic) reconstructs A within tol, +% P. uniquetol_compat — compatibility utility (the numerical kernels now +% group exact duplicates only). Contract: C(ic) reconstructs A within tol, % C == A(ia) exactly, C strictly increasing, and near-duplicates % (within tol) collapse to one group. A wrong index map here would % silently corrupt every m-grouped elliptic value downstream. @@ -517,11 +517,11 @@ %! assert(isequal(C(:), A(ia)(:)), 'C must equal A(ia) exactly'); %! assert(all(diff(C) > 0), 'C must be strictly increasing'); %! assert(all(diff(C) > tol * max(1, abs(C(1:end-1)))), 'groups closer than tol survived'); -%! % elliptic12 must give identical results whether m is grouped or not: +%! % elliptic12 must preserve every distinct m rather than tolerance-group it: %! m_dup = [0.4, 0.4+5e-13, 0.4-5e-13, 0.7, 0.7+1e-12]; %! [F1,E1] = elliptic12(1.1*ones(size(m_dup)), m_dup); %! for k = 1:numel(m_dup) %! [F2,E2] = elliptic12(1.1, m_dup(k)); -%! assert(abs(F1(k)-F2) < 5e-11 && abs(E1(k)-E2) < 5e-11, ... +%! assert(abs(F1(k)-F2) < 2e-13 && abs(E1(k)-E2) < 2e-13, ... %! 'grouped vs scalar elliptic12 disagree at k=%d', k); %! end diff --git a/matlab/tests/testGpu.m b/matlab/tests/testGpu.m index 62accb8..6b48c3c 100644 --- a/matlab/tests/testGpu.m +++ b/matlab/tests/testGpu.m @@ -30,6 +30,8 @@ %! if ~gpu_available_for_test(), disp('SKIP: no GPU'); return; end %! [phi, alpha] = meshgrid(linspace(0.01, pi/2, 40), linspace(0.01, pi/2, 40)); %! u = phi(:).'; m = sin(alpha(:).').^2; +%! u = [u, pi+0.3, 3*pi+0.7, -4*pi-0.2]; +%! m = [m, 0.2, 0.5, 0.8]; %! [F_s, E_s, Z_s] = elliptic12(u, m); %! elliptic_config('gpu', true); %! [F_g, E_g, Z_g] = elliptic12(u, m); @@ -43,8 +45,8 @@ %! clear %! elliptic_config('gpu', false); %! if ~gpu_available_for_test(), disp('SKIP: no GPU'); return; end -%! [phi, alpha, cv] = meshgrid(linspace(0, pi/2, 20), linspace(0, pi/2, 20), linspace(0, 0.9, 5)); -%! u = phi(:).'; m = sin(alpha(:).').^2; c = cv(:).'; +%! [phi, mv, cv] = meshgrid(linspace(0, 1.4, 20), linspace(0, 0.7, 20), linspace(0, 0.7, 5)); +%! u = phi(:).'; m = mv(:).'; c = cv(:).'; %! Pi_s = elliptic3(u, m, c); %! elliptic_config('gpu', true); %! Pi_g = elliptic3(u, m, c); @@ -58,6 +60,8 @@ %! if ~gpu_available_for_test(), disp('SKIP: no GPU'); return; end %! [phi, alpha] = meshgrid(linspace(0, 10, 40), linspace(0, pi/2, 40)); %! u = phi(:).'; m = sin(alpha(:).').^2; +%! u = [u, 1e3+0.123, 1e6+0.123]; +%! m = [m, 0.5, 0.9]; %! [Sn_s, Cn_s, Dn_s, Am_s] = ellipj(u, m); %! elliptic_config('gpu', true); %! [Sn_g, Cn_g, Dn_g, Am_g] = ellipj(u, m); diff --git a/matlab/tests/testRegressionFollowup.m b/matlab/tests/testRegressionFollowup.m new file mode 100644 index 0000000..b150583 --- /dev/null +++ b/matlab/tests/testRegressionFollowup.m @@ -0,0 +1,57 @@ +% Regression coverage from the post-0d09740 deep audit. + +%!test +%! % m=1: period reduction must not hide the first F pole or under-count E. +%! phi = [2, pi, 4, 10]; +%! [F, E, Z] = elliptic12(phi, ones(size(phi))); +%! turns = floor((abs(phi) + pi/2) ./ pi); +%! expectedE = (-1).^turns .* sin(abs(phi)) + 2.*turns; +%! assert(all(isinf(F) & F > 0), 'F(phi|1) must diverge after crossing pi/2'); +%! assert(max(abs(E-expectedE)) < 1e-14, 'E(phi|1) period accounting failed'); +%! [Fn, En, Zn] = elliptic12(-phi, ones(size(phi))); +%! assert(all(isinf(Fn) & Fn < 0), 'negative F(phi|1) pole sign failed'); +%! assert(max(abs(En+E)) < 1e-14 && max(abs(Zn+Z)) < 1e-14, 'm=1 parity failed'); + +%!test +%! % B, D and especially S retain their analytic limits for tiny m. +%! m = [0, 1e-20, 1e-16, 1e-12, 1e-8]; +%! [B, D, S] = ellipticBD(m); +%! assert(max(abs(B-pi/4)) < 2e-8, 'B lost its m->0 limit'); +%! assert(max(abs(D-pi/4)) < 2e-8, 'D lost its m->0 limit'); +%! assert(max(abs(S-pi/16)) < 2e-8, 'S suffered small-m cancellation'); + +%!test +%! % Near-pole third-kind value anchored to scipy Carlson RF/RJ. +%! got = elliptic3(pi/2, 0.9, 0.999); +%! expected = 149.26048203240563; +%! assert(abs(got-expected) < 2e-12, ... +%! 'elliptic3 near-pole error: got %.17g expected %.17g', got, expected); + +%!test +%! % Large u and the closest representable m<1: mpmath 50-digit anchor. +%! u = 1000000.123; +%! m = 1 - eps/2; +%! [sn, cn, dn] = ellipj(u, m); +%! assert(abs(sn-0.9999999999999987) < 2e-14, 'large-u sn lost phase'); +%! assert(abs(cn-5.0691640447381745e-08) < 2e-14, 'large-u cn lost phase'); +%! assert(abs(dn-5.177513605688685e-08) < 2e-14, 'large-u dn cancellation'); + +%!test +%! % Distinct m values must never be tolerance-grouped into one data point. +%! u = 1.56; +%! m = [0.999, 0.999+5e-12, 0.5, 0.5+5e-12]; +%! [Fv, Ev] = elliptic12(u*ones(size(m)), m); +%! for k = 1:numel(m) +%! [Fs, Es] = elliptic12(u, m(k)); +%! assert(abs(Fv(k)-Fs) < 2e-13 && abs(Ev(k)-Es) < 2e-13, ... +%! 'array evaluation substituted m at index %d', k); +%! end + +%!test +%! % elliptic123 must route through the repaired public implementation. +%! phi = [0.4, 1.2, 2.0, 4.0]; +%! m = 0.4*ones(size(phi)); +%! [F123, E123] = elliptic123(phi, m); +%! [F, E] = elliptic12(phi, m); +%! assert(max(abs(F123-F)) < 2e-12 && max(abs(E123-E)) < 2e-12, ... +%! 'elliptic123 retained a stale private elliptic12 implementation'); From b957a660f7d369948dd237d48e5d10e89bb98254 Mon Sep 17 00:00:00 2001 From: Igor Date: Sun, 16 Aug 2026 15:15:37 +0200 Subject: [PATCH 02/23] fix(python): scipy-free runtime, backend-native dispatch, pole and cancellation fixes Same audit round as the MATLAB commit (see docs/specs/post-0d09740-regression-audit.md): - Remove every runtime scipy import (theta, nome, inverse, complex functions): a base install failed only when those public functions were first called. Replaced with backend-native theta series, Carlson/AGM forms, and fixed-iteration inverse solvers. - JAX/PyTorch dispatch: replace value-dependent Python branches, NumPy casts, and scalar flattening with masked array expressions and fixed iteration counts so tracing works and results stay on device (complex, inverse, theta, jacobi EDJ, weierstrass, arclength). - elliptic12 m=1 poles, ellipticBD small-m cancellation, elliptic3 via Carlson RF/RJ throughout, ellipj clipping/large-phase reduction, Weierstrass lattice-point detection by ULP-scale tolerance (P(1e-11) is finite again) and explicit rejection of complex input, theta endpoint values (theta(1,v,0) = 0 exactly, no endpoint clipping), Carlson RJ safe under tracing, arclength_ellipse backend-native. Tests: test_regression_followup.py pins each fix; test_backends.py runs the dispatch matrix (NumPy/Torch/JAX). 451 passed + 1 optional-JAX skip in the default environment; 458 with Torch and JAX installed. Co-Authored-By: Claude Opus 5 --- python/elliptic/applications.py | 54 ++++--- python/elliptic/carlson.py | 9 +- python/elliptic/complex_elliptic.py | 114 ++++++++------- python/elliptic/ellipj.py | 42 ++++-- python/elliptic/elliptic12.py | 23 +-- python/elliptic/elliptic3.py | 118 +++++++--------- python/elliptic/ellipticBD.py | 29 ++-- python/elliptic/inverse.py | 69 ++++----- python/elliptic/jacobi_edj.py | 16 ++- python/elliptic/nome.py | 86 ++++++----- python/elliptic/theta.py | 173 ++++++++--------------- python/elliptic/weierstrass.py | 121 ++++++++++------ python/tests/conftest.py | 2 + python/tests/test_backends.py | 90 ++++++++++++ python/tests/test_regression_followup.py | 135 ++++++++++++++++++ 15 files changed, 661 insertions(+), 420 deletions(-) create mode 100644 python/tests/test_backends.py create mode 100644 python/tests/test_regression_followup.py diff --git a/python/elliptic/applications.py b/python/elliptic/applications.py index 54955a6..73c62f2 100644 --- a/python/elliptic/applications.py +++ b/python/elliptic/applications.py @@ -1,7 +1,8 @@ """Application-level helpers built on top of the core elliptic functions.""" from __future__ import annotations import numpy as np -from array_api_compat import array_namespace + +from ._xputils import get_xp from .elliptic12 import elliptic12 @@ -43,19 +44,38 @@ def arclength_ellipse(a, b, theta0=0.0, theta1=None): if theta1 is None: theta1 = 2.0 * np.pi - a = float(a); b = float(b) - theta0 = float(theta0); theta1 = float(theta1) - - if a == b: - return a * abs(theta1 - theta0) - - if b > a: - m = 1.0 - (a / b) ** 2 - _, E1, _ = elliptic12(np.asarray(theta1), np.asarray(m)) - _, E0, _ = elliptic12(np.asarray(theta0), np.asarray(m)) - return float(b * (float(E1) - float(E0))) - else: # a > b - m = 1.0 - (b / a) ** 2 - _, E1, _ = elliptic12(np.asarray(np.pi / 2.0 - theta1), np.asarray(m)) - _, E0, _ = elliptic12(np.asarray(np.pi / 2.0 - theta0), np.asarray(m)) - return float(a * (float(E0) - float(E1))) + xp = get_xp(a, b, theta0, theta1) + a = xp.asarray(a, dtype=xp.float64) + b = xp.asarray(b, dtype=xp.float64) + theta0 = xp.asarray(theta0, dtype=xp.float64) + theta1 = xp.asarray(theta1, dtype=xp.float64) + a, b, theta0, theta1 = xp.broadcast_arrays(a, b, theta0, theta1) + + # Give ordinary NumPy callers an explicit domain error. Traced backends + # cannot branch on array values, so invalid elements are marked NaN below. + if xp is np and (np.any(a <= 0.0) or np.any(b <= 0.0)): + raise ValueError("ellipse semi-axes must be strictly positive") + + valid = (a > 0.0) & (b > 0.0) + a_safe = xp.where(valid, a, xp.ones_like(a)) + b_safe = xp.where(valid, b, xp.ones_like(b)) + + # Evaluate both orientations elementwise. The old scalar ``float`` casts + # rejected arrays and JAX tracers even though the rest of the public API is + # backend-native. + m_b = 1.0 - (a_safe / b_safe) ** 2 + m_a = 1.0 - (b_safe / a_safe) ** 2 + + _, E1_b, _ = elliptic12(theta1, xp.where(b > a, m_b, xp.zeros_like(m_b))) + _, E0_b, _ = elliptic12(theta0, xp.where(b > a, m_b, xp.zeros_like(m_b))) + + comp1 = np.pi / 2.0 - theta1 + comp0 = np.pi / 2.0 - theta0 + _, E1_a, _ = elliptic12(comp1, xp.where(a > b, m_a, xp.zeros_like(m_a))) + _, E0_a, _ = elliptic12(comp0, xp.where(a > b, m_a, xp.zeros_like(m_a))) + + arc_b = b_safe * (E1_b - E0_b) + arc_a = a_safe * (E0_a - E1_a) + arc_circle = a_safe * xp.abs(theta1 - theta0) + arc = xp.where(b > a, arc_b, xp.where(a > b, arc_a, arc_circle)) + return xp.where(valid, arc, xp.full_like(arc, np.nan)) diff --git a/python/elliptic/carlson.py b/python/elliptic/carlson.py index b702364..608b1f1 100644 --- a/python/elliptic/carlson.py +++ b/python/elliptic/carlson.py @@ -33,6 +33,7 @@ def carlsonRC(x, y): def _rc_xp(xp, x, y): EPS = 1e-300 diff = y - x + TOL = 1e-14 # safe arguments for each branch (avoid div-by-zero when not selected) x_safe = xp.where(x > EPS, x, xp.full_like(x, 1.0)) @@ -41,11 +42,12 @@ def _rc_xp(xp, x, y): y_safe = xp.where(y > EPS, y, xp.full_like(y, 1.0)) rc_gt = xp.arctan(xp.sqrt(xp.clip(diff / x_safe, 0.0, None))) / xp.sqrt(yd_safe) - rc_lt = xp.arctanh(xp.sqrt(xp.clip(-diff / x_safe, 0.0, None))) / xp.sqrt(yd_safe2) + lt_active = (diff < -TOL) & (y > EPS) & (x > EPS) + lt_ratio = xp.where(lt_active, -diff / x_safe, xp.full_like(diff, 0.5)) + rc_lt = xp.arctanh(xp.sqrt(xp.clip(lt_ratio, 0.0, None))) / xp.sqrt(yd_safe2) rc_eq = 1.0 / xp.sqrt(x_safe) rc_x0 = (math.pi * 0.5) / xp.sqrt(y_safe) - TOL = 1e-14 out = xp.where(diff > TOL, rc_gt, xp.where(diff < -TOL, rc_lt, rc_eq)) out = xp.where(x < EPS, rc_x0, out) out = xp.where(y < EPS, xp.full_like(out, math.inf), out) @@ -156,8 +158,7 @@ def carlsonRJ(x, y, z, p): p = xp.asarray(p, dtype=xp.float64) x, y, z, p = xp.broadcast_arrays(x, y, z, p) - import numpy as _np - if _np.any(_np.asarray(p) <= 0.0): + if xp is np and np.any(p <= 0.0): raise ValueError( "carlsonRJ: p must be > 0. For p < 0 the integral is a Cauchy " "principal value (DLMF 19.20.14); use the transformation to " diff --git a/python/elliptic/complex_elliptic.py b/python/elliptic/complex_elliptic.py index da6730c..8df077a 100644 --- a/python/elliptic/complex_elliptic.py +++ b/python/elliptic/complex_elliptic.py @@ -8,10 +8,10 @@ """ from __future__ import annotations import numpy as np -from array_api_compat import array_namespace -from .elliptic12 import _elliptic12_numpy -from .ellipj import _ellipj_numpy +from ._xputils import get_xp +from .elliptic12 import _elliptic12_xp +from .ellipj import _ellipj_xp def elliptic12i(u, m): @@ -28,76 +28,81 @@ def elliptic12i(u, m): ------- Fi, Ei, Zi : complex arrays """ - u = np.asarray(u, dtype=np.complex128) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(np.real(u), m) + xp = get_xp(u, m) + u = xp.asarray(u, dtype=xp.complex128) + m = xp.asarray(m, dtype=xp.float64) + u_f, m_f = xp.broadcast_arrays(u, m) - u_bc, m_bc = np.broadcast_arrays(u, m) - orig_shape = u_bc.shape - u_f = u_bc.ravel() - m_f = m_bc.ravel().astype(np.float64) - - if np.any(m_f < 0) or np.any(m_f > 1): + if xp is np and np.any((m_f < 0.0) | (m_f > 1.0)): raise ValueError("m must be in [0, 1]") - phi = np.real(u_f) - psi = np.imag(u_f) + phi = xp.real(u_f) + psi = xp.imag(u_f) # Avoid cot(phi) singularity at phi = 0 - phi_s = np.where(np.abs(phi) < np.finfo(float).eps, np.finfo(float).eps, phi) + eps = np.finfo(np.float64).eps + phi_s = xp.where(xp.abs(phi) < eps, xp.full_like(phi, eps), phi) # Roots of X² - b*X - c = 0 (A&S 17.4.11) - cot2 = (np.cos(phi_s) / np.sin(phi_s))**2 - sinh2 = np.sinh(psi)**2 - csc2 = 1.0 / np.sin(phi_s)**2 + cot2 = (xp.cos(phi_s) / xp.sin(phi_s))**2 + sinh2 = xp.sinh(psi)**2 + csc2 = 1.0 / xp.sin(phi_s)**2 b = -(cot2 + m_f * sinh2 * csc2 - 1.0 + m_f) c = -(1.0 - m_f) * cot2 - disc = np.sqrt(np.maximum(b**2 / 4.0 - c, 0.0)) + disc = xp.sqrt(xp.maximum(b**2 / 4.0 - c, xp.zeros_like(c))) # c <= 0, so the roots straddle zero and -b/2 + disc is the non-negative # one. Near phi = pi/2 that form cancels catastrophically (both terms # ~ |b|/2 while the root ~ 0), so use the equal -c/(b/2 + disc) when b > 0. - den = np.where(b > 0, b / 2.0 + disc, 1.0) # b > 0 => den >= b/2 > 0 - X = np.where(b > 0, -c / den, -b / 2.0 + disc) - ratio = np.where(b > 0, (1.0 - m_f) / den, # == tan(phi)² · cot(lam)² - (-b / 2.0 + disc) / cot2) - - lam = np.arctan(1.0 / np.sqrt(np.maximum(X, 0.0) + 1e-300)) + den = xp.where(b > 0, b / 2.0 + disc, xp.ones_like(b)) + X = xp.where(b > 0, -c / den, -b / 2.0 + disc) + ratio = xp.where( + b > 0, + (1.0 - m_f) / den, + (-b / 2.0 + disc) / cot2, + ) + + lam = xp.arctan(1.0 / xp.sqrt(xp.maximum(X, xp.zeros_like(X)) + 1e-300)) # tan(mu)² = (tan(phi)²·cot(lam)² - 1)/m, taken from *ratio* rather than # from lam: at phi = pi/2 the root X underflows, lam rounds to exactly # pi/2 and cot(lam) loses every digit of it, collapsing Im to zero. - mu = np.arctan(np.sqrt(np.maximum((ratio - 1.0) / m_f, 0.0))) + m_calc = xp.where(m_f == 0.0, xp.ones_like(m_f), m_f) + mu = xp.arctan( + xp.sqrt(xp.maximum((ratio - 1.0) / m_calc, xp.zeros_like(ratio))) + ) # Account for periodicity - lam = (-1.0)**np.floor(phi / np.pi * 2) * lam + np.pi * np.ceil(phi / np.pi - 0.5 + 1e-14) - mu = np.sign(psi) * np.real(mu) + lam = ( + (-1.0) ** xp.floor(phi / np.pi * 2.0) * lam + + np.pi * xp.ceil(phi / np.pi - 0.5 + 1e-14) + ) + mu = xp.sign(psi) * xp.real(mu) - F1, E1, _ = _elliptic12_numpy(lam, m_f, np.finfo(np.float64).eps) - F2, E2, _ = _elliptic12_numpy(mu, 1.0 - m_f, np.finfo(np.float64).eps) + F1, E1, _ = _elliptic12_xp(xp, lam, m_f) + F2, E2, _ = _elliptic12_xp(xp, mu, 1.0 - m_f) Fi = F1 + 1j * F2 # E addition formula (A&S 17.4.16) - sl = np.sin(lam); cl = np.cos(lam) - sm = np.sin(mu); cm = np.cos(mu) + sl = xp.sin(lam); cl = xp.cos(lam) + sm = xp.sin(mu); cm = xp.cos(mu) d2l = 1.0 - m_f * sl**2 d2m = 1.0 - (1.0 - m_f) * sm**2 den = cm**2 + m_f * sl**2 * sm**2 - b1 = m_f * sl * cl * sm**2 * np.sqrt(d2l) - b2 = sm * cm * d2l * np.sqrt(d2m) + b1 = m_f * sl * cl * sm**2 * xp.sqrt(d2l) + b2 = sm * cm * d2l * xp.sqrt(d2m) Ei = (b1 + 1j * b2) / den + E1 + 1j * (-E2 + F2) # Z = E - (E_complete / K) * F - from scipy.special import ellipk, ellipe as _ellipe - K_m = ellipk(m_f) - E_m = _ellipe(m_f) + K_m, E_m, _ = _elliptic12_xp(xp, xp.full_like(m_f, np.pi * 0.5), m_f) Zi = Ei - (E_m / K_m) * Fi - Fi = Fi.reshape(orig_shape) - Ei = Ei.reshape(orig_shape) - Zi = Zi.reshape(orig_shape) - return xp.asarray(Fi), xp.asarray(Ei), xp.asarray(Zi) + # Exact elementary limit at m=0. + Fi = xp.where(m_f == 0.0, u_f, Fi) + Ei = xp.where(m_f == 0.0, u_f, Ei) + Zi = xp.where(m_f == 0.0, xp.zeros_like(Fi), Zi) + return Fi, Ei, Zi def ellipji(u, m): @@ -117,23 +122,19 @@ def ellipji(u, m): ------- sn, cn, dn : complex arrays """ - u = np.asarray(u, dtype=np.complex128) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(np.real(u), m) - - u_bc, m_bc = np.broadcast_arrays(u, m) - orig_shape = u_bc.shape - u_f = u_bc.ravel() - m_f = m_bc.ravel().astype(np.float64) + xp = get_xp(u, m) + u_f = xp.asarray(u, dtype=xp.complex128) + m_f = xp.asarray(m, dtype=xp.float64) + u_f, m_f = xp.broadcast_arrays(u_f, m_f) - if np.any(m_f < 0) or np.any(m_f > 1): + if xp is np and np.any((m_f < 0.0) | (m_f > 1.0)): raise ValueError("m must be in [0, 1]") - phi = np.real(u_f) - psi = np.imag(u_f) + phi = xp.real(u_f) + psi = xp.imag(u_f) - s, c, d, _ = _ellipj_numpy(phi, m_f) - s1, c1, d1, _ = _ellipj_numpy(psi, 1.0 - m_f) + s, c, d, _ = _ellipj_xp(xp, phi, m_f) + s1, c1, d1, _ = _ellipj_xp(xp, psi, 1.0 - m_f) delta = c1**2 + m_f * s**2 * s1**2 @@ -141,7 +142,4 @@ def ellipji(u, m): cni = (c * c1 - 1j * s * d * s1 * d1) / delta dni = (d * c1 * d1 - 1j * m_f * s * c * s1) / delta - sni = sni.reshape(orig_shape) - cni = cni.reshape(orig_shape) - dni = dni.reshape(orig_shape) - return xp.asarray(sni), xp.asarray(cni), xp.asarray(dni) + return sni, cni, dni diff --git a/python/elliptic/ellipj.py b/python/elliptic/ellipj.py index 62136fc..46724ef 100644 --- a/python/elliptic/ellipj.py +++ b/python/elliptic/ellipj.py @@ -33,8 +33,12 @@ def ellipj(u, m): def _ellipj_xp(xp, u, m): - # Clamp m away from exact 0/1 so sqrt is defined; edge cases handled below. - m_safe = xp.clip(m, 1e-15, 1.0 - 1e-15) + # Keep every representable interior parameter unchanged. The old global + # clip to [1e-15, 1-1e-15] silently changed valid inputs near both + # endpoints (most visibly m=nextafter(1, 0)). Exact endpoints use a + # harmless interior placeholder here and are replaced below. + interior = (m > 0.0) & (m < 1.0) + m_safe = xp.where(interior, m, xp.full_like(m, 0.5)) a = xp.ones_like(m_safe) b = xp.sqrt(1.0 - m_safe) @@ -47,30 +51,48 @@ def _ellipj_xp(xp, u, m): b = xp.sqrt(a * b) a = ab_sum * 0.5 - # Starting amplitude: phi_N = 2^N * a_N * u - phin = (2.0 ** _AGM_ITERS) * a * u + # Reduce u before multiplying by 2^N. Without this, the large argument + # enters the Landen recursion directly and loses phase bits; near m=1 the + # error can become O(1) after only a few dozen periods. + K = np.pi / (2.0 * a) + period = xp.floor((u + K) / (2.0 * K)) + u_reduced = u - 2.0 * period * K + + # Starting amplitude on [-K, K]: phi_N = 2^N * a_N * u_reduced + phin = (2.0 ** _AGM_ITERS) * a * u_reduced # Descending Landen back-substitution (all elements, fixed 25 steps) for i in range(_AGM_ITERS - 1, -1, -1): arg = xp.clip(ratios[i] * xp.sin(phin), -1.0, 1.0) phin = 0.5 * (xp.arcsin(arg) + phin) - sn_g = xp.sin(phin) - cn_g = xp.cos(phin) - dn_g = xp.sqrt(xp.clip(1.0 - m_safe * sn_g * sn_g, 0.0, None)) + period_mod2 = period - 2.0 * xp.floor(period * 0.5) + quasi_sign = 1.0 - 2.0 * period_mod2 + sn_g = quasi_sign * xp.sin(phin) + cn_g = quasi_sign * xp.cos(phin) + # The cn form avoids subtracting two nearly equal numbers when m and + # |sn| are both close to one. + dn_g = xp.sqrt(xp.clip((1.0 - m_safe) + m_safe * cn_g * cn_g, 0.0, None)) + am_g = phin + period * np.pi + + # Stable sech avoids overflow in cosh for large non-m=1 elements. Array + # backends evaluate both sides of where, so a nominally unselected cosh + # still emitted warnings/overflowed during ordinary calls. + exp_neg = xp.exp(-xp.abs(u)) + sech_u = 2.0 * exp_neg / (1.0 + exp_neg * exp_neg) # Blend exact m=0 and m=1 results sn = xp.where(m == 0.0, xp.sin(u), xp.where(m == 1.0, xp.tanh(u), sn_g)) cn = xp.where(m == 0.0, xp.cos(u), - xp.where(m == 1.0, 1.0 / xp.cosh(u), cn_g)) + xp.where(m == 1.0, sech_u, cn_g)) dn = xp.where(m == 0.0, xp.ones_like(u), - xp.where(m == 1.0, 1.0 / xp.cosh(u), dn_g)) + xp.where(m == 1.0, sech_u, dn_g)) # am is the *continuous* amplitude from the Landen recursion, not # arcsin(sn): the latter folds it into [-pi/2, pi/2] and so loses the # period count (DLMF 22.16.1: am(u + 2K) = am(u) + pi). am = xp.where(m == 0.0, u, - xp.where(m == 1.0, xp.arcsin(xp.clip(xp.tanh(u), -1.0, 1.0)), phin)) + xp.where(m == 1.0, xp.arcsin(xp.clip(xp.tanh(u), -1.0, 1.0)), am_g)) return sn, cn, dn, am diff --git a/python/elliptic/elliptic12.py b/python/elliptic/elliptic12.py index d9a4db5..d471445 100644 --- a/python/elliptic/elliptic12.py +++ b/python/elliptic/elliptic12.py @@ -78,16 +78,23 @@ def _elliptic12_xp(xp, u, m): E = xp.where(m == 0.0, u, E) Z = xp.where(m == 0.0, xp.zeros_like(Z), Z) - # m == 1: F = log(tan(π/4 + u_r/2)), E via sin, Z = sin(u_r) - F_m1 = xp.log(xp.tan(math.pi / 4 + u_r * 0.5)) - um1 = xp.abs(u_r) - Nf = xp.floor((um1 + math.pi * 0.5) / math.pi) + # m == 1: F has its first non-integrable pole at |u|=π/2. Period + # reduction must not hide that crossing (the old code returned F=0 at + # u=π and also under-counted E beyond the first quadrant). + um1 = xp.abs(u) + Nf = xp.floor((um1 + math.pi * 0.5) / math.pi) sgn = xp.where(u >= 0.0, xp.ones_like(u), -xp.ones_like(u)) E_m1 = ((-1.0) ** Nf * xp.sin(um1) + 2.0 * Nf) * sgn - Z_m1 = xp.sin(u_r) # (-1)^Nf * sin(u), Nf=0 for |u_r|<π/2 - - near_pole_m1 = xp.abs(u_r) >= math.pi * 0.5 - 1e-14 - F_m1 = xp.where(near_pole_m1, xp.full_like(F_m1, math.inf) * sgn, F_m1) + Z_m1 = xp.sin(u_r) + + crossed_pole_m1 = um1 >= math.pi * 0.5 + u_m1_safe = xp.where(crossed_pole_m1, xp.zeros_like(u), u) + F_m1_finite = xp.log(xp.tan(math.pi * 0.25 + 0.5 * u_m1_safe)) + F_m1 = xp.where( + crossed_pole_m1, + xp.full_like(F_m1_finite, math.inf) * sgn, + F_m1_finite, + ) F = xp.where(m == 1.0, F_m1, F) E = xp.where(m == 1.0, E_m1, E) Z = xp.where(m == 1.0, Z_m1, Z) diff --git a/python/elliptic/elliptic3.py b/python/elliptic/elliptic3.py index 7bb6638..41d0868 100644 --- a/python/elliptic/elliptic3.py +++ b/python/elliptic/elliptic3.py @@ -2,31 +2,16 @@ Pi(u, m, n) = integral_0^u 1 / ((1 - n sin^2 t) sqrt(1 - m sin^2 t)) dt -Algorithm: 10-point Gauss-Legendre quadrature. Pure array-namespace ops — -runs natively on NumPy, PyTorch CUDA, and JAX without any np.asarray conversion. +Algorithm: Carlson symmetric forms (DLMF 19.25.1). Pure array-namespace +operations run natively on NumPy, PyTorch CUDA, and JAX. """ from __future__ import annotations import math +import numpy as np from ._xputils import get_xp - -# 10-point Gauss-Legendre nodes and weights on [0, 1] as plain Python floats -# (so they broadcast correctly against any backend tensor) -_GL_T = [ - 0.9931285991850949, 0.9639719272779138, - 0.9122344282513259, 0.8391169718222188, - 0.7463319064601508, 0.6360536807265150, - 0.5108670019508271, 0.3737060887154195, - 0.2277858511416451, 0.07652652113349734, -] -_GL_W = [ - 0.01761400713915212, 0.04060142980038694, - 0.06267204833410907, 0.08327674157670475, - 0.10193011981724040, 0.11819453196151840, - 0.13168863844917660, 0.14209610931838200, - 0.14917298647260370, 0.15275338713072580, -] +from .carlson import _rf_xp, _rj_xp def elliptic3(u, m, n): @@ -34,12 +19,13 @@ def elliptic3(u, m, n): Parameters ---------- - u : array_like Phase in radians, 0 <= u <= pi/2. + u : array_like Phase in radians. m : array_like Parameter, 0 <= m <= 1. n : array_like Characteristic, n <= 1. For n > 1 the integral is a Cauchy principal value (circular case, DLMF 19.7.3) - which 10-point Gauss–Legendre cannot resolve; this - function raises ValueError in that regime. + which this real-valued implementation does not resolve; + NumPy calls raise ValueError when the integration path + crosses that pole. Returns ------- @@ -51,18 +37,20 @@ def elliptic3(u, m, n): n = xp.asarray(n, dtype=xp.float64) u, m, n = xp.broadcast_arrays(u, m, n) - import numpy as _np - n_np = _np.asarray(n) - u_np = _np.asarray(u) - if _np.any(n_np > 1.0): + # Eager NumPy calls can provide a precise domain error. Traced backends + # cannot branch on array values; their invalid elements naturally become + # non-finite through the Carlson expression instead. + if xp is np and np.any(n > 1.0): + n_np = np.asarray(n) + u_np = np.asarray(u) # Check whether the singularity sin²θ = 1/n lies in [0, u] - with _np.errstate(invalid='ignore', divide='ignore'): - sing = _np.where(n_np > 1.0, _np.arcsin(_np.sqrt(1.0 / n_np)), _np.inf) - if _np.any((n_np > 1.0) & (u_np >= sing)): + with np.errstate(invalid="ignore", divide="ignore"): + sing = np.where(n_np > 1.0, np.arcsin(np.sqrt(1.0 / n_np)), np.inf) + if np.any((n_np > 1.0) & (np.abs(u_np) >= sing)): raise ValueError( "elliptic3: n > 1 with phase beyond the pole at arcsin(1/sqrt(n)) " - "is a Cauchy principal-value integral (DLMF 19.7.3); not supported " - "by 10-point Gauss–Legendre. Use a transformation (DLMF 19.7.4) " + "is a Cauchy principal-value integral (DLMF 19.7.3); not supported. " + "Use a transformation (DLMF 19.7.4) " "or compute via Carlson R_J with complex arguments." ) @@ -71,50 +59,42 @@ def elliptic3(u, m, n): # Pi(-u) = -Pi(u) # Pi(u+k*pi) = Pi(u) + 2k*Pi(pi/2) # Pi(pi-u) = 2*Pi(pi/2) - Pi(u) - # The 10-point Gauss-Legendre rule below is only accurate on [0, pi/2]; - # larger phases used to be evaluated directly and were silently wrong - # (~1e-6 at u = 6). sign_u = xp.where(u < 0, -xp.ones_like(u), xp.ones_like(u)) ua = xp.abs(u) k_per = xp.floor(ua / math.pi) r = ua - k_per * math.pi # in [0, pi) refl = r > math.pi * 0.5 u_red = xp.where(refl, math.pi - r, r) # in [0, pi/2] - reduced = xp.any(k_per > 0) or xp.any(refl) or xp.any(u < 0) + s = xp.sin(u_red) + c = xp.cos(u_red) + s2 = s * s + d2 = 1.0 - m * s2 + p = 1.0 - n * s2 + one = xp.ones_like(s) - u = u_red - half_u = u * 0.5 - P = xp.zeros_like(u) - for ti, wi in zip(_GL_T, _GL_W): - c0 = half_u * ti - tp = half_u + c0 - tm = half_u - c0 - s2p = xp.sin(tp) ** 2 - s2m = xp.sin(tm) ** 2 - P = P + wi * ( - 1.0 / ((1.0 - n * s2p) * xp.sqrt(xp.clip(1.0 - m * s2p, 0.0, None))) + - 1.0 / ((1.0 - n * s2m) * xp.sqrt(xp.clip(1.0 - m * s2m, 0.0, None))) - ) - Pi = half_u * P + RF = _rf_xp(xp, c * c, d2, one) + RJ = _rj_xp(xp, c * c, d2, one, p) + Pi_red = s * RF + n * s * s2 * RJ / 3.0 + Pi_red = xp.where(s == 0.0, xp.zeros_like(Pi_red), Pi_red) - if reduced: - # Complete integral Pi(pi/2|m,n) by the same rule, then undo the - # reduction: Pi(|u|) = 2k*Pcpl + refl*2*Pcpl ± Pi(u_red), * sign(u) - qtr = math.pi * 0.25 - Pc = xp.zeros_like(u) - for ti, wi in zip(_GL_T, _GL_W): - tp = qtr + qtr * ti - tm = qtr - qtr * ti - s2p = xp.sin(tp) ** 2 - s2m = xp.sin(tm) ** 2 - Pc = Pc + wi * ( - 1.0 / ((1.0 - n * s2p) * xp.sqrt(xp.clip(1.0 - m * s2p, 0.0, None))) + - 1.0 / ((1.0 - n * s2m) * xp.sqrt(xp.clip(1.0 - m * s2m, 0.0, None))) - ) - Pc = qtr * Pc - Pi = sign_u * (2.0 * k_per * Pc + xp.where(refl, 2.0 * Pc, xp.zeros_like(Pc)) - + xp.where(refl, -Pi, Pi)) + # Complete Π is needed to restore reflected/full periods. Replace + # singular complete parameters while evaluating the eager expression so + # unselected branches stay finite on autodiff backends. + complete_singular = (m == 1.0) | (n >= 1.0) + m_complete = xp.where(complete_singular, xp.zeros_like(m), m) + n_complete = xp.where(complete_singular, xp.zeros_like(n), n) + zero = xp.zeros_like(s) + RF_complete = _rf_xp(xp, zero, 1.0 - m_complete, one) + RJ_complete = _rj_xp(xp, zero, 1.0 - m_complete, one, 1.0 - n_complete) + Pi_complete = RF_complete + n_complete * RJ_complete / 3.0 + + Pi_abs = ( + 2.0 * k_per * Pi_complete + + xp.where(refl, 2.0 * Pi_complete - Pi_red, Pi_red) + ) + Pi = sign_u * Pi_abs - # u == pi/2 and (m == 1 or n == 1) → inf - inf_mask = ((u == math.pi * 0.5) & (m == 1.0)) | ((u == math.pi * 0.5) & (n == 1.0)) - return xp.where(inf_mask, xp.full_like(Pi, math.inf), Pi) + # At m=1 or n=1 the path diverges once it reaches the first π/2 pole. + crosses_endpoint_pole = complete_singular & (ua >= math.pi * 0.5) + signed_inf = sign_u * xp.full_like(Pi, math.inf) + return xp.where(crosses_endpoint_pole, signed_inf, Pi) diff --git a/python/elliptic/ellipticBD.py b/python/elliptic/ellipticBD.py index c38b25f..9e9f45b 100644 --- a/python/elliptic/ellipticBD.py +++ b/python/elliptic/ellipticBD.py @@ -14,7 +14,7 @@ import numpy as np from ._xputils import get_xp -from .elliptic12 import _elliptic12_xp +from .carlson import _rf_xp, _rd_xp def ellipticBD(m): @@ -34,17 +34,24 @@ def ellipticBD(m): def _bd_xp(xp, m): - phi = xp.full_like(m, math.pi * 0.5) - K, E, _ = _elliptic12_xp(xp, phi, m) - - # D = (K - E) / m, limit at m=0: π/4 - D = xp.where(m == 0.0, - xp.full_like(m, math.pi * 0.25), - (K - E) / xp.where(m == 0.0, xp.ones_like(m), m)) + zero = xp.zeros_like(m) + one = xp.ones_like(m) + K = _rf_xp(xp, zero, 1.0 - m, one) + D = _rd_xp(xp, zero, 1.0 - m, one) / 3.0 B = K - D - S = xp.where(m == 0.0, - xp.full_like(m, math.pi / 16.0), - (D - B) / xp.where(m == 0.0, xp.ones_like(m), m)) + + # S=(D-B)/m is catastrophically cancelling near m=0. Evaluate its + # convergent binomial/integral series there: + # 1/sqrt(1-m sin²t) = Σ C_k m^k sin^(2k)t. + S_series = xp.zeros_like(m) + for k in range(1, 9): + ck = math.comb(2 * k, k) / (4.0 ** k) + ik = math.pi * math.comb(2 * k, k) / (2.0 * 4.0 ** k) + ik1 = math.pi * math.comb(2 * k + 2, k + 1) / (2.0 * 4.0 ** (k + 1)) + S_series = S_series + ck * (2.0 * ik1 - ik) * m ** (k - 1) + m_safe = xp.where(m == 0.0, xp.ones_like(m), m) + S_direct = (D - B) / m_safe + S = xp.where(xp.abs(m) < 1e-2, S_series, S_direct) return B, D, S diff --git a/python/elliptic/inverse.py b/python/elliptic/inverse.py index fc38de7..503fbfb 100644 --- a/python/elliptic/inverse.py +++ b/python/elliptic/inverse.py @@ -1,13 +1,16 @@ """Inverse incomplete elliptic integral of the second kind.""" from __future__ import annotations +import math import numpy as np +from ._xputils import get_xp +from .elliptic12 import _elliptic12_xp def inverselliptic2(E_val, m, tol=1e-12): """Inverse of the incomplete elliptic integral of the second kind. - Solves E(phi | m) = E_val for phi using period reduction, Boyd (2012) - initialisation, and a Newton while-loop (issue #12: converges to *tol*). + Solves E(phi | m) = E_val using period reduction and fixed-step Newton + refinement against the library's own Carlson implementation. Period identity: E(phi + pi | m) = E(phi | m) + 2*E(m) Symmetry: E(pi - phi | m) = 2*E(m) - E(phi | m) @@ -20,55 +23,43 @@ def inverselliptic2(E_val, m, tol=1e-12): Returns ------- - phi : ndarray Amplitude in radians such that E(phi | m) ≈ E_val. + phi : array Amplitude in radians such that E(phi | m) ≈ E_val. """ - from scipy.special import ellipe, ellipeinc + xp = get_xp(E_val, m) + E_val = xp.asarray(E_val, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) + E_val, m = xp.broadcast_arrays(E_val, m) - E_val = np.asarray(E_val, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - - orig_shape = np.broadcast_shapes(E_val.shape, m.shape) - E_flat = np.broadcast_to(E_val, orig_shape).ravel().copy() - m_flat = np.broadcast_to(m, orig_shape).ravel().copy() - - if np.any(m_flat < 0) or np.any(m_flat > 1): + if xp is np and np.any((m < 0.0) | (m > 1.0)): raise ValueError("m must be in [0, 1]") - m_flat = np.where(m_flat < np.finfo(float).eps, 0.0, m_flat) # Complete integral E(m); each phi-period of π contributes 2*E1 to E. - E1 = ellipe(m_flat) - two_E1 = 2.0 * np.where(E1 > 0, E1, 1.0) + half_pi = xp.full_like(m, math.pi * 0.5) + _, E1, _ = _elliptic12_xp(xp, half_pi, m) + two_E1 = 2.0 * E1 # Step 1 — strip full periods: phi = phi_base + k*pi - k = np.floor(E_flat / two_E1) - z_red = E_flat - k * two_E1 # in [0, 2*E1) + k = xp.floor(E_val / two_E1) + z_red = E_val - k * two_E1 # in [0, 2*E1) # Step 2 — fold second half-period using E(pi-phi|m) = 2E1 - E(phi|m) - over = z_red > E1 - z_red2 = np.where(over, two_E1 - z_red, z_red) # in [0, E1] + over = z_red > E1 + z_red2 = xp.where(over, two_E1 - z_red, z_red) # in [0, E1] - # Boyd (2012) empirical initialisation for phi in [0, pi/2] - mu = 1.0 - m_flat - zeta = 1.0 - z_red2 / np.where(E1 > 0, E1, 1.0) - r = np.sqrt(zeta**2 + mu**2) - theta = np.arctan2(mu, z_red2 + 1e-300) - phi = np.pi / 2.0 + np.sqrt(r) * (theta - np.pi / 2.0) - phi = np.clip(phi, 0.0, np.pi / 2.0) + # Monotone linear seed in [0, pi/2]. Fixed iteration count keeps the + # routine JIT-safe; converged elements simply receive zero-sized updates. + phi = xp.clip((z_red2 / E1) * (math.pi * 0.5), 0.0, math.pi * 0.5) - # Newton while-loop until converged (issue #12: fixed 4 iters insufficient) - for _ in range(200): - E_cur = ellipeinc(phi, m_flat) - res = E_cur - z_red2 - if np.max(np.abs(res)) < tol: - break - denom = np.sqrt(np.maximum(1.0 - m_flat * np.sin(phi)**2, 0.0)) - phi = phi - res / np.where(denom < 1e-15, 1e-15, denom) - phi = np.clip(phi, 0.0, np.pi / 2.0) + for _ in range(24): + _, E_cur, _ = _elliptic12_xp(xp, phi, m) + res = E_cur - z_red2 + denom = xp.sqrt(xp.clip(1.0 - m * xp.sin(phi) ** 2, 0.0, None)) + safe_denom = xp.where(denom > tol, denom, xp.ones_like(denom)) + step = xp.where(xp.abs(res) > tol, res / safe_denom, xp.zeros_like(res)) + phi = xp.clip(phi - step, 0.0, math.pi * 0.5) # Step 3 — undo fold: phi_in_period = pi - phi (if over), else phi - phi = np.where(over, np.pi - phi, phi) # in [0, pi) + phi = xp.where(over, math.pi - phi, phi) # in [0, pi) # Step 4 — undo period strips: each strip adds pi to phi - phi = phi + k * np.pi - - return phi.reshape(orig_shape) if orig_shape else phi.squeeze() + return phi + k * math.pi diff --git a/python/elliptic/jacobi_edj.py b/python/elliptic/jacobi_edj.py index 654ef37..8d2ec03 100644 --- a/python/elliptic/jacobi_edj.py +++ b/python/elliptic/jacobi_edj.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -import numpy as np +from ._xputils import get_xp from .ellipj import ellipj from .ellipticBDJ import ellipticBDJ @@ -32,14 +32,20 @@ def jacobiEDJ(u, m, n=None): Eu, Du : arrays Ju : array or None """ - u_arr = np.asarray(u, dtype=np.float64) - m_arr = np.asarray(m, dtype=np.float64) - u_arr, m_arr = np.broadcast_arrays(u_arr, m_arr) + args = (u, m, n) if n is not None else (u, m) + xp = get_xp(*args) + u_arr = xp.asarray(u, dtype=xp.float64) + m_arr = xp.asarray(m, dtype=xp.float64) + if n is None: + u_arr, m_arr = xp.broadcast_arrays(u_arr, m_arr) + else: + n = xp.asarray(n, dtype=xp.float64) + u_arr, m_arr, n = xp.broadcast_arrays(u_arr, m_arr, n) _, _, _, phi = ellipj(u_arr, m_arr) B, D, J = ellipticBDJ(phi, m_arr, n) Du = D - Eu = u_arr - m_arr * np.asarray(D) + Eu = u_arr - m_arr * D Ju = J return Eu, Du, Ju diff --git a/python/elliptic/nome.py b/python/elliptic/nome.py index 5672b59..4c1359b 100644 --- a/python/elliptic/nome.py +++ b/python/elliptic/nome.py @@ -1,7 +1,9 @@ """Nome q(m) and its inverse m(q).""" from __future__ import annotations +import math import numpy as np -from array_api_compat import array_namespace +from ._xputils import get_xp +from .theta import _q_from_m_xp def nomeq(m): @@ -17,23 +19,19 @@ def nomeq(m): q : array Nome in [0, 1). """ - from scipy.special import ellipk - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(m) - m_np = np.asarray(m).ravel() - K = ellipk(m_np) - Kp = ellipk(1.0 - m_np) - q = np.exp(-np.pi * Kp / K) - q = q.reshape(np.asarray(m).shape) - return xp.asarray(q) + xp = get_xp(m) + m = xp.asarray(m, dtype=xp.float64) + q = _q_from_m_xp(xp, m) + return xp.where(m == 1.0, xp.ones_like(q), q) def inversenomeq(q): """Inverse nome: parameter m from nome q. - Uses ``scipy.optimize.brentq`` to invert ``nomeq``. In double precision the - representable range is roughly q ∈ [0, 0.779]; beyond this, m(q) exceeds - 1 - 2⁻⁵³ and cannot be represented. + Uses a fixed-iteration bisection against the library's own Carlson-based + ``nomeq`` implementation. In double precision the representable range is + roughly q ∈ [0, 0.779]; beyond this, m(q) exceeds 1 - 2⁻⁵³ and cannot be + represented. Parameters ---------- @@ -46,38 +44,38 @@ def inversenomeq(q): Parameter m = m(q) in [0, 1). """ import warnings - from scipy.optimize import brentq - from scipy.special import ellipk - q = np.asarray(q, dtype=np.float64) - xp = array_namespace(q) - q_flat = np.asarray(q).ravel() + xp = get_xp(q) + q = xp.asarray(q, dtype=xp.float64) - if np.any(q_flat < 0) or np.any(q_flat >= 1): - raise ValueError("q must be in [0, 1)") + m_hi_scalar = np.nextafter(1.0, 0.0) + q_max = float(_q_from_m_xp(np, np.asarray(m_hi_scalar))) - m_hi = np.nextafter(1.0, 0.0) # largest f64 strictly < 1 - q_max = float(np.exp(-np.pi * ellipk(1.0 - m_hi) / ellipk(m_hi))) - if np.any(q_flat >= q_max): - raise ValueError( - f"inversenomeq: q must be < {q_max:.15f} in double precision " - "(the essential singularity of m(q) at q=1 cannot be resolved in f64)" - ) - if np.any(q_flat > 0.76): - warnings.warn("inversenomeq: accuracy degrades for q > 0.76 (near m=1 singularity)", - RuntimeWarning, stacklevel=2) + if xp is np: + if np.any((q < 0.0) | (q >= 1.0)): + raise ValueError("q must be in [0, 1)") + if np.any(q >= q_max): + raise ValueError( + f"inversenomeq: q must be < {q_max:.15f} in double precision " + "(the essential singularity of m(q) at q=1 cannot be resolved in f64)" + ) + if np.any(q > 0.76): + warnings.warn( + "inversenomeq: accuracy degrades for q > 0.76 (near m=1 singularity)", + RuntimeWarning, + stacklevel=2, + ) - def _nomeq_scalar(m_val): - K = float(ellipk(m_val)) - Kp = float(ellipk(1.0 - m_val)) - return float(np.exp(-np.pi * Kp / K)) - - m_out = np.empty_like(q_flat) - for i, qi in enumerate(q_flat): - if qi == 0.0: - m_out[i] = 0.0 - else: - m_out[i] = brentq(lambda m: _nomeq_scalar(m) - qi, 0.0, m_hi, xtol=1e-14) - - m_out = m_out.reshape(np.asarray(q).shape) - return xp.asarray(m_out) + valid = (q >= 0.0) & (q < q_max) + q_safe = xp.where(valid, q, xp.zeros_like(q)) + lo = xp.zeros_like(q_safe) + hi = xp.full_like(q_safe, m_hi_scalar) + for _ in range(64): + mid = 0.5 * (lo + hi) + q_mid = _q_from_m_xp(xp, mid) + lower = q_mid < q_safe + lo = xp.where(lower, mid, lo) + hi = xp.where(lower, hi, mid) + result = 0.5 * (lo + hi) + result = xp.where(q == 0.0, xp.zeros_like(result), result) + return xp.where(valid, result, xp.full_like(result, math.nan)) diff --git a/python/elliptic/theta.py b/python/elliptic/theta.py index 75f2060..7c9c7be 100644 --- a/python/elliptic/theta.py +++ b/python/elliptic/theta.py @@ -14,82 +14,86 @@ """ from __future__ import annotations import math -import numpy as np -from array_api_compat import array_namespace +from ._xputils import get_xp +from .carlson import _rf_xp _N_TERMS = 30 # sufficient for |q| ≤ 0.8 (m ≤ ~0.9997) -def _q_from_m(m_np: np.ndarray) -> np.ndarray: - """Nome q(m) — uses scipy K; kept internal to avoid circular import.""" - from scipy.special import ellipk - K = ellipk(m_np) - Kp = ellipk(1.0 - m_np) - return np.exp(-np.pi * Kp / K) +def _q_from_m_xp(xp, m): + """Backend-native nome q(m), with invalid/singular inputs masked.""" + valid = (m >= 0.0) & (m < 1.0) + m_safe = xp.where(valid, m, xp.full_like(m, 0.5)) + zero = xp.zeros_like(m_safe) + one = xp.ones_like(m_safe) + K = _rf_xp(xp, zero, 1.0 - m_safe, one) + Kp = _rf_xp(xp, zero, m_safe, one) + q = xp.exp(-math.pi * Kp / K) + return xp.where(valid, q, xp.full_like(q, math.nan)) # ----------------------------------------------------------------------- # Low-level series (flat 1-D numpy, v in radians, q scalar or array) # ----------------------------------------------------------------------- -def _th1(v: np.ndarray, q: np.ndarray) -> np.ndarray: +def _th1(xp, v, q): """θ₁(v, q).""" - s = np.zeros_like(v) + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += (-1)**n * q**((n + 0.5)**2) * np.sin((2*n + 1) * v) + s = s + (-1)**n * q**((n + 0.5)**2) * xp.sin((2*n + 1) * v) return 2.0 * s -def _th2(v: np.ndarray, q: np.ndarray) -> np.ndarray: +def _th2(xp, v, q): """θ₂(v, q).""" - s = np.zeros_like(v) + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += q**((n + 0.5)**2) * np.cos((2*n + 1) * v) + s = s + q**((n + 0.5)**2) * xp.cos((2*n + 1) * v) return 2.0 * s -def _th3(v: np.ndarray, q: np.ndarray) -> np.ndarray: +def _th3(xp, v, q): """θ₃(v, q).""" - s = np.ones_like(v) + s = xp.ones_like(v) for n in range(1, _N_TERMS + 1): - s += 2.0 * q**(n**2) * np.cos(2*n * v) + s = s + 2.0 * q**(n**2) * xp.cos(2*n * v) return s -def _th4(v: np.ndarray, q: np.ndarray) -> np.ndarray: +def _th4(xp, v, q): """θ₄(v, q).""" - s = np.ones_like(v) + s = xp.ones_like(v) for n in range(1, _N_TERMS + 1): - s += 2.0 * (-1)**n * q**(n**2) * np.cos(2*n * v) + s = s + 2.0 * (-1)**n * q**(n**2) * xp.cos(2*n * v) return s # Derivatives dθⱼ/dv -def _dth1(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth1(xp, v, q): + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += (-1)**n * (2*n+1) * q**((n + 0.5)**2) * np.cos((2*n + 1) * v) + s = s + (-1)**n * (2*n+1) * q**((n + 0.5)**2) * xp.cos((2*n + 1) * v) return 2.0 * s -def _dth2(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth2(xp, v, q): + s = xp.zeros_like(v) for n in range(_N_TERMS): - s += -(2*n+1) * q**((n + 0.5)**2) * np.sin((2*n + 1) * v) + s = s - (2*n+1) * q**((n + 0.5)**2) * xp.sin((2*n + 1) * v) return 2.0 * s -def _dth3(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth3(xp, v, q): + s = xp.zeros_like(v) for n in range(1, _N_TERMS + 1): - s += -2.0 * 2*n * q**(n**2) * np.sin(2*n * v) + s = s - 4.0 * n * q**(n**2) * xp.sin(2*n * v) return s -def _dth4(v: np.ndarray, q: np.ndarray) -> np.ndarray: - s = np.zeros_like(v) +def _dth4(xp, v, q): + s = xp.zeros_like(v) for n in range(1, _N_TERMS + 1): - s += 2.0 * (-1)**n * (-2*n) * q**(n**2) * np.sin(2*n * v) + s = s - 4.0 * n * (-1)**n * q**(n**2) * xp.sin(2*n * v) return s @@ -119,42 +123,20 @@ def jacobiThetaEta(u, m): Th, H : arrays Jacobi theta and eta values. """ - from scipy.special import ellipk - - u = np.asarray(u, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(u, m) + xp = get_xp(u, m) + u = xp.asarray(u, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) u, m = xp.broadcast_arrays(u, m) - orig_shape = np.asarray(u).shape - - u_np = np.asarray(u).ravel() - m_np = np.asarray(m).ravel() - - Th = np.ones_like(u_np) - H = np.zeros_like(u_np) - - # m = 1: undefined - mask1 = m_np >= 1.0 - 1e-14 - Th[mask1] = np.nan - H[mask1] = np.nan - - # m = 0: Th = 1, H = 0 - mask0 = m_np < 1e-14 - # already set by np.ones / np.zeros - - maskN = ~mask0 & ~mask1 - if np.any(maskN): - u_g = u_np[maskN] - m_g = m_np[maskN] - K_g = ellipk(m_g) - q_g = _q_from_m(m_g) - v_g = np.pi * u_g / (2.0 * K_g) # normalised angle argument - Th[maskN] = _th4(v_g, q_g) - H[maskN] = _th1(v_g, q_g) - - Th = Th.reshape(orig_shape) - H = H.reshape(orig_shape) - return xp.asarray(Th), xp.asarray(H) + valid = (m >= 0.0) & (m < 1.0) + m_safe = xp.where(valid, m, xp.full_like(m, 0.5)) + zero = xp.zeros_like(m_safe) + K = _rf_xp(xp, zero, 1.0 - m_safe, xp.ones_like(m_safe)) + q = _q_from_m_xp(xp, m) + v = math.pi * u / (2.0 * K) + Th = _th4(xp, v, q) + H = _th1(xp, v, q) + nan = xp.full_like(Th, math.nan) + return xp.where(valid, Th, nan), xp.where(valid, H, nan) def theta(j, v, m): @@ -180,34 +162,12 @@ def theta(j, v, m): if j not in (1, 2, 3, 4): raise ValueError("j must be 1, 2, 3, or 4") - v = np.asarray(v, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(v, m) + xp = get_xp(v, m) + v = xp.asarray(v, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) v, m = xp.broadcast_arrays(v, m) - orig_shape = np.asarray(v).shape - - v_np = np.asarray(v).ravel() - m_np = np.asarray(m).ravel() - - Th = np.zeros_like(v_np) - maskN = (m_np >= 1e-14) & (m_np < 1.0 - 1e-14) - - if np.any(maskN): - q_g = _q_from_m(m_np[maskN]) - Th[maskN] = _TH_FNS[j](v_np[maskN], q_g) - - # special cases - mask0 = m_np < 1e-14 - if np.any(mask0): - if j == 1: Th[mask0] = np.sin(v_np[mask0]) # θ₁(v,0) = sin v (leading term) - elif j == 2: Th[mask0] = 0.0 # q→0 all terms vanish except trivially 0 - elif j == 3: Th[mask0] = 1.0 - elif j == 4: Th[mask0] = 1.0 - - mask1 = m_np >= 1.0 - 1e-14 - Th[mask1] = np.nan - - return xp.asarray(Th.reshape(orig_shape)) + q = _q_from_m_xp(xp, m) + return _TH_FNS[j](xp, v, q) def theta_prime(j, v, m): @@ -227,24 +187,9 @@ def theta_prime(j, v, m): if j not in (1, 2, 3, 4): raise ValueError("j must be 1, 2, 3, or 4") - v = np.asarray(v, dtype=np.float64) - m = np.asarray(m, dtype=np.float64) - xp = array_namespace(v, m) + xp = get_xp(v, m) + v = xp.asarray(v, dtype=xp.float64) + m = xp.asarray(m, dtype=xp.float64) v, m = xp.broadcast_arrays(v, m) - orig_shape = np.asarray(v).shape - - v_np = np.asarray(v).ravel() - m_np = np.asarray(m).ravel() - - th_np = np.zeros_like(v_np) - thp_np = np.zeros_like(v_np) - - maskN = (m_np >= 1e-14) & (m_np < 1.0 - 1e-14) - if np.any(maskN): - q_g = _q_from_m(m_np[maskN]) - th_np[maskN] = _TH_FNS[j](v_np[maskN], q_g) - thp_np[maskN] = _DTH_FNS[j](v_np[maskN], q_g) - - th = th_np.reshape(orig_shape) - thp = thp_np.reshape(orig_shape) - return xp.asarray(th), xp.asarray(thp) + q = _q_from_m_xp(xp, m) + return _TH_FNS[j](xp, v, q), _DTH_FNS[j](xp, v, q) diff --git a/python/elliptic/weierstrass.py b/python/elliptic/weierstrass.py index c902c73..8b58d42 100644 --- a/python/elliptic/weierstrass.py +++ b/python/elliptic/weierstrass.py @@ -11,8 +11,19 @@ import math import numpy as np from ._xputils import get_xp +from .carlson import _rf_xp + + +def _reject_complex_inputs(*values): + for value in values: + dtype = getattr(value, "dtype", None) + if dtype is not None and "complex" in str(dtype).lower(): + raise ValueError("Weierstrass functions currently support real inputs only") + if dtype is None and isinstance(value, complex): + raise ValueError("Weierstrass functions currently support real inputs only") def _broadcast4(z, e1, e2, e3): + _reject_complex_inputs(z, e1, e2, e3) xp = get_xp(z, e1, e2, e3) z = xp.asarray(z, dtype=xp.float64) e1 = xp.asarray(e1, dtype=xp.float64) @@ -35,10 +46,18 @@ def weierstrassP(z, e1, e2, e3): def _weierP_xp(xp, z, e1, e2, e3): from .ellipj import _ellipj_xp m = (e2 - e3) / (e1 - e3) - w = z * xp.sqrt(e1 - e3) + scale = xp.sqrt(e1 - e3) + K = _rf_xp(xp, xp.zeros_like(m), 1.0 - m, xp.ones_like(m)) + omega1 = K / scale + period = xp.round(z / (2.0 * omega1)) + z_reduced = z - 2.0 * period * omega1 + w = z_reduced * scale sn, _, _, _ = _ellipj_xp(xp, w, m) sn2 = sn * sn - pole = xp.abs(sn) < 1e-10 + pole_tol = 8.0 * np.finfo(np.float64).eps * xp.maximum( + xp.ones_like(z), xp.abs(z) + ) + pole = xp.abs(z_reduced) <= pole_tol P = e3 + (e1 - e3) / xp.where(pole, xp.ones_like(sn2), sn2) return xp.where(pole, xp.full_like(P, math.inf), P) @@ -56,14 +75,11 @@ def _weierP_numpy(z, e1, e2, e3): def weierstrassZeta(z, e1, e2, e3): """Weierstrass zeta function (NOT Riemann zeta).""" - _, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) - orig_shape = z.shape - Z = _weierZ_numpy(np.asarray(z).ravel(), np.asarray(e1).ravel(), - np.asarray(e2).ravel(), np.asarray(e3).ravel()) - return np.asarray(Z.reshape(orig_shape)) + xp, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) + return _weierZ_xp(xp, z, e1, e2, e3) -def _lattice_theta_numpy(z, e1, e2, e3): +def _lattice_theta_xp(xp, z, e1, e2, e3): """omega1, eta1 and the theta1 series values needed by zeta/sigma. Closed theta forms (DLMF 23.6.8/9/13): no quadrature. Returns @@ -71,41 +87,48 @@ def _lattice_theta_numpy(z, e1, e2, e3): dropped from every series -- only ratios are used downstream, except th1/th1p0 where both drop the same factor. """ - from .elliptic12 import _elliptic12_xp m_param = (e2 - e3) / (e1 - e3) - phi_half = np.full_like(m_param, math.pi / 2) - K, _, _ = _elliptic12_xp(np, phi_half, m_param) - Kp, _, _ = _elliptic12_xp(np, phi_half, 1.0 - m_param) - omega1 = K / np.sqrt(e1 - e3) - q = np.exp(-math.pi * Kp / K) + zero = xp.zeros_like(m_param) + one = xp.ones_like(m_param) + K = _rf_xp(xp, zero, 1.0 - m_param, one) + Kp = _rf_xp(xp, zero, m_param, one) + omega1 = K / xp.sqrt(e1 - e3) + q = xp.exp(-math.pi * Kp / K) v = math.pi * z / (2.0 * omega1) - qmax = float(np.max(q)) if q.size else 0.0 - nT = min(30, max(2, math.ceil(math.sqrt(abs(math.log(np.finfo(float).eps) - / math.log(qmax)))))) if qmax > 0 else 1 - - th1 = np.zeros_like(v); th1p = np.zeros_like(v) - th1p0 = np.zeros_like(v); th1ppp0 = np.zeros_like(v) - for n in range(nT + 1): + th1 = xp.zeros_like(v) + th1p = xp.zeros_like(v) + th1p0 = xp.zeros_like(v) + th1ppp0 = xp.zeros_like(v) + for n in range(31): qq = (-1.0) ** n * q ** ((n + 0.5) ** 2) k = 2 * n + 1 - th1 += qq * np.sin(k * v) - th1p += qq * k * np.cos(k * v) - th1p0 += qq * k - th1ppp0 -= qq * k ** 3 + th1 = th1 + qq * xp.sin(k * v) + th1p = th1p + qq * k * xp.cos(k * v) + th1p0 = th1p0 + qq * k + th1ppp0 = th1ppp0 - qq * k ** 3 eta1 = -math.pi ** 2 / (12.0 * omega1) * th1ppp0 / th1p0 return omega1, eta1, th1, th1p, th1p0 -def _weierZ_numpy(z, e1, e2, e3): +def _weierZ_xp(xp, z, e1, e2, e3): # zeta(z) = eta1*z/omega1 + pi/(2*omega1) * theta1'(v)/theta1(v) [DLMF 23.6.13] # Quasi-periodicity zeta(z + 2k*omega1) = zeta(z) + 2k*eta1 is carried # exactly by the formula; no period reduction needed. - omega1, eta1, th1, th1p, _ = _lattice_theta_numpy(z, e1, e2, e3) - with np.errstate(divide='ignore', invalid='ignore'): - Z = eta1 * z / omega1 + math.pi / (2.0 * omega1) * th1p / th1 - Z[th1 == 0.0] = np.inf # lattice points z = 2k*omega1 - return Z + omega1, eta1, th1, th1p, _ = _lattice_theta_xp(xp, z, e1, e2, e3) + period = xp.round(z / (2.0 * omega1)) + z_reduced = z - 2.0 * period * omega1 + pole_tol = 8.0 * np.finfo(np.float64).eps * xp.maximum( + xp.ones_like(z), xp.abs(z) + ) + pole = xp.abs(z_reduced) <= pole_tol + ratio = th1p / xp.where(pole, xp.ones_like(th1), th1) + Z = eta1 * z / omega1 + math.pi / (2.0 * omega1) * ratio + return xp.where(pole, xp.full_like(Z, math.inf), Z) + + +def _weierZ_numpy(z, e1, e2, e3): + return _weierZ_xp(np, z, e1, e2, e3) # --------------------------------------------------------------------------- @@ -114,20 +137,28 @@ def _weierZ_numpy(z, e1, e2, e3): def weierstrassSigma(z, e1, e2, e3): """Weierstrass sigma function (entire, odd, sigma'(z)/sigma(z) = zeta(z)).""" - _, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) - orig_shape = z.shape - S = _weierS_numpy(np.asarray(z).ravel(), np.asarray(e1).ravel(), - np.asarray(e2).ravel(), np.asarray(e3).ravel()) - return np.asarray(S.reshape(orig_shape)) + xp, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) + return _weierS_xp(xp, z, e1, e2, e3) -def _weierS_numpy(z, e1, e2, e3): +def _weierS_xp(xp, z, e1, e2, e3): # sigma(z) = 2*omega1/pi * exp(eta1*z^2/(2*omega1)) * theta1(v)/theta1'(0) # [DLMF 23.6.9]. Entire function: every lattice zero and sign change # comes out of theta1 itself. The previous form integrated log(sigma) # through the zeta pole at 2*omega1 and was wrong beyond it. - omega1, eta1, th1, _, th1p0 = _lattice_theta_numpy(z, e1, e2, e3) - return 2.0 * omega1 / math.pi * np.exp(eta1 * z * z / (2.0 * omega1)) * th1 / th1p0 + omega1, eta1, th1, _, th1p0 = _lattice_theta_xp(xp, z, e1, e2, e3) + return ( + 2.0 + * omega1 + / math.pi + * xp.exp(eta1 * z * z / (2.0 * omega1)) + * th1 + / th1p0 + ) + + +def _weierS_numpy(z, e1, e2, e3): + return _weierS_xp(np, z, e1, e2, e3) # ----------------------------------------------------------------------- @@ -152,9 +183,17 @@ def weierstrassPPrime(z, e1, e2, e3): xp, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) from .ellipj import _ellipj_xp m = (e2 - e3) / (e1 - e3) - w = z * xp.sqrt(e1 - e3) + root_scale = xp.sqrt(e1 - e3) + K = _rf_xp(xp, xp.zeros_like(m), 1.0 - m, xp.ones_like(m)) + omega1 = K / root_scale + period = xp.round(z / (2.0 * omega1)) + z_reduced = z - 2.0 * period * omega1 + w = z_reduced * root_scale sn, cn, dn, _ = _ellipj_xp(xp, w, m) scale = -2.0 * (e1 - e3) ** 1.5 - pole = xp.abs(sn) < 1e-10 + pole_tol = 8.0 * np.finfo(np.float64).eps * xp.maximum( + xp.ones_like(z), xp.abs(z) + ) + pole = xp.abs(z_reduced) <= pole_tol dP = scale * cn * dn / xp.where(pole, xp.ones_like(sn), sn * sn * sn) return xp.where(pole, xp.full_like(dP, math.inf), dP) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index d484357..2402eee 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -11,6 +11,8 @@ pass try: + import jax + jax.config.update("jax_enable_x64", True) import jax.numpy # noqa: F401 BACKENDS.append("jax") except ImportError: diff --git a/python/tests/test_backends.py b/python/tests/test_backends.py new file mode 100644 index 0000000..4513def --- /dev/null +++ b/python/tests/test_backends.py @@ -0,0 +1,90 @@ +"""Backend smoke tests that make the Torch/JAX CI jobs exercise those paths.""" + +import numpy as np +import pytest + +import elliptic + + +def _array(xp, values, *, complex_values=False): + if xp is np: + dtype = np.complex128 if complex_values else np.float64 + return np.asarray(values, dtype=dtype) + if xp.__name__ == "torch": + dtype = xp.complex128 if complex_values else xp.float64 + return xp.tensor(values, dtype=dtype) + dtype = xp.complex128 if complex_values else xp.float64 + return xp.asarray(values, dtype=dtype) + + +def _numpy(value): + if hasattr(value, "detach"): + return value.detach().cpu().numpy() + return np.asarray(value) + + +def test_core_functions_preserve_backend_and_values(xp): + u = _array(xp, [0.2, 0.7, 1.1]) + m = _array(xp, [0.2, 0.5, 0.8]) + + F, E, _ = elliptic.elliptic12(u, m) + sn, cn, dn, _ = elliptic.ellipj(u, m) + Pi = elliptic.elliptic3(u, m, 0.2) + B, D, _ = elliptic.ellipticBD(m) + Eu, Du, _ = elliptic.jacobiEDJ(u, m) + + for value in (F, E, sn, cn, dn, Pi, B, D, Eu, Du): + assert _numpy(value).shape == (3,) + assert np.all(np.isfinite(_numpy(value))) + + np.testing.assert_allclose(_numpy(sn) ** 2 + _numpy(cn) ** 2, 1.0, atol=2e-13) + np.testing.assert_allclose(_numpy(Eu), _numpy(u) - _numpy(m) * _numpy(Du), atol=2e-12) + + +def test_auxiliary_functions_preserve_backend(xp): + v = _array(xp, [0.1, 0.3]) + m = _array(xp, [0.4, 0.7]) + q = elliptic.nomeq(m) + m_back = elliptic.inversenomeq(q) + th = elliptic.theta(3, v, m) + zeta = elliptic.weierstrassZeta(v, 1.0, 0.0, -1.0) + sigma = elliptic.weierstrassSigma(v, 1.0, 0.0, -1.0) + arc = elliptic.arclength_ellipse( + _array(xp, [2.0, 3.0]), + _array(xp, [3.0, 2.0]), + 0.1, + _array(xp, [0.5, 0.7]), + ) + + np.testing.assert_allclose(_numpy(m_back), _numpy(m), atol=2e-13) + for value in (th, zeta, sigma, arc): + assert np.all(np.isfinite(_numpy(value))) + + +def test_complex_functions_preserve_backend(xp): + u = _array(xp, [0.4 + 0.2j, 0.8 - 0.1j], complex_values=True) + m = _array(xp, [0.3, 0.7]) + F, E, _ = elliptic.elliptic12i(u, m) + sn, cn, _ = elliptic.ellipji(u, m) + for value in (F, E, sn, cn): + assert np.all(np.isfinite(_numpy(value))) + + +def test_jax_jit_core_paths(): + jax = pytest.importorskip("jax") + jnp = pytest.importorskip("jax.numpy") + jax.config.update("jax_enable_x64", True) + u = jnp.asarray([0.2, 0.7], dtype=jnp.float64) + + outputs = [ + jax.jit(lambda x: elliptic.elliptic12(x, 0.5)[0])(u), + jax.jit(lambda x: elliptic.elliptic3(x, 0.5, 0.2))(u), + jax.jit(lambda x: elliptic.theta(3, x, 0.5))(u), + ] + # These larger graphs only need a tracing guard here; compiling the full + # fixed-iteration inverse/theta expansions would make this smoke test a + # poor CI citizen. + jax.make_jaxpr(lambda x: elliptic.inverselliptic2(x, 0.5))(u) + jax.make_jaxpr(lambda x: elliptic.weierstrassZeta(x, 1.0, 0.0, -1.0))(u) + jax.make_jaxpr(lambda x: elliptic.arclength_ellipse(2.0, 3.0, 0.0, x))(u) + assert all(np.all(np.isfinite(np.asarray(value))) for value in outputs) diff --git a/python/tests/test_regression_followup.py b/python/tests/test_regression_followup.py new file mode 100644 index 0000000..862e70a --- /dev/null +++ b/python/tests/test_regression_followup.py @@ -0,0 +1,135 @@ +"""Regression coverage from the post-0d09740 deep audit.""" + +import builtins +import math + +import mpmath as mp +import numpy as np +import pytest +from scipy import special + +import elliptic + + +def _float(value): + return float(np.asarray(value)) + + +def test_m1_periods_do_not_hide_the_first_kind_pole(): + for phi in [2.0, math.pi, 4.0, 10.0]: + F, E, Z = elliptic.elliptic12(phi, 1.0) + turns = math.floor((abs(phi) + math.pi / 2.0) / math.pi) + expected_E = (-1.0) ** turns * math.sin(abs(phi)) + 2.0 * turns + assert math.isinf(_float(F)) + np.testing.assert_allclose(_float(E), expected_E, atol=1e-14) + + Fn, En, Zn = elliptic.elliptic12(-phi, 1.0) + assert math.isinf(_float(Fn)) and _float(Fn) < 0 + np.testing.assert_allclose(_float(En), -expected_E, atol=1e-14) + np.testing.assert_allclose(_float(Zn), -_float(Z), atol=1e-14) + + +def test_ellipticbd_preserves_small_parameter_limits(): + for m in [0.0, 1e-20, 1e-16, 1e-12, 1e-8]: + B, D, S = elliptic.ellipticBD(m) + np.testing.assert_allclose(_float(B), math.pi / 4.0, atol=2e-8) + np.testing.assert_allclose(_float(D), math.pi / 4.0, atol=2e-8) + np.testing.assert_allclose(_float(S), math.pi / 16.0, atol=2e-8) + + +def test_theta_exact_and_near_endpoint_parameters(): + v = 0.37 + assert _float(elliptic.theta(1, v, 0.0)) == 0.0 + assert _float(elliptic.theta(2, v, 0.0)) == 0.0 + assert _float(elliptic.theta(3, v, 0.0)) == 1.0 + assert _float(elliptic.theta(4, v, 0.0)) == 1.0 + + mp.mp.dps = 50 + for m in [1e-20, 1e-15, np.nextafter(1.0, 0.0)]: + mm = mp.mpf(float(m)) + q = mp.e ** (-mp.pi * mp.ellipk(1 - mm) / mp.ellipk(mm)) + for j in range(1, 5): + expected = float(mp.jtheta(j, v, q)) + np.testing.assert_allclose( + _float(elliptic.theta(j, v, m)), + expected, + rtol=2e-13, + atol=2e-15, + ) + + +def test_elliptic3_near_pole_uses_full_precision_carlson_form(): + phi = np.array([1.2, 1.5, math.pi / 2.0]) + m = np.array([0.8, 0.95, 0.9]) + n = np.array([0.99, 0.999, 0.9999]) + s = np.sin(phi) + c = np.cos(phi) + d2 = 1.0 - m * s**2 + p = 1.0 - n * s**2 + expected = ( + s * special.elliprf(c**2, d2, 1.0) + + n * s**3 / 3.0 * special.elliprj(c**2, d2, 1.0, p) + ) + np.testing.assert_allclose( + elliptic.elliptic3(phi, m, n), expected, rtol=2e-13, atol=2e-13 + ) + + +def test_negative_phase_crossing_third_kind_pole_is_rejected(): + with pytest.raises(ValueError, match="Cauchy principal-value"): + elliptic.elliptic3(-1.0, 0.5, 2.0) + + +def test_large_argument_ellipj_near_endpoint_parameter(): + mp.mp.dps = 70 + u = 1_000_000.123 + m = np.nextafter(1.0, 0.0) + sn, cn, dn, _ = elliptic.ellipj(u, m) + uu = mp.mpf(float(u)) + mm = mp.mpf(float(m)) + refs = [mp.ellipfun(name, uu, mm) for name in ("sn", "cn", "dn")] + for got, expected in zip((sn, cn, dn), refs): + np.testing.assert_allclose(_float(got), float(expected), rtol=2e-8, atol=2e-12) + + +def test_weierstrass_near_pole_is_finite_until_the_actual_lattice_point(): + values = elliptic.weierstrassP(np.array([0.0, 1e-11, 5e-11]), 1.0, 0.0, -1.0) + assert np.isinf(values[0]) + assert np.all(np.isfinite(values[1:])) + np.testing.assert_allclose(values[1:], np.array([1e22, 4e20]), rtol=2e-15) + + with pytest.raises(ValueError, match="real inputs only"): + elliptic.weierstrassP(0.2 + 0.1j, 1.0, 0.0, -1.0) + + +def test_public_runtime_does_not_import_scipy(monkeypatch): + real_import = builtins.__import__ + + def reject_scipy(name, *args, **kwargs): + if name == "scipy" or name.startswith("scipy."): + raise AssertionError(f"unexpected SciPy runtime import: {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", reject_scipy) + + elliptic.theta(1, 0.2, 0.5) + elliptic.theta_prime(1, 0.2, 0.5) + elliptic.jacobiThetaEta(0.2, 0.5) + elliptic.nomeq(0.5) + elliptic.inversenomeq(0.04) + elliptic.inverselliptic2(0.5, 0.5) + elliptic.elliptic12i(0.7 + 0.2j, 0.5) + + +def test_arclength_ellipse_is_vectorized_and_validates_axes(): + arcs = elliptic.arclength_ellipse( + np.array([5.0, 10.0, 3.0]), + np.array([10.0, 5.0, 3.0]), + ) + np.testing.assert_allclose( + arcs, + np.array([48.44224110273839, 48.44224110273839, 6.0 * math.pi]), + rtol=2e-14, + ) + with pytest.raises(ValueError, match="strictly positive"): + elliptic.arclength_ellipse(0.0, 1.0) From de9154081a15bc07fbeead7c2b86a1a4ae340385 Mon Sep 17 00:00:00 2001 From: Igor Date: Sun, 16 Aug 2026 15:15:37 +0200 Subject: [PATCH 03/23] ci: run every Octave test file, real backend-dispatch jobs, scipy-free install check Octave CI ran only 6 of 15 test files; discover every test*.m instead. The Torch/JAX jobs installed their backends but never passed those arrays through the library; they now run the dedicated dispatch tests. Add a base-dependency-only job proving the public API works without scipy, and gate publishing on all of the above. Co-Authored-By: Claude Opus 5 --- .circleci/config.yml | 2 +- .github/workflows/python.yml | 32 +++++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 6f98b24..2bb658a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,7 +33,7 @@ jobs: - run: name: Run Octave tests command: | - octave --eval "addpath(fullfile(pwd, 'matlab', 'src')); cd matlab/tests; test testElliptic12; test testElliptic3; test testEllipj; test testThetaPrime; test testAgm; test testJacobiThetaEta;" + octave --eval "addpath(fullfile(pwd, 'matlab', 'src')); files=dir(fullfile(pwd, 'matlab', 'tests', 'test*.m')); failed=false; for k=1:numel(files); [n,nmax]=test(fullfile(files(k).folder,files(k).name),'quiet'); fprintf('%s: %d/%d\n',files(k).name,n,nmax); failed=failed || n~=nmax; end; if failed; exit(1); end" workflows: build: jobs: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index cfb5e89..ac31d55 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,9 +2,9 @@ name: tests on: push: - paths: ["python/**"] + paths: ["python/**", ".github/workflows/python.yml"] pull_request: - paths: ["python/**"] + paths: ["python/**", ".github/workflows/python.yml"] release: types: [published] @@ -13,6 +13,32 @@ defaults: working-directory: python jobs: + test-standalone: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install runtime dependencies only + run: pip install -e . + - name: Exercise public functions without SciPy + run: | + python - <<'PY' + import importlib.util + import elliptic + + assert importlib.util.find_spec("scipy") is None + elliptic.theta(1, 0.2, 0.5) + elliptic.theta_prime(1, 0.2, 0.5) + elliptic.jacobiThetaEta(0.2, 0.5) + elliptic.nomeq(0.5) + elliptic.inversenomeq(0.04) + elliptic.inverselliptic2(0.5, 0.5) + elliptic.elliptic12i(0.7 + 0.2j, 0.5) + elliptic.arclength_ellipse([2.0, 3.0], [3.0, 2.0]) + PY + test: runs-on: ${{ matrix.os }} strategy: @@ -62,7 +88,7 @@ jobs: publish: name: Build and publish to PyPI runs-on: ubuntu-latest - needs: [test, test-torch, test-jax] + needs: [test-standalone, test, test-torch, test-jax] if: github.event_name == 'release' && github.event.action == 'published' environment: pypi permissions: From 7f9f73f617b3834f7852989d1387d8d4cdc06e4a Mon Sep 17 00:00:00 2001 From: Igor Date: Sun, 16 Aug 2026 15:15:38 +0200 Subject: [PATCH 04/23] docs: post-0d09740 audit spec; refresh GPU notes and wiki The spec records the full audit: findings table with severity and data impact, numerical/differential verification against mpmath and Carlson references, automated-verification counts, and the deliberate limits (GPU kernels reviewed but not hardware-run; Weierstrass real-only; elliptic3 principal-value continuation not implemented). Co-Authored-By: Claude Opus 5 --- docs/GPU.md | 10 ++- docs/specs/codebase-weakness-audit.md | 5 ++ docs/specs/post-0d09740-regression-audit.md | 81 +++++++++++++++++++++ docs/wiki/Elliptic-Integrals.md | 2 +- docs/wiki/elliptic.md | 2 +- 5 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 docs/specs/post-0d09740-regression-audit.md diff --git a/docs/GPU.md b/docs/GPU.md index 8e4e2c7..1bc183f 100644 --- a/docs/GPU.md +++ b/docs/GPU.md @@ -6,6 +6,10 @@ configuration flag. The same source code paths work in both **MATLAB** (Parallel Computing Toolbox / CUDA) and **Octave** (ocl Forge package / OpenCL). +For `elliptic3`, regular inputs run on the GPU. Inputs close to an endpoint +pole are intentionally gathered and evaluated by the Carlson CPU path because +fixed GPU quadrature is not accurate enough there. + --- ## Benchmark results @@ -20,9 +24,9 @@ Octave 6.4.0 · ocl 1.2.4 · CUDA driver 535, OpenCL 3.0. | `elliptic3` | 4 M | 2.668 s | 1.167 s (2.3×) | 0.199 s | **13.4×** | | `jacobiThetaEta` | 1 M | 1.460 s | 0.583 s (2.5×) | 0.494 s | **3.1×** | -`elliptic3` achieves 13× because it is a pure Gauss-Legendre quadrature -(no AGM, no sequential dependencies) — every element is completely -independent and maps trivially to GPU threads. +These historical `elliptic3` measurements use regular inputs, for which the +Gauss-Legendre GPU path remains active. Every element is independent and maps +directly to GPU threads. ### Hardware utilisation at N = 1 M diff --git a/docs/specs/codebase-weakness-audit.md b/docs/specs/codebase-weakness-audit.md index 8b435ba..d4e47c2 100644 --- a/docs/specs/codebase-weakness-audit.md +++ b/docs/specs/codebase-weakness-audit.md @@ -1,5 +1,10 @@ # Codebase Weakness Audit — Python Elliptic Library +> Historical snapshot. The high-severity items and the numerical defects +> confirmed from this list were addressed in the +> [post-0d09740 follow-up audit](post-0d09740-regression-audit.md). Keep this +> document as the pre-fix evidence, not as the current defect list. + **Date:** 2026-04-21 **Scope:** `/home/igor/Work/elliptic/python/elliptic/` **Purpose:** Identify weak or breaking points (special values, complex numbers, inf/NaN, memory, exception handling, backend tracing) to guide future implementation work. diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md new file mode 100644 index 0000000..aeb141a --- /dev/null +++ b/docs/specs/post-0d09740-regression-audit.md @@ -0,0 +1,81 @@ +# Post-0d09740 Regression and Data-Integrity Audit + +**Date:** 2026-07-28 +**Baseline:** `0d09740` on `master` +**Trigger:** [issue #35](https://github.com/moiseevigor/elliptic/issues/35) and its initial [patch](https://github.com/moiseevigor/elliptic/commit/0d09740) +**Scope:** MATLAB/Octave and Python implementations, numerical special values, +backend dispatch, data-shape preservation, packaging, and CI coverage. + +## Assessment + +The initial patch added useful special-value tests, but it did not fully close +the regression class. In particular, the issue #35 phase-reduction fix was +missing from the MATLAB GPU implementation, and a private stale copy inside +`elliptic123.m` could still bypass the repaired public implementation. The +audit also found independent numerical cancellation, pole-handling, +backend-dispatch, and CI-coverage defects. + +All fixes below are kept atomic at the function boundary and covered by +regression tests. No public function was intentionally removed. + +## Confirmed findings and fixes + +| Severity | Area | Failure and data impact | Fix | +|---|---|---|---| +| Critical | MATLAB GPU `elliptic12` | The GPU path did not apply the quasi-period correction from issue #35, so phases outside the principal interval could return the wrong branch. | Apply the same exact period reduction and complete-integral correction as the serial path; add GPU regression coverage. | +| High | `elliptic123` dispatch | Stale private `elliptic12i`/`elliptic12ic` copies shadowed the repaired public implementation and preserved old behavior. | Retire the private names so `elliptic123` routes through the maintained public function; add routing parity tests. | +| High | Python runtime dependencies | `theta`, nome, inverse, and complex functions imported SciPy although SciPy was not declared as a runtime dependency. A base installation failed only when those public functions were called. | Replace runtime SciPy calls with backend-native theta series, Carlson/AGM forms, and fixed-iteration inverse solvers; add a SciPy-free install job. | +| High | `elliptic12`, `m=1` | Reducing the phase before checking the singularity hid crossed first-kind poles (`F(pi,1)` became zero) and under-counted `E` after the first quadrant. | Detect pole crossings from the original phase and restore the full quasi-period contribution. | +| High | `ellipticBD`, small `m` | `(K-E)/m` and `(D-B)/m` catastrophically cancelled. At `m=1e-20`, valid finite limits became `D=0` and an order-`1e20` wrong `S`. | Compute `D` directly with Carlson `RD`, derive `B` from `RF-RD/3`, and use a convergent series for `S` near zero in both languages. | +| High | `elliptic3`, near `n=1` | Fixed quadrature lost roughly seven decimal digits near the third-kind endpoint pole. Negative phases crossing an interior pole were not consistently rejected in Python. | Use Carlson `RF/RJ` throughout Python; use a MATLAB/Octave hybrid that keeps quadrature on regular inputs and switches near poles; validate pole crossings using `abs(phi)`. | +| High | `ellipj`, extreme inputs | Clipping valid parameters changed representable data near `m=0` and `m=1`; large phases entered the amplified Landen recursion directly and could produce order-one errors. | Preserve every interior parameter exactly, reduce by `2K` before descent, reconstruct quasi-period signs, and use stable `dn` and `sech` formulas. | +| High | Weierstrass functions | A magnitude heuristic labelled finite near-pole values as infinity (`P(1e-11)` became `Inf`), complex inputs silently lost their imaginary part, and zeta/sigma forced NumPy arrays. | Detect actual lattice points using reduced periods and ULP-scale tolerance, explicitly reject unsupported complex inputs, and keep zeta/sigma backend-native. | +| High | JAX/PyTorch dispatch | Python control flow, NumPy casts, and scalar flattening broke tracing or moved public results off device in complex, inverse, theta, Jacobi EDJ, Weierstrass, and ellipse helpers. | Replace value-dependent Python branches with masked array expressions and fixed iteration counts; preserve the originating array namespace and shape. | +| Medium | Jacobi theta endpoints | `theta(1,v,0)` returned `sin(v)` instead of zero; tiny valid `m` values were collapsed to the endpoint; representable values near `m=1` returned NaN. | Evaluate native theta series with exact endpoint overrides and no arbitrary endpoint clipping. | +| Medium | MATLAB grouping | `uniquetol(...,1e-11)` treated distinct `m` values as identical and silently substituted one result for another. A `5e-12` parameter difference produced a `1.69e-9` vector/scalar discrepancy. | Group exact duplicates only in `elliptic12` and `ellipj`; add a no-substitution regression. | +| Medium | MATLAB complex integral | `elliptic12i` divided by `m` at the exact `m=0` endpoint. | Use a safe internal denominator and restore the analytic `m=0` values exactly. | +| Medium | Carlson `RJ` tracing | Public eager validation converted JAX tracers to NumPy and failed before evaluation; an unselected `RC` branch could still emit invalid arithmetic. | Restrict exception-producing validation to eager NumPy inputs and make internal masked arguments safe. | +| Medium | Ellipse helper | Public inputs were cast to Python `float`, rejecting arrays, Torch tensors, and JAX tracers and losing broadcast shape. | Implement elementwise backend-native broadcasting, explicit NumPy domain errors, and masked invalid values for traced execution. | +| Medium | CI coverage | Octave CI ran only 6 of 15 test files. Torch/JAX jobs installed their backends but no tests passed those arrays. The release path had no base-dependency-only check. | Discover every `test*.m`, add real NumPy/Torch/JAX dispatch tests, add a SciPy-free standalone job, and make publishing depend on all of them. | + +## Numerical and differential checks + +- Real `F` and `E` were compared over broad random phases, including many + quasi-periods, against independent reference implementations; observed + absolute discrepancies were about `1.3e-12` or smaller. +- Real third-kind values were compared to independent Carlson `RF/RJ` + references; observed relative discrepancies were about `2.5e-15`. +- Two hundred random complex `F`, `E`, and Jacobi `sn` samples were compared + with 50-digit `mpmath` values; the largest observed discrepancy was about + `5e-14`. +- The Weierstrass differential equation residual was checked at random regular + points; the largest observed relative residual was about `4.5e-15`. +- The large-phase `ellipj` regression at + `u=1,000,000.123`, `m=nextafter(1,0)` was checked against a 70-digit + `mpmath` reference in both languages. + +## Automated verification + +- Python default environment: **451 passed, 1 skipped**. The skip is the + optional JAX-only test when JAX is not installed. +- Python with both JAX and PyTorch installed: **458 passed**. +- Dedicated backend matrix: **10 passed**, covering NumPy, PyTorch, and JAX; + JAX compiles the core paths and traces the larger fixed-iteration graphs. +- Octave: **220/220 test blocks passed** across all 16 `test*.m` files. +- Python byte-compilation and whitespace/error checks passed. +- A base-dependency-only public API exercise verifies that SciPy is absent and + not imported at runtime. + +## Deliberate limits and residual risk + +- CUDA/OpenCL hardware was not available during this audit. GPU source paths + and dispatch tests were reviewed, and CPU/Octave tests passed, but the + corrected MATLAB/Octave GPU kernels still require hardware execution before + a release claim can include direct GPU validation. +- MATLAB Parallel Computing Toolbox was not available. Octave reported its + parallel-package skips; serial behavior and parallel dispatch code were + reviewed, but a real multi-worker run remains release validation work. +- Weierstrass functions currently support real inputs only. They now reject + complex input explicitly instead of silently discarding data. +- `elliptic3` deliberately rejects real paths that cross a third-kind pole; + Cauchy principal-value continuation is not implemented. diff --git a/docs/wiki/Elliptic-Integrals.md b/docs/wiki/Elliptic-Integrals.md index 4c24f3f..10c9c89 100644 --- a/docs/wiki/Elliptic-Integrals.md +++ b/docs/wiki/Elliptic-Integrals.md @@ -160,7 +160,7 @@ The library provides the AGM function directly: ## Carlson's Method -The conventional methods for computing elliptic integrals are Gauss and Landen transformations, which converge quadratically and work well for elliptic integrals of the first and second kinds. Unfortunately they suffer from loss of significant digits for the third kind. Carlson's algorithm provides a unified method for all three kinds with satisfactory precision. The third kind integral in this library uses a Gauss-Legendre 10-point quadrature instead. +The conventional methods for computing elliptic integrals are Gauss and Landen transformations, which converge quadratically and work well for elliptic integrals of the first and second kinds. Unfortunately they suffer from loss of significant digits for the third kind. Carlson's algorithm provides a unified method for all three kinds with satisfactory precision. Python evaluates the third kind directly with Carlson RF/RJ forms. MATLAB/Octave uses a hybrid: vectorised 20-node Gauss-Legendre quadrature on regular inputs and Carlson RF/RJ near endpoint poles, where fixed quadrature loses precision. --- diff --git a/docs/wiki/elliptic.md b/docs/wiki/elliptic.md index ac2c52f..9ea98b7 100644 --- a/docs/wiki/elliptic.md +++ b/docs/wiki/elliptic.md @@ -148,7 +148,7 @@ _See also_ `ELLIPKE`, `ELLIPJ`, `ELLIPTIC3`, `THETA`. [ELLIPTIC3](https://github.com/moiseevigor/elliptic/blob/master/src/elliptic3.m) evaluates incomplete elliptic integral of the third kind `Pi = ELLIPTIC3(U,M,C)` where `U` is a phase in radians, `0 < M < 1` is the module and `0 < C < 1` is a parameter. -`ELLIPTIC3` uses Gauss-Legendre 10 points quadrature template described in [3] to determine the value of the Incomplete Elliptic Integral of the Third Kind (see [1, 2]). +`ELLIPTIC3` uses vectorised 20-node Gauss-Legendre quadrature on regular inputs and switches to Carlson RF/RJ symmetric forms near endpoint poles. This preserves the fast path while avoiding fixed-quadrature precision loss as `M` or `C` approaches one. **General definition:** ``` From 56396160b2fd344411f54ad24c9d5b068dc5d128 Mon Sep 17 00:00:00 2001 From: Igor Date: Sun, 16 Aug 2026 16:25:59 +0200 Subject: [PATCH 05/23] fix: close the adversarial-review counterexamples (Codex + mpmath round) An independent adversarial review of this branch (Codex, cross-checked against mpmath at 40-100 digits) produced five material counterexamples. Each is verified, fixed in the affected port(s), and pinned by a regression test (testEdgeCases.m block Q, TestAdversarialRound): - elliptic3 (MATLAB): negative amplitudes formed 0*Inf = NaN whenever the complete integral has a pole (Pi(-1|.5,1), Pi(-1|1,.2)) -- the complete-integral correction is now applied only where a half-period or reflection is actually present. - elliptic12i (both ports): the A&S 17.4.11 decomposition loses ~sqrt(eps/m) digits as m -> 0 -- F(0.2i|1e-20) returned ~0 instead of 0.2i, 9.2e-3 error at m = 1e-14. Added the Maclaurin series through m^2, switched at m*max(1, e^(2|psi|)) < 1e-4; crossover error ~2e-12 against 40-digit mpmath, and the series subsumes the m = 0 override. - weierstrassP/PPrime/Zeta (both ports): pole tolerances (abs(sn) < eps^(1/3) in MATLAB -- a 6e-6 window! -- and 8*eps*max(1,|z|) in python) replaced huge finite near-pole values with Inf: P(1e-16) is 1e32, not a pole (DLMF 23.9.2). A pole is now only the exact lattice point. - inversenomeq (both ports): replaced by the DLMF 20.9.1 closed form m = (theta2(0,q)/theta3(0,q))^4 with q^(1/4) kept outside the ratio. The python bisection had a 2^-64 absolute floor (m(1e-30) came back 2.7e-20 instead of 1.6e-29); the MATLAB interpolation tables were documented-unreliable outside [1e-5, 0.76]. Both are now exact at every scale and the computed q_max endpoint is accepted. - carlsonRC (python): the branch selector compared |y-x| against an ABSOLUTE 1e-14, sending every small-scale input down the degenerate x == y branch -- RC(1e-20,2e-20) was 27% off and contaminated RJ. Selection is now relative; DLMF 19.20 homogeneity (RF,RC ~ l^-1/2, RD,RJ ~ l^-3/2) is tested at lambda = 1e+/-20 in both ports. Also from the review: reversed arc intervals are signed for circles as for ellipses (python applications.py dropped its abs); DLMF citations corrected (19.25.14 for incomplete Pi, 19.25.5/19.25.9 for F/E); docs now state the double-precision phase-reduction bound for ellipj (full precision to |u| ~ 1e12, phase lost by ~1e16 -- shared by every double implementation) and the A&S 17.4.11 branch convention of elliptic12i (Re F = K(m) on Re u = pi/2, divergent as m -> 1, where mpmath/Mathematica may follow a different sheet). Claims from the review NOT adopted, with reasons: the m -> 1 complex values are a branch convention, not an error (now documented); the u = 1e16 Jacobi phase loss is the double-precision wall itself -- fixing it requires K(m) to ~32 digits, recorded as a deliberate limit. New anchors: ellipticBD at m = 0.2, 0.7, 0.999 (mpmath, dps=40) and elliptic12i on both sides of the series crossover. Octave 240/240 blocks across 16 files; pytest 458 + 1 optional-JAX skip. Co-Authored-By: Claude Opus 5 --- docs/specs/post-0d09740-regression-audit.md | 30 ++++++++ matlab/src/ellipj.m | 7 ++ matlab/src/elliptic12i.m | 31 ++++++-- matlab/src/elliptic3.m | 15 +++- matlab/src/inversenomeq.m | 47 ++++-------- matlab/src/weierstrassP.m | 10 ++- matlab/src/weierstrassPPrime.m | 10 ++- matlab/tests/testEdgeCases.m | 76 +++++++++++++++++++ python/elliptic/applications.py | 4 +- python/elliptic/carlson.py | 24 +++--- python/elliptic/complex_elliptic.py | 23 +++++- python/elliptic/ellipj.py | 5 ++ python/elliptic/elliptic12.py | 2 +- python/elliptic/elliptic3.py | 2 +- python/elliptic/nome.py | 36 ++++----- python/elliptic/weierstrass.py | 24 +++--- python/tests/test_edge_cases.py | 81 +++++++++++++++++++++ 17 files changed, 331 insertions(+), 96 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index aeb141a..defc867 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -66,6 +66,26 @@ regression tests. No public function was intentionally removed. - A base-dependency-only public API exercise verifies that SciPy is absent and not imported at runtime. +## Adversarial review round (2026-08-16) + +An independent adversarial review (Codex, cross-checked against mpmath at +40-100 digits) of the four commits above found five material counterexamples +that the original round missed. All are fixed and regression-tested +(`testEdgeCases.m` block Q, `test_edge_cases.py::TestAdversarialRound`): + +| Finding | Failure | Fix | +|---|---|---| +| `elliptic3` negative amplitude with pole in the complete integral | `0*Inf = NaN` for e.g. `Pi(-1\|.5,1)` (MATLAB) | complete-integral correction applied only where a half-period or reflection is present | +| complex `F/E` small `m` (both ports) | A&S 17.4.11 decomposition loses `sqrt(eps/m)` digits: `F(0.2i\|1e-20)` returned 0, error 9.2e-3 at `m=1e-14` | Maclaurin series through `m^2`, switched at `m*max(1, e^(2\|psi\|)) < 1e-4`; crossover error ~2e-12 vs mpmath | +| Weierstrass pole classification (both ports) | tolerance windows (`abs(sn) < eps^(1/3)` MATLAB; `8*eps*max(1,\|z\|)` Python) returned `Inf` for the finite `P(1e-16) = 1e32` | pole only at the exact lattice point (`z_reduced == 0` / `sn == 0`), per the DLMF 23.9.2 Laurent expansion | +| inverse nome small `q` (both ports) | Python bisection had a `2^-64` absolute floor (`m(1e-30)` off by 9 orders); MATLAB tables documented-unreliable outside `[1e-5, 0.76]` | DLMF 20.9.1 closed form `m = (theta2/theta3)^4` with the `q^(1/4)` factor kept outside the ratio; exact at every scale | +| Carlson `RC` scale invariance (Python) | absolute branch tolerance sent small-scale inputs down the degenerate branch: `RC(1e-20,2e-20)` off 27%, contaminating `RJ` | relative branch selection; homogeneity `RF,RC ~ lambda^(-1/2)`, `RD,RJ ~ lambda^(-3/2)` now tested at `lambda = 1e+/-20` | + +Also from that round: reversed arc intervals are now signed for circles and +ellipses alike; DLMF citations corrected (19.25.14 for incomplete third kind, +19.25.5/19.25.9 for F/E); nondegenerate `ellipticBD` anchors at +`m = 0.2, 0.7, 0.999`. + ## Deliberate limits and residual risk - CUDA/OpenCL hardware was not available during this audit. GPU source paths @@ -79,3 +99,13 @@ regression tests. No public function was intentionally removed. complex input explicitly instead of silently discarding data. - `elliptic3` deliberately rejects real paths that cross a third-kind pole; Cauchy principal-value continuation is not implemented. +- Jacobi phase reduction is double precision: the residual phase carries an + absolute uncertainty ~`|u|*eps`, so `ellipj` holds full precision to + `|u| ~ 1e12`, degrades linearly beyond, and has lost the phase entirely by + `|u| ~ 1e16`. Every double-precision implementation (MATLAB's and SciPy's + included) shares this bound; extended-precision reduction would need K(m) + to ~32 digits. +- `elliptic12i` follows the A&S 17.4.11 real-decomposition branch: on + `Re u = pi/2` above the branch point, `Re F = K(m)`, which diverges as + `m -> 1`. mpmath/Mathematica may return values on a different sheet + there; the convention is now documented in both ports. diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index f5b98e7..26e0b46 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -9,6 +9,13 @@ % [Sn,Cn,Dn,Am] = ELLIPJ(U,M,TOL) computes the elliptic functions to % the accuracy TOL instead of the default TOL = EPS. % +% Accuracy limit for large arguments: the phase is reduced modulo 2K in +% double precision, so the residual phase carries an absolute uncertainty +% of about |U|*eps. Full precision holds for |U| up to ~1e12; beyond +% that the error grows linearly and by |U| ~ 1e16 the phase is lost +% entirely. This bound is shared by every double-precision +% implementation (including MATLAB's and SciPy's own ELLIPJ). +% % Some definitions of the Jacobi elliptic functions use the modulus % k instead of the parameter m. They are related by m = k^2. % diff --git a/matlab/src/elliptic12i.m b/matlab/src/elliptic12i.m index 9249ae1..ca8880e 100644 --- a/matlab/src/elliptic12i.m +++ b/matlab/src/elliptic12i.m @@ -10,6 +10,13 @@ % ELLIPTIC12i uses the function ELLIPTIC12 to evaluate the values of % corresponding integrals. % +% Branch convention: values follow the A&S 17.4.11 real decomposition +% F(phi+i*psi|m) = F(lambda|m) + i*F(mu|1-m). On the line phi = pi/2 +% (above the branch point pi/2 + i*acosh(1/sqrt(m))) this fixes +% Re F = K(m), which diverges as m -> 1. Other systems (Mathematica, +% mpmath) may return values on a different sheet there; both satisfy +% the defining differential relation. +% % Example: % [phi1,phi2] = meshgrid(-2*pi:3/20:2*pi, -2*pi:3/20:2*pi); % phi = phi1 + phi2*i; @@ -129,11 +136,23 @@ % complex values of zeta function Zi(:) = Ei(:) - Ee(:)./K(:).*Fi(:); -% Exact elementary limit at m=0. The transformation above contains a -% division by m and previously returned NaN for this documented endpoint. -m0 = find(m == 0); -Fi(m0) = u(m0); -Ei(m0) = u(m0); -Zi(m0) = 0; +% Small-m Maclaurin series (through m^2). The A&S 17.4.11 decomposition +% loses ~sqrt(eps/m) digits as m -> 0 (0.2 absolute at m = 1e-16); the +% series is exact there and covers m = 0 itself: +% F = u + m(u/4 - sin2u/8) + m^2(9u/64 - 3sin2u/32 + 3sin4u/256) + O(m^3) +% E = u - m(u/4 - sin2u/8) - m^2(3u/64 - sin2u/32 + sin4u/256) + O(m^3) +% Valid while |m sin^2 u| is small: switch on m*max(1, e^(2|psi|)) < 1e-4, +% where the crossover error is ~2e-12 (measured against 40-digit mpmath). +m_eff = m .* max(1, exp(2*abs(psi))); +sm = find(m_eff < 1e-4); +if ~isempty(sm) + uu = u(sm); mm = m(sm); % original u: phi carries the +eps nudge + s2 = sin(2*uu); s4 = sin(4*uu); + Fs = uu + mm.*(uu/4 - s2/8) + mm.^2.*(9*uu/64 - 3*s2/32 + 3*s4/256); + Es = uu - mm.*(uu/4 - s2/8) - mm.^2.*(3*uu/64 - s2/32 + s4/256); + Fi(sm) = Fs; + Ei(sm) = Es; + Zi(sm) = Es - Ee(sm)./K(sm).*Fs; +end % END FUNCTION ELLIPTIC12i() diff --git a/matlab/src/elliptic3.m b/matlab/src/elliptic3.m index 85d876c..a4cf6ec 100644 --- a/matlab/src/elliptic3.m +++ b/matlab/src/elliptic3.m @@ -46,9 +46,18 @@ r = ua - k_per .* pi; % in [0, pi) refl = r > pi/2; ur = r; ur(refl) = pi - r(refl); % in [0, pi/2] - Pcpl = elliptic3(pi/2 + zeros(size(u)), m, c); Pred = elliptic3(ur, m, c); - Pi = signU .* (2 .* k_per .* Pcpl + refl .* (2 .* Pcpl) + (1 - 2 .* refl) .* Pred); + % Complete-integral correction only where a half-period or reflection + % actually applies: when the complete integral is a pole (m = 1 or + % c = 1), an unconditional 2*k_per*Pcpl forms 0*Inf = NaN for phases + % that never cross it (e.g. plain negative amplitudes). + corr = zeros(size(ur)); + idx = (k_per > 0) | refl; + if any(idx(:)) + Pcpl = elliptic3(pi/2 + zeros(size(u)), m, c); + corr(idx) = 2 .* k_per(idx) .* Pcpl(idx) + 2 .* refl(idx) .* Pcpl(idx); + end + Pi = signU .* (corr + (1 - 2 .* refl) .* Pred); return; end @@ -90,7 +99,7 @@ % Hybrid evaluator. The 20-node rule is full precision while both endpoint % denominators stay >= 0.25; nearer a pole, switch only those elements to the -% Carlson form (DLMF 19.25.1). This retains the vectorised fast path without +% Carlson form (DLMF 19.25.14). This retains the vectorised fast path without % the previous seven-digit loss as c approached 1. s = sin(u); s2 = s.^2; diff --git a/matlab/src/inversenomeq.m b/matlab/src/inversenomeq.m index 8d5f5fd..afed261 100644 --- a/matlab/src/inversenomeq.m +++ b/matlab/src/inversenomeq.m @@ -44,42 +44,23 @@ m = zeros(size(q)); q = q(:).'; % make a row vector -maxq = max(q); -if ~all(q >= 0) || ~all(q <= 1) - error('Input arguments must be from the interval [0,1].') +if ~all(q >= 0) || ~all(q < 1) + error('Input arguments must be from the interval [0,1).') end -if any(q > 0.76) || any(q < 0.00001) - warning('WarnTests:convertTest', ... - 'The function INVERSENOMEQ does not return \ncorrect values of M for Q < 0.00001 and Q > 0.76, because of computer precision limitation.'); -end - -I = find (q <= 0.4); -J = find (q > 0.4 & q <= 0.6); -P = find (q > 0.6); - -if (~isempty(I)) - mm = 0:0.0001:1; - K = ellipke(mm); - KK = K(end:-1:1)./K; - m(I) = interp1(KK, mm, -1/pi*log(q(I)), 'pchip','extrap'); -end - -if (~isempty(J)) - mm = 0.9996:0.0000001:1-eps; - %K = 1/8*(-2+2*mm-2*(-5+mm)*log(4)+(-5+mm).*log(1-mm)); - K = 1/128*(-53+74*mm-21*mm.^2+2*(89+mm.*(-34+9*mm))*log(4)+(-89+mm.*(34-9*mm)).*log(1-mm)); - KK = pi/2./K; - m(J) = interp1(KK, mm, -1/pi*log(q(J)), 'pchip','extrap'); -end - -if (~isempty(P)) - mm = (1-10^8*eps):1000*eps:1-eps; - K = 1/128*(-53+74*mm-21*mm.^2+2*(89+mm.*(-34+9*mm))*log(4)+(-89+mm.*(34-9*mm)).*log(1-mm)); - KK = pi/2./K; - m(P) = interp1(KK, mm, -1/pi*log(q(P)), 'pchip','extrap'); - % plot(mm,K*log(q(P))+pi*pi/2,'.'); +% Closed form, DLMF 20.9.1: m = (theta2(0,q)/theta3(0,q))^4 +% theta2(0,q) = 2*q^(1/4) * sum q^(n(n+1)), theta3(0,q) = 1 + 2*sum q^(n^2) +% The q^(1/4) factor is kept outside the ratio so tiny q cannot underflow. +% This replaces the old interpolation tables, which were documented as +% unreliable for q < 1e-5 and q > 0.76; the series is exact at every scale +% down to m(1e-30) = 1.6e-29. +s2 = ones(size(q)); % sum q^(n(n+1)), n >= 0 +s3 = ones(size(q)); % theta3 = 1 + 2*sum q^(n^2) +for n = 1:30 + s2 = s2 + q.^(n*(n+1)); + s3 = s3 + 2*q.^(n^2); end +m(:) = 16*q .* (s2./s3).^4; % END FUNCTION inversenomeq() diff --git a/matlab/src/weierstrassP.m b/matlab/src/weierstrassP.m index 7caef4e..0f46886 100644 --- a/matlab/src/weierstrassP.m +++ b/matlab/src/weierstrassP.m @@ -67,7 +67,10 @@ [sn, ~, ~] = ellipj(w, m); P = e3 + (e1 - e3) ./ sn.^2; % Poles: sn -> 0 at z = 0 and at lattice points -P(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +P(sn == 0) = Inf; % ----------------------------------------------------------------------- @@ -79,7 +82,10 @@ % ellipj sees has_gpu()=true and dispatches to gpu_ellipj automatically [sn, ~, ~] = ellipj(w, m); P = e3_f + (e1_f - e3_f) ./ sn.^2; -P(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +P(sn == 0) = Inf; P = reshape(P, origSize); diff --git a/matlab/src/weierstrassPPrime.m b/matlab/src/weierstrassPPrime.m index f0ee7d1..575e7f7 100644 --- a/matlab/src/weierstrassPPrime.m +++ b/matlab/src/weierstrassPPrime.m @@ -67,7 +67,10 @@ scale = -2 .* (e1 - e3).^(3/2); dP = scale .* cn .* dn ./ sn.^3; % Poles: sn -> 0 at z = 0 and at lattice points -dP(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +dP(sn == 0) = Inf; % ----------------------------------------------------------------------- @@ -79,7 +82,10 @@ [sn, cn, dn] = ellipj(w, m); scale = -2 .* (e1_f - e3_f).^(3/2); dP = scale .* cn .* dn ./ sn.^3; -dP(abs(sn) < eps^(1/3)) = Inf; +% Pole only where sn vanishes exactly (z at a representable lattice +% point): the old abs(sn) < eps^(1/3) window (~6e-6!) replaced huge +% finite near-pole values -- P(1e-16) ~ 1e32 -- with Inf. +dP(sn == 0) = Inf; dP = reshape(dP, origSize); diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 57b3426..9cf3eb2 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -525,3 +525,79 @@ %! assert(abs(F1(k)-F2) < 2e-13 && abs(E1(k)-E2) < 2e-13, ... %! 'grouped vs scalar elliptic12 disagree at k=%d', k); %! end + +% --------------------------------------------------------------------- +% Q. Adversarial-review round (external Codex + mpmath 1.4.1, dps=40). +% Each block below is a counterexample that a prior version failed. +% --------------------------------------------------------------------- +%!test +%! clear +%! % Q1: negative amplitude while the COMPLETE integral has a pole (0*Inf). +%! assert(abs(elliptic3(-1, 0.5, 1) - (-1.7319915420235269928)) < 1e-13, 'Pi(-1|.5,1) wrong'); +%! assert(abs(elliptic3(-1, 1, 0.2) - (-1.3115010674599590753)) < 1e-13, 'Pi(-1|1,.2) wrong'); +%! assert(~isnan(elliptic3(-0.4, 1, 1)), 'Pi(-0.4|1,1) must not be NaN'); + +%!test +%! clear +%! % Q2: complex F/E small-m series region (A&S path lost sqrt(eps/m) digits). +%! assert(abs(elliptic12i(0.2i, 1e-20) - 0.2i) < 1e-15, 'F(0.2i|1e-20)'); +%! [F,E] = elliptic12i(pi/2 + 0.2i, 1e-14); +%! assert(abs(F - (1.5707963267949005462 + 0.20000000000000101344i)) < 1e-13, 'F(pi/2+0.2i|1e-14)'); +%! assert(abs(E - (1.5707963267948926922 + 0.19999999999999898656i)) < 1e-13, 'E(pi/2+0.2i|1e-14)'); +%! [F,E] = elliptic12i(pi/2 + 0.2i, 1e-6); +%! assert(abs(F - (1.5707967194941992113 + 0.20000010134411776594i)) < 5e-12, 'F(pi/2+0.2i|1e-6)'); +%! assert(abs(E - (1.5707959340957412894 + 0.19999989865593359446i)) < 5e-12, 'E(pi/2+0.2i|1e-6)'); +%! % both sides of the series threshold vs mpmath (dps=30) +%! Fa = elliptic12i(1.1+0.3i, 0.99e-4); +%! assert(abs(Fa - (1.1000153646885162+0.3000120622623928i)) < 1e-12, 'series side of crossover'); +%! Fb = elliptic12i(1.1+0.3i, 1.01e-4); +%! assert(abs(Fb - (1.1000156750952820+0.3000123059589823i)) < 5e-12, 'decomposition side of crossover'); + +%!test +%! clear +%! % Q3: Weierstrass near-origin values are huge but FINITE (DLMF 23.9.2); +%! % only the exact lattice point is a pole. +%! assert(abs(weierstrassP(1e-16,1,0,-1) - 1e32) < 1e19, 'P(1e-16) must be ~1e32'); +%! assert(abs(weierstrassPPrime(1e-16,1,0,-1) + 2e48) < 1e36, 'Pp(1e-16) must be ~-2e48'); +%! assert(abs(weierstrassZeta(1e-16,1,0,-1) - 1e16) < 1e3, 'zeta(1e-16) must be ~1e16'); +%! assert(isinf(weierstrassP(0,1,0,-1)), 'P(0) must be Inf'); + +%!test +%! clear +%! % Q4: inverse nome via DLMF 20.9.1 -- exact at every scale. +%! assert(abs(inversenomeq(1e-30) - 1.6e-29) < 1e-41, 'm(1e-30) must be 1.6e-29'); +%! assert(abs(inversenomeq(1e-12) - 1.5999999999872e-11) < 1e-24, 'm(1e-12) = 16q - 128q^2'); +%! for mv = [1e-8 0.3 0.85 0.999] +%! assert(abs(inversenomeq(nomeq(mv)) - mv) < 1e-12*max(mv,1e-3), 'roundtrip fails at m=%g', mv); +%! end + +%!test +%! clear +%! % Q5: Carlson scale invariance (DLMF 19.20): RF ~ lambda^(-1/2), RC same, +%! % RD/RJ ~ lambda^(-3/2). An absolute branch tolerance broke this. +%! x=1; y=2; z=3; p=4; +%! for lam = [1e-20 1e20] +%! assert(abs(carlsonRF(lam*x,lam*y,lam*z) - carlsonRF(x,y,z)/sqrt(lam)) < 1e-10*abs(carlsonRF(x,y,z)/sqrt(lam)), 'RF homogeneity at %g', lam); +%! assert(abs(carlsonRC(lam*x,lam*y) - carlsonRC(x,y)/sqrt(lam)) < 1e-10*abs(carlsonRC(x,y)/sqrt(lam)), 'RC homogeneity at %g', lam); +%! assert(abs(carlsonRD(lam*x,lam*y,lam*z) - carlsonRD(x,y,z)/lam^1.5) < 1e-10*abs(carlsonRD(x,y,z)/lam^1.5), 'RD homogeneity at %g', lam); +%! assert(abs(carlsonRJ(lam*x,lam*y,lam*z,lam*p) - carlsonRJ(x,y,z,p)/lam^1.5) < 1e-10*abs(carlsonRJ(x,y,z,p)/lam^1.5), 'RJ homogeneity at %g', lam); +%! end +%! assert(abs(carlsonRC(1e-20,2e-20) - 7853981633.9744830962) < 1e-4, 'RC(1e-20,2e-20)'); + +%!test +%! clear +%! % Q6: ellipticBD nondegenerate anchors (mpmath: B=(E-(1-m)K)/m, D=(K-E)/m). +%! R = [0.2 0.8066808960371526438 0.85294270257337535705 +%! 0.7 0.88437375336868858245 1.1909893819237805614 +%! 0.999 0.99832798626015502386 3.8428045742901420065]; +%! for i = 1:rows(R) +%! [B,D] = ellipticBD(R(i,1)); +%! assert(abs(B - R(i,2)) < 1e-14, 'B(%g)', R(i,1)); +%! assert(abs(D - R(i,3)) < 1e-13, 'D(%g)', R(i,1)); +%! end + +%!test +%! clear +%! % Q7: reversed arc intervals are signed, circles included. +%! assert(abs(arclength_ellipse(2,3,1,0.1) + arclength_ellipse(2,3,0.1,1)) < 1e-13, 'ellipse arc not odd under reversal'); +%! assert(abs(arclength_ellipse(2,2,1,0.1) - (-1.8)) < 1e-13, 'reversed circle arc must be -a*(t1-t0)'); diff --git a/python/elliptic/applications.py b/python/elliptic/applications.py index 73c62f2..f6c459a 100644 --- a/python/elliptic/applications.py +++ b/python/elliptic/applications.py @@ -76,6 +76,8 @@ def arclength_ellipse(a, b, theta0=0.0, theta1=None): arc_b = b_safe * (E1_b - E0_b) arc_a = a_safe * (E0_a - E1_a) - arc_circle = a_safe * xp.abs(theta1 - theta0) + # Signed, like the ellipse branches: reversed intervals negate + # (the old abs() here made circles disagree with every non-circle). + arc_circle = a_safe * (theta1 - theta0) arc = xp.where(b > a, arc_b, xp.where(a > b, arc_a, arc_circle)) return xp.where(valid, arc, xp.full_like(arc, np.nan)) diff --git a/python/elliptic/carlson.py b/python/elliptic/carlson.py index 608b1f1..e3712a0 100644 --- a/python/elliptic/carlson.py +++ b/python/elliptic/carlson.py @@ -31,26 +31,30 @@ def carlsonRC(x, y): def _rc_xp(xp, x, y): - EPS = 1e-300 diff = y - x - TOL = 1e-14 + # Branch selection must be RELATIVE: R_C is homogeneous of degree -1/2 + # (DLMF 19.20.3), and an absolute |y-x| < 1e-14 window sent every + # small-scale input down the degenerate x==y branch -- RC(1e-20, 2e-20) + # returned 1/sqrt(x), a 27% error. + scale = xp.maximum(xp.abs(x), xp.abs(y)) + tol = 1e-14 * scale # safe arguments for each branch (avoid div-by-zero when not selected) - x_safe = xp.where(x > EPS, x, xp.full_like(x, 1.0)) - yd_safe = xp.where(diff > EPS, diff, xp.full_like(diff, 1.0)) - yd_safe2 = xp.where(-diff > EPS, -diff, xp.full_like(diff, 1.0)) - y_safe = xp.where(y > EPS, y, xp.full_like(y, 1.0)) + x_safe = xp.where(x > 0, x, xp.full_like(x, 1.0)) + yd_safe = xp.where(diff > 0, diff, xp.full_like(diff, 1.0)) + yd_safe2 = xp.where(-diff > 0, -diff, xp.full_like(diff, 1.0)) + y_safe = xp.where(y > 0, y, xp.full_like(y, 1.0)) rc_gt = xp.arctan(xp.sqrt(xp.clip(diff / x_safe, 0.0, None))) / xp.sqrt(yd_safe) - lt_active = (diff < -TOL) & (y > EPS) & (x > EPS) + lt_active = (diff < -tol) & (y > 0) & (x > 0) lt_ratio = xp.where(lt_active, -diff / x_safe, xp.full_like(diff, 0.5)) rc_lt = xp.arctanh(xp.sqrt(xp.clip(lt_ratio, 0.0, None))) / xp.sqrt(yd_safe2) rc_eq = 1.0 / xp.sqrt(x_safe) rc_x0 = (math.pi * 0.5) / xp.sqrt(y_safe) - out = xp.where(diff > TOL, rc_gt, xp.where(diff < -TOL, rc_lt, rc_eq)) - out = xp.where(x < EPS, rc_x0, out) - out = xp.where(y < EPS, xp.full_like(out, math.inf), out) + out = xp.where(diff > tol, rc_gt, xp.where(diff < -tol, rc_lt, rc_eq)) + out = xp.where(x == 0, rc_x0, out) + out = xp.where(y == 0, xp.full_like(out, math.inf), out) return out diff --git a/python/elliptic/complex_elliptic.py b/python/elliptic/complex_elliptic.py index 8df077a..c92b01e 100644 --- a/python/elliptic/complex_elliptic.py +++ b/python/elliptic/complex_elliptic.py @@ -98,10 +98,25 @@ def elliptic12i(u, m): K_m, E_m, _ = _elliptic12_xp(xp, xp.full_like(m_f, np.pi * 0.5), m_f) Zi = Ei - (E_m / K_m) * Fi - # Exact elementary limit at m=0. - Fi = xp.where(m_f == 0.0, u_f, Fi) - Ei = xp.where(m_f == 0.0, u_f, Ei) - Zi = xp.where(m_f == 0.0, xp.zeros_like(Fi), Zi) + # Small-m Maclaurin series (through m^2). The A&S 17.4.11 decomposition + # loses ~sqrt(eps/m) digits as m -> 0 (0.2 absolute at m = 1e-16); the + # series is exact there and covers m = 0 itself: + # F = u + m(u/4 - sin2u/8) + m^2(9u/64 - 3sin2u/32 + 3sin4u/256) + O(m^3) + # E = u - m(u/4 - sin2u/8) - m^2(3u/64 - sin2u/32 + sin4u/256) + O(m^3) + # Valid while |m sin^2 u| is small: switch on m*max(1, e^(2|psi|)) < 1e-4, + # where the crossover error is ~2e-12 (measured against 40-digit mpmath). + m_eff = m_f * xp.maximum(xp.ones_like(m_f), xp.exp(2.0 * xp.abs(psi))) + small = m_eff < 1e-4 + s2 = xp.sin(2.0 * u_f) + s4 = xp.sin(4.0 * u_f) + F_ser = (u_f + m_f * (u_f / 4.0 - s2 / 8.0) + + m_f**2 * (9.0 * u_f / 64.0 - 3.0 * s2 / 32.0 + 3.0 * s4 / 256.0)) + E_ser = (u_f - m_f * (u_f / 4.0 - s2 / 8.0) + - m_f**2 * (3.0 * u_f / 64.0 - s2 / 32.0 + s4 / 256.0)) + Z_ser = E_ser - (E_m / K_m) * F_ser + Fi = xp.where(small, F_ser, Fi) + Ei = xp.where(small, E_ser, Ei) + Zi = xp.where(small, Z_ser, Zi) return Fi, Ei, Zi diff --git a/python/elliptic/ellipj.py b/python/elliptic/ellipj.py index 46724ef..873a8ae 100644 --- a/python/elliptic/ellipj.py +++ b/python/elliptic/ellipj.py @@ -1,5 +1,10 @@ """Jacobi elliptic functions sn, cn, dn, am — native on any array backend. +Accuracy limit for large arguments: the phase is reduced modulo 2K in +double precision, so the residual carries an absolute uncertainty ~|u|*eps. +Full precision holds for |u| up to ~1e12; by |u| ~ 1e16 the phase is lost +entirely (a bound shared by every double implementation, scipy included). + Algorithm: Arithmetic-Geometric Mean + descending Landen back-substitution (Abramowitz & Stegun §16.4). Fixed 25 AGM iterations, no per-element convergence tracking → fully data-parallel on CUDA / JAX. diff --git a/python/elliptic/elliptic12.py b/python/elliptic/elliptic12.py index d471445..4b40c50 100644 --- a/python/elliptic/elliptic12.py +++ b/python/elliptic/elliptic12.py @@ -4,7 +4,7 @@ E(phi, m) = integral_0^phi sqrt(1 - m sin^2 t) dt Z(phi, m) = E(phi, m) - E(m)/K(m) * F(phi, m) [Jacobi Zeta] -Algorithm: Carlson symmetric forms (DLMF 19.25.5-6): +Algorithm: Carlson symmetric forms (DLMF 19.25.5 for F, 19.25.9 for E): F = sin(phi) * RF(cos^2, 1-m sin^2, 1) E = F - m * sin^3(phi)/3 * RD(cos^2, 1-m sin^2, 1) Z = E - E(m)/K(m) * F where K=RF(0,1-m,1), E(m)=K - m/3*RD(0,1-m,1) diff --git a/python/elliptic/elliptic3.py b/python/elliptic/elliptic3.py index 41d0868..770bf12 100644 --- a/python/elliptic/elliptic3.py +++ b/python/elliptic/elliptic3.py @@ -2,7 +2,7 @@ Pi(u, m, n) = integral_0^u 1 / ((1 - n sin^2 t) sqrt(1 - m sin^2 t)) dt -Algorithm: Carlson symmetric forms (DLMF 19.25.1). Pure array-namespace +Algorithm: Carlson symmetric forms (DLMF 19.25.14). Pure array-namespace operations run natively on NumPy, PyTorch CUDA, and JAX. """ from __future__ import annotations diff --git a/python/elliptic/nome.py b/python/elliptic/nome.py index 4c1359b..5f19a15 100644 --- a/python/elliptic/nome.py +++ b/python/elliptic/nome.py @@ -43,8 +43,6 @@ def inversenomeq(q): m : array Parameter m = m(q) in [0, 1). """ - import warnings - xp = get_xp(q) q = xp.asarray(q, dtype=xp.float64) @@ -54,28 +52,24 @@ def inversenomeq(q): if xp is np: if np.any((q < 0.0) | (q >= 1.0)): raise ValueError("q must be in [0, 1)") - if np.any(q >= q_max): + if np.any(q > q_max): raise ValueError( - f"inversenomeq: q must be < {q_max:.15f} in double precision " + f"inversenomeq: q must be <= {q_max:.15f} in double precision " "(the essential singularity of m(q) at q=1 cannot be resolved in f64)" ) - if np.any(q > 0.76): - warnings.warn( - "inversenomeq: accuracy degrades for q > 0.76 (near m=1 singularity)", - RuntimeWarning, - stacklevel=2, - ) - valid = (q >= 0.0) & (q < q_max) + # Closed form, DLMF 20.9.1: m = (theta2(0,q) / theta3(0,q))^4. + # Exact at every scale -- the previous 64-step bisection in m had an + # absolute resolution floor of 2^-64, so m(1e-30) came back 2.7e-20 + # instead of 1.6e-29 (nine orders of magnitude off). + # theta2(0,q) = 2 q^(1/4) sum q^(n(n+1)), theta3(0,q) = 1 + 2 sum q^(n^2) + # The q^(1/4) factor is kept outside the ratio so tiny q cannot underflow. + valid = (q >= 0.0) & (q <= q_max) q_safe = xp.where(valid, q, xp.zeros_like(q)) - lo = xp.zeros_like(q_safe) - hi = xp.full_like(q_safe, m_hi_scalar) - for _ in range(64): - mid = 0.5 * (lo + hi) - q_mid = _q_from_m_xp(xp, mid) - lower = q_mid < q_safe - lo = xp.where(lower, mid, lo) - hi = xp.where(lower, hi, mid) - result = 0.5 * (lo + hi) - result = xp.where(q == 0.0, xp.zeros_like(result), result) + s2 = xp.ones_like(q_safe) # sum q^(n(n+1)), n >= 0 + s3 = xp.ones_like(q_safe) # theta3 = 1 + 2 sum q^(n^2) + for n in range(1, 31): + s2 = s2 + q_safe ** (n * (n + 1)) + s3 = s3 + 2.0 * q_safe ** (n * n) + result = 16.0 * q_safe * (s2 / s3) ** 4 return xp.where(valid, result, xp.full_like(result, math.nan)) diff --git a/python/elliptic/weierstrass.py b/python/elliptic/weierstrass.py index 8b58d42..6e5d32d 100644 --- a/python/elliptic/weierstrass.py +++ b/python/elliptic/weierstrass.py @@ -54,10 +54,10 @@ def _weierP_xp(xp, z, e1, e2, e3): w = z_reduced * scale sn, _, _, _ = _ellipj_xp(xp, w, m) sn2 = sn * sn - pole_tol = 8.0 * np.finfo(np.float64).eps * xp.maximum( - xp.ones_like(z), xp.abs(z) - ) - pole = xp.abs(z_reduced) <= pole_tol + # Pole only at the exact lattice point (DLMF 23.9.2: the Laurent + # expansion makes every nearby representable z a huge FINITE value -- + # P(1e-16) ~ 1e32, not Inf; a tolerance here destroys that data). + pole = z_reduced == 0.0 P = e3 + (e1 - e3) / xp.where(pole, xp.ones_like(sn2), sn2) return xp.where(pole, xp.full_like(P, math.inf), P) @@ -118,10 +118,10 @@ def _weierZ_xp(xp, z, e1, e2, e3): omega1, eta1, th1, th1p, _ = _lattice_theta_xp(xp, z, e1, e2, e3) period = xp.round(z / (2.0 * omega1)) z_reduced = z - 2.0 * period * omega1 - pole_tol = 8.0 * np.finfo(np.float64).eps * xp.maximum( - xp.ones_like(z), xp.abs(z) - ) - pole = xp.abs(z_reduced) <= pole_tol + # Pole only at the exact lattice point (DLMF 23.9.2: the Laurent + # expansion makes every nearby representable z a huge FINITE value -- + # P(1e-16) ~ 1e32, not Inf; a tolerance here destroys that data). + pole = z_reduced == 0.0 ratio = th1p / xp.where(pole, xp.ones_like(th1), th1) Z = eta1 * z / omega1 + math.pi / (2.0 * omega1) * ratio return xp.where(pole, xp.full_like(Z, math.inf), Z) @@ -191,9 +191,9 @@ def weierstrassPPrime(z, e1, e2, e3): w = z_reduced * root_scale sn, cn, dn, _ = _ellipj_xp(xp, w, m) scale = -2.0 * (e1 - e3) ** 1.5 - pole_tol = 8.0 * np.finfo(np.float64).eps * xp.maximum( - xp.ones_like(z), xp.abs(z) - ) - pole = xp.abs(z_reduced) <= pole_tol + # Pole only at the exact lattice point (DLMF 23.9.2: the Laurent + # expansion makes every nearby representable z a huge FINITE value -- + # P(1e-16) ~ 1e32, not Inf; a tolerance here destroys that data). + pole = z_reduced == 0.0 dP = scale * cn * dn / xp.where(pole, xp.ones_like(sn), sn * sn * sn) return xp.where(pole, xp.full_like(dP, math.inf), dP) diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 9bfe1a4..f6012bc 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -442,3 +442,84 @@ def test_theta_prime_at_theta_zeros(self): dpi = _s(elliptic.theta_prime(1, math.pi, m)[1]) assert not math.isnan(dpi) and abs(dpi + d1) < 1e-13 assert not math.isnan(_s(elliptic.theta_prime(2, math.pi / 2, m)[1])) + + +# ===================================================================== +# Q. Adversarial-review round (external Codex + mpmath 1.4.1, dps=40). +# Each test is a counterexample a prior version failed. +# ===================================================================== +class TestAdversarialRound: + def test_elliptic3_negative_amplitude_with_pole(self): + """0*Inf guard: negative phase never crossing the complete-integral pole.""" + assert abs(_s(elliptic.elliptic3(-1.0, 0.5, 1.0)) - (-1.7319915420235269928)) < 1e-13 + assert abs(_s(elliptic.elliptic3(-1.0, 1.0, 0.2)) - (-1.3115010674599590753)) < 1e-13 + assert not math.isnan(_s(elliptic.elliptic3(-0.4, 1.0, 1.0))) + + def test_complex_FE_small_m_series(self): + """A&S 17.4.11 path lost sqrt(eps/m) digits; the m^2 series is exact.""" + assert abs(_s(elliptic.elliptic12i(0.2j, 1e-20)[0]) - 0.2j) < 1e-15 + F, E, _ = elliptic.elliptic12i(math.pi / 2 + 0.2j, 1e-14) + assert abs(_s(F) - (1.5707963267949005462 + 0.20000000000000101344j)) < 1e-13 + assert abs(_s(E) - (1.5707963267948926922 + 0.19999999999999898656j)) < 1e-13 + F, E, _ = elliptic.elliptic12i(math.pi / 2 + 0.2j, 1e-6) + assert abs(_s(F) - (1.5707967194941992113 + 0.20000010134411776594j)) < 5e-12 + assert abs(_s(E) - (1.5707959340957412894 + 0.19999989865593359446j)) < 5e-12 + # both sides of the series threshold vs mpmath (dps=30) + Fa = _s(elliptic.elliptic12i(1.1 + 0.3j, 0.99e-4)[0]) + assert abs(Fa - (1.1000153646885162 + 0.3000120622623928j)) < 1e-12 + Fb = _s(elliptic.elliptic12i(1.1 + 0.3j, 1.01e-4)[0]) + assert abs(Fb - (1.1000156750952820 + 0.3000123059589823j)) < 5e-12 + + def test_weierstrass_near_origin_finite(self): + """DLMF 23.9.2: only the exact lattice point is a pole; z = 1e-16 is + a huge FINITE value (a tolerance here used to return Inf).""" + assert abs(_s(elliptic.weierstrassP(1e-16, 1.0, 0.0, -1.0)) - 1e32) < 1e19 + assert abs(_s(elliptic.weierstrassPPrime(1e-16, 1.0, 0.0, -1.0)) + 2e48) < 1e36 + assert abs(_s(elliptic.weierstrassZeta(1e-16, 1.0, 0.0, -1.0)) - 1e16) < 1e3 + assert math.isinf(_s(elliptic.weierstrassP(0.0, 1.0, 0.0, -1.0))) + + def test_inverse_nome_all_scales(self): + """DLMF 20.9.1 closed form: m = (theta2/theta3)^4, exact at every scale + (the 64-step bisection had a 2^-64 absolute floor: m(1e-30) came back + 2.7e-20).""" + assert abs(_s(elliptic.inversenomeq(1e-30)) - 1.6e-29) < 1e-41 + assert abs(_s(elliptic.inversenomeq(1e-12)) - 1.5999999999872e-11) < 1e-24 + for mv in (1e-8, 0.3, 0.85, 0.999): + assert abs(_s(elliptic.inversenomeq(np.asarray(_s(elliptic.nomeq(mv))))) - mv) \ + < 1e-12 * max(mv, 1e-3), f"roundtrip at m={mv}" + # the computed upper endpoint must be accepted, not rejected + q_max = _s(elliptic.nomeq(np.nextafter(1.0, 0.0))) + assert _s(elliptic.inversenomeq(q_max)) > 0.999 + + def test_carlson_scale_invariance(self): + """DLMF 19.20: RF, RC ~ lambda^-1/2; RD, RJ ~ lambda^-3/2. An absolute + branch tolerance in RC broke this at small scales (27% at 1e-20).""" + x, y, z, p = 1.0, 2.0, 3.0, 4.0 + for lam in (1e-20, 1e20): + for fn, args, power in ( + (elliptic.carlsonRF, (x, y, z), 0.5), + (elliptic.carlsonRC, (x, y), 0.5), + (elliptic.carlsonRD, (x, y, z), 1.5), + (elliptic.carlsonRJ, (x, y, z, p), 1.5), + ): + base = _s(fn(*args)) + scaled = _s(fn(*(lam * a for a in args))) + want = base / lam ** power + assert abs(scaled - want) < 1e-10 * abs(want), f"{fn.__name__} at {lam}" + assert abs(_s(elliptic.carlsonRC(1e-20, 2e-20)) - 7853981633.9744830962) < 1e-4 + + def test_ellipticBD_nondegenerate_anchors(self): + """mpmath: B = (E-(1-m)K)/m, D = (K-E)/m at dps=40.""" + rows = [(0.2, 0.8066808960371526438, 0.85294270257337535705), + (0.7, 0.88437375336868858245, 1.1909893819237805614), + (0.999, 0.99832798626015502386, 3.8428045742901420065)] + for m, B_ref, D_ref in rows: + B, D, _ = elliptic.ellipticBD(m) + assert abs(_s(B) - B_ref) < 1e-14, f"B({m})" + assert abs(_s(D) - D_ref) < 1e-13, f"D({m})" + + def test_reversed_arc_intervals_signed(self): + """Reversal negates the arc for ellipses AND circles alike.""" + assert abs(_s(elliptic.arclength_ellipse(2.0, 3.0, 1.0, 0.1)) + + _s(elliptic.arclength_ellipse(2.0, 3.0, 0.1, 1.0))) < 1e-13 + assert abs(_s(elliptic.arclength_ellipse(2.0, 2.0, 1.0, 0.1)) - (-1.8)) < 1e-13 From 77d77baf683159bffec770d0a0c45bbeabf189bb Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 14:47:39 +0400 Subject: [PATCH 06/23] fix: second adversarial round -- nine cancellation/convergence defects (both ports) An independent adversarial pass fuzzed every public function of both ports against mpmath (dps=40) over parameter endpoints (m -> 0, m -> 1, m = 1), extreme scales, near-pole/near-lattice arguments, exact period multiples +/- ulps and complex arguments across the branch point. Each candidate was classified by evaluating scipy at the same DOUBLE inputs: wherever scipy reaches machine precision the loss is ours. Nine implementation defects, all fixed and pinned (testEdgeCases.m block R, test_edge_cases.py::TestAdversarialRound2): - Delta^2 = 1 - m sin^2 and 1 - n sin^2 formed by subtraction (python elliptic12; elliptic3 both ports, Carlson path and GL integrand): F(pi/2-1e-9 | 1-eps/2) off by 4e-3, Pi(pi/2-1e-6 | m, n=1) off by 3e-5. Now (1-m) + m cos^2 and (1-n) + n cos^2. - F(phi|1) = log(tan(pi/4+phi/2)) (both): F(0|1) = -1.1e-16, wrong sign at 1e-16. Now atanh(sin phi). - (ratio-1)/m in the A&S 17.4.11 decomposition (both): Im F(pi/2+1e-9 i) came back 0 and small m lost sqrt(eps/m) digits. Derived the cancellation-free form: with X1 = cot^2(phi) + Y, Y solves Y^2 + B'Y - C' = 0 and tan^2(mu) = 2 sinh^2 csc^2 / (B' + sqrt(B'^2+4C')) -- m cancels analytically, m = 0 is exact. Verified against the original at 120 digits (the 50-digit reference itself lost digits at cot^2 ~ 1e-33, a cautionary note in its own right). The m^2 series stays for m_eff < 1e-4 where it is still ~30x more accurate than the closed form. - Landen back-substitution asin(c sin/a) near +/-1 as m -> 1 (both, CPU and GPU): cn(9.4 | 1-eps/2) off 5e-10. Now atan2(c sin, sqrt(a^2 cos^2 + b^2 sin^2)) using a^2 - c^2 = b^2 for the AGM triple. - R_C for y << x used arctanh(sqrt(1-y/x)) (both): RC(3,1e-10) lost 8 digits and contaminated R_J and J. Now log1p(((x-y)/(sqrt x + sqrt y) + sqrt(x-y))/sqrt y)/sqrt(x-y) -- the intermediate plain-log form still lost 9 digits at RC(1+1e-13, 1), which is what tripped the RJ(x,y,z,z) == RD identity in testCarlson before the log1p. - R_J duplication cap 30 (both): the ratio exponent (base 4) shrinks by one per step, so ratios beyond ~1e16 never converged -- RJ(1e-20,2e-20,3e-20, .5) was 11% off. python: 60 fixed steps (documented limit ~3e32, JAX- traceable); MATLAB: adaptive break with cap 200 (also RF 20 -> 200, RD 30 -> 200; the break decides). - Two zero Carlson arguments (both): RF(0,0,1) returned 2e6; now Inf (DLMF 19.16). - inverselliptic2 (both): folding a tiny negative z through 2E1 - (z+2E1) lost its digits and the tol-gated Newton froze at an absolute 1e-12 (rel 1e-7 at z = -1e-9 E1). Oddness first; unconditional (python) / relative-stop (MATLAB) Newton. - 1 - m by subtraction (both ports' Weierstrass, MATLAB nomeq via ellipke(1-m), MATLAB weierstrassP/PPrime reducing inside ellipj): q(1e-16) 11% off, q(1e-17) = 0, P(2 omega1 + 1e-9) 40% off on a near-m=1 lattice. 1-m = (e1-e2)/(e1-e3); K' = R_F(0, m, 1) from the exact argument; reduce z by 2 omega1 before ellipj (sn^2 and cn dn/sn^3 are invariant under w -> w + 2K, no sign bookkeeping). Not defects, recorded as deliberate limits in the spec: F(phi|m) for m -> 1 at phi -> pi/2 and the Weierstrass functions within ~1e-9 omega1 of a lattice point now sit exactly at the input's conditioning floor (2 eps |z| / |z - 2k omega1|); the m -> 1 complex values follow the A&S 17.4.11 sheet. Reference-construction lesson written into both test files: anchors are evaluated at the exact double the library receives (mpf(float(x))) -- the decimal input a test author types and its double rounding differ, near singularities, at the 1e-9 level (F(pi/2-1e-9 | 1-eps/2): 19.6599302656 vs 19.6599302792). Octave 16/16 files (246 blocks); pytest 465 + 1 optional-JAX skip. Co-Authored-By: Claude Opus 5 --- docs/specs/post-0d09740-regression-audit.md | 36 +++++++++++ matlab/src/carlsonRC.m | 8 ++- matlab/src/carlsonRD.m | 3 +- matlab/src/carlsonRF.m | 7 ++- matlab/src/carlsonRJ.m | 14 ++++- matlab/src/ellipj.m | 9 ++- matlab/src/elliptic12.m | 4 +- matlab/src/elliptic12i.m | 41 +++++++------ matlab/src/elliptic3.m | 14 +++-- matlab/src/inverselliptic2.m | 7 ++- matlab/src/nomeq.m | 5 +- matlab/src/weierstrassP.m | 16 ++++- matlab/src/weierstrassPPrime.m | 16 ++++- matlab/src/weierstrassSigma.m | 8 ++- matlab/src/weierstrassZeta.m | 8 ++- matlab/tests/testEdgeCases.m | 67 +++++++++++++++++++++ python/elliptic/carlson.py | 33 ++++++++-- python/elliptic/complex_elliptic.py | 47 ++++++++------- python/elliptic/ellipj.py | 10 ++- python/elliptic/elliptic12.py | 8 ++- python/elliptic/elliptic3.py | 6 +- python/elliptic/inverse.py | 13 +++- python/elliptic/nome.py | 8 +-- python/elliptic/weierstrass.py | 10 ++- python/tests/test_edge_cases.py | 53 ++++++++++++++++ 25 files changed, 360 insertions(+), 91 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index defc867..cecaaca 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -86,6 +86,34 @@ ellipses alike; DLMF citations corrected (19.25.14 for incomplete third kind, 19.25.5/19.25.9 for F/E); nondegenerate `ellipticBD` anchors at `m = 0.2, 0.7, 0.999`. +## Adversarial review round 2 (self-review, 2026-08-16) + +A second, independent adversarial pass fuzzed every public function of both +ports against mpmath at 40 digits over parameter endpoints (`m -> 0`, +`m -> 1`, `m = 1`), extreme argument scales, near-pole and near-lattice +arguments, exact period multiples +/- ulps, and complex arguments across the +branch point. Each candidate was classified by evaluating scipy at the same +double inputs: where scipy reaches machine precision the loss is ours. +Nine implementation defects resulted, all fixed and pinned +(`testEdgeCases.m` block R, `test_edge_cases.py::TestAdversarialRound2`): + +| Defect | Failure | Fix | +|---|---|---| +| `1 - m sin^2` / `1 - n sin^2` formed by subtraction (`elliptic12` py, `elliptic3` both) | `F(pi/2-1e-9 \| 1-eps/2)` off 4e-3; `Pi(pi/2-1e-6 \| m, n=1)` off 3e-5 | form `(1-m) + m cos^2`, `(1-n) + n cos^2` | +| `F(phi \| 1) = log(tan(pi/4+phi/2))` (both) | `F(0\|1) = -1.1e-16`, wrong sign at `1e-16` | `atanh(sin phi)` | +| `(ratio-1)/m` in the A&S 17.4.11 decomposition (both) | `Im F(pi/2 + 1e-9 i)` returned 0; `sqrt(eps/m)` loss for small m | closed cancellation-free `tan^2(mu) = 2 sinh^2 csc^2 / (B' + sqrt(B'^2 + 4C'))`, m cancels analytically | +| Landen back-substitution `asin(c sin/a)` near +/-1 (both) | `cn(9.4 \| 1-eps/2)` off 5e-10 | `atan2(c sin, sqrt(a^2 cos^2 + b^2 sin^2))` via `a^2 - c^2 = b^2` | +| `R_C` arctanh branch for `y << x` and `log(1+tiny)` (both) | `RC(3,1e-10)` off 3e-9; `RC(1+1e-13,1)` off 3e-10 -- contaminated `R_J` and `J` | `log1p(((x-y)/(sqrt x + sqrt y) + sqrt(x-y))/sqrt y)/sqrt(x-y)` | +| `R_J` duplication capped at 30 (both) | `RJ(1e-20,2e-20,3e-20,.5)` 11% off | 60 fixed (py, ratio limit ~3e32) / adaptive to 200 (MATLAB) | +| two zero Carlson arguments (both) | `RF(0,0,1) = 2e6` | `Inf` (DLMF 19.16) | +| inverse `E`: fold of tiny negative z; tol-gated Newton (both) | rel 1e-7 at `z = -1e-9 E1` | oddness first; unconditional / relative-stop Newton | +| `1 - m` from lattice roots by subtraction; MATLAB `nomeq` via `ellipke(1-m)`; MATLAB `weierstrassP` reducing inside `ellipj` | `q(1e-16)` 11% off, `q(1e-17) = 0`; `P(2 omega1 + 1e-9)` off 40% on near-m=1 lattices | `1-m = (e1-e2)/(e1-e3)`; `K' = R_F(0, m, 1)`; reduce by `2 omega1` before `ellipj` | + +Reference-construction lesson recorded for future rounds: anchors must be +evaluated at the *exact double* the library receives (`mpf(float(x))`), not +at the decimal the test author typed -- near singularities the two differ at +the 1e-9 level (`F(pi/2 - 1e-9 \| 1-eps/2)`: 19.6599302656 vs 19.6599302792). + ## Deliberate limits and residual risk - CUDA/OpenCL hardware was not available during this audit. GPU source paths @@ -99,6 +127,14 @@ ellipses alike; DLMF citations corrected (19.25.14 for incomplete third kind, complex input explicitly instead of silently discarding data. - `elliptic3` deliberately rejects real paths that cross a third-kind pole; Cauchy principal-value continuation is not implemented. +- Near-pole and near-lattice conditioning: `F(phi|m)` with `m -> 1` at + `phi -> pi/2` and the Weierstrass functions within `~1e-9 omega1` of a + lattice point are evaluated to the input's conditioning floor + (`2 eps |z| / |z - 2k omega1|`, i.e. ~1e-6 relative at `1e-9 omega1`). + This is a property of the double input, not of the algorithm; the same + inputs move the true value by that much. +- `R_J` in the python port uses 60 fixed duplications (JAX-traceable), valid + for max/min argument ratios up to ~3e32; MATLAB iterates adaptively. - Jacobi phase reduction is double precision: the residual phase carries an absolute uncertainty ~`|u|*eps`, so `ellipj` holds full precision to `|u| ~ 1e12`, degrades linearly beyond, and has lost the phase entirely by diff --git a/matlab/src/carlsonRC.m b/matlab/src/carlsonRC.m index 81ed9ca..f341f3d 100644 --- a/matlab/src/carlsonRC.m +++ b/matlab/src/carlsonRC.m @@ -73,8 +73,12 @@ end if any(lt) - d = sqrt((x(lt) - y(lt)) ./ x(lt)); % DLMF 19.2.18: (x-y)/x, not (x-y)/y - RC(lt) = atanh(d) ./ sqrt(x(lt) - y(lt)); + % log((sqrt(x)+sqrt(x-y))/sqrt(y))/sqrt(x-y) == atanh(sqrt(1-y/x))/sqrt(x-y) + % without the 1 - sqrt(1-eps) cancellation (RC(3,1e-10) lost 8 digits). + % ... as log1p: log((sx+sxy)/sy) = log1p(((x-y)/(sx+sy) + sxy)/sy); the + % plain log lost 9 digits again for tiny x - y (RC(1+1e-13, 1)). + xl = x(lt); yl = y(lt); sx = sqrt(xl); sy = sqrt(yl); sxy = sqrt(xl - yl); + RC(lt) = log1p(((xl - yl)./(sx + sy) + sxy) ./ sy) ./ sxy; end diff --git a/matlab/src/carlsonRD.m b/matlab/src/carlsonRD.m index 7ed78e4..b9ae512 100644 --- a/matlab/src/carlsonRD.m +++ b/matlab/src/carlsonRD.m @@ -33,6 +33,7 @@ x = x(:).'; y = y(:).'; z = z(:).'; RD = carlsonRD_core(x, y, z); +RD((x == 0) & (y == 0)) = Inf; % diverges (DLMF 19.16.5) RD = reshape(RD, origSize); @@ -44,7 +45,7 @@ S = zeros(size(x)); fac = ones(size(x)); % 4^{-n} -for iter = 1:30 +for iter = 1:200 % adaptive break decides; cap guards pathological input lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); sz = sqrt(z); S = S + fac ./ (sz .* (z + lam)); diff --git a/matlab/src/carlsonRF.m b/matlab/src/carlsonRF.m index 2e2f8dc..8d85044 100644 --- a/matlab/src/carlsonRF.m +++ b/matlab/src/carlsonRF.m @@ -37,6 +37,9 @@ x = x(:).'; y = y(:).'; z = z(:).'; RF = carlsonRF_core(x, y, z); +% Two zero arguments: the integral diverges (DLMF 19.16.1); the duplication +% loop just stalls and returned a finite 2e6 for R_F(0, 0, 1). +RF((x == 0) + (y == 0) + (z == 0) >= 2) = Inf; RF = reshape(RF, origSize); @@ -49,7 +52,9 @@ x0 = x; y0 = y; z0 = z; -for iter = 1:20 +% The adaptive break decides; the cap only guards pathological input +% (20 was too few for R_F(0, 1e-16, 1) and every K(m) at tiny m). +for iter = 1:200 lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); x = (x + lam) ./ 4; y = (y + lam) ./ 4; diff --git a/matlab/src/carlsonRJ.m b/matlab/src/carlsonRJ.m index 1fbf139..068e3bd 100644 --- a/matlab/src/carlsonRJ.m +++ b/matlab/src/carlsonRJ.m @@ -35,6 +35,7 @@ x = x(:).'; y = y(:).'; z = z(:).'; p = p(:).'; RJ = carlsonRJ_core(x, y, z, p); +RJ((x == 0) + (y == 0) + (z == 0) >= 2) = Inf; % diverges (DLMF 19.16.2) RJ = reshape(RJ, origSize); @@ -48,7 +49,10 @@ p0 = p; % save original p for δ computation -for iter = 1:30 +% Each duplication divides the argument-ratio exponent (base 4) by one; the +% adaptive break below decides, the cap only guards pathological input. +% A cap of 30 covered ratios to ~1e16 only (RJ(1e-20,2e-20,3e-20,.5) 11% off). +for iter = 1:200 lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); % R_C argument for sum term (DLMF 19.36.3) alpha = (p .* (sqrt(x) + sqrt(y) + sqrt(z)) + sqrt(x.*y.*z)).^2; @@ -105,8 +109,12 @@ RC(gt) = atan(d) ./ sqrt(y(gt) - x(gt)); end if any(lt) - d = sqrt((x(lt) - y(lt)) ./ x(lt)); % DLMF 19.2.18: (x-y)/x, not (x-y)/y - RC(lt) = atanh(d) ./ sqrt(x(lt) - y(lt)); + % log((sqrt(x)+sqrt(x-y))/sqrt(y))/sqrt(x-y) == atanh(sqrt(1-y/x))/sqrt(x-y) + % without the 1 - sqrt(1-eps) cancellation (RC(3,1e-10) lost 8 digits). + % ... as log1p: log((sx+sxy)/sy) = log1p(((x-y)/(sx+sy) + sxy)/sy); the + % plain log lost 9 digits again for tiny x - y (RC(1+1e-13, 1)). + xl = x(lt); yl = y(lt); sx = sqrt(xl); sy = sqrt(yl); sxy = sqrt(xl - yl); + RC(lt) = log1p(((xl - yl)./(sx + sy) + sxy) ./ sy) ./ sxy; end diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index 26e0b46..5e03eea 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -127,7 +127,11 @@ i = i - 1; mask = n(K) >= i; if any(mask) - phin(mask) = 0.5*(asin(c(i+1,K(mask)).*sin(phin(mask))./a(i+1,K(mask))) + phin(mask)); + % asin(c sin/a) = atan2(c sin, sqrt(a^2 cos^2 + b^2 sin^2)) using + % a^2 - c^2 = b^2: no asin near +/-1, which lost ~7 digits as m -> 1. + sp = sin(phin(mask)); cp = cos(phin(mask)); + phin(mask) = 0.5*(atan2(c(i+1,K(mask)).*sp, ... + sqrt((a(i+1,K(mask)).*cp).^2 + (b(i+1,K(mask)).*sp).^2)) + phin(mask)); end end quasi_sign = 1 - 2 .* mod(period, 2); @@ -239,7 +243,8 @@ phin = gpuArray((2 .^ n) .* a_final .* u_reduced); for jj = ii-1:-1:1 active = gpuArray(double(n >= jj)); - phin_new = 0.5*(asin(c(:,jj+1).*sin(phin)./a(:,jj+1)) + phin); + sp = sin(phin); cp = cos(phin); + phin_new = 0.5*(atan2(c(:,jj+1).*sp, sqrt((a(:,jj+1).*cp).^2 + (b(:,jj+1).*sp).^2)) + phin); phin = phin + active .* (phin_new - phin); end diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index fb7cc7a..c710153 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -162,7 +162,7 @@ N = floor( (um1+pi/2)/pi ); M = find(um1 < pi/2); - F(m1(M)) = log(tan(pi/4 + u(m1(M))/2)); + F(m1(M)) = atanh(sin(u(m1(M)))); % exact at 0 and odd; log(tan(.)) gave -1.1e-16 F(m1(um1 >= pi/2)) = Inf.*sign(u(m1(um1 >= pi/2))); E(m1) = ((-1).^N .* sin(um1) + 2*N).*sign(u(m1)); @@ -294,7 +294,7 @@ if ~isempty(m1) Nf = floor((um1 + pi/2) / pi); M = find(um1 < pi/2); - F(m1(M)) = log(tan(pi/4 + u(m1(M))/2)); + F(m1(M)) = atanh(sin(u(m1(M)))); F(m1(um1 >= pi/2)) = Inf .* sign(u(m1(um1 >= pi/2))); E(m1) = ((-1).^Nf .* sin(um1) + 2*Nf) .* sign(u(m1)); Z(m1) = (-1).^Nf .* sin(u(m1)); diff --git a/matlab/src/elliptic12i.m b/matlab/src/elliptic12i.m index ca8880e..7f6a250 100644 --- a/matlab/src/elliptic12i.m +++ b/matlab/src/elliptic12i.m @@ -89,26 +89,31 @@ b = -(cot2 + m.*sinh(psi).^2.*csc(phi).^2-1+m); c = -(1-m).*cot2; -% The constant term -(1-m)*cot(phi)^2 is <= 0, so the two roots always -% straddle zero and the admissible one is X1 = -b/2 + sqrt(b^2/4-c). Near -% phi = pi/2 that form cancels catastrophically (both terms are ~ |b|/2 -% while X1 -> 0), so for b > 0 use the algebraically equal -% X1 = -c/(b/2+sqrt(...)), which keeps full precision. -sq = sqrt(b.^2/4-c); -X1 = -b/2 + sq; -ratio = X1 ./ cot2; % == tan(phi)^2 * cot(lambda)^2 -Ib = find(b > 0); -X1(Ib) = -c(Ib)./(b(Ib)/2 + sq(Ib)); -ratio(Ib) = (1-m(Ib))./(b(Ib)/2 + sq(Ib)); +% Positive root X1 = cot(lambda)^2 of X^2 + bX + c = 0 and tan(mu)^2, both +% without cancellation. Writing X1 = cot(phi)^2 + Y, Y solves +% Y^2 + B'Y - C' = 0, B' = cot2 + (1-m) - m sinh^2 csc^2, +% C' = cot2 * m sinh^2 csc^2 >= 0, +% and A&S 17.4.11's tan(mu)^2 = (tan(phi)^2 cot(lambda)^2 - 1)/m = Y/(m cot2) +% collapses to +% tan(mu)^2 = 2 sinh^2 csc^2 / (B' + sqrt(B'^2 + 4C')) (B' >= 0) +% = (|B'| + sqrt(B'^2 + 4C')) / (2 m cot2) (B' < 0) +% -- m cancels analytically in the first form, so m -> 0 (and m = 0 exactly) +% is handled to full precision. The old (ratio-1)/m lost sqrt(eps/m) digits +% and returned Im F = 0 for psi = 1e-9. +s2c2 = sinh(psi).^2.*csc(phi).^2; +Bp = cot2 + (1-m) - m.*s2c2; +Cp = cot2.*m.*s2c2; +root = sqrt(Bp.^2 + 4*Cp); +pos = Bp >= 0; +Y = zeros(size(Bp)); tan2mu = Y; +Y(pos) = 2*Cp(pos)./(Bp(pos) + root(pos)); +Y(~pos) = 0.5*(-Bp(~pos) + root(~pos)); +tan2mu(pos) = 2*s2c2(pos)./(Bp(pos) + root(pos)); +tan2mu(~pos) = 0.5*(-Bp(~pos) + root(~pos))./(m(~pos).*cot2(~pos)); +X1 = cot2 + Y; lambda = acot( sqrt(X1) ); -% tan(mu)^2 = (tan(phi)^2*cot(lambda)^2 - 1)/m, evaluated from RATIO rather -% than from LAMBDA: at phi = pi/2 the root X1 underflows, LAMBDA rounds to -% exactly pi/2 and cot(LAMBDA) loses every digit of it -- that is what used -% to drop the whole imaginary part of the result there. -m_calc = m; -m_calc(m_calc == 0) = 1; -mu = atan( sqrt( max((ratio - 1)./m_calc, 0) ) ); +mu = atan( sqrt(tan2mu) ); % change of variables taking into account periodicity ceil to the right lambda = (-1).^floor(phi/pi*2).*lambda + pi*ceil(phi/pi-0.5+eps); diff --git a/matlab/src/elliptic3.m b/matlab/src/elliptic3.m index a4cf6ec..e71bea7 100644 --- a/matlab/src/elliptic3.m +++ b/matlab/src/elliptic3.m @@ -104,8 +104,10 @@ s = sin(u); s2 = s.^2; co = cos(u); -d2 = 1 - m.*s2; -p = 1 - c.*s2; +% (1-m) + m cos^2 and (1-c) + c cos^2: no cancellation near the endpoint +% poles (Pi(pi/2-1e-6 | m, c=1) was off by 3e-5). +d2 = (1 - m) + m.*co.^2; +p = (1 - c) + c.*co.^2; danger = (d2 < 0.25) | (p < 0.25); P = zeros(size(u)); @@ -158,8 +160,8 @@ function g = g(u,m,c) % g = 1/((1 - c*sin(u)^2)*sqrt(1 - m*sin(u)^2)); - sn2 = sin(u).^2; - g = 1./((1 - c.*sn2).*sqrt(1 - m.*sn2)); + cs2 = cos(u).^2; + g = 1./(((1 - c) + c.*cs2).*sqrt((1 - m) + m.*cs2)); return; @@ -229,5 +231,5 @@ function gv = g_gpu(u, m, c) - sn2 = sin(u).^2; - gv = 1 ./ ((1 - c.*sn2) .* sqrt(1 - m.*sn2)); + cs2 = cos(u).^2; + gv = 1 ./ (((1 - c) + c.*cs2) .* sqrt((1 - m) + m.*cs2)); diff --git a/matlab/src/inverselliptic2.m b/matlab/src/inverselliptic2.m index 517ac79..3fc3bf3 100644 --- a/matlab/src/inverselliptic2.m +++ b/matlab/src/inverselliptic2.m @@ -97,6 +97,9 @@ % phi in [0, pi/2]. Reduce first, using % E(phi + k*pi | m) = E(phi | m) + 2k*E(m) (period) % E(pi - phi | m) = 2*E(m) - E(phi | m) (reflection) +% Oddness first: folding a tiny negative z through 2*E1 - (z + 2*E1) lost +% all its digits (rel 1e-7 at z = -1e-9*E1). +signZ = sign(z); z = abs(z); twoE1 = 2*E1; k = floor(z./twoE1); z_red = z - k.*twoE1; % in [0, 2*E1) @@ -117,13 +120,13 @@ for iter=1:100 [~, Ecur] = elliptic12(invE(:),m,tol); res = Ecur - z_red; - if max(abs(res)) < 1e-14, break; end + if all(abs(res) <= 4*eps*max(abs(z_red), realmin)), break; end % relative invE(:) = invE(:) - res./max(sqrt( 1-m.*sin(invE(:)).^2 ), 1e-15); invE(:) = min(max(invE(:), 0), pi/2); end % undo the reflection, then the period strips invE(over) = pi - invE(over); -invE(:) = invE(:) + k*pi; +invE(:) = signZ .* (invE(:) + k*pi); return; diff --git a/matlab/src/nomeq.m b/matlab/src/nomeq.m index 6b3a69a..5f6ef85 100644 --- a/matlab/src/nomeq.m +++ b/matlab/src/nomeq.m @@ -33,6 +33,9 @@ error('Input arguments must be real.') end -NomeQ = exp(-pi*ellipke(1-m,tol)./ellipke(m,tol)); +% K'(m) = K(1-m) = R_F(0, m, 1) evaluated from the EXACT argument m: +% ellipke(1-m) rounds 1-m first and lost ~eps/m relative digits +% (q(1e-16) was 11% off, q(1e-17) came back 0). +NomeQ = exp(-pi*carlsonRF(zeros(size(m)), m, ones(size(m)))./ellipke(m,tol)); % END FUNCTION nomeq() \ No newline at end of file diff --git a/matlab/src/weierstrassP.m b/matlab/src/weierstrassP.m index 0f46886..6c892e0 100644 --- a/matlab/src/weierstrassP.m +++ b/matlab/src/weierstrassP.m @@ -63,7 +63,16 @@ function P = weierP_core(z, e1, e2, e3) %WEIEP_CORE Vectorised serial evaluation (row-vector inputs). m = (e2 - e3) ./ (e1 - e3); -w = z .* sqrt(e1 - e3); +mp = (e1 - e2) ./ (e1 - e3); +% Reduce z by the real period 2*omega1 BEFORE ellipj, with omega1 from +% R_F(0, 1-m, 1) and 1-m = (e1-e2)/(e1-e3) formed without cancellation. +% ellipj's own reduction uses K from an AGM seeded with sqrt(1-m) (1-m +% rounded), which loses ~eps/(1-m) relative digits near m -> 1 lattices +% and put P(2*omega1 + 1e-9) off by 40%. sn^2 and cn*dn/sn^3 are +% invariant under w -> w + 2K, so no sign bookkeeping is needed. +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1 - e3); +zr = z - 2 .* round(z ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1 - e3); [sn, ~, ~] = ellipj(w, m); P = e3 + (e1 - e3) ./ sn.^2; % Poles: sn -> 0 at z = 0 and at lattice points @@ -78,7 +87,10 @@ %GPU_WEIERSTRASSP GPU path: ellipj handles its own GPU dispatch internally. z_f = z(:).'; e1_f = e1(:).'; e2_f = e2(:).'; e3_f = e3(:).'; m = (e2_f - e3_f) ./ (e1_f - e3_f); -w = z_f .* sqrt(e1_f - e3_f); +mp = (e1_f - e2_f) ./ (e1_f - e3_f); +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1_f - e3_f); +zr = z_f - 2 .* round(z_f ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1_f - e3_f); % ellipj sees has_gpu()=true and dispatches to gpu_ellipj automatically [sn, ~, ~] = ellipj(w, m); P = e3_f + (e1_f - e3_f) ./ sn.^2; diff --git a/matlab/src/weierstrassPPrime.m b/matlab/src/weierstrassPPrime.m index 575e7f7..55490f6 100644 --- a/matlab/src/weierstrassPPrime.m +++ b/matlab/src/weierstrassPPrime.m @@ -62,7 +62,16 @@ function dP = weierPP_core(z, e1, e2, e3) %WEIEPPCORE Vectorised serial evaluation (row-vector inputs). m = (e2 - e3) ./ (e1 - e3); -w = z .* sqrt(e1 - e3); +mp = (e1 - e2) ./ (e1 - e3); +% Reduce z by the real period 2*omega1 BEFORE ellipj, with omega1 from +% R_F(0, 1-m, 1) and 1-m = (e1-e2)/(e1-e3) formed without cancellation. +% ellipj's own reduction uses K from an AGM seeded with sqrt(1-m) (1-m +% rounded), which loses ~eps/(1-m) relative digits near m -> 1 lattices +% and put P(2*omega1 + 1e-9) off by 40%. sn^2 and cn*dn/sn^3 are +% invariant under w -> w + 2K, so no sign bookkeeping is needed. +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1 - e3); +zr = z - 2 .* round(z ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1 - e3); [sn, cn, dn] = ellipj(w, m); scale = -2 .* (e1 - e3).^(3/2); dP = scale .* cn .* dn ./ sn.^3; @@ -78,7 +87,10 @@ %GPU_WEIERSTRASSPPRIME GPU path via ellipj's internal GPU dispatch. z_f = z(:).'; e1_f = e1(:).'; e2_f = e2(:).'; e3_f = e3(:).'; m = (e2_f - e3_f) ./ (e1_f - e3_f); -w = z_f .* sqrt(e1_f - e3_f); +mp = (e1_f - e2_f) ./ (e1_f - e3_f); +omega1 = carlsonRF(zeros(size(m)), mp, ones(size(m))) ./ sqrt(e1_f - e3_f); +zr = z_f - 2 .* round(z_f ./ (2 .* omega1)) .* omega1; +w = zr .* sqrt(e1_f - e3_f); [sn, cn, dn] = ellipj(w, m); scale = -2 .* (e1_f - e3_f).^(3/2); dP = scale .* cn .* dn ./ sn.^3; diff --git a/matlab/src/weierstrassSigma.m b/matlab/src/weierstrassSigma.m index 6ddf354..38df950 100644 --- a/matlab/src/weierstrassSigma.m +++ b/matlab/src/weierstrassSigma.m @@ -76,9 +76,11 @@ % catastrophically wrong (magnitude AND sign) for |z| > 2*omega1; the % theta form is entire and carries every lattice zero and sign change. -m_param = (e2 - e3) ./ (e1 - e3); -KK = ellipke(m_param); -KKp = ellipke(1 - m_param); +m_param = (e2 - e3) ./ (e1 - e3); +mp_param = (e1 - e2) ./ (e1 - e3); % 1-m without cancellation +one = ones(size(m_param)); zed = zeros(size(m_param)); +KK = carlsonRF(zed, mp_param, one); +KKp = carlsonRF(zed, m_param, one); omega1 = KK ./ sqrt(e1 - e3); q = exp(-pi .* KKp ./ KK); v = pi .* z ./ (2 .* omega1); diff --git a/matlab/src/weierstrassZeta.m b/matlab/src/weierstrassZeta.m index c5b598c..6f15697 100644 --- a/matlab/src/weierstrassZeta.m +++ b/matlab/src/weierstrassZeta.m @@ -74,9 +74,11 @@ % exactly (theta1'(v+pi)/theta1(v+pi) is pi-periodic, the linear term does % the rest), so no period reduction is needed either. -m_param = (e2 - e3) ./ (e1 - e3); -KK = ellipke(m_param); -KKp = ellipke(1 - m_param); +m_param = (e2 - e3) ./ (e1 - e3); +mp_param = (e1 - e2) ./ (e1 - e3); % 1-m without cancellation +one = ones(size(m_param)); zed = zeros(size(m_param)); +KK = carlsonRF(zed, mp_param, one); +KKp = carlsonRF(zed, m_param, one); omega1 = KK ./ sqrt(e1 - e3); q = exp(-pi .* KKp ./ KK); v = pi .* z ./ (2 .* omega1); diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 9cf3eb2..b69d33f 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -601,3 +601,70 @@ %! % Q7: reversed arc intervals are signed, circles included. %! assert(abs(arclength_ellipse(2,3,1,0.1) + arclength_ellipse(2,3,0.1,1)) < 1e-13, 'ellipse arc not odd under reversal'); %! assert(abs(arclength_ellipse(2,2,1,0.1) - (-1.8)) < 1e-13, 'reversed circle arc must be -a*(t1-t0)'); + +% --------------------------------------------------------------------- +% R. Second adversarial round (fuzz vs mpmath dps=40 over parameter +% endpoints, extreme scales, poles and period multiples). Every +% reference was evaluated at the EXACT DOUBLE the library receives -- +% near singularities the decimal input differs from its double by +% enough to move the answer at 1e-9. Each block is a counterexample a +% prior version failed; scipy reaches machine precision on all of them. +% --------------------------------------------------------------------- +%!test +%! clear +%! m1 = 1 - eps/2; +%! % R1: m -> 1 near phi = pi/2 (Delta^2 formed as (1-m) + m cos^2) +%! assert(abs(elliptic12(pi/2-1e-9, m1) - 19.65993026560449767) < 1e-12*20, 'F(pi/2-1e-9 | 1-eps/2)'); +%! % R2: m = 1 exactly: F must be exactly 0 at 0 and odd +%! assert(elliptic12(0, 1) == 0, 'F(0|1) must be exactly 0'); +%! assert(abs(elliptic12(1e-16, 1) - 1e-16) < 1e-31, 'F(1e-16|1) must be 1e-16'); +%! % R3: third kind at the endpoint poles +%! assert(abs(elliptic3(pi/2-1e-6, 0.3, 1) - 1195228.2584444625825) < 1e-12*1.2e6, 'Pi(pi/2-1e-6 | .3, c=1)'); +%! assert(abs(elliptic3(pi/2-1e-6, 1-1e-8, 0.9) - 88.615055050793590585) < 1e-12*90, 'Pi(pi/2-1e-6 | 1-1e-8, .9)'); + +%!test +%! clear +%! % R4: Carlson -- disparate scales, tiny y, near-equal args, double zeros +%! assert(abs(carlsonRJ(1e-20,2e-20,3e-20,0.5) - 43616756114.805842986) < 1e-12*4.4e10, 'RJ disparate scales'); +%! assert(abs(carlsonRJ(2,3,4,1e-10) - 7.179193296087372323) < 1e-13*7.2, 'RJ small p'); +%! assert(abs(carlsonRC(3,1e-10) - 7.3643213780616827229) < 1e-13*7.4, 'RC(3,1e-10)'); +%! assert(abs(carlsonRC(1.0000000000001,1) - 0.99999999999998334665) < 1e-14, 'RC(1+1e-13,1)'); +%! assert(isinf(carlsonRF(0,0,1)) && isinf(carlsonRD(0,0,1)) && isinf(carlsonRJ(0,0,1,2)), 'two zero args must be Inf'); + +%!test +%! clear +%! % R5: Jacobi functions at m -> 1 (atan2 Landen step) +%! assert(abs(ellipj(9.375277798108883, 1-eps/2) - 0) >= 0); % smoke: callable +%! [~,cn] = ellipj(9.375277798108883, 1-eps/2); +%! assert(abs(cn - 0.00016958935096417269446) < 1e-13*1.7e-4, 'cn(9.375 | 1-eps/2)'); +%! [~,cn] = ellipj(7, 1-1e-12); +%! assert(abs(cn - 0.0018237622775256289351) < 1e-13*1.8e-3, 'cn(7 | 1-1e-12)'); + +%!test +%! clear +%! % R6: inverse E for tiny negative z (oddness first, relative stop) +%! for m = [0 0.5 1-1e-8] +%! [~,E1] = ellipke(m); z = -1e-9*E1; +%! [~,Eb] = elliptic12(inverselliptic2(z, m), m); +%! assert(abs(Eb - z) < 1e-13*abs(z), 'inverselliptic2 tiny negative z at m=%g', m); +%! end + +%!test +%! clear +%! % R7: complex F -- cancellation-free tan^2(mu): tiny psi and small m +%! assert(abs(imag(elliptic12i(pi/2 + 1e-9i, 0.9)) - 3.1622776601683798848e-9) < 1e-12*3.2e-9, 'Im F(pi/2+1e-9i | .9)'); +%! F = elliptic12i(pi/2 + 1e-9i, 1-eps/2); +%! assert(abs(F - (19.754694640120759063 + 0.095049319491958534055i)) < 1e-9*20, 'F(pi/2+1e-9i | 1-eps/2)'); +%! F = elliptic12i(0.4 + 0.3i, 1e-4); +%! assert(abs(F - (0.39999936996865927219 + 0.30000195549059365219i)) < 1e-13, 'F(0.4+0.3i | 1e-4)'); + +%!test +%! clear +%! % R8: nome at tiny m (K' from the exact argument m); Weierstrass with the +%! % period computed from 1-m = (e1-e2)/(e1-e3) on a near-m=1 lattice +%! assert(abs(nomeq(1e-16) - 6.2500000000000001819e-18) < 1e-13*6.25e-18, 'q(1e-16)'); % pi*K'/K ~ 40 eps amplification +%! assert(abs(nomeq(1e-17) - 6.25e-19) < 1e-13*6.25e-19, 'q(1e-17)'); +%! e1 = 0.5000001; e2 = 0.5; e3 = -1.0000001; z = 13.391953465243201; +%! assert(abs(weierstrassP(z,e1,e2,e3) - 0.51848214450600943279) < 1e-12, 'P near-1 lattice'); +%! assert(abs(weierstrassZeta(z,e1,e2,e3) - -5.4787546526901492279) < 1e-12*5.5, 'Zeta near-1 lattice'); +%! assert(abs(weierstrassSigma(z,e1,e2,e3) - 1.822626274365935705e-13) < 1e-12*1.8e-13, 'Sigma near-1 lattice'); diff --git a/python/elliptic/carlson.py b/python/elliptic/carlson.py index e3712a0..f3e1987 100644 --- a/python/elliptic/carlson.py +++ b/python/elliptic/carlson.py @@ -1,7 +1,7 @@ """Carlson symmetric elliptic integrals RF, RD, RJ, RC. All use Carlson's duplication algorithm with fixed iteration counts -(20 for RF, 30 for RD/RJ) so they are JAX-traceable and run natively on +(20 for RF, 30 for RD, 60 for RJ) so they are JAX-traceable and run natively on any array backend (NumPy, PyTorch CUDA, JAX). References @@ -48,7 +48,16 @@ def _rc_xp(xp, x, y): rc_gt = xp.arctan(xp.sqrt(xp.clip(diff / x_safe, 0.0, None))) / xp.sqrt(yd_safe) lt_active = (diff < -tol) & (y > 0) & (x > 0) lt_ratio = xp.where(lt_active, -diff / x_safe, xp.full_like(diff, 0.5)) - rc_lt = xp.arctanh(xp.sqrt(xp.clip(lt_ratio, 0.0, None))) / xp.sqrt(yd_safe2) + # x > y: log((sqrt(x)+sqrt(x-y))/sqrt(y))/sqrt(x-y) -- algebraically + # arctanh(sqrt(1-y/x))/sqrt(x-y), but without the 1 - sqrt(1-eps) + # cancellation that lost 8 digits at RC(3, 1e-10). + lt_y = xp.where(lt_active, y, xp.ones_like(y)) + lt_x = xp.where(lt_active, x, xp.full_like(x, 2.0)) + # ... and as log1p, since log((sx+sxy)/sy) = log1p(((x-y)/(sx+sy) + sxy)/sy): + # the plain log lost 9 digits again when x - y was tiny (RC(1+1e-13, 1)). + sx, sy = xp.sqrt(lt_x), xp.sqrt(lt_y) + sxy = xp.sqrt(xp.clip(lt_x - lt_y, 0.0, None)) + rc_lt = xp.log1p(((lt_x - lt_y) / (sx + sy) + sxy) / sy) / xp.sqrt(yd_safe2) rc_eq = 1.0 / xp.sqrt(x_safe) rc_x0 = (math.pi * 0.5) / xp.sqrt(y_safe) @@ -77,7 +86,12 @@ def carlsonRF(x, y, z): y = xp.asarray(y, dtype=xp.float64) z = xp.asarray(z, dtype=xp.float64) x, y, z = xp.broadcast_arrays(x, y, z) - return _rf_xp(xp, x, y, z) + out = _rf_xp(xp, x, y, z) + # Two zero arguments: the integral diverges (DLMF 19.16.1); the fixed + # duplication count otherwise returns a finite number. + two0 = ((x == 0).astype(xp.float64) + (y == 0).astype(xp.float64) + + (z == 0).astype(xp.float64)) >= 2 + return xp.where(two0, xp.full_like(out, math.inf), out) def _rf_xp(xp, x, y, z): @@ -113,7 +127,8 @@ def carlsonRD(x, y, z): y = xp.asarray(y, dtype=xp.float64) z = xp.asarray(z, dtype=xp.float64) x, y, z = xp.broadcast_arrays(x, y, z) - return _rd_xp(xp, x, y, z) + out = _rd_xp(xp, x, y, z) + return xp.where((x == 0) & (y == 0), xp.full_like(out, math.inf), out) # DLMF 19.16.5 def _rd_xp(xp, x, y, z): @@ -168,13 +183,19 @@ def carlsonRJ(x, y, z, p): "principal value (DLMF 19.20.14); use the transformation to " "a q > 0 argument before calling." ) - return _rj_xp(xp, x, y, z, p) + out = _rj_xp(xp, x, y, z, p) + two0 = ((x == 0).astype(xp.float64) + (y == 0).astype(xp.float64) + + (z == 0).astype(xp.float64)) >= 2 + return xp.where(two0, xp.full_like(out, math.inf), out) # DLMF 19.16.2 def _rj_xp(xp, x, y, z, p): S = xp.zeros_like(x) fac = xp.ones_like(x) - for _ in range(30): + # 60 duplications: each halves the argument-ratio exponent (base 4), so + # the series is valid for max/min argument ratios up to ~4^54 = 3e32. + # 30 covered only ~1e16 -- RJ(1e-20, 2e-20, 3e-20, 0.5) was 11% off. + for _ in range(60): lam = xp.sqrt(x * y) + xp.sqrt(y * z) + xp.sqrt(z * x) alpha = (p * (xp.sqrt(x) + xp.sqrt(y) + xp.sqrt(z)) + xp.sqrt(x * y * z)) ** 2 beta = p * (p + lam) ** 2 diff --git a/python/elliptic/complex_elliptic.py b/python/elliptic/complex_elliptic.py index c92b01e..3405a6e 100644 --- a/python/elliptic/complex_elliptic.py +++ b/python/elliptic/complex_elliptic.py @@ -50,27 +50,32 @@ def elliptic12i(u, m): b = -(cot2 + m_f * sinh2 * csc2 - 1.0 + m_f) c = -(1.0 - m_f) * cot2 - disc = xp.sqrt(xp.maximum(b**2 / 4.0 - c, xp.zeros_like(c))) - - # c <= 0, so the roots straddle zero and -b/2 + disc is the non-negative - # one. Near phi = pi/2 that form cancels catastrophically (both terms - # ~ |b|/2 while the root ~ 0), so use the equal -c/(b/2 + disc) when b > 0. - den = xp.where(b > 0, b / 2.0 + disc, xp.ones_like(b)) - X = xp.where(b > 0, -c / den, -b / 2.0 + disc) - ratio = xp.where( - b > 0, - (1.0 - m_f) / den, - (-b / 2.0 + disc) / cot2, - ) - - lam = xp.arctan(1.0 / xp.sqrt(xp.maximum(X, xp.zeros_like(X)) + 1e-300)) - # tan(mu)² = (tan(phi)²·cot(lam)² - 1)/m, taken from *ratio* rather than - # from lam: at phi = pi/2 the root X underflows, lam rounds to exactly - # pi/2 and cot(lam) loses every digit of it, collapsing Im to zero. - m_calc = xp.where(m_f == 0.0, xp.ones_like(m_f), m_f) - mu = xp.arctan( - xp.sqrt(xp.maximum((ratio - 1.0) / m_calc, xp.zeros_like(ratio))) - ) + # Positive root X1 = cot^2(lambda) of X^2 + bX + c = 0 and tan^2(mu), + # both without cancellation. Writing X1 = cot^2(phi) + Y, Y solves + # Y^2 + B'Y - C' = 0, B' = cot^2 + (1-m) - m sinh^2 csc^2, + # C' = cot^2 * m sinh^2 csc^2 >= 0, + # and A&S 17.4.11's tan^2(mu) = (tan^2 phi cot^2 lambda - 1)/m = Y/(m cot^2) + # collapses to + # tan^2(mu) = 2 sinh^2 csc^2 / (B' + sqrt(B'^2 + 4C')) (B' >= 0) + # = (|B'| + sqrt(B'^2 + 4C')) / (2 m cot^2) (B' < 0) + # -- m cancels analytically in the first form, so m -> 0 (and m = 0 + # exactly) is handled to full precision. The old (ratio - 1)/m lost + # sqrt(eps/m) digits and returned Im F = 0 for psi = 1e-9. + s2c2 = sinh2 * csc2 + Bp = cot2 + (1.0 - m_f) - m_f * s2c2 + Cp = cot2 * m_f * s2c2 + root = xp.sqrt(Bp * Bp + 4.0 * Cp) + pos = Bp >= 0.0 + Y = xp.where(pos, 2.0 * Cp / xp.where(pos, Bp + root, xp.ones_like(Bp)), + 0.5 * (-Bp + root)) + X = cot2 + Y + m_cot = xp.where(pos, xp.ones_like(cot2), m_f * cot2) + tan2mu = xp.where(pos, + 2.0 * s2c2 / xp.where(pos, Bp + root, xp.ones_like(Bp)), + 0.5 * (-Bp + root) / xp.where(pos, xp.ones_like(m_cot), m_cot)) + + lam = xp.arctan(1.0 / xp.sqrt(X + 1e-300)) + mu = xp.arctan(xp.sqrt(tan2mu)) # Account for periodicity lam = ( diff --git a/python/elliptic/ellipj.py b/python/elliptic/ellipj.py index 873a8ae..60484ce 100644 --- a/python/elliptic/ellipj.py +++ b/python/elliptic/ellipj.py @@ -50,9 +50,11 @@ def _ellipj_xp(xp, u, m): # Forward AGM: store ratio = (a-b)/(a+b) = c_new/a_new for back-sub ratios = [] + bratios = [] # b_{n+1}/a_{n+1} = 2 sqrt(ab)/(a+b) for _ in range(_AGM_ITERS): ab_sum = a + b ratios.append((a - b) / ab_sum) + bratios.append(2.0 * xp.sqrt(a * b) / ab_sum) b = xp.sqrt(a * b) a = ab_sum * 0.5 @@ -68,8 +70,12 @@ def _ellipj_xp(xp, u, m): # Descending Landen back-substitution (all elements, fixed 25 steps) for i in range(_AGM_ITERS - 1, -1, -1): - arg = xp.clip(ratios[i] * xp.sin(phin), -1.0, 1.0) - phin = 0.5 * (xp.arcsin(arg) + phin) + # arcsin(r sin phi) = atan2(r sin phi, sqrt(cos^2 phi + (b/a)^2 sin^2 phi)) + # using a^2 - c^2 = b^2 for the AGM triple: no arcsin near +/-1, which + # lost ~7 digits when m -> 1 (cn(9.4 | 1-eps/2) was 5e-10 off). + sp = xp.sin(phin); cp = xp.cos(phin) + phin = 0.5 * (xp.arctan2(ratios[i] * sp, + xp.sqrt(cp * cp + (bratios[i] * sp) ** 2)) + phin) period_mod2 = period - 2.0 * xp.floor(period * 0.5) quasi_sign = 1.0 - 2.0 * period_mod2 diff --git a/python/elliptic/elliptic12.py b/python/elliptic/elliptic12.py index 4b40c50..d150815 100644 --- a/python/elliptic/elliptic12.py +++ b/python/elliptic/elliptic12.py @@ -55,7 +55,9 @@ def _elliptic12_xp(xp, u, m): s = xp.sin(u_r) c = xp.cos(u_r) - d2 = 1.0 - m * s * s + # (1-m) + m cos^2 avoids the cancellation in 1 - m sin^2 when m -> 1 + # and phi -> pi/2 (F(pi/2-1e-9 | 1-eps/2) was off by 4e-3). + d2 = (1.0 - m) + m * c * c RF = _rf_xp(xp, c * c, d2, xp.ones_like(u_r)) RD = _rd_xp(xp, c * c, d2, xp.ones_like(u_r)) @@ -89,7 +91,9 @@ def _elliptic12_xp(xp, u, m): crossed_pole_m1 = um1 >= math.pi * 0.5 u_m1_safe = xp.where(crossed_pole_m1, xp.zeros_like(u), u) - F_m1_finite = xp.log(xp.tan(math.pi * 0.25 + 0.5 * u_m1_safe)) + # atanh(sin u) is exact at u = 0 and odd; log(tan(pi/4 + u/2)) gave + # F(0|1) = -1.1e-16 and the wrong sign at u = 1e-16. + F_m1_finite = xp.arctanh(xp.sin(u_m1_safe)) F_m1 = xp.where( crossed_pole_m1, xp.full_like(F_m1_finite, math.inf) * sgn, diff --git a/python/elliptic/elliptic3.py b/python/elliptic/elliptic3.py index 770bf12..9b0e00e 100644 --- a/python/elliptic/elliptic3.py +++ b/python/elliptic/elliptic3.py @@ -68,8 +68,10 @@ def elliptic3(u, m, n): s = xp.sin(u_red) c = xp.cos(u_red) s2 = s * s - d2 = 1.0 - m * s2 - p = 1.0 - n * s2 + # (1-m) + m cos^2 and (1-n) + n cos^2: no cancellation near the + # endpoint poles (Pi(pi/2-1e-6 | m, n=1) was off by 3e-5). + d2 = (1.0 - m) + m * c * c + p = (1.0 - n) + n * c * c one = xp.ones_like(s) RF = _rf_xp(xp, c * c, d2, one) diff --git a/python/elliptic/inverse.py b/python/elliptic/inverse.py index 503fbfb..b4c56ca 100644 --- a/python/elliptic/inverse.py +++ b/python/elliptic/inverse.py @@ -38,6 +38,11 @@ def inverselliptic2(E_val, m, tol=1e-12): _, E1, _ = _elliptic12_xp(xp, half_pi, m) two_E1 = 2.0 * E1 + # Oddness first: E(-phi) = -E(phi). Folding a tiny negative z through + # 2*E1 - (z + 2*E1) lost all its digits (rel 1e-7 at z = -1e-9*E1). + sgn = xp.where(E_val < 0.0, -xp.ones_like(E_val), xp.ones_like(E_val)) + E_val = xp.abs(E_val) + # Step 1 — strip full periods: phi = phi_base + k*pi k = xp.floor(E_val / two_E1) z_red = E_val - k * two_E1 # in [0, 2*E1) @@ -55,11 +60,13 @@ def inverselliptic2(E_val, m, tol=1e-12): res = E_cur - z_red2 denom = xp.sqrt(xp.clip(1.0 - m * xp.sin(phi) ** 2, 0.0, None)) safe_denom = xp.where(denom > tol, denom, xp.ones_like(denom)) - step = xp.where(xp.abs(res) > tol, res / safe_denom, xp.zeros_like(res)) + # unconditional step: gating on |res| > tol froze the solver at an + # absolute 1e-12, i.e. only ~3 relative digits for |z| ~ 1e-9 + step = res / safe_denom phi = xp.clip(phi - step, 0.0, math.pi * 0.5) # Step 3 — undo fold: phi_in_period = pi - phi (if over), else phi phi = xp.where(over, math.pi - phi, phi) # in [0, pi) - # Step 4 — undo period strips: each strip adds pi to phi - return phi + k * math.pi + # Step 4 — undo period strips and the sign + return sgn * (phi + k * math.pi) diff --git a/python/elliptic/nome.py b/python/elliptic/nome.py index 5f19a15..edc5372 100644 --- a/python/elliptic/nome.py +++ b/python/elliptic/nome.py @@ -28,10 +28,10 @@ def nomeq(m): def inversenomeq(q): """Inverse nome: parameter m from nome q. - Uses a fixed-iteration bisection against the library's own Carlson-based - ``nomeq`` implementation. In double precision the representable range is - roughly q ∈ [0, 0.779]; beyond this, m(q) exceeds 1 - 2⁻⁵³ and cannot be - represented. + Closed form m = (theta2(0,q)/theta3(0,q))^4 (DLMF 20.9.1), exact at every + scale. In double precision the representable range is q ∈ [0, q_max] with + q_max = nomeq(nextafter(1, 0)) ≈ 0.7789; beyond this m(q) exceeds 1 - 2⁻⁵³ + and cannot be represented. Parameters ---------- diff --git a/python/elliptic/weierstrass.py b/python/elliptic/weierstrass.py index 6e5d32d..69c95e7 100644 --- a/python/elliptic/weierstrass.py +++ b/python/elliptic/weierstrass.py @@ -46,8 +46,9 @@ def weierstrassP(z, e1, e2, e3): def _weierP_xp(xp, z, e1, e2, e3): from .ellipj import _ellipj_xp m = (e2 - e3) / (e1 - e3) + mp = (e1 - e2) / (e1 - e3) # 1-m without cancellation scale = xp.sqrt(e1 - e3) - K = _rf_xp(xp, xp.zeros_like(m), 1.0 - m, xp.ones_like(m)) + K = _rf_xp(xp, xp.zeros_like(m), mp, xp.ones_like(m)) omega1 = K / scale period = xp.round(z / (2.0 * omega1)) z_reduced = z - 2.0 * period * omega1 @@ -88,9 +89,11 @@ def _lattice_theta_xp(xp, z, e1, e2, e3): th1/th1p0 where both drop the same factor. """ m_param = (e2 - e3) / (e1 - e3) + mp_param = (e1 - e2) / (e1 - e3) # 1-m without cancellation: near + # m -> 1 lattices, 1.0 - m loses the digits omega1 and eta1 depend on zero = xp.zeros_like(m_param) one = xp.ones_like(m_param) - K = _rf_xp(xp, zero, 1.0 - m_param, one) + K = _rf_xp(xp, zero, mp_param, one) Kp = _rf_xp(xp, zero, m_param, one) omega1 = K / xp.sqrt(e1 - e3) q = xp.exp(-math.pi * Kp / K) @@ -183,8 +186,9 @@ def weierstrassPPrime(z, e1, e2, e3): xp, z, e1, e2, e3 = _broadcast4(z, e1, e2, e3) from .ellipj import _ellipj_xp m = (e2 - e3) / (e1 - e3) + mp = (e1 - e2) / (e1 - e3) root_scale = xp.sqrt(e1 - e3) - K = _rf_xp(xp, xp.zeros_like(m), 1.0 - m, xp.ones_like(m)) + K = _rf_xp(xp, xp.zeros_like(m), mp, xp.ones_like(m)) omega1 = K / root_scale period = xp.round(z / (2.0 * omega1)) z_reduced = z - 2.0 * period * omega1 diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index f6012bc..dd38d9d 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -523,3 +523,56 @@ def test_reversed_arc_intervals_signed(self): assert abs(_s(elliptic.arclength_ellipse(2.0, 3.0, 1.0, 0.1)) + _s(elliptic.arclength_ellipse(2.0, 3.0, 0.1, 1.0))) < 1e-13 assert abs(_s(elliptic.arclength_ellipse(2.0, 2.0, 1.0, 0.1)) - (-1.8)) < 1e-13 + + +# ===================================================================== +# R. Second adversarial round — fuzz vs mpmath (dps=40) over parameter +# endpoints, extreme scales, poles and period multiples. Every +# reference was evaluated at the EXACT DOUBLE the library receives: +# near singularities the decimal input and its double rounding differ +# enough to move the answer at 1e-9. scipy reaches machine precision +# on every case below; each was an implementation loss, now fixed. +# ===================================================================== +class TestAdversarialRound2: + M1 = float(np.nextafter(1.0, 0.0)) + + def test_F_near_pole_m_to_1_and_m_equal_1(self): + assert abs(_s(elliptic.elliptic12(math.pi/2 - 1e-9, self.M1)[0]) - 19.65993026560449767) < 1e-12*20 + assert _s(elliptic.elliptic12(0.0, 1.0)[0]) == 0.0 + assert abs(_s(elliptic.elliptic12(1e-16, 1.0)[0]) - 1e-16) < 1e-31 + + def test_third_kind_endpoint_poles(self): + assert abs(_s(elliptic.elliptic3(math.pi/2 - 1e-6, 0.3, 1.0)) - 1195228.2584444625825) < 1e-12*1.2e6 + assert abs(_s(elliptic.elliptic3(math.pi/2 - 1e-6, 1 - 1e-8, 0.9)) - 88.615055050793590585) < 1e-12*90 + + def test_carlson_disparate_scales_tiny_y_near_equal_double_zero(self): + assert abs(_s(elliptic.carlsonRJ(1e-20, 2e-20, 3e-20, 0.5)) - 43616756114.805842986) < 1e-12*4.4e10 + assert abs(_s(elliptic.carlsonRJ(2.0, 3.0, 4.0, 1e-10)) - 7.179193296087372323) < 1e-13*7.2 + assert abs(_s(elliptic.carlsonRC(3.0, 1e-10)) - 7.3643213780616827229) < 1e-13*7.4 + assert abs(_s(elliptic.carlsonRC(1.0000000000001, 1.0)) - 0.99999999999998334665) < 1e-14 + assert math.isinf(_s(elliptic.carlsonRF(0.0, 0.0, 1.0))) + assert math.isinf(_s(elliptic.carlsonRD(0.0, 0.0, 1.0))) + assert math.isinf(_s(elliptic.carlsonRJ(0.0, 0.0, 1.0, 2.0))) + + def test_ellipj_m_to_1(self): + assert abs(_s(elliptic.ellipj(9.375277798108883, self.M1)[1]) - 0.00016958935096417269446) < 1e-13*1.7e-4 + assert abs(_s(elliptic.ellipj(7.0, 1 - 1e-12)[1]) - 0.0018237622775256289351) < 1e-13*1.8e-3 + + def test_inverse_E_tiny_negative_z(self): + for m in (0.0, 0.5, 1 - 1e-8): + z = -1e-9 * float(ellipe(m)) + phi = _s(elliptic.inverselliptic2(z, m)) + assert abs(float(ellipeinc(phi, m)) - z) < 1e-13 * abs(z), f"m={m}" + + def test_complex_F_tiny_psi_and_small_m(self): + assert abs(_s(elliptic.elliptic12i(math.pi/2 + 1e-9j, 0.9)[0]).imag - 3.1622776601683798848e-9) < 1e-12*3.2e-9 + F = _s(elliptic.elliptic12i(math.pi/2 + 1e-9j, self.M1)[0]) + assert abs(F - complex(19.754694640120759063, 0.095049319491958534055)) < 1e-9*20 + F = _s(elliptic.elliptic12i(0.4 + 0.3j, 1e-4)[0]) + assert abs(F - complex(0.39999936996865927219, 0.30000195549059365219)) < 1e-13 + + def test_weierstrass_near_m1_lattice(self): + e1, e2, e3, z = 0.5000001, 0.5, -1.0000001, 13.391953465243201 + assert abs(_s(elliptic.weierstrassP(z, e1, e2, e3)) - 0.51848214450600943279) < 1e-12 + assert abs(_s(elliptic.weierstrassZeta(z, e1, e2, e3)) - -5.4787546526901492279) < 1e-12*5.5 + assert abs(_s(elliptic.weierstrassSigma(z, e1, e2, e3)) - 1.822626274365935705e-13) < 1e-12*1.8e-13 From c91af0959d1ca8b61be2d3d6182a6c9b093cec50 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 15:27:33 +0400 Subject: [PATCH 07/23] fix: defects found on the first L4 hardware run (GPU paths + torch backend) Ran the library on a real NVIDIA L4 (Cloud Run job, europe-west1): PyTorch CUDA, JAX CUDA, and Octave + ocl/OpenCL. Three things only hardware could catch, plus one the run's warnings pointed at: - gpu_ellipj (MATLAB): dn(I) = sqrt((1-m(I)) + m(I).*cn(I).^2) mixed the column m(I) with the row-indexed cn(I) into a 4005x4005 outer product ("nonconformant arguments") -- the kernel had never executed on a device. Keep cn as a column local. Every MATLAB GPU kernel is now also exercised locally through gpuArray/gather identity stubs (worst CPU/GPU-path difference 3.8e-16 across elliptic12, ellipj, elliptic3, ellipticBDJ, jacobiThetaEta, weierstrassP/Zeta; matrix shapes preserved). - gpu_jacobiThetaEta (MATLAB) still carried the retired AGM-product form with its input perturbation hack, so GPU theta values disagreed with the CPU q-series by up to 5e-9. Now the same q-series on gpuArray. - carlsonRF/RJ (python): the two-zero-argument mask used .astype, which torch tensors do not have -- every torch call into R_F/R_J raised. Pure boolean algebra now; the whole torch backend was re-verified on 44 outputs. - F(phi|1): atanh(sin phi) saturates when sin phi rounds to 1 (F(pi/2-1e-9|1) came back inf with a divide-by-zero warning in the run log); asinh(tan phi) -- the inverse Gudermannian -- is exact at 0, odd, and finite there (21.416412873533655 vs mpmath 21.416412873533656). Both ports. JAX CUDA on the L4: all 44 outputs on device and within 1e-14 of numpy except Weierstrass P/P'/zeta at 1-3e-12 -- 1/sn^2 and theta1'/theta1 near lattice points amplify the 1-2 ulp differences between GPU and CPU transcendental implementations; a cross-backend, not a correctness, gap. 2e6-point jitted elliptic12 on the L4: 0.018 s. Octave 16/16 files (246 blocks); pytest 465 + 1 optional-JAX skip. Co-Authored-By: Claude Opus 5 --- matlab/src/ellipj.m | 9 ++-- matlab/src/elliptic12.m | 4 +- matlab/src/jacobiThetaEta.m | 82 +++++++++++------------------------ python/elliptic/carlson.py | 10 +++-- python/elliptic/elliptic12.py | 7 +-- 5 files changed, 42 insertions(+), 70 deletions(-) diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index 5e03eea..d0093a0 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -250,10 +250,11 @@ quasi_sign = 1 - 2 .* mod(period, 2); phin_cpu = gather(phin); - am(I) = phin_cpu + period .* pi; - sn(I) = quasi_sign .* sin(phin_cpu); - cn(I) = quasi_sign .* cos(phin_cpu); - dn(I) = sqrt((1 - m(I)) + m(I) .* cn(I).^2); + cn_val = quasi_sign .* cos(phin_cpu); % keep everything a column: + am(I) = phin_cpu + period .* pi; % m(I) is a column here but + sn(I) = quasi_sign .* sin(phin_cpu); % cn(I) indexes a row, so + cn(I) = cn_val; % m(I).*cn(I) was an outer + dn(I) = sqrt((1 - m(I)) + m(I) .* cn_val.^2); % product (found on L4) end % Special cases: m = {0, 1} diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index c710153..bdba83c 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -162,7 +162,7 @@ N = floor( (um1+pi/2)/pi ); M = find(um1 < pi/2); - F(m1(M)) = atanh(sin(u(m1(M)))); % exact at 0 and odd; log(tan(.)) gave -1.1e-16 + F(m1(M)) = asinh(tan(u(m1(M)))); % gd^-1: exact at 0, odd, and no saturation as sin(u) -> 1 F(m1(um1 >= pi/2)) = Inf.*sign(u(m1(um1 >= pi/2))); E(m1) = ((-1).^N .* sin(um1) + 2*N).*sign(u(m1)); @@ -294,7 +294,7 @@ if ~isempty(m1) Nf = floor((um1 + pi/2) / pi); M = find(um1 < pi/2); - F(m1(M)) = atanh(sin(u(m1(M)))); + F(m1(M)) = asinh(tan(u(m1(M)))); F(m1(um1 >= pi/2)) = Inf .* sign(u(m1(um1 >= pi/2))); E(m1) = ((-1).^Nf .* sin(um1) + 2*Nf) .* sign(u(m1)); Z(m1) = (-1).^Nf .* sin(u(m1)); diff --git a/matlab/src/jacobiThetaEta.m b/matlab/src/jacobiThetaEta.m index 03f5d1c..4148bb6 100644 --- a/matlab/src/jacobiThetaEta.m +++ b/matlab/src/jacobiThetaEta.m @@ -154,72 +154,40 @@ function [Th,H] = gpu_jacobiThetaEta(u, m, tol) -%GPU_JACOBITHETAETA Internal helper: compute jacobiThetaEta using gpuArray. -% Compatible with both MATLAB gpuArray and Octave ocl package. +%GPU_JACOBITHETAETA Internal helper: q-series on gpuArray (elementwise). +% Same series as the serial core (A&S 16.27, 16.38). The previous GPU +% helper still carried the retired AGM-product form and its input +% perturbation hack, so GPU results disagreed with the CPU by up to 5e-9. origSize = size(u); Th = zeros(origSize); H = zeros(origSize); - - m = m(:); - u = u(:); - - if any(m(:) < 0) || any(m(:) > 1), error('M must be in the range 0 <= M <= 1.'); end + m = m(:); u = u(:); + if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end KK = ellipke(m); - period_condition = u./KK/2 - floor(u./KK/2); - - I_odd = find(abs(m-1) > 10*eps & abs(m) > 10*eps & abs(period_condition - 0.5) < 10*eps); - if ~isempty(I_odd) - u(I_odd) = u(I_odd) + 100000*eps; - m(I_odd) = m(I_odd) + 10000*eps; + q = exp(-pi .* ellipke(1-m) ./ KK); + q(~(q < 1)) = 0; + v = pi .* u ./ (2 .* KK); + qmax = max([q(:); 0]); + if qmax > 0 + nTerms = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); + else + nTerms = 1; end - - I = find(abs(m-1) > 10*eps & abs(m) > 10*eps); - if ~isempty(I) - mmax = length(I); - mu = m(I); - - % Transposed layout: rows=elements, cols=iterations (OCL-friendly) - MAX_ITER = 12; - a = gpuArray(zeros(mmax, MAX_ITER)); - b = gpuArray(zeros(mmax, MAX_ITER)); - c = gpuArray(zeros(mmax, MAX_ITER)); - a(:,1) = gpuArray(ones(mmax,1)); - c(:,1) = gpuArray(sqrt(mu)); - b(:,1) = gpuArray(sqrt(1 - mu)); - n = zeros(mmax, 1); - ii = 1; - while any(gather(abs(c(:,ii))) > tol) - ii = ii + 1; - a(:,ii) = 0.5 * (a(:,ii-1) + b(:,ii-1)); - b(:,ii) = sqrt(a(:,ii-1) .* b(:,ii-1)); - c(:,ii) = 0.5 * (a(:,ii-1) - b(:,ii-1)); - mask = logical(gather((abs(c(:,ii)) <= tol) & (abs(c(:,ii-1)) > tol))); - n(mask) = ii - 1; - end - - % Ascending Landen back-substitution with multiplicative masking - phin = gpuArray((2 .^ n) .* gather(a(:,ii)) .* u(I)); - phin_pred = phin; - prodth = gpuArray(ones(mmax, MAX_ITER)); - for jj = ii-1:-1:1 - active = gpuArray(double(n >= jj)); - phin_new = 0.5*(asin(c(:,jj+1).*sin(phin)./a(:,jj+1)) + phin); - phin_upd = phin + active .* (phin_new - phin); - prodth(:,jj) = 1 + active .* ((sec(2*phin_upd - phin_pred)).^(1/2^(jj+1)) - 1); - if jj > 1, phin_pred = phin_upd; end - phin = phin_upd; - end - - th_save = sqrt(2*sqrt(1 - m(I)) .* KK(I)/pi .* ... - gather(cos(phin_pred - phin) ./ cos(phin))) .* gather(prod(prodth, 2)); - Th(I) = th_save; - H(I) = sqrt(sqrt(m(I))) .* gather(sin(phin)) .* th_save; + qg = gpuArray(q); vg = gpuArray(v); + Thg = gpuArray(ones(size(v))); + Hg = gpuArray(zeros(size(v))); + for nn = 1:nTerms + Thg = Thg + 2*(-1)^nn .* qg.^(nn^2) .* cos(2*nn .* vg); end + for nn = 0:nTerms + Hg = Hg + 2*(-1)^nn .* qg.^((nn+0.5)^2) .* sin((2*nn+1) .* vg); + end + Th(:) = gather(Thg); + H(:) = gather(Hg); % Special cases: m = {0, 1} m0 = find(abs(m) < 10*eps); if ~isempty(m0), Th(m0) = 1; H(m0) = sqrt(sqrt(m(m0))) .* sin(u(m0)); end - m1 = find(abs(m-1) < 10*eps); - if ~isempty(m1), Th(m1) = NaN; H(m1) = NaN; end \ No newline at end of file + if ~isempty(m1), Th(m1) = NaN; H(m1) = NaN; end diff --git a/python/elliptic/carlson.py b/python/elliptic/carlson.py index f3e1987..6d9cf15 100644 --- a/python/elliptic/carlson.py +++ b/python/elliptic/carlson.py @@ -89,8 +89,9 @@ def carlsonRF(x, y, z): out = _rf_xp(xp, x, y, z) # Two zero arguments: the integral diverges (DLMF 19.16.1); the fixed # duplication count otherwise returns a finite number. - two0 = ((x == 0).astype(xp.float64) + (y == 0).astype(xp.float64) - + (z == 0).astype(xp.float64)) >= 2 + # pure boolean algebra: torch tensors have no .astype, and this must + # stay backend-native (caught on the L4 hardware run) + two0 = ((x == 0) & (y == 0)) | ((x == 0) & (z == 0)) | ((y == 0) & (z == 0)) return xp.where(two0, xp.full_like(out, math.inf), out) @@ -184,8 +185,9 @@ def carlsonRJ(x, y, z, p): "a q > 0 argument before calling." ) out = _rj_xp(xp, x, y, z, p) - two0 = ((x == 0).astype(xp.float64) + (y == 0).astype(xp.float64) - + (z == 0).astype(xp.float64)) >= 2 + # pure boolean algebra: torch tensors have no .astype, and this must + # stay backend-native (caught on the L4 hardware run) + two0 = ((x == 0) & (y == 0)) | ((x == 0) & (z == 0)) | ((y == 0) & (z == 0)) return xp.where(two0, xp.full_like(out, math.inf), out) # DLMF 19.16.2 diff --git a/python/elliptic/elliptic12.py b/python/elliptic/elliptic12.py index d150815..9b200e6 100644 --- a/python/elliptic/elliptic12.py +++ b/python/elliptic/elliptic12.py @@ -91,9 +91,10 @@ def _elliptic12_xp(xp, u, m): crossed_pole_m1 = um1 >= math.pi * 0.5 u_m1_safe = xp.where(crossed_pole_m1, xp.zeros_like(u), u) - # atanh(sin u) is exact at u = 0 and odd; log(tan(pi/4 + u/2)) gave - # F(0|1) = -1.1e-16 and the wrong sign at u = 1e-16. - F_m1_finite = xp.arctanh(xp.sin(u_m1_safe)) + # asinh(tan u) (the inverse Gudermannian): exact at u = 0, odd, and it does + # not saturate the way atanh(sin u) does when sin u rounds to 1 near pi/2 + # (F(pi/2-1e-9|1) is 21.4, not inf); log(tan(pi/4+u/2)) gave F(0|1) = -1e-16. + F_m1_finite = xp.arcsinh(xp.tan(u_m1_safe)) F_m1 = xp.where( crossed_pole_m1, xp.full_like(F_m1_finite, math.inf) * sgn, From 50eb9d65a4a2fa20164402b236e4ab09cdc4936a Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 15:39:39 +0400 Subject: [PATCH 08/23] fix: device placement and OpenCL dispatch defects from the second L4 run Second hardware run (Cloud Run L4, europe-west1): JAX CUDA clean on all 44 outputs (jitted 2e6-point elliptic12: 0.018 s vs 7.30 s numpy); the pytest backend matrix passed on the device; two further defects only a real device exposes: - weierstrassP/PPrime/Zeta/Sigma (python): _broadcast4 turned the Python-float roots into 0-d CPU tensors, and torch's broadcast_arrays refuses to mix them with CUDA tensors ("Expected all tensors to be on the same device"). Scalars are now materialised with full_like on the device of the array input. Located without a GPU via torch 'meta' tensors, which carry a device but no data and still enforce the same-device rule -- that probe now passes for every public function. _agm.py's bare xp.ones(N) (currently unused) had the same hazard. - gpu_ellipticBDJ (MATLAB): handed ocl arrays to the Carlson duplication, which needs logical indexing ocl does not support, and carlsonRF's isreal() rejects ocl arrays outright. There is no OpenCL kernel for it; the GPU dispatch now evaluates the serial core on host arrays (identical results), which is stated in the code. Octave 16/16 files; pytest 465 + 1 optional-JAX skip; torch CPU smoke of the full public API 44/44. Co-Authored-By: Claude Opus 5 --- matlab/src/ellipticBDJ.m | 9 +++++---- python/elliptic/_agm.py | 2 +- python/elliptic/weierstrass.py | 14 ++++++++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/matlab/src/ellipticBDJ.m b/matlab/src/ellipticBDJ.m index e426f2e..237fa5d 100644 --- a/matlab/src/ellipticBDJ.m +++ b/matlab/src/ellipticBDJ.m @@ -138,10 +138,11 @@ % ----------------------------------------------------------------------- function [B, D, J] = gpu_ellipticBDJ(phi, m, n, compute_J, origSize) -[B, D, J] = ellipticBDJ_core(gpuArray(phi(:).'), gpuArray(m(:).'), ... - ifelse(compute_J, gpuArray(n(:).'), []), compute_J, origSize); -B = gather(B); D = gather(D); -if compute_J, J = gather(J); end +% No OpenCL kernel: the Carlson duplication needs data-dependent masking +% and logical indexing, which ocl arrays do not support, and carlsonRF's +% isreal() check rejects them outright (seen on an L4). Evaluate the +% serial core on host arrays; results are identical to the CPU path. +[B, D, J] = ellipticBDJ_core(phi(:).', m(:).', ifelse(compute_J, n(:).', []), compute_J, origSize); % ----------------------------------------------------------------------- diff --git a/python/elliptic/_agm.py b/python/elliptic/_agm.py index c8265fd..b855014 100644 --- a/python/elliptic/_agm.py +++ b/python/elliptic/_agm.py @@ -21,7 +21,7 @@ def agm_coeffs(m, xp): iters = _AGM_ITERS # Allocate: we'll build columns one by one (works for numpy/torch/jax) - a = [xp.ones(N, dtype=xp.float64)] + a = [xp.ones_like(m)] # device-preserving (xp.ones(N) lands on the CPU in torch) b = [xp.sqrt(1.0 - m)] c = [xp.sqrt(m)] diff --git a/python/elliptic/weierstrass.py b/python/elliptic/weierstrass.py index 69c95e7..44e96fd 100644 --- a/python/elliptic/weierstrass.py +++ b/python/elliptic/weierstrass.py @@ -25,10 +25,16 @@ def _reject_complex_inputs(*values): def _broadcast4(z, e1, e2, e3): _reject_complex_inputs(z, e1, e2, e3) xp = get_xp(z, e1, e2, e3) - z = xp.asarray(z, dtype=xp.float64) - e1 = xp.asarray(e1, dtype=xp.float64) - e2 = xp.asarray(e2, dtype=xp.float64) - e3 = xp.asarray(e3, dtype=xp.float64) + # Python-scalar roots must be materialised on the SAME DEVICE as the + # array inputs: xp.asarray(1.5) is a CPU tensor in torch, and + # broadcast_arrays refuses to mix it with CUDA tensors (found on L4). + args = [z, e1, e2, e3] + ref = next((a for a in args if hasattr(a, 'device') or hasattr(a, 'shape')), None) + def dev(a): + if ref is not None and not hasattr(a, 'shape'): + return xp.full_like(xp.asarray(ref, dtype=xp.float64), float(a)) + return xp.asarray(a, dtype=xp.float64) + z, e1, e2, e3 = (dev(a) for a in args) z, e1, e2, e3 = xp.broadcast_arrays(z, e1, e2, e3) return xp, z, e1, e2, e3 From a9fdc2f70538563ad45498e01e720fdf40885eb9 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 15:48:07 +0400 Subject: [PATCH 09/23] fix(ellipticBD): GPU dispatch must not hand ocl arrays to the Carlson core Third L4 run: torch CUDA 44/44, JAX CUDA 44/44, pytest backend matrix 10/10, every Octave OpenCL kernel within 4.8e-16 of the CPU, testGpu 9/9 on the device. The forced-GPU pass over testEdgeCases then hit the last sibling of the ellipticBDJ defect: gpu_ellipticBD wrapped m in gpuArray before calling the Carlson-based core, which carlsonRF rejects (isreal(ocl array) is false). Host arrays now, identical results. Co-Authored-By: Claude Opus 5 --- matlab/src/ellipticBD.m | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/matlab/src/ellipticBD.m b/matlab/src/ellipticBD.m index f4d9d99..f44802b 100644 --- a/matlab/src/ellipticBD.m +++ b/matlab/src/ellipticBD.m @@ -100,7 +100,11 @@ % ----------------------------------------------------------------------- function [B, D, S] = gpu_ellipticBD(m, origSize) %GPU_ELLIPTICBD GPU path. -[B, D, S] = ellipticBD_core(gpuArray(m(:).'), origSize); +% No OpenCL kernel: the Carlson duplication inside the core needs logical +% indexing that ocl arrays lack, and carlsonRF's isreal() rejects them +% (seen on an L4 with elliptic_config('gpu', true)). Host arrays; identical +% results to the CPU path. +[B, D, S] = ellipticBD_core(m(:).', origSize); B = gather(B); D = gather(D); S = gather(S); From 96d7348d0237d5697d4a2663d963ceccffc808ee Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 16:08:53 +0400 Subject: [PATCH 10/23] fix(ellipticBD): drop gather() on host arrays in the GPU dispatch Fifth L4 run's stack trace: ocl's gather() rejects non-ocl arrays (ocl_to_octave: invalid argument type), and the previous fix had left gather(B/D/S) after switching gpu_ellipticBD to the host core. Every other gather() in the tree applies to a real gpuArray result (ellipj, elliptic12, elliptic3, jacobiThetaEta), all verified on the device. Co-Authored-By: Claude Opus 5 --- matlab/src/ellipticBD.m | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/matlab/src/ellipticBD.m b/matlab/src/ellipticBD.m index f44802b..d0cfc25 100644 --- a/matlab/src/ellipticBD.m +++ b/matlab/src/ellipticBD.m @@ -103,9 +103,8 @@ % No OpenCL kernel: the Carlson duplication inside the core needs logical % indexing that ocl arrays lack, and carlsonRF's isreal() rejects them % (seen on an L4 with elliptic_config('gpu', true)). Host arrays; identical -% results to the CPU path. +% results to the CPU path. No gather(): ocl's gather rejects host arrays. [B, D, S] = ellipticBD_core(m(:).', origSize); -B = gather(B); D = gather(D); S = gather(S); % ----------------------------------------------------------------------- From 6503c5ee6b237d8f6d7bfdbaa3fd475407b96d5d Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 18:06:31 +0400 Subject: [PATCH 11/23] fix: third adversarial round -- reduction rounding, silent domain gaps, NaN handling (both ports) Dense random fuzz (m log-uniform to within 1e-14 of both endpoints, phases over +/-13 periods and at odd multiples of pi/2 with 1e-9 jitter) against mpmath, plus a logic/API abuse probe. Findings: - Phase reduction u - k*pi rounded k*pi, costing eps*|u| in the reduced phase; near pi/2 at m -> 1 that is amplified ~1e5x into Z and Pi (1e-10). Cody-Waite split (u - k*PI_HI) - k*PI_LO in elliptic12, elliptic3 and ellipticBDJ, both ports. What remains is the double-precision floor of the reduced phase itself. - python ellipj returned sn(u | 0.5) -- the interior AGM placeholder -- for m = 1.5 or m = NaN, with no error. New check_range() raises on eager numpy and NaN-masks on device backends; applied to ellipj, elliptic12 and nomeq; NaN inputs now propagate. - Every `xp is np` eager check in the package (elliptic12i, elliptic3, inverse, carlson, nome, applications) was silently skipped for ndarray and numpy-scalar inputs, because array_namespace() returns the array_api_compat.numpy module for those. is_numpy() recognises both. - MATLAB elliptic12 crashed inside unique() for a NaN m ("subscripts must be ..."); NaN in, NaN out now, without leaking into neighbours. - elliptic12i(-0.0) and (0) returned eps from the cot(phi) nudge; exact zero, sign preserved. - python R_J: 60 -> 100 duplications (argument-ratio limit 3e32 -> 4e56); the fuzz hit ratio 1.9e44 at 9e-12. Verified clean in the same round: no input mutation, vector/scalar agreement bit-for-bit over 300 random points incl. m in {0, 1, 1e-17, nextafter(1,0)}, matrix shapes preserved by every function, empty in -> empty out, F(conj u) = conj F(u), F(-u) = -F(u), sn(conj u) = conj sn(u). Tests: testEdgeCases.m block S, TestAdversarialRound3 (anchors at the exact double inputs). Octave 16/16 files; pytest 468 + 1 optional-JAX skip. Co-Authored-By: Claude Opus 5 --- docs/specs/post-0d09740-regression-audit.md | 23 ++++++++++++++ matlab/src/elliptic12.m | 8 +++-- matlab/src/elliptic12i.m | 4 +++ matlab/src/elliptic3.m | 4 ++- matlab/src/ellipticBDJ.m | 4 ++- matlab/tests/testEdgeCases.m | 27 ++++++++++++++++ python/elliptic/_xputils.py | 25 +++++++++++++++ python/elliptic/applications.py | 4 +-- python/elliptic/carlson.py | 12 +++---- python/elliptic/complex_elliptic.py | 9 ++++-- python/elliptic/ellipj.py | 9 ++++-- python/elliptic/elliptic12.py | 13 ++++++-- python/elliptic/elliptic3.py | 7 +++-- python/elliptic/ellipticBDJ.py | 3 +- python/elliptic/inverse.py | 4 +-- python/elliptic/nome.py | 5 +-- python/tests/test_edge_cases.py | 35 +++++++++++++++++++++ 17 files changed, 168 insertions(+), 28 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index cecaaca..357856c 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -114,6 +114,29 @@ evaluated at the *exact double* the library receives (`mpf(float(x))`), not at the decimal the test author typed -- near singularities the two differ at the 1e-9 level (`F(pi/2 - 1e-9 \| 1-eps/2)`: 19.6599302656 vs 19.6599302792). +## Adversarial review round 3 (dense fuzz + API abuse, 2026-08-16) + +Dense random fuzz (600 points/function/seed, m log-uniform to within 1e-14 of +both endpoints, phases over +/-13 periods and at odd multiples of pi/2 with +1e-9 jitter) against mpmath, plus a logic/API abuse probe (shapes, dtypes, +NaN/Inf/-0 propagation, input mutation, vector-vs-scalar consistency, +out-of-domain parameters, complex symmetries). Everything numerical now sits +at the input's conditioning floor; the logical findings were real: + +| Finding | Was | Fix | +|---|---|---| +| Phase reduction `u - k*pi` rounds `k*pi` (both ports, `elliptic12`/`elliptic3`/`ellipticBDJ`) | `eps*\|u\|` in the reduced phase; near pi/2 at m -> 1 amplified 1e5x into Z and Pi (1e-10) | Cody-Waite split `(u - k*PI_HI) - k*PI_LO` | +| python `ellipj` with `m` outside [0,1] or NaN | returned sn(u \| 0.5) -- the interior placeholder leaked | `check_range` (raise on numpy, NaN mask on device backends) + NaN propagation; also `elliptic12`, `nomeq` | +| MATLAB `elliptic12` with NaN `m` | crashed inside `unique()` ("subscripts must be...") | NaN in, NaN out, excluded from the grouping | +| `elliptic12i(-0.0)` / `(0)` (both) | `eps` from the cot(phi) nudge | exact zero (sign preserved) | +| python `R_J` argument ratios beyond 3e32 | 9e-12 at ratio 1.9e44 | 100 duplications (limit ~4e56) | + +Verified clean in the same round: no public function mutates its inputs; +vector and scalar calls agree bit-for-bit over 300 random points including +m in {0, 1, 1e-17, nextafter(1,0)}; matrix shapes are preserved by every +function; empty input returns empty; F(conj u) = conj F(u), F(-u) = -F(u) and +sn(conj u) = conj sn(u) hold exactly. + ## Deliberate limits and residual risk - CUDA/OpenCL hardware was not available during this audit. GPU source paths diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index bdba83c..8204a5c 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -82,7 +82,9 @@ % smaller than eps = 2.220446049250313e-16, if so we suppose it equal zero m(m pi/2) signU = sign(u); ua = abs(u); k_per = floor(ua ./ pi); - r = ua - k_per .* pi; % in [0, pi) + % Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at + % eps*|u_r| instead of eps*|u| (pi_lo = pi - double(pi) = 1.2246467991473532e-16). + r = (ua - k_per .* pi) - k_per .* 1.2246467991473532e-16; % in [0, pi) refl = r > pi/2; ur = r; ur(refl) = pi - r(refl); % in [0, pi/2] Pred = elliptic3(ur, m, c); diff --git a/matlab/src/ellipticBDJ.m b/matlab/src/ellipticBDJ.m index 237fa5d..3deed45 100644 --- a/matlab/src/ellipticBDJ.m +++ b/matlab/src/ellipticBDJ.m @@ -91,7 +91,9 @@ % D(φ+k·pi|m) = D(φ|m) + 2k·D(m) % J(φ+k·pi,n|m) = J(φ,n|m) + 2k·J(n|m) k = ceil(phi./pi - 0.5); -phi = phi - k .* pi; % now in (-pi/2, pi/2] +% Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at +% eps*|u_r| instead of eps*|u| (pi_lo = pi - double(pi) = 1.2246467991473532e-16). +phi = (phi - k .* pi) - k .* 1.2246467991473532e-16; % now in (-pi/2, pi/2] s = sin(phi); c = cos(phi); diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index b69d33f..e854aa7 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -668,3 +668,30 @@ %! assert(abs(weierstrassP(z,e1,e2,e3) - 0.51848214450600943279) < 1e-12, 'P near-1 lattice'); %! assert(abs(weierstrassZeta(z,e1,e2,e3) - -5.4787546526901492279) < 1e-12*5.5, 'Zeta near-1 lattice'); %! assert(abs(weierstrassSigma(z,e1,e2,e3) - 1.822626274365935705e-13) < 1e-12*1.8e-13, 'Sigma near-1 lattice'); + +% --------------------------------------------------------------------- +% S. Third adversarial round: dense random fuzz + API abuse. +% S1 Cody-Waite pi reduction: F/E/Z/Pi at u = 5.5*pi + 8e-10, m = 1-1.5e-13 +% (mpmath at the exact double inputs). Before the split, k*pi rounding +% cost eps*|u| in the reduced phase, amplified 1e5x by dZ/dphi there. +% S2 NaN in, NaN out (a NaN m used to crash elliptic12's grouping). +% S3 elliptic12i(-0) is -0 exactly (the cot nudge returned eps). +% S4 R_J at an argument ratio of 1.9e44 (fixed 100 duplications). +% --------------------------------------------------------------------- +%!test +%! clear +%! u = 17.27875959554386; m = 0.99999999999985; +%! [F,E,Z] = elliptic12(u, m); +%! assert(abs(F - 177.65640489133312311) < 2e-9, 'F at 5.5pi+8e-10, m->1 (conditioning floor ~1e-9)'); +%! assert(abs(E - 11.000000000012911122) < 1e-12, 'E at 5.5pi+8e-10'); +%! assert(abs(Z - (-0.00012790056416609388015)) < 1e-10, 'Z at 5.5pi+8e-10: k*pi rounding used to cost 1e-9'); +%! assert(abs(elliptic3(u, m, 0.3) - 248.50046674013002377) < 3e-9, 'Pi at 5.5pi+8e-10'); + +%!test +%! clear +%! assert(isnan(elliptic12(0.3, NaN)) && isnan(elliptic12(NaN, 0.5)), 'elliptic12 must propagate NaN'); +%! v = elliptic12([0.3 0.5 0.7], [0.2 NaN 0.4]); +%! assert(isnan(v(2)) && ~any(isnan(v([1 3]))), 'NaN must not leak into neighbours'); +%! assert(isnan(ellipj(0.3, NaN)), 'ellipj must propagate NaN'); +%! assert(elliptic12i(-0, 0.5) == 0 && elliptic12i(0, 0.5) == 0, 'F(0) must be exactly 0'); +%! assert(abs(carlsonRJ(2.798e-18, 5.954e-24, 9.634e-23, 1.134e21) - 9.9678905686736778972e-12) < 1e-12*1e-11, 'RJ at ratio 1.9e44'); diff --git a/python/elliptic/_xputils.py b/python/elliptic/_xputils.py index e852fdc..053a400 100644 --- a/python/elliptic/_xputils.py +++ b/python/elliptic/_xputils.py @@ -5,6 +5,31 @@ import numpy as np +def is_numpy(xp): + """True for the eager numpy namespace, whichever module object represents + it: ``numpy`` itself, or ``array_api_compat.numpy`` (what + array_namespace() returns for numpy arrays and numpy scalars). Every + ``xp is np`` test in the package used to miss the latter, so eager domain + checks were silently skipped for ndarray inputs.""" + return xp is np or getattr(xp, "__name__", "") in ("numpy", "array_api_compat.numpy") + + +def check_range(xp, x, lo, hi, what): + """Domain check that is honest on every backend. + + numpy (eager): raise ValueError for any value outside [lo, hi] -- NaN is + let through and propagates. Traced/device backends (JAX, torch): return + a validity mask so callers can emit NaN instead of a silent placeholder + value (ellipj(0.3, 1.5) used to return sn(0.3 | 0.5)). + """ + valid = ~((x < lo) | (x > hi)) + if is_numpy(xp): + bad = ~valid & ~np.isnan(x) + if np.any(bad): + raise ValueError(f"{what} must be in [{lo}, {hi}]") + return valid + + def get_xp(*args): """Return the array namespace for *args*, defaulting to numpy for plain scalars.""" api_objs = [a for a in args if is_array_api_obj(a)] diff --git a/python/elliptic/applications.py b/python/elliptic/applications.py index f6c459a..42ca2fc 100644 --- a/python/elliptic/applications.py +++ b/python/elliptic/applications.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy from .elliptic12 import elliptic12 @@ -53,7 +53,7 @@ def arclength_ellipse(a, b, theta0=0.0, theta1=None): # Give ordinary NumPy callers an explicit domain error. Traced backends # cannot branch on array values, so invalid elements are marked NaN below. - if xp is np and (np.any(a <= 0.0) or np.any(b <= 0.0)): + if is_numpy(xp) and (np.any(a <= 0.0) or np.any(b <= 0.0)): raise ValueError("ellipse semi-axes must be strictly positive") valid = (a > 0.0) & (b > 0.0) diff --git a/python/elliptic/carlson.py b/python/elliptic/carlson.py index 6d9cf15..0cef695 100644 --- a/python/elliptic/carlson.py +++ b/python/elliptic/carlson.py @@ -1,7 +1,7 @@ """Carlson symmetric elliptic integrals RF, RD, RJ, RC. All use Carlson's duplication algorithm with fixed iteration counts -(20 for RF, 30 for RD, 60 for RJ) so they are JAX-traceable and run natively on +(20 for RF, 30 for RD, 100 for RJ) so they are JAX-traceable and run natively on any array backend (NumPy, PyTorch CUDA, JAX). References @@ -14,7 +14,7 @@ import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy # --------------------------------------------------------------------------- @@ -178,7 +178,7 @@ def carlsonRJ(x, y, z, p): p = xp.asarray(p, dtype=xp.float64) x, y, z, p = xp.broadcast_arrays(x, y, z, p) - if xp is np and np.any(p <= 0.0): + if is_numpy(xp) and np.any(p <= 0.0): raise ValueError( "carlsonRJ: p must be > 0. For p < 0 the integral is a Cauchy " "principal value (DLMF 19.20.14); use the transformation to " @@ -194,10 +194,10 @@ def carlsonRJ(x, y, z, p): def _rj_xp(xp, x, y, z, p): S = xp.zeros_like(x) fac = xp.ones_like(x) - # 60 duplications: each halves the argument-ratio exponent (base 4), so - # the series is valid for max/min argument ratios up to ~4^54 = 3e32. + # 100 duplications: each divides the argument-ratio exponent (base 4) by + # one, so the series is valid for max/min argument ratios up to ~4^94 = 4e56. # 30 covered only ~1e16 -- RJ(1e-20, 2e-20, 3e-20, 0.5) was 11% off. - for _ in range(60): + for _ in range(100): lam = xp.sqrt(x * y) + xp.sqrt(y * z) + xp.sqrt(z * x) alpha = (p * (xp.sqrt(x) + xp.sqrt(y) + xp.sqrt(z)) + xp.sqrt(x * y * z)) ** 2 beta = p * (p + lam) ** 2 diff --git a/python/elliptic/complex_elliptic.py b/python/elliptic/complex_elliptic.py index 3405a6e..640cd49 100644 --- a/python/elliptic/complex_elliptic.py +++ b/python/elliptic/complex_elliptic.py @@ -9,7 +9,7 @@ from __future__ import annotations import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy from .elliptic12 import _elliptic12_xp from .ellipj import _ellipj_xp @@ -33,7 +33,7 @@ def elliptic12i(u, m): m = xp.asarray(m, dtype=xp.float64) u_f, m_f = xp.broadcast_arrays(u, m) - if xp is np and np.any((m_f < 0.0) | (m_f > 1.0)): + if is_numpy(xp) and np.any((m_f < 0.0) | (m_f > 1.0)): raise ValueError("m must be in [0, 1]") phi = xp.real(u_f) @@ -122,6 +122,9 @@ def elliptic12i(u, m): Fi = xp.where(small, F_ser, Fi) Ei = xp.where(small, E_ser, Ei) Zi = xp.where(small, Z_ser, Zi) + # u == 0 exactly (incl. -0.0): the cot(phi) nudge above would return eps + z0 = u_f == 0 + Fi = xp.where(z0, u_f, Fi); Ei = xp.where(z0, u_f, Ei); Zi = xp.where(z0, xp.zeros_like(Zi), Zi) return Fi, Ei, Zi @@ -147,7 +150,7 @@ def ellipji(u, m): m_f = xp.asarray(m, dtype=xp.float64) u_f, m_f = xp.broadcast_arrays(u_f, m_f) - if xp is np and np.any((m_f < 0.0) | (m_f > 1.0)): + if is_numpy(xp) and np.any((m_f < 0.0) | (m_f > 1.0)): raise ValueError("m must be in [0, 1]") phi = xp.real(u_f) diff --git a/python/elliptic/ellipj.py b/python/elliptic/ellipj.py index 60484ce..44cb66e 100644 --- a/python/elliptic/ellipj.py +++ b/python/elliptic/ellipj.py @@ -11,9 +11,10 @@ """ from __future__ import annotations +import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, check_range _AGM_ITERS = 25 @@ -34,7 +35,11 @@ def ellipj(u, m): u = xp.asarray(u, dtype=xp.float64) m = xp.asarray(m, dtype=xp.float64) u, m = xp.broadcast_arrays(u, m) - return _ellipj_xp(xp, u, m) + valid = check_range(xp, m, 0.0, 1.0, 'm') & ~xp.isnan(m) & ~xp.isnan(u) + sn, cn, dn, am = _ellipj_xp(xp, u, m) + nan = xp.full_like(sn, math.nan) + return (xp.where(valid, sn, nan), xp.where(valid, cn, nan), + xp.where(valid, dn, nan), xp.where(valid, am, nan)) def _ellipj_xp(xp, u, m): diff --git a/python/elliptic/elliptic12.py b/python/elliptic/elliptic12.py index 9b200e6..644c44d 100644 --- a/python/elliptic/elliptic12.py +++ b/python/elliptic/elliptic12.py @@ -17,7 +17,10 @@ import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, check_range + +_PI_HI = 3.141592653589793 # double nearest pi +_PI_LO = 1.2246467991473532e-16 # pi - _PI_HI from .carlson import _rf_xp, _rd_xp, _rf_numpy, _rd_numpy @@ -37,14 +40,18 @@ def elliptic12(u, m): u = xp.asarray(u, dtype=xp.float64) m = xp.asarray(m, dtype=xp.float64) u, m = xp.broadcast_arrays(u, m) - return _elliptic12_xp(xp, u, m) + valid = check_range(xp, m, 0.0, 1.0, 'm') + F, E, Z = _elliptic12_xp(xp, u, m) + nan = xp.full_like(F, math.nan) + return xp.where(valid, F, nan), xp.where(valid, E, nan), xp.where(valid, Z, nan) def _elliptic12_xp(xp, u, m): """Backend-native F, E, Z via Carlson forms. u and m are 1-D xp arrays.""" # Period reduction: F(u+kπ|m) = F(u|m) + 2k·K(m), Z period π k = xp.round(u / math.pi) - u_r = u - k * math.pi # reduced to (-π/2, π/2] + # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| + u_r = (u - k * _PI_HI) - k * _PI_LO # reduced to (-π/2, π/2] # Complete integrals K(m), E(m) via Carlson z0 = xp.zeros_like(m) diff --git a/python/elliptic/elliptic3.py b/python/elliptic/elliptic3.py index 9b0e00e..ee622e5 100644 --- a/python/elliptic/elliptic3.py +++ b/python/elliptic/elliptic3.py @@ -10,7 +10,7 @@ import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy from .carlson import _rf_xp, _rj_xp @@ -40,7 +40,7 @@ def elliptic3(u, m, n): # Eager NumPy calls can provide a precise domain error. Traced backends # cannot branch on array values; their invalid elements naturally become # non-finite through the Carlson expression instead. - if xp is np and np.any(n > 1.0): + if is_numpy(xp) and np.any(n > 1.0): n_np = np.asarray(n) u_np = np.asarray(u) # Check whether the singularity sin²θ = 1/n lies in [0, u] @@ -62,7 +62,8 @@ def elliptic3(u, m, n): sign_u = xp.where(u < 0, -xp.ones_like(u), xp.ones_like(u)) ua = xp.abs(u) k_per = xp.floor(ua / math.pi) - r = ua - k_per * math.pi # in [0, pi) + # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| + r = (ua - k_per * 3.141592653589793) - k_per * 1.2246467991473532e-16 # in [0, pi) refl = r > math.pi * 0.5 u_red = xp.where(refl, math.pi - r, r) # in [0, pi/2] s = xp.sin(u_red) diff --git a/python/elliptic/ellipticBDJ.py b/python/elliptic/ellipticBDJ.py index e097db8..b096cf5 100644 --- a/python/elliptic/ellipticBDJ.py +++ b/python/elliptic/ellipticBDJ.py @@ -55,7 +55,8 @@ def ellipticBDJ(phi, m, n=None): # D(phi+k*pi|m) = D(phi|m) + 2k*D(m) # J(phi+k*pi,n|m) = J(phi,n|m) + 2k*J(n|m) k = xp.ceil(phi / math.pi - 0.5) - phi = phi - k * math.pi # now in (-pi/2, pi/2] + # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| + phi = (phi - k * 3.141592653589793) - k * 1.2246467991473532e-16 # now in (-pi/2, pi/2] s = xp.sin(phi) c = xp.cos(phi) diff --git a/python/elliptic/inverse.py b/python/elliptic/inverse.py index b4c56ca..d5c6a02 100644 --- a/python/elliptic/inverse.py +++ b/python/elliptic/inverse.py @@ -2,7 +2,7 @@ from __future__ import annotations import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy from .elliptic12 import _elliptic12_xp @@ -30,7 +30,7 @@ def inverselliptic2(E_val, m, tol=1e-12): m = xp.asarray(m, dtype=xp.float64) E_val, m = xp.broadcast_arrays(E_val, m) - if xp is np and np.any((m < 0.0) | (m > 1.0)): + if is_numpy(xp) and np.any((m < 0.0) | (m > 1.0)): raise ValueError("m must be in [0, 1]") # Complete integral E(m); each phi-period of π contributes 2*E1 to E. diff --git a/python/elliptic/nome.py b/python/elliptic/nome.py index edc5372..6265848 100644 --- a/python/elliptic/nome.py +++ b/python/elliptic/nome.py @@ -2,7 +2,7 @@ from __future__ import annotations import math import numpy as np -from ._xputils import get_xp +from ._xputils import get_xp, check_range, is_numpy from .theta import _q_from_m_xp @@ -21,6 +21,7 @@ def nomeq(m): """ xp = get_xp(m) m = xp.asarray(m, dtype=xp.float64) + check_range(xp, m, 0.0, 1.0, 'm') # traced backends already NaN-mask inside q = _q_from_m_xp(xp, m) return xp.where(m == 1.0, xp.ones_like(q), q) @@ -49,7 +50,7 @@ def inversenomeq(q): m_hi_scalar = np.nextafter(1.0, 0.0) q_max = float(_q_from_m_xp(np, np.asarray(m_hi_scalar))) - if xp is np: + if is_numpy(xp): if np.any((q < 0.0) | (q >= 1.0)): raise ValueError("q must be in [0, 1)") if np.any(q > q_max): diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index dd38d9d..5ad1575 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -576,3 +576,38 @@ def test_weierstrass_near_m1_lattice(self): assert abs(_s(elliptic.weierstrassP(z, e1, e2, e3)) - 0.51848214450600943279) < 1e-12 assert abs(_s(elliptic.weierstrassZeta(z, e1, e2, e3)) - -5.4787546526901492279) < 1e-12*5.5 assert abs(_s(elliptic.weierstrassSigma(z, e1, e2, e3)) - 1.822626274365935705e-13) < 1e-12*1.8e-13 + + +# ===================================================================== +# S. Third adversarial round: dense random fuzz + API abuse +# ===================================================================== +class TestAdversarialRound3: + def test_cody_waite_reduction_point(self): + """u = 5.5*pi + 8e-10, m = 1-1.5e-13 (mpmath at the exact doubles): before + the Cody-Waite split, k*pi rounding cost eps*|u| in the reduced phase, + amplified ~1e5x by dZ/dphi near pi/2 at m -> 1.""" + u, m = 17.27875959554386, 0.99999999999985 + F, E, Z = elliptic.elliptic12(u, m) + assert abs(_s(F) - 177.65640489133312311) < 2e-9 + assert abs(_s(E) - 11.000000000012911122) < 1e-12 + assert abs(_s(Z) - (-0.00012790056416609388015)) < 1e-10 + assert abs(_s(elliptic.elliptic3(u, m, 0.3)) - 248.50046674013002377) < 3e-9 + + def test_domain_and_nan_are_honest(self): + """Out-of-range m raises on numpy; NaN propagates and never becomes a + placeholder value (ellipj(0.3, 1.5) used to return sn(0.3 | 0.5)).""" + for bad in (1.5, -1e-17, np.nextafter(1.0, 2.0)): + with pytest.raises(ValueError): elliptic.ellipj(0.3, bad) + with pytest.raises(ValueError): elliptic.elliptic12(0.3, bad) + with pytest.raises(ValueError): elliptic.nomeq(bad) + assert math.isnan(_s(elliptic.ellipj(0.3, float('nan'))[0])) + assert math.isnan(_s(elliptic.elliptic12(0.3, float('nan'))[0])) + v = elliptic.elliptic12(np.array([0.3, 0.5, 0.7]), np.array([0.2, np.nan, 0.4]))[0] + assert np.isnan(v[1]) and not np.isnan(v[[0, 2]]).any() + + def test_exact_zero_complex_and_RJ_extreme_ratio(self): + # note: the Python literal -0.0+0j already evaluates to 0j; use complex(-0.0, 0.0) + F0 = _s(elliptic.elliptic12i(complex(-0.0, 0.0), 0.5)[0]) + assert F0 == 0 and math.copysign(1.0, F0.real) < 0 # -0.0 preserved + assert _s(elliptic.elliptic12i(0j, 0.5)[0]) == 0 + assert abs(_s(elliptic.carlsonRJ(2.798e-18, 5.954e-24, 9.634e-23, 1.134e21)) - 9.9678905686736778972e-12) < 1e-12 * 1e-11 From 93e56e50d410037f6104e979e163d9c9837c0f57 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 18:15:18 +0400 Subject: [PATCH 12/23] fix(theta): angle-addition recurrence; theta() evaluates directly on v (both ports) Adversarial round 4 (loop iteration 2): complex-plane periods of the Jacobi functions (2K, 2iK', 4iK'), Pi with n > 1 below its pole and n = -1e4, degenerate/near-degenerate Weierstrass lattices and extreme-scale roots, degenerate ellipses -- all clean. Two real findings: - Theta series lost accuracy at large arguments: forming (2n+1)*v and 2n*v as double products rounds by eps*|k v| (python 1.4e-12 at v ~ 1.2e8, 9e-9 at v ~ 1e11; MATLAB 2.2e-10 because theta() also round-tripped v -> u = 2Kv/pi -> v through jacobiThetaEta). All theta series in both ports (theta, theta_prime, jacobiThetaEta, the Weierstrass theta1 loops) now take sin/cos of the multiples from the angle-addition recurrence started at sin v, cos v; MATLAB gains a shared theta_series.m and theta() evaluates on v directly. Verified exact (1e-16) to v = 1e11 against mpmath. - python Weierstrass accepted unsorted roots and returned NaN silently (MATLAB errors); now raises on numpy. Equal neighbouring roots stay accepted as the legitimate m = 0 / m = 1 degenerate lattices. Tests: testEdgeCases.m block T, TestAdversarialRound4. Octave 16/16 files; pytest 471 + 1 optional-JAX skip. Co-Authored-By: Claude Opus 5 --- matlab/src/jacobiThetaEta.m | 17 ++------- matlab/src/theta.m | 27 +++++--------- matlab/src/theta_prime.m | 41 ++------------------- matlab/src/theta_series.m | 63 +++++++++++++++++++++++++++++++++ matlab/src/weierstrassSigma.m | 8 +++-- matlab/src/weierstrassZeta.m | 8 +++-- matlab/tests/testEdgeCases.m | 26 ++++++++++++++ python/elliptic/theta.py | 53 ++++++++++++++++++++------- python/elliptic/weierstrass.py | 16 +++++++-- python/tests/test_edge_cases.py | 29 +++++++++++++++ 10 files changed, 199 insertions(+), 89 deletions(-) create mode 100644 matlab/src/theta_series.m diff --git a/matlab/src/jacobiThetaEta.m b/matlab/src/jacobiThetaEta.m index 4148bb6..13b5091 100644 --- a/matlab/src/jacobiThetaEta.m +++ b/matlab/src/jacobiThetaEta.m @@ -87,21 +87,8 @@ q(~(q < 1)) = 0; % m == 1 (and NaN) handled below v = pi .* u ./ (2 .* KK); -qmax = max([q(:); 0]); -if qmax > 0 - nTerms = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); -else - nTerms = 1; -end - -Th = ones(size(v)); -H = zeros(size(v)); -for nn = 1:nTerms - Th = Th + 2*(-1)^nn .* q.^(nn^2) .* cos(2*nn .* v); -end -for nn = 0:nTerms - H = H + 2*(-1)^nn .* q.^((nn+0.5)^2) .* sin((2*nn+1) .* v); -end +Th = theta_series(4, v, q, tol); +H = theta_series(1, v, q, tol); % Special cases: m = {0, 1} m0 = find(abs(m) < 10*eps); diff --git a/matlab/src/theta.m b/matlab/src/theta.m index 381f198..ade54d9 100644 --- a/matlab/src/theta.m +++ b/matlab/src/theta.m @@ -61,24 +61,15 @@ error('M must be in the range 0 <= M <= 1.'); end -K = ellipke(m); -u = 2*K.*v/pi; - -switch type - case { '1', 1 } - [th, H] = jacobiThetaEta(u,m,tol); - Th(:) = H; - return; - case { '2', 2 } - [th, H] = jacobiThetaEta(u+K,m,tol); - Th(:) = H; - return; - case { '3', 3 } - Th(:) = jacobiThetaEta(u+K,m,tol); - return; - case { '4', 4 } - Th(:) = jacobiThetaEta(u,m,tol); - return; +% Evaluate the q-series directly on v. The old route v -> u = 2Kv/pi -> +% jacobiThetaEta -> v = pi*u/(2K) round-tripped the argument and lost eps*|v| +% (2e-10 at v ~ 1e8); THETA_SERIES also avoids the k*v product rounding. +q = exp(-pi .* ellipke(1-m) ./ ellipke(m)); +q(~(q < 1)) = 0; % m == 1: series diverges -> NaN below +Th(:) = theta_series(type, v, q, tol); +Th(m == 1) = NaN; +if type == 1 + Th(m == 0) = 0; % theta_1(v, 0) = 0 exactly end % END FUNCTION theta() \ No newline at end of file diff --git a/matlab/src/theta_prime.m b/matlab/src/theta_prime.m index 1c94b89..b8b4ed3 100644 --- a/matlab/src/theta_prime.m +++ b/matlab/src/theta_prime.m @@ -88,47 +88,12 @@ error('M must be in the range 0 <= M <= 1.'); end -th = theta(j, z, m); % Moiseev's θ-function - -% Derivative directly from the defining q-series (A&S 16.27): -% θ1'(v) = 2 Σ (-1)^n (2n+1) q^((n+1/2)^2) cos((2n+1)v) -% θ2'(v) = -2 Σ (2n+1) q^((n+1/2)^2) sin((2n+1)v) -% θ3'(v) = -4 Σ n q^(n^2) sin(2nv) -% θ4'(v) = -4 Σ (-1)^n n q^(n^2) sin(2nv) -% The previous logarithmic-derivative form th*(2K/pi)*(Z + cn.*dn./sn) -% returned NaN (0*Inf) wherever the theta itself vanishes: θ1 at z = k*pi, -% θ2 at z = pi/2 + k*pi. The series has no such holes. K = ellipke(m); Kp = ellipke(1 - m); q = exp(-pi .* Kp ./ K); q(~(q < 1)) = 0; % m == 1 guard +[th, thp] = theta_series(j, z, q, tol); +th(m == 1) = NaN; thp(m == 1) = NaN; +if j == 1, th(m == 0) = 0; end -qmax = max([q(:); 0]); -if qmax > 0 - nT = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); -else - nT = 1; -end - -thp = zeros(size(z)); -switch j - case 1 - for n = 0:nT - thp = thp + 2*(-1)^n * (2*n+1) .* q.^((n+0.5)^2) .* cos((2*n+1).*z); - end - case 2 - for n = 0:nT - thp = thp - 2*(2*n+1) .* q.^((n+0.5)^2) .* sin((2*n+1).*z); - end - case 3 - for n = 1:nT - thp = thp - 4*n .* q.^(n^2) .* sin(2*n.*z); - end - case 4 - for n = 1:nT - thp = thp - 4*(-1)^n * n .* q.^(n^2) .* sin(2*n.*z); - end end - -end - diff --git a/matlab/src/theta_series.m b/matlab/src/theta_series.m new file mode 100644 index 0000000..ec86e8e --- /dev/null +++ b/matlab/src/theta_series.m @@ -0,0 +1,63 @@ +function [th, thp] = theta_series(j, v, q, tol) +%THETA_SERIES Jacobi theta function theta_j(v, q) and d/dv from the q-series. +% [TH, THP] = THETA_SERIES(J, V, Q, TOL) evaluates (A&S 16.27) +% theta_1 = 2 sum (-1)^n q^((n+1/2)^2) sin((2n+1)v) +% theta_2 = 2 sum q^((n+1/2)^2) cos((2n+1)v) +% theta_3 = 1 + 2 sum q^(n^2) cos(2nv) +% theta_4 = 1 + 2 sum (-1)^n q^(n^2) cos(2nv) +% and its v-derivative, for arrays V and Q of the same size (0 <= Q < 1). +% +% sin/cos of the multiples (2n+1)v and 2nv come from the angle-addition +% recurrence started at sin v, cos v: forming k*v as a double product +% rounds by eps*|k v|, which cost 1e-12 at v ~ 1e8 and 1e-8 at v ~ 1e11. +% +% Shared by THETA, THETA_PRIME, JACOBITHETAETA, WEIERSTRASSZETA/SIGMA. + +if nargin < 4, tol = eps; end +qmax = max([q(:); 0]); +if qmax > 0 + nT = min(1000, max(1, ceil(sqrt(log(tol) / log(qmax))))); +else + nT = 1; +end +s1 = sin(v); c1 = cos(v); +s2 = 2 .* s1 .* c1; c2 = 1 - 2 .* s1.^2; % sin 2v, cos 2v +th = zeros(size(v)); thp = th; +switch j + case 1 + sk = s1; ck = c1; + for n = 0:nT + qq = (-1)^n .* q.^((n+0.5)^2); + th = th + qq .* sk; + thp = thp + qq .* (2*n+1) .* ck; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + th = 2*th; thp = 2*thp; + case 2 + sk = s1; ck = c1; + for n = 0:nT + qq = q.^((n+0.5)^2); + th = th + qq .* ck; + thp = thp - qq .* (2*n+1) .* sk; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + th = 2*th; thp = 2*thp; + case 3 + sk = s2; ck = c2; th = ones(size(v)); + for n = 1:nT + qq = q.^(n^2); + th = th + 2 .* qq .* ck; + thp = thp - 4*n .* qq .* sk; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + case 4 + sk = s2; ck = c2; th = ones(size(v)); + for n = 1:nT + qq = (-1)^n .* q.^(n^2); + th = th + 2 .* qq .* ck; + thp = thp - 4*n .* qq .* sk; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); + end + otherwise + error('theta_series: J must be 1, 2, 3, or 4.'); +end diff --git a/matlab/src/weierstrassSigma.m b/matlab/src/weierstrassSigma.m index 38df950..c8f5fa1 100644 --- a/matlab/src/weierstrassSigma.m +++ b/matlab/src/weierstrassSigma.m @@ -104,13 +104,17 @@ end th1 = zeros(size(v)); th1p = th1; th1p0 = zeros(size(v)); th1ppp0 = th1p0; +% sin/cos of (2n+1)v by angle-addition from sin v, cos v (k*v as a double +% product rounds by eps*|k v|; see THETA_SERIES) +sk = sin(v); ck = cos(v); s2 = 2 .* sk .* ck; c2 = 1 - 2 .* sk.^2; for n = 0:nT qq = (-1)^n .* q.^((n+0.5)^2); k = 2*n + 1; - th1 = th1 + qq .* sin(k .* v); - th1p = th1p + qq .* k .* cos(k .* v); + th1 = th1 + qq .* sk; + th1p = th1p + qq .* k .* ck; th1p0 = th1p0 + qq .* k; th1ppp0 = th1ppp0 - qq .* k^3; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); end diff --git a/matlab/src/weierstrassZeta.m b/matlab/src/weierstrassZeta.m index 6f15697..8ac99bd 100644 --- a/matlab/src/weierstrassZeta.m +++ b/matlab/src/weierstrassZeta.m @@ -104,13 +104,17 @@ end th1 = zeros(size(v)); th1p = th1; th1p0 = zeros(size(v)); th1ppp0 = th1p0; +% sin/cos of (2n+1)v by angle-addition from sin v, cos v (k*v as a double +% product rounds by eps*|k v|; see THETA_SERIES) +sk = sin(v); ck = cos(v); s2 = 2 .* sk .* ck; c2 = 1 - 2 .* sk.^2; for n = 0:nT qq = (-1)^n .* q.^((n+0.5)^2); k = 2*n + 1; - th1 = th1 + qq .* sin(k .* v); - th1p = th1p + qq .* k .* cos(k .* v); + th1 = th1 + qq .* sk; + th1p = th1p + qq .* k .* ck; th1p0 = th1p0 + qq .* k; th1ppp0 = th1ppp0 - qq .* k^3; + [sk, ck] = deal(sk.*c2 + ck.*s2, ck.*c2 - sk.*s2); end diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index e854aa7..1e0af7b 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -695,3 +695,29 @@ %! assert(isnan(ellipj(0.3, NaN)), 'ellipj must propagate NaN'); %! assert(elliptic12i(-0, 0.5) == 0 && elliptic12i(0, 0.5) == 0, 'F(0) must be exactly 0'); %! assert(abs(carlsonRJ(2.798e-18, 5.954e-24, 9.634e-23, 1.134e21) - 9.9678905686736778972e-12) < 1e-12*1e-11, 'RJ at ratio 1.9e44'); + +%% --------------------------------------------------------------------- +%% T. Theta at a huge argument (mpmath jtheta at the exact double v). +%% Forming (2n+1)*v as a double product rounded by eps*|k v| (2e-10 here, +%% 9e-9 at v ~ 1e11); the series now uses the angle-addition recurrence, +%% and theta() no longer round-trips v -> u -> v through jacobiThetaEta. +%% --------------------------------------------------------------------- +%!test +%! clear +%! v = 123456789.123; +%! [t, tp] = theta_prime(1, v, 0.4); +%! assert(abs(t - (0.84585020823346348431)) < 2e-15, 'theta1 at v=1.2e8'); +%! assert(abs(tp - (0.015114923736622936955)) < 2e-14, 'theta1'' at v=1.2e8'); +%! assert(abs(theta(1, v, 0.4) - (0.84585020823346348431)) < 2e-15, 'theta() at v=1.2e8'); +%! [t, tp] = theta_prime(2, v, 0.4); +%! assert(abs(t - (0.014932290326334898348)) < 2e-15, 'theta2 at v=1.2e8'); +%! assert(abs(tp - (-0.84241862816186020729)) < 2e-14, 'theta2'' at v=1.2e8'); +%! assert(abs(theta(2, v, 0.4) - (0.014932290326334898348)) < 2e-15, 'theta() at v=1.2e8'); +%! [t, tp] = theta_prime(3, v, 0.4); +%! assert(abs(t - (0.93627542467710214194)) < 2e-15, 'theta3 at v=1.2e8'); +%! assert(abs(tp - (-0.004519191759268069986)) < 2e-14, 'theta3'' at v=1.2e8'); +%! assert(abs(theta(3, v, 0.4) - (0.93627542467710214194)) < 2e-15, 'theta() at v=1.2e8'); +%! [t, tp] = theta_prime(4, v, 0.4); +%! assert(abs(t - (1.0637286984176921296)) < 2e-15, 'theta4 at v=1.2e8'); +%! assert(abs(tp - (0.0045203629452149075654)) < 2e-14, 'theta4'' at v=1.2e8'); +%! assert(abs(theta(4, v, 0.4) - (1.0637286984176921296)) < 2e-15, 'theta() at v=1.2e8'); diff --git a/python/elliptic/theta.py b/python/elliptic/theta.py index 7c9c7be..03e7190 100644 --- a/python/elliptic/theta.py +++ b/python/elliptic/theta.py @@ -36,64 +36,93 @@ def _q_from_m_xp(xp, m): # Low-level series (flat 1-D numpy, v in radians, q scalar or array) # ----------------------------------------------------------------------- +def _trig_start(xp, v): + """sin/cos of v, 2v once; all higher multiples come from the angle-addition + recurrence. Forming (2n+1)*v as a double product rounds by eps*|k v|, + which cost 1e-12 at v ~ 1e8 and 1e-8 at v ~ 1e11; the recurrence keeps + every term accurate to ~n*eps relative to the exact sin(v), cos(v).""" + s1 = xp.sin(v); c1 = xp.cos(v) + return s1, c1, 2.0 * s1 * c1, 1.0 - 2.0 * s1 * s1 # s1, c1, sin 2v, cos 2v + + def _th1(xp, v, q): - """θ₁(v, q).""" + """θ₁(v, q) = 2 Σ (-1)^n q^((n+1/2)^2) sin((2n+1)v).""" + sk, ck, s2, c2 = _trig_start(xp, v) s = xp.zeros_like(v) for n in range(_N_TERMS): - s = s + (-1)**n * q**((n + 0.5)**2) * xp.sin((2*n + 1) * v) + s = s + (-1)**n * q**((n + 0.5)**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s def _th2(xp, v, q): - """θ₂(v, q).""" + """θ₂(v, q) = 2 Σ q^((n+1/2)^2) cos((2n+1)v).""" + sk, ck, s2, c2 = _trig_start(xp, v) s = xp.zeros_like(v) for n in range(_N_TERMS): - s = s + q**((n + 0.5)**2) * xp.cos((2*n + 1) * v) + s = s + q**((n + 0.5)**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s def _th3(xp, v, q): - """θ₃(v, q).""" + """θ₃(v, q) = 1 + 2 Σ q^(n^2) cos(2nv).""" + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 # sin 2v, cos 2v s = xp.ones_like(v) for n in range(1, _N_TERMS + 1): - s = s + 2.0 * q**(n**2) * xp.cos(2*n * v) + s = s + 2.0 * q**(n**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s def _th4(xp, v, q): - """θ₄(v, q).""" + """θ₄(v, q) = 1 + 2 Σ (-1)^n q^(n^2) cos(2nv).""" + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 s = xp.ones_like(v) for n in range(1, _N_TERMS + 1): - s = s + 2.0 * (-1)**n * q**(n**2) * xp.cos(2*n * v) + s = s + 2.0 * (-1)**n * q**(n**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s # Derivatives dθⱼ/dv def _dth1(xp, v, q): + sk, ck, s2, c2 = _trig_start(xp, v) s = xp.zeros_like(v) for n in range(_N_TERMS): - s = s + (-1)**n * (2*n+1) * q**((n + 0.5)**2) * xp.cos((2*n + 1) * v) + s = s + (-1)**n * (2*n+1) * q**((n + 0.5)**2) * ck + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s def _dth2(xp, v, q): + sk, ck, s2, c2 = _trig_start(xp, v) s = xp.zeros_like(v) for n in range(_N_TERMS): - s = s - (2*n+1) * q**((n + 0.5)**2) * xp.sin((2*n + 1) * v) + s = s - (2*n+1) * q**((n + 0.5)**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return 2.0 * s def _dth3(xp, v, q): + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 s = xp.zeros_like(v) for n in range(1, _N_TERMS + 1): - s = s - 4.0 * n * q**(n**2) * xp.sin(2*n * v) + s = s - 4.0 * n * q**(n**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s def _dth4(xp, v, q): + _, _, s2, c2 = _trig_start(xp, v) + sk, ck = s2, c2 s = xp.zeros_like(v) for n in range(1, _N_TERMS + 1): - s = s - 4.0 * n * (-1)**n * q**(n**2) * xp.sin(2*n * v) + s = s - 4.0 * n * (-1)**n * q**(n**2) * sk + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 return s diff --git a/python/elliptic/weierstrass.py b/python/elliptic/weierstrass.py index 44e96fd..0ed52cb 100644 --- a/python/elliptic/weierstrass.py +++ b/python/elliptic/weierstrass.py @@ -35,6 +35,13 @@ def dev(a): return xp.full_like(xp.asarray(ref, dtype=xp.float64), float(a)) return xp.asarray(a, dtype=xp.float64) z, e1, e2, e3 = (dev(a) for a in args) + # Root ordering e1 >= e2 >= e3 with e1 > e3 (equal neighbours are the + # legitimate m = 0 / m = 1 degenerate lattices). Unsorted roots used to + # fall through as m > 1 and return NaN silently; MATLAB errors there. + from ._xputils import is_numpy + ok = (e1 >= e2) & (e2 >= e3) & (e1 > e3) + if is_numpy(xp) and not bool(xp.all(ok | xp.isnan(e1 + e2 + e3))): + raise ValueError("Weierstrass roots must satisfy e1 >= e2 >= e3 with e1 > e3") z, e1, e2, e3 = xp.broadcast_arrays(z, e1, e2, e3) return xp, z, e1, e2, e3 @@ -109,13 +116,18 @@ def _lattice_theta_xp(xp, z, e1, e2, e3): th1p = xp.zeros_like(v) th1p0 = xp.zeros_like(v) th1ppp0 = xp.zeros_like(v) + # sin/cos of (2n+1)v by angle-addition from sin v, cos v: the products + # k*v round by eps*|k v| (1e-12 at v ~ 1e8) -- see theta._trig_start + sk, ck = xp.sin(v), xp.cos(v) + s2, c2 = 2.0 * sk * ck, 1.0 - 2.0 * sk * sk for n in range(31): qq = (-1.0) ** n * q ** ((n + 0.5) ** 2) k = 2 * n + 1 - th1 = th1 + qq * xp.sin(k * v) - th1p = th1p + qq * k * xp.cos(k * v) + th1 = th1 + qq * sk + th1p = th1p + qq * k * ck th1p0 = th1p0 + qq * k th1ppp0 = th1ppp0 - qq * k ** 3 + sk, ck = sk * c2 + ck * s2, ck * c2 - sk * s2 eta1 = -math.pi ** 2 / (12.0 * omega1) * th1ppp0 / th1p0 return omega1, eta1, th1, th1p, th1p0 diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 5ad1575..35287e6 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -611,3 +611,32 @@ def test_exact_zero_complex_and_RJ_extreme_ratio(self): assert F0 == 0 and math.copysign(1.0, F0.real) < 0 # -0.0 preserved assert _s(elliptic.elliptic12i(0j, 0.5)[0]) == 0 assert abs(_s(elliptic.carlsonRJ(2.798e-18, 5.954e-24, 9.634e-23, 1.134e21)) - 9.9678905686736778972e-12) < 1e-12 * 1e-11 + + +# ===================================================================== +# T. Theta at a huge argument; Weierstrass root ordering +# ===================================================================== +class TestAdversarialRound4: + def test_theta_huge_argument(self): + """mpmath jtheta at the exact double v = 123456789.123: the products + (2n+1)*v rounded by eps*|k v| before the angle-addition recurrence.""" + v = 123456789.123 + t, tp = elliptic.theta_prime(1, v, 0.4) + assert abs(_s(t) - (0.84585020823346348431)) < 2e-15 and abs(_s(tp) - (0.015114923736622936955)) < 2e-14 + assert abs(_s(elliptic.theta(1, v, 0.4)) - (0.84585020823346348431)) < 2e-15 + t, tp = elliptic.theta_prime(2, v, 0.4) + assert abs(_s(t) - (0.014932290326334898348)) < 2e-15 and abs(_s(tp) - (-0.84241862816186020729)) < 2e-14 + assert abs(_s(elliptic.theta(2, v, 0.4)) - (0.014932290326334898348)) < 2e-15 + t, tp = elliptic.theta_prime(3, v, 0.4) + assert abs(_s(t) - (0.93627542467710214194)) < 2e-15 and abs(_s(tp) - (-0.004519191759268069986)) < 2e-14 + assert abs(_s(elliptic.theta(3, v, 0.4)) - (0.93627542467710214194)) < 2e-15 + t, tp = elliptic.theta_prime(4, v, 0.4) + assert abs(_s(t) - (1.0637286984176921296)) < 2e-15 and abs(_s(tp) - (0.0045203629452149075654)) < 2e-14 + assert abs(_s(elliptic.theta(4, v, 0.4)) - (1.0637286984176921296)) < 2e-15 + + def test_weierstrass_root_order_is_enforced(self): + with pytest.raises(ValueError): elliptic.weierstrassP(0.5, 0.5, 1.0, -1.5) # unsorted + with pytest.raises(ValueError): elliptic.weierstrassZeta(0.5, 1.0, 1.0, 1.0) # e1 == e3 + # equal neighbours are the legitimate degenerate lattices (m = 1 / m = 0) + assert math.isfinite(_s(elliptic.weierstrassP(0.5, 1.0, 1.0, -2.0))) + assert math.isfinite(_s(elliptic.weierstrassP(0.5, 1.0, 0.0, 0.0))) From 43884a685d250733650a817553397e81c0f9eedd Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 18:27:40 +0400 Subject: [PATCH 13/23] fix(matlab): batch-independent results, parallel recursion guard, GPU phase tail Adversarial rounds 4-5 (see docs/specs/post-0d09740-regression-audit.md): * carlsonRF/RD/RJ: per-element convergence. The duplication loop stopped on a whole-vector test, so the number of steps applied to an element depended on its batch mates: chunked vs serial elliptic3 differed by an ulp on 6 of 1000 points. An `active` mask now freezes each element at its own converged step. * elliptic12 / ellipj (CPU and GPU): use the first converged AGM row per element, a(n+1), instead of the batch-wide last row a(max(n)); K and the Landen back-substitution scale no longer depend on batch composition. * elliptic12 GPU: Cody-Waite tail term in the phase reduction, as in the CPU path. Without it Z was off by 3.6e-11 and E by 1.2e-10 at u = 1e6, m = 1 - eps/2 (k * 1.22e-16 with k = 318310). * par_worker: recursion guard. A worker that sees parallel mode still enabled re-enters the parallel dispatcher when N is an exact multiple of chunk_size and recurses until SIGILL; the worker now forces elliptic_config('parallel', false) for the duration of its call. * testParallel: new block evaluates every parallel-capable function serially and chunked (N in {cs-1, cs, 2cs, 3cs, 3cs+7}) under temporary get_nworkers/parcellfun stubs and requires bit-identical results. Octave: 16 test files, 0 failures. Python: unchanged, pytest green. GPU identity-stub probe: CPU vs GPU path max diff 0.0 on 4005 points. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 22 +++++++++++ matlab/src/carlsonRD.m | 22 ++++++----- matlab/src/carlsonRF.m | 15 +++++--- matlab/src/carlsonRJ.m | 22 ++++++----- matlab/src/ellipj.m | 5 ++- matlab/src/elliptic12.m | 16 +++++--- matlab/src/par_worker.m | 24 ++++++++++-- matlab/tests/testParallel.m | 42 +++++++++++++++++++++ 8 files changed, 132 insertions(+), 36 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index 357856c..27c7409 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -137,6 +137,28 @@ m in {0, 1, 1e-17, nextafter(1,0)}; matrix shapes are preserved by every function; empty input returns empty; F(conj u) = conj F(u), F(-u) = -F(u) and sn(conj u) = conj sn(u) hold exactly. +## Adversarial review rounds 4-5 (theta recurrence, parallel and GPU paths, batch independence, 2026-09-02) + +Attack surface: the code paths the unit tests never exercised on a +developer machine (parallel chunking, the GPU kernels under identity +stubs) and the invariant "a value must not depend on what else is in the +batch". + +| # | Attack | Result | Fix | +|---|--------|--------|-----| +| 4.1 | theta functions at large argument (`v ~ 1e8`) | `sin(k*v)` with `k*v` as a double product loses `eps*|k v|` (1e-12 at `v = 1e8`) in every series of `theta.m`, `theta_prime.m`, `jacobiThetaEta.m`, `weierstrassZeta.m`, `weierstrassSigma.m`, `theta.py`, `weierstrass.py` | one shared `theta_series.m` (all three MATLAB theta callers) and `theta._trig_start` (Python): `sin/cos((2n+1)v)` by angle-addition recurrence from `sin v, cos v`, so every term carries only the rounding of the reduced argument | +| 4.2 | parallel chunk path: `N` an exact multiple of `chunk_size` | `par_worker` re-entered the parallel dispatcher from inside a worker (stub `get_nworkers` ignored the `parallel` flag) and recursed until SIGILL | recursion guard in `par_worker.m`: the worker forces `elliptic_config('parallel', false)` for the duration of its call (restored in `unwind_protect_cleanup`) | +| 4.3 | chunked vs serial `elliptic3` on 1000 random points | 6 of 1000 differed by one ulp (rel 2.4e-16): the Carlson cores stopped on a whole-vector convergence test, so the number of duplication steps applied to an element depended on its batch mates | per-element convergence in `carlsonRF/RD/RJ.m`: an `active` mask freezes each element at its own converged step | +| 4.4 | same attack on `elliptic12` and `ellipj` | the final AGM row used was `max(n)` over the batch (`a(mn,:)`), so K and the Landen back-substitution scale changed with batch composition | first converged row per element (`a(n+1)`) in both CPU and GPU paths | +| 4.5 | GPU `elliptic12` at `u = 1e6`, `m = 1 - eps/2` vs CPU | Z off by 3.6e-11, E by 1.2e-10: the GPU phase reduction lacked the Cody-Waite tail term (`k * 1.22e-16 = 3.9e-11` at `k = 318310`) | tail term added to the GPU reduction, matching the CPU path | + +New test: `testParallel.m` block "chunking is exact" builds temporary +`get_nworkers`/`parcellfun` stubs, evaluates every parallel-capable +function serially and chunked for `N` in `{cs-1, cs, 2cs, 3cs, 3cs+7}` +(`cs = chunk_size`) and requires bit-identical results. The GPU-stub +probe (identity `gpuArray/gather`) now agrees with the CPU path to 0.0 on +4005 points including the large-`u` near-`m = 1` cases. + ## Deliberate limits and residual risk - CUDA/OpenCL hardware was not available during this audit. GPU source paths diff --git a/matlab/src/carlsonRD.m b/matlab/src/carlsonRD.m index b9ae512..7449192 100644 --- a/matlab/src/carlsonRD.m +++ b/matlab/src/carlsonRD.m @@ -45,19 +45,21 @@ S = zeros(size(x)); fac = ones(size(x)); % 4^{-n} -for iter = 1:200 % adaptive break decides; cap guards pathological input +% Per-element convergence: each element stops when IT has converged, so a +% value never depends on what else is in the batch (a vector-wide break +% made chunked and serial evaluations differ by an ulp). +active = true(size(x)); +for iter = 1:200 % per-element break decides; cap guards pathological input lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); sz = sqrt(z); - S = S + fac ./ (sz .* (z + lam)); - fac = fac ./ 4; - x = (x + lam) ./ 4; - y = (y + lam) ./ 4; - z = (z + lam) ./ 4; + S(active) = S(active) + fac(active) ./ (sz(active) .* (z(active) + lam(active))); + fac(active) = fac(active) ./ 4; + x(active) = (x(active) + lam(active)) ./ 4; + y(active) = (y(active) + lam(active)) ./ 4; + z(active) = (z(active) + lam(active)) ./ 4; A = (x + y + 3.*z) ./ 5; - rng = max([abs(x-A); abs(y-A); abs(z-A)]); - if rng < cr * min(A) - break; - end + active = active & (max([abs(x-A); abs(y-A); abs(z-A)], [], 1) >= cr * A); + if ~any(active), break; end end A = (x + y + 3.*z) ./ 5; diff --git a/matlab/src/carlsonRF.m b/matlab/src/carlsonRF.m index 8d85044..cb8143f 100644 --- a/matlab/src/carlsonRF.m +++ b/matlab/src/carlsonRF.m @@ -54,15 +54,18 @@ % The adaptive break decides; the cap only guards pathological input % (20 was too few for R_F(0, 1e-16, 1) and every K(m) at tiny m). +% Per-element convergence: each element stops when IT has converged, so a +% value never depends on what else is in the batch (a vector-wide break +% made chunked and serial evaluations differ by an ulp). +active = true(size(x)); for iter = 1:200 lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); - x = (x + lam) ./ 4; - y = (y + lam) ./ 4; - z = (z + lam) ./ 4; + x(active) = (x(active) + lam(active)) ./ 4; + y(active) = (y(active) + lam(active)) ./ 4; + z(active) = (z(active) + lam(active)) ./ 4; A = (x + y + z) ./ 3; - if max(max(abs(x - A)), max(max(abs(y - A)), abs(z - A))) < cr * min(A) - break; - end + active = active & (max([abs(x - A); abs(y - A); abs(z - A)], [], 1) >= cr * A); + if ~any(active), break; end end A = (x + y + z) ./ 3; diff --git a/matlab/src/carlsonRJ.m b/matlab/src/carlsonRJ.m index 068e3bd..f739374 100644 --- a/matlab/src/carlsonRJ.m +++ b/matlab/src/carlsonRJ.m @@ -52,22 +52,24 @@ % Each duplication divides the argument-ratio exponent (base 4) by one; the % adaptive break below decides, the cap only guards pathological input. % A cap of 30 covered ratios to ~1e16 only (RJ(1e-20,2e-20,3e-20,.5) 11% off). +% Per-element convergence: each element stops when IT has converged, so a +% value never depends on what else is in the batch (a vector-wide break +% made chunked and serial evaluations differ by an ulp). +active = true(size(x)); for iter = 1:200 lam = sqrt(x.*y) + sqrt(y.*z) + sqrt(z.*x); % R_C argument for sum term (DLMF 19.36.3) alpha = (p .* (sqrt(x) + sqrt(y) + sqrt(z)) + sqrt(x.*y.*z)).^2; beta = p .* (p + lam).^2; - S = S + fac .* carlsonRC_core(alpha, beta); - fac = fac ./ 4; - x = (x + lam) ./ 4; - y = (y + lam) ./ 4; - z = (z + lam) ./ 4; - p = (p + lam) ./ 4; + S(active) = S(active) + fac(active) .* carlsonRC_core(alpha(active), beta(active)); + fac(active) = fac(active) ./ 4; + x(active) = (x(active) + lam(active)) ./ 4; + y(active) = (y(active) + lam(active)) ./ 4; + z(active) = (z(active) + lam(active)) ./ 4; + p(active) = (p(active) + lam(active)) ./ 4; A = (x + y + z + 2.*p) ./ 5; - rng = max([abs(x-A); abs(y-A); abs(z-A); abs(p-A)]); - if rng < cr * min(A) - break; - end + active = active & (max([abs(x-A); abs(y-A); abs(z-A); abs(p-A)], [], 1) >= cr * A); + if ~any(active), break; end end A = (x + y + z + 2.*p) ./ 5; diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index d0093a0..116b9ac 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -122,7 +122,8 @@ period = floor((u(I) + K_vals) ./ (2 .* K_vals)); u_reduced = u(I) - 2 .* period .* K_vals; phin = zeros(1,mmax); - phin(:) = (2 .^ n(K)).*a(i,K).*u_reduced; + a_fin = reshape(a(sub2ind(size(a), n(K) + 1, K)), 1, mmax); % per-element converged AGM row (K may be a column) + phin(:) = (2 .^ n(K)).*a_fin.*u_reduced; while i > 1 i = i - 1; mask = n(K) >= i; @@ -234,7 +235,7 @@ % Reduce by the 2K quasi-period before the amplified Landen phase. % This mirrors the serial path and prevents large-argument phase loss. - a_final = gather(a(:,ii)); + a_cpu = gather(a); a_final = a_cpu(sub2ind(size(a_cpu), (1:mmax)', n + 1)); % per-element converged row K_vals = carlsonRF(zeros(size(mu)), 1-mu, ones(size(mu))); period = floor((u(I) + K_vals) ./ (2 .* K_vals)); u_reduced = u(I) - 2 .* period .* K_vals; diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index 8204a5c..d33a2cd 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -127,7 +127,10 @@ % (-pi/2, pi/2]: u_work is |u|, and the Landen branch term % pi*ceil(phin/pi-0.5) below misfires when phin lands on -pi/2, which a % symmetric reduction hits for every u within an ulp of pi/2 + k*pi. - K_vals = pi ./ (2 .* a(mn,:)); % K(m) for each unique m + % first converged AGM row per unique m (a(n+1,:)), not the batch-wide last + % row: a value must not depend on what else is in the batch + a_fin = reshape(a(sub2ind(size(a), n + 1, 1:mumax)), 1, mumax); + K_vals = pi ./ (2 .* a_fin); % K(m) for each unique m u_work = signU .* u(I); % == abs(u(I)) k_per = floor(u_work ./ pi); % number of full half-periods % Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at @@ -150,7 +153,7 @@ end end - Ff = phin ./ (a(mn,K).*e*2) + K_per; % F_reduced + period correction + Ff = phin ./ (a_fin(K).*e*2) + K_per; % F_reduced + period correction F(I) = Ff.*signU; % Incomplete Ell. Int. of the First Kind Z(I) = Cp.*signU; % Jacobi Zeta Function E(I) = (Cp + (1 - 1/2*C) .* Ff).*signU; % Incomplete Ell. Int. of the Second Kind @@ -265,10 +268,13 @@ % Landen descent and restore 2*k*K afterwards. The previous GPU % branch still evaluated the unreduced phase and therefore retained % the v4.1.0 regression even after the CPU path was repaired. - K_vals = pi ./ (2 .* a(:,mn)); + a_cpu = gather(a); a_fin = a_cpu(sub2ind(size(a_cpu), (1:mmax)', n + 1)); % per-element converged row + K_vals = pi ./ (2 .* a_fin); u_work = signU .* u(I); k_per = floor(u_work ./ pi); - phin = gpuArray(u_work - k_per .* pi); + % Cody-Waite split of pi, as in the CPU path: without the tail term the + % reduced phase is off by k*1.2e-16 (4e-11 at u = 1e6), which Z and E see. + phin = gpuArray((u_work - k_per .* pi) - k_per .* 1.2246467991473532e-16); K_per = 2 .* gpuArray(k_per) .* K_vals; C = gpuArray(zeros(mmax, 1)); Cp = gpuArray(zeros(mmax, 1)); @@ -283,7 +289,7 @@ Cp = Cp + active .* c(:,jj+1) .* sin(phin); end - Ff = phin ./ (a(:,mn) .* e * 2) + K_per; + Ff = phin ./ (gpuArray(a_fin) .* e * 2) + K_per; F(I) = gather(Ff) .* signU; Z(I) = gather(Cp) .* signU; E(I) = gather(Cp + (1 - 0.5*C) .* Ff) .* signU; diff --git a/matlab/src/par_worker.m b/matlab/src/par_worker.m index 02a71a9..99d569d 100644 --- a/matlab/src/par_worker.m +++ b/matlab/src/par_worker.m @@ -1,10 +1,27 @@ function result = par_worker(func_name, varargin) %PAR_WORKER Generic parallel worker for Octave parcellfun. % Calls the named function with the given arguments and packs -% multiple outputs into a cell array. Workers run in fresh -% processes where elliptic_config defaults to parallel=false, -% preventing recursive parallelism. +% multiple outputs into a cell array. +% +% The parallel dispatch is switched OFF for the duration of the call and +% restored afterwards. Relying on parcellfun workers being fresh +% processes (where the config defaults to parallel=false) was not +% enough: whenever N is a multiple of chunk_size every chunk has exactly +% chunk_size elements, so an in-process evaluation (a serial parcellfun +% fallback, a shared-state worker, or plain testing) re-entered the +% dispatch from inside the worker without bound and crashed Octave. + was_parallel = elliptic_config('parallel'); + elliptic_config('parallel', false); + unwind_protect + result = par_worker_dispatch(func_name, varargin{:}); + unwind_protect_cleanup + elliptic_config('parallel', was_parallel); + end_unwind_protect +end + + +function result = par_worker_dispatch(func_name, varargin) switch func_name case 'elliptic12' [F, E, Z] = elliptic12(varargin{:}); @@ -57,3 +74,4 @@ otherwise error('par_worker: unknown function %s', func_name); end +end diff --git a/matlab/tests/testParallel.m b/matlab/tests/testParallel.m index 740aa4d..e4ba554 100644 --- a/matlab/tests/testParallel.m +++ b/matlab/tests/testParallel.m @@ -76,3 +76,45 @@ %! [F2, E2] = elliptic12(u, m); %! assert(isequal(F1, F2), 'Serial results must be deterministic'); %! assert(isequal(E1, E2), 'Serial results must be deterministic'); + +% --------------------------------------------------------------------- +% Chunking path exercised WITHOUT the parallel package: a temporary dir +% shadows get_nworkers (3 workers) and provides a serial parcellfun with +% the same calling convention. Covers N below, at and above exact +% multiples of chunk_size -- the exact-multiple case re-entered the +% dispatch from inside par_worker without bound and crashed Octave. +% --------------------------------------------------------------------- +%!test +%! d = tempname(); mkdir(d); +%! fid = fopen(fullfile(d, 'get_nworkers.m'), 'w'); +%! fprintf(fid, 'function n = get_nworkers()\nif ~elliptic_config(''parallel''), n = 0; else n = 3; end\n'); fclose(fid); +%! fid = fopen(fullfile(d, 'parcellfun.m'), 'w'); +%! fprintf(fid, ['function varargout = parcellfun(nproc, fn, varargin)\n' ... +%! 'uo = true; args = varargin; k = find(strcmp(args, ''UniformOutput''), 1);\n' ... +%! 'if ~isempty(k), uo = args{k+1}; args(k:k+1) = []; end\n' ... +%! 'n = numel(args{1}); out = cell(1, n);\n' ... +%! 'for i = 1:n, a = cell(1, numel(args)); for j = 1:numel(args), a{j} = args{j}{i}; end; out{i} = fn(a{:}); end\n' ... +%! 'if uo, varargout{1} = [out{:}]; else varargout{1} = out; end\n']); fclose(fid); +%! addpath(d); +%! old_par = elliptic_config('parallel'); old_cs = elliptic_config('chunk_size'); +%! unwind_protect +%! cs = 500; +%! for N = [cs-1, cs, 2*cs, 3*cs, 3*cs+7] +%! rand('seed', N); u = rand(1,N)*40-20; m = rand(1,N)*0.98+0.01; c = rand(1,N)*0.9; +%! elliptic_config('parallel', false); +%! [F1,E1] = elliptic12(u,m); s1 = ellipj(u,m); P1 = elliptic3(u,m,c); [B1,D1,J1] = ellipticBDJ(u,m,c); +%! elliptic_config('parallel', true); elliptic_config('chunk_size', cs); +%! [F2,E2] = elliptic12(u,m); s2 = ellipj(u,m); P2 = elliptic3(u,m,c); [B2,D2,J2] = ellipticBDJ(u,m,c); +%! assert(isequal(size(F2), size(F1)) && max(abs(F2-F1)) == 0 && max(abs(E2-E1)) == 0, 'elliptic12 chunked != serial at N=%d', N); +%! assert(max(abs(s2-s1)) == 0, 'ellipj chunked != serial at N=%d', N); +%! assert(max(abs(P2-P1)) == 0, 'elliptic3 chunked != serial at N=%d', N); +%! assert(max(abs(J2-J1)) == 0 && max(abs(B2-B1)) == 0, 'ellipticBDJ chunked != serial at N=%d', N); +%! end +%! [ph, al] = meshgrid(linspace(0.1, 3, 40), linspace(0.05, 0.9, 30)); % matrix through the chunked path +%! Fm = elliptic12(ph, al); elliptic_config('parallel', false); Fs = elliptic12(ph, al); +%! assert(isequal(size(Fm), [30 40]) && max(abs(Fm(:)-Fs(:))) == 0, 'matrix shape/values through chunking'); +%! assert(elliptic_config('parallel') == false, 'par_worker must restore the parallel flag'); +%! unwind_protect_cleanup +%! elliptic_config('parallel', old_par); elliptic_config('chunk_size', old_cs); +%! rmpath(d); confirm_recursive_rmdir(false, 'local'); rmdir(d, 's'); +%! end_unwind_protect From 50bf14d4140430496fa90516a1b9803275305d83 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 18:55:28 +0400 Subject: [PATCH 14/23] fix: eight defects from a 3000-point cross-port parity sweep at extreme m Both ports were evaluated on identical random doubles (m down to 1e-16 and up to 1-1e-16, |u| up to 1e5) and every disagreement above 1e-13 was adjudicated against mpmath at the exact inputs. See round 6 in docs/specs/post-0d09740-regression-audit.md. MATLAB * elliptic12 (CPU+GPU): m in [eps^2, ~5e-16] returned F = E = Inf -- the AGM converges in one step, no Landen step ran and the scale e stayed 0. Scale 2^(n-2) now in closed form. * elliptic12 (CPU+GPU): the E/K sum stopped one AGM term early; E was off by 1.4e-13 near m -> 1. * theta, theta_prime, jacobiThetaEta: nome from ellipke(1-m) rounded 1-m first; theta1 was off by 1e-5 at m ~ 1e-16. K'(m) = R_F(0, m, 1) now. * elliptic3: the reflection used elliptic3(double(pi/2)) as the complete integral; cos(double(pi/2)) = 6e-17 leaves a 2e-7 sliver at m = 1-eps/2 (3e-10 relative). Exact Carlson complete form now. * elliptic3: c < 0 accepted (the Python port already did), via Carlson. * carlsonRJ: series term E3 = XYZ + 2 E2 P + 4 P^3 (DLMF 19.36.2), was 3 P^3 -- O(eps^4) residual of 1e-13 relative at the 0.0015 tolerance. * ellipticBDJ: n > 1 beyond the pole returned complex J silently; n = 1 returned NaN (0*Inf in the period term). Error / masked now. Both ports * k*pi reduction: the "Cody-Waite" split used double(pi) as the head, so k*pi already rounded by eps*|u| (2.3e-10 at u = 1e6). New sub_kpi / _xputils.sub_kpi: 25-bit PI_A, PI_B (k*PI exact for k < 2^28) + PI_C, verified to 4e-16 over 20000 random k < 2^27. Jacobi Zeta at u = 8e4 went from 1.5e-11 to 3e-17. * carlson.py: same E3 coefficient (masked there by 100 fixed duplications). * ellipticBDJ.py: n > 1 beyond the pole raised nothing and returned 1.147 (principal value 0.859) because the private _rj_xp skips the p > 0 check; n = 1 gave NaN. ValueError (NumPy) / NaN (traced) now. Tests * testEdgeCases block U / TestAdversarialRound6: mpmath anchors at the exact doubles for every defect above. * testDocExamples.m runs every docstring Example block; pytest now runs --doctest-modules (one example printed a 0-d array). After the fixes the sweep agrees with mpmath to < 2e-15 relative on F, E, Z, Pi, theta1 in both ports. Octave: 17 files, 0 failures; pytest 478 passed. --- docs/specs/post-0d09740-regression-audit.md | 29 ++++++++++++ matlab/src/carlsonRJ.m | 2 +- matlab/src/elliptic12.m | 27 ++++++----- matlab/src/elliptic3.m | 19 +++++--- matlab/src/ellipticBDJ.m | 20 ++++++-- matlab/src/jacobiThetaEta.m | 4 +- matlab/src/sub_kpi.m | 15 ++++++ matlab/src/theta.m | 4 +- matlab/src/theta_prime.m | 2 +- matlab/tests/testDocExamples.m | 46 ++++++++++++++++++ matlab/tests/testEdgeCases.m | 50 ++++++++++++++++++++ matlab/tests/testElliptic3.m | 2 +- python/elliptic/_xputils.py | 16 +++++++ python/elliptic/applications.py | 2 +- python/elliptic/carlson.py | 2 +- python/elliptic/elliptic12.py | 6 +-- python/elliptic/elliptic3.py | 4 +- python/elliptic/ellipticBDJ.py | 26 ++++++++-- python/octave-workspace | Bin 0 -> 447275 bytes python/pyproject.toml | 4 +- python/tests/test_edge_cases.py | 42 ++++++++++++++++ 21 files changed, 279 insertions(+), 43 deletions(-) create mode 100644 matlab/src/sub_kpi.m create mode 100644 matlab/tests/testDocExamples.m create mode 100644 python/octave-workspace diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index 27c7409..0370066 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -159,6 +159,35 @@ function serially and chunked for `N` in `{cs-1, cs, 2cs, 3cs, 3cs+7}` probe (identity `gpuArray/gather`) now agrees with the CPU path to 0.0 on 4005 points including the large-`u` near-`m = 1` cases. +## Adversarial review round 6 (cross-port parity sweep at extreme m, 2026-09-02) + +Attack: 3000 random points with `m` drawn from `U(0,1)`, `1 - 10^U(-16,-3)` +and `10^U(-16,-3)`, `u` from `U(-3,3)` and `U(-1e5,1e5)`, characteristic +`n` from `U(0,0.999)`; both ports evaluated on the identical doubles and +every disagreement above 1e-13 was adjudicated with mpmath at the exact +inputs. Eight defects, several older than the April refactor: + +| # | Function | Defect | Fix | +|---|----------|--------|-----| +| 6.1 | `elliptic12.m` (CPU + GPU) | `m` in `[eps^2, ~5e-16]`: the AGM converges in one step, no Landen step ran, the scale `e` stayed 0 and `F = E = Inf` | scale `2^(n-2)` in closed form (`n <= 1 -> 1/2`) | +| 6.2 | `theta.m`, `theta_prime.m`, `jacobiThetaEta.m` | nome from `ellipke(1-m)`: `1-m` rounds first, `q` was 30% off at `m ~ 1e-16` and `theta1` off by 1e-5 | `K'(m) = R_F(0, m, 1)` from the exact `m`, as `nomeq` already did | +| 6.3 | `elliptic12.m` (CPU + GPU) | `E/K = 1 - sum 2^(j-1) c_j^2` stopped one AGM term early (`2^(n-2) c_{n-1}^2 ~ 1e-14` near `m -> 1`); `E(-1.65|1-4e-15)` off by 1.4e-13 | the C sum takes `n` terms, the descent still `n-1` | +| 6.4 | all `k*pi` reductions (both ports) | the "Cody-Waite" split used `double(pi)` as the head, so `k*pi` itself rounded by `eps*|u|` (2.3e-10 at `u = 1e6`); Jacobi Zeta at `u = 8e4` was off by 1.5e-11 | `sub_kpi` / `_xputils.sub_kpi`: 25-bit `PI_A`, `PI_B` (products exact for `k < 2^28`) plus `PI_C`; verified to 4e-16 over 20000 random `k < 2^27` | +| 6.5 | `elliptic3.m` | the reflection `Pi(pi-u) = 2 Pi(pi/2) - Pi(u)` used `elliptic3(double(pi/2))`: `cos(double(pi/2)) = 6e-17`, and near `m = 1` the sliver `6e-17/(sqrt(1-m)(1-c))` is 2e-7 (relative 3e-10) | complete integral from the exact Carlson form `R_F(0,1-m,1) + (c/3) R_J(0,1-m,1,1-c)` | +| 6.6 | `carlsonRJ.m`, `carlson.py` | series term `E3 = XYZ + 2 E2 P + 3 P^3`; DLMF 19.36.2 has `4 P^3`. With the 0.0015 stopping tolerance the O(eps^4) residual was 1e-13 relative (Python masked it by running 100 duplications) | coefficient corrected; checked in exact arithmetic: residual 3e-22 | +| 6.7 | `ellipticBDJ` (both ports) | `n > 1` with the phase beyond the pole: MATLAB returned complex `J` silently, Python 1.147 (principal value 0.859); `n = 1` returned NaN from `0 * inf` in the period term | error (MATLAB / NumPy) or NaN (traced backends) beyond the pole; the complete `J(n|m)` is only added where a period was removed | +| 6.8 | `elliptic3.m` | rejected `c < 0` although the integral is standard there and the Python port accepts it | `c <= 1` accepted; `c < 0` routed to the Carlson branch (the 20-node rule loses digits as `1/(1+|c| sin^2)` narrows) | + +After the fixes the same 3000-point sweep agrees with mpmath to `< 2e-15` +relative on F, E, Z, Pi, theta1 in both ports (previously up to 3e-10, 1e-5 +for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` +(1e-11): the `4K` period is not a constant, so `u - 4kK` rounds by +`eps*|u|` in both ports; this is the documented limit of `ellipj`. + +Also in this round: every MATLAB docstring `Example:` block now runs as a +test (`testDocExamples.m`) and the Python docstrings run under +`pytest --doctest-modules` (one example printed a 0-d array and was fixed). + ## Deliberate limits and residual risk - CUDA/OpenCL hardware was not available during this audit. GPU source paths diff --git a/matlab/src/carlsonRJ.m b/matlab/src/carlsonRJ.m index f739374..953db05 100644 --- a/matlab/src/carlsonRJ.m +++ b/matlab/src/carlsonRJ.m @@ -79,7 +79,7 @@ P = -(X + Y + Z) ./ 2; % (A-p)/A E2 = X.*Y + X.*Z + Y.*Z - 3.*P.^2; -E3 = X.*Y.*Z + 2.*E2.*P + 3.*P.^3; +E3 = X.*Y.*Z + 2.*E2.*P + 4.*P.^3; % DLMF 19.36.2 (was 3P^3: O(eps^4) truncation, 1e-13 relative) E4 = (2.*X.*Y.*Z + E2.*P + 3.*P.^3) .* P; E5 = X.*Y.*Z.*P.^2; diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index d33a2cd..e6cf747 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -133,22 +133,27 @@ K_vals = pi ./ (2 .* a_fin); % K(m) for each unique m u_work = signU .* u(I); % == abs(u(I)) k_per = floor(u_work ./ pi); % number of full half-periods - % Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at - % eps*|u_r| instead of eps*|u| (pi_lo = pi - double(pi) = 1.2246467991473532e-16). - phin0 = (u_work - k_per .* pi) - k_per .* 1.2246467991473532e-16; % reduced to [0, pi) + phin0 = sub_kpi(u_work, k_per); % reduced to [0, pi), error eps*|phin0| (see SUB_KPI) K_per = 2 .* k_per .* K_vals(K); % period correction for F phin = zeros(1,mmax); C = zeros(1,mmax); - Cp = C; e = zeros(1,mmax); phin(:) = phin0; + Cp = C; phin(:) = phin0; + % Landen scale 2^(n-2) in closed form: F = phi_{n-1} / (2^(n-1) a_n). Assigning + % it inside the loop left e = 0 when no step ran (n <= 1, i.e. m in + % [eps^2, ~5e-16]) and F, E came out Inf. n = 0 (c_0 <= tol) also needs 1/2. + e = 2 .^ (max(n(K), 1) - 2); c2 = c.^2; e_vals = 2.^(0:mn-1); % pre-compute powers of 2 - for i = 1:mn % Descending Landen Transformation + for i = 1:mn % Descending Landen Transformation (C: mn terms, see below) + % E/K = 1 - sum_{j>=0} 2^(j-1) c_j^2 (A&S 17.6.4) needs one term more than + % the Landen descent: stopping C at i = n-1 dropped 2^(n-2) c_{n-1}^2, up to + % 1e-14 near m -> 1 (c_{n-1} ~ 1e-8), and E(u|1-4e-15) was off by 1.4e-13. + maskC = n(K) >= i; + C(maskC) = C(maskC) + e_vals(i)*c2(i,K(maskC)); mask = n(K) > i; if any(mask) phin(mask) = atan(b(i,K(mask))./a(i,K(mask)).*tan(phin(mask))) + ... pi.*ceil(phin(mask)/pi - 0.5) + phin(mask); - e(mask) = e_vals(i); - C(mask) = C(mask) + e_vals(i)*c2(i,K(mask)); Cp(mask)= Cp(mask) + c(i+1,K(mask)).*sin(phin(mask)); end end @@ -262,7 +267,7 @@ mn = max(n); % Precompute e from n (avoids GPU assignment in Landen loop) e_vals = 2 .^ (0:mn-1); - e = gpuArray(e_vals(max(n-1, 1))(:)); % column, e(j)=e_vals(n(j)-1) + e = gpuArray(2 .^ (max(n(:), 1) - 2)); % column, 2^(n-2); n <= 1 -> 1/2 (no Landen step) % Mirror the serial issue-#35 fix: reduce the phase before the % Landen descent and restore 2*k*K afterwards. The previous GPU @@ -272,9 +277,7 @@ K_vals = pi ./ (2 .* a_fin); u_work = signU .* u(I); k_per = floor(u_work ./ pi); - % Cody-Waite split of pi, as in the CPU path: without the tail term the - % reduced phase is off by k*1.2e-16 (4e-11 at u = 1e6), which Z and E see. - phin = gpuArray((u_work - k_per .* pi) - k_per .* 1.2246467991473532e-16); + phin = gpuArray(sub_kpi(u_work, k_per)); % exact k*pi split, as in the CPU path K_per = 2 .* gpuArray(k_per) .* K_vals; C = gpuArray(zeros(mmax, 1)); Cp = gpuArray(zeros(mmax, 1)); @@ -285,7 +288,7 @@ phin_new = atan(b(:,jj)./a(:,jj).*tan(phin)) + ... pi.*ceil(phin/pi - 0.5) + phin; phin = phin + active .* (phin_new - phin); - C = C + active .* e_vals(jj) .* c2(:,jj); + C = C + gpuArray(double(n >= jj)) .* e_vals(jj) .* c2(:,jj); % one term more than the descent (A&S 17.6.4) Cp = Cp + active .* c(:,jj+1) .* sin(phin); end diff --git a/matlab/src/elliptic3.m b/matlab/src/elliptic3.m index 0c48cc3..46fbfcb 100644 --- a/matlab/src/elliptic3.m +++ b/matlab/src/elliptic3.m @@ -30,8 +30,8 @@ if ~isreal(u) || ~isreal(m) || ~isreal(c) error('Input arguments must be real.') end -if any(m < 0) || any(m > 1) || any(c < 0) || any(c > 1), - error('M and C must be in the range [0, 1].'); +if any(m < 0) || any(m > 1) || any(c > 1), + error('M must be in the range [0, 1] and C <= 1.'); end % Reduce the phase to [0, pi/2] using oddness and the quasi-period % (the integrand is pi-periodic and even about every multiple of pi/2): @@ -43,9 +43,7 @@ if any(u(:) < 0) || any(u(:) > pi/2) signU = sign(u); ua = abs(u); k_per = floor(ua ./ pi); - % Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at - % eps*|u_r| instead of eps*|u| (pi_lo = pi - double(pi) = 1.2246467991473532e-16). - r = (ua - k_per .* pi) - k_per .* 1.2246467991473532e-16; % in [0, pi) + r = sub_kpi(ua, k_per); % in [0, pi), error eps*|r| (see SUB_KPI) refl = r > pi/2; ur = r; ur(refl) = pi - r(refl); % in [0, pi/2] Pred = elliptic3(ur, m, c); @@ -56,7 +54,12 @@ corr = zeros(size(ur)); idx = (k_per > 0) | refl; if any(idx(:)) - Pcpl = elliptic3(pi/2 + zeros(size(u)), m, c); + % Exact complete integral Pi(c|m) = R_F(0,1-m,1) + (c/3) R_J(0,1-m,1,1-c) + % (DLMF 19.25.?) -- NOT elliptic3(pi/2,...): cos(double(pi/2)) = 6e-17 is + % not 0, and near m = 1 the sliver between double(pi/2) and pi/2 is + % 6e-17/(sqrt(1-m)(1-c)) ~ 2e-7 (relative 3e-10 in the reflected value). + Pcpl = carlsonRF(zeros(size(u)), 1 - m, ones(size(u))) + ... + c ./ 3 .* carlsonRJ(zeros(size(u)), 1 - m, ones(size(u)), 1 - c); corr(idx) = 2 .* k_per(idx) .* Pcpl(idx) + 2 .* refl(idx) .* Pcpl(idx); end Pi = signU .* (corr + (1 - 2 .* refl) .* Pred); @@ -110,7 +113,9 @@ % poles (Pi(pi/2-1e-6 | m, c=1) was off by 3e-5). d2 = (1 - m) + m.*co.^2; p = (1 - c) + c.*co.^2; -danger = (d2 < 0.25) | (p < 0.25); +% c < 0 (allowed, as in the Python port): the integrand 1/(1 + |c| sin^2) +% narrows as |c| grows and the 20-node rule loses digits, so use Carlson. +danger = (d2 < 0.25) | (p < 0.25) | (c < 0); P = zeros(size(u)); regular = find(~danger); diff --git a/matlab/src/ellipticBDJ.m b/matlab/src/ellipticBDJ.m index 3deed45..50e3fe5 100644 --- a/matlab/src/ellipticBDJ.m +++ b/matlab/src/ellipticBDJ.m @@ -93,7 +93,7 @@ k = ceil(phi./pi - 0.5); % Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at % eps*|u_r| instead of eps*|u| (pi_lo = pi - double(pi) = 1.2246467991473532e-16). -phi = (phi - k .* pi) - k .* 1.2246467991473532e-16; % now in (-pi/2, pi/2] +phi = sub_kpi(phi, k); % now in (-pi/2, pi/2], error eps*|phi| (see SUB_KPI) s = sin(phi); c = cos(phi); @@ -128,11 +128,25 @@ if compute_J % 1 − n·s² (denominator parameter for R_J) p = 1 - n .* s.^2; + % n > 1 with the phase beyond the pole at arcsin(1/sqrt(n)) is a Cauchy + % principal-value integral (DLMF 19.7.3): R_J needs p > 0, and sqrt of a + % negative p silently produced COMPLEX output (J(1, 1.5|0.5) came out + % 0.86 - 1.81i). The complete J(n|m) is only needed when a period was + % removed (k ~= 0); at n = 1 it is a pole and 0*Inf made J NaN. + if any(p <= 0) || any(k ~= 0 & n >= 1) + error(['ellipticBDJ: n > 1 with phase beyond the pole at arcsin(1/sqrt(n)) ' ... + '(or n >= 1 with |phi| > pi/2) is a Cauchy principal-value integral ' ... + '(DLMF 19.7.3); not supported.']); + end RJ = carlsonRJ(c.^2, d2, one, p); J_val = s3o3 .* RJ; J_val(s == 0) = 0; - J_cpl = carlsonRJ(zed, 1-m, one, 1-n) ./ 3; % J(n|m) - J = reshape(J_val + 2 .* k .* J_cpl, origSize); + J = J_val; + kk = find(k ~= 0); + if ~isempty(kk) + J(kk) = J(kk) + 2 .* k(kk) .* carlsonRJ(zed(kk), 1-m(kk), one(kk), 1-n(kk)) ./ 3; % + 2k J(n|m) + end + J = reshape(J, origSize); else J = []; end diff --git a/matlab/src/jacobiThetaEta.m b/matlab/src/jacobiThetaEta.m index 13b5091..e46dd32 100644 --- a/matlab/src/jacobiThetaEta.m +++ b/matlab/src/jacobiThetaEta.m @@ -83,7 +83,7 @@ % q^(n^2) and is accurate to full double precision; the previous AGM-product % form lost ~11 digits of the overall normalisation and needed a deliberate % perturbation of u and m at the odd half-periods to stay finite. -q = exp(-pi .* ellipke(1-m) ./ KK); +q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ KK); % K(1-m) from the exact m (see NOMEQ) q(~(q < 1)) = 0; % m == 1 (and NaN) handled below v = pi .* u ./ (2 .* KK); @@ -152,7 +152,7 @@ if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end KK = ellipke(m); - q = exp(-pi .* ellipke(1-m) ./ KK); + q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ KK); % K(1-m) from the exact m (see NOMEQ) q(~(q < 1)) = 0; v = pi .* u ./ (2 .* KK); qmax = max([q(:); 0]); diff --git a/matlab/src/sub_kpi.m b/matlab/src/sub_kpi.m new file mode 100644 index 0000000..5ab04e0 --- /dev/null +++ b/matlab/src/sub_kpi.m @@ -0,0 +1,15 @@ +function r = sub_kpi(u, k) +%SUB_KPI r = u - k*pi with the product formed exactly (three-term split of pi). +% R = SUB_KPI(U, K) returns U - K*PI for integer-valued K, accurate to +% eps*|R| rather than eps*|U|. PI is split as PI_A + PI_B + PI_C where +% PI_A and PI_B carry 25 significant bits each, so K*PI_A and K*PI_B are +% exact in double for |K| < 2^28 (|U| < 8e8); the remaining K*PI_C rounds +% at eps*|K|*1.6e-8, far below eps*|R|. Using double(pi) as the leading +% term (the previous "Cody-Waite" split) does not help: K*double(pi) +% already rounds by eps*|U| (2.3e-10 at U = 1e6), which Jacobi Zeta and E +% inherit. +% +% PI_A = 0x1.921fb5p+1, PI_B = 0x1.110b46p-26, PI_C = pi - PI_A - PI_B +% (residual 1.3e-24). Works elementwise on host or GPU arrays. +r = ((u - k .* 3.1415926218032837) - k .* 1.5893254712295857e-08) - k .* 1.5893254834760535e-08; +end diff --git a/matlab/src/theta.m b/matlab/src/theta.m index ade54d9..95d36e5 100644 --- a/matlab/src/theta.m +++ b/matlab/src/theta.m @@ -64,7 +64,9 @@ % Evaluate the q-series directly on v. The old route v -> u = 2Kv/pi -> % jacobiThetaEta -> v = pi*u/(2K) round-tripped the argument and lost eps*|v| % (2e-10 at v ~ 1e8); THETA_SERIES also avoids the k*v product rounding. -q = exp(-pi .* ellipke(1-m) ./ ellipke(m)); +% K'(m) = R_F(0, m, 1) from the exact m: ellipke(1-m) rounds 1-m first and the +% nome was 30% off at m ~ 1e-16 (theta1 off by 1e-5); see NOMEQ. +q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ ellipke(m)); q(~(q < 1)) = 0; % m == 1: series diverges -> NaN below Th(:) = theta_series(type, v, q, tol); Th(m == 1) = NaN; diff --git a/matlab/src/theta_prime.m b/matlab/src/theta_prime.m index b8b4ed3..6a06493 100644 --- a/matlab/src/theta_prime.m +++ b/matlab/src/theta_prime.m @@ -89,7 +89,7 @@ end K = ellipke(m); -Kp = ellipke(1 - m); +Kp = carlsonRF(zeros(size(m)), m, ones(size(m))); % K(1-m) from the exact m (see NOMEQ) q = exp(-pi .* Kp ./ K); q(~(q < 1)) = 0; % m == 1 guard [th, thp] = theta_series(j, z, q, tol); diff --git a/matlab/tests/testDocExamples.m b/matlab/tests/testDocExamples.m new file mode 100644 index 0000000..d8fe942 --- /dev/null +++ b/matlab/tests/testDocExamples.m @@ -0,0 +1,46 @@ +function testDocExamples() +%TESTDOCEXAMPLES Every "Example:" block in the docstrings must run. +% Extracts the indented code under each "Example" heading of every +% matlab/src/*.m docstring and evaluates it in an isolated workspace. +% Lines starting with "Note" or "See also" end the block. The Python +% port runs its docstring examples through pytest --doctest-modules. +end + +%!function [ok, msg] = run_doc_example(src__) +%! ok = true; msg = ''; +%! try +%! evalc(src__); +%! catch err__ +%! ok = false; msg = err__.message; +%! end + +%!test +%! src = fullfile(fileparts(mfilename('fullpath')), '..', 'src'); +%! addpath(src); +%! files = dir(fullfile(src, '*.m')); +%! nrun = 0; +%! for f = 1:numel(files) +%! lines = strsplit(fileread(fullfile(files(f).folder, files(f).name)), "\n"); +%! i = 1; +%! while i <= numel(lines) +%! if ~isempty(regexp(lines{i}, '^\s*%\s*Example', 'once')) +%! code = {}; j = i + 1; +%! while j <= numel(lines) && ~isempty(regexp(lines{j}, '^\s*%\s{3,}\S', 'once')) +%! c = regexprep(lines{j}, '^\s*%\s*', ''); +%! if ~isempty(regexp(c, '^\s*(Note|See also)', 'once')), break; end +%! if isempty(regexp(c, '^\s*%', 'once')), code{end+1} = c; end +%! j = j + 1; +%! end +%! if ~isempty(code) +%! nrun = nrun + 1; +%! [ok, msg] = run_doc_example(strjoin(code, "\n")); +%! assert(ok, sprintf('%s:%d docstring example failed: %s', files(f).name, i, msg)); +%! end +%! i = j; +%! else +%! i = i + 1; +%! end +%! end +%! end +%! assert(nrun >= 8, sprintf('expected at least 8 docstring examples, found %d', nrun)); + diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 1e0af7b..2a8a91a 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -721,3 +721,53 @@ %! assert(abs(t - (1.0637286984176921296)) < 2e-15, 'theta4 at v=1.2e8'); %! assert(abs(tp - (0.0045203629452149075654)) < 2e-14, 'theta4'' at v=1.2e8'); %! assert(abs(theta(4, v, 0.4) - (1.0637286984176921296)) < 2e-15, 'theta() at v=1.2e8'); + +%% --------------------------------------------------------------------- +%% U. Round 6 (cross-port parity sweep at extreme m, 2026-09-02). Every +%% anchor is mpmath at the EXACT double inputs. +%% - m in [eps^2, ~5e-16]: the AGM converges in one step, no Landen step +%% ran and the scale e stayed 0 -> F = E = Inf. +%% - theta nome from ellipke(1-m): 1-m rounds, q was 30% off at m ~ 1e-16. +%% - E/K sum stopped one AGM term early: E off by 1.4e-13 near m -> 1. +%% - k*pi reduction: k*double(pi) rounds by eps*|u| (2e-10 at u = 1e6); +%% sub_kpi splits pi into 25-bit parts so k*PI_A, k*PI_B are exact. +%% - elliptic3 reflection used Pi(double(pi/2)) as the complete integral; +%% cos(double(pi/2)) = 6e-17 is not 0 and the sliver is 2e-7 at m = 1-eps/2. +%% - carlsonRJ series: E3 = XYZ + 2 E2 P + 4 P^3 (DLMF 19.36.2), not 3 P^3. +%% - ellipticBDJ: n > 1 beyond the pole gave complex J silently; n = 1 gave NaN. +%% - elliptic3 now accepts c < 0 (as the Python port does). +%% --------------------------------------------------------------------- +%!test +%! clear +%! [F, E] = elliptic12([1 1], [3e-16 5e-16]); +%! assert(all(isfinite(F)) && all(isfinite(E)), 'F, E must be finite for m ~ 3e-16 (were Inf)'); +%! assert(abs(F(1) - 1.0) < 5e-16 && abs(E(1) - 0.99999999999999996) < 5e-16, 'F(1|3e-16), E(1|3e-16)'); +%! assert(abs(F(2) - 1.0000000000000001) < 5e-16 && abs(E(2) - 0.99999999999999993) < 5e-16, 'F(1|5e-16), E(1|5e-16)'); +%! assert(abs(theta(1, 34401.9, 1.6e-16) - 0.00011178415088289534) < 1e-13 * 1.1e-4, 'theta1 at m = 1.6e-16 (nome from exact m)'); +%! assert(abs(theta_prime(1, 6577.39, 1.5e-16) - (-9.8878892558450512e-5)) < 1e-13 * 1e-4, 'theta_prime at m = 1.5e-16'); +%! [~, H] = jacobiThetaEta(6577.39 * 2 * ellipke(1.5e-16) / pi, 1.5e-16); +%! assert(abs(H - (-9.8878892558450512e-5)) < 1e-12 * 1e-4, 'jacobiThetaEta eta at m = 1.5e-16'); +%! [F, E] = elliptic12(-1.65181, 0.99999999999999578); +%! assert(abs(E - (-1.0032798131910099)) < 2e-14, 'E near m -> 1 (missing AGM term gave 1.4e-13)'); +%! assert(abs(F - (-32.666065762173088)) < 1e-14 * 33, 'F near m -> 1'); +%! u = 80101.48788857895; m = 0.9999533239086507; % 25497*pi + 0.3 +%! [F, E, Z] = elliptic12(u, m); +%! assert(abs(Z - 0.2477141143165845) < 2e-14, 'Jacobi Zeta at u = 8e4 (k*pi split)'); +%! assert(abs(E - 51001.284415600044) < 1e-14 * 51001, 'E at u = 8e4'); +%! assert(abs(F - 324959.38078465716) < 1e-14 * 324959, 'F at u = 8e4'); +%! [~, ~, Z] = elliptic12(1000000.123, 1 - eps/2); +%! assert(abs(Z - (-0.220434859492317)) < 2e-14, 'Jacobi Zeta at u = 1e6, m = 1-eps/2'); +%! assert(abs(elliptic3(-2.70143, 1 - eps/2, 0.9723) - (-1249.3300419938347)) < 1e-14 * 1249, 'elliptic3 reflection at m = 1-eps/2 (complete integral must be exact)'); +%! assert(abs(carlsonRJ(0.1, 0.2, 1, 3.0) - 1.1311524759367163) < 5e-15 * 1.13, 'RJ series E3 coefficient'); +%! assert(abs(carlsonRJ(0.292, 0.646, 1, 1.354) - 1.2806109121365949) < 5e-15 * 1.28, 'RJ series E3 coefficient'); +%! [~, ~, J] = ellipticBDJ(1, 0.5, 1); +%! assert(abs(J - 0.64877476917835824) < 5e-15, 'J at n = 1 (was NaN)'); +%! [~, ~, J] = ellipticBDJ(0.5, 0.5, 1.5); +%! assert(abs(J - 0.052791966372572887) < 5e-16, 'J at n = 1.5 below the pole'); +%! err = ''; +%! try, ellipticBDJ(1, 0.5, 1.5); catch e, err = e.message; end +%! assert(~isempty(strfind(err, 'principal')), 'J at n = 1.5 beyond the pole must error, not return complex'); +%! assert(abs(elliptic3(1, 0.5, -0.5) - 0.9560406633267465) < 5e-16, 'elliptic3 with c = -0.5'); +%! assert(abs(elliptic3(1, 0.5, -3.0) - 0.66684868942035313) < 5e-16, 'elliptic3 with c = -3'); +%! assert(abs(elliptic3(1, 0.5, -100.0) - 0.1523863772236308) < 5e-16, 'elliptic3 with c = -100 (Carlson branch)'); +%! assert(abs(elliptic3(4, 0.9, -100.0) - 0.4921742710224714) < 5e-15, 'elliptic3 with c = -100, reduced phase'); diff --git a/matlab/tests/testElliptic3.m b/matlab/tests/testElliptic3.m index ffc34bb..c66a37e 100644 --- a/matlab/tests/testElliptic3.m +++ b/matlab/tests/testElliptic3.m @@ -8,7 +8,7 @@ %! assert(false, "Module out of range didn't throw an error."); %! catch err % Verify that the error message contains the expected string -%! assert(~isempty(strfind(err.message, 'M and C must be in the range [0, 1].')), ... +%! assert(~isempty(strfind(err.message, 'M must be in the range [0, 1] and C <= 1.')), ... %! 'Unexpected error message: %s', err.message); %! end diff --git a/python/elliptic/_xputils.py b/python/elliptic/_xputils.py index 053a400..13127e2 100644 --- a/python/elliptic/_xputils.py +++ b/python/elliptic/_xputils.py @@ -5,6 +5,22 @@ import numpy as np +# Three-term split of pi: _PI_A and _PI_B carry 25 significant bits each, so +# k*_PI_A and k*_PI_B are exact in double for |k| < 2**28 (|u| < 8e8); the +# remaining k*_PI_C rounds at eps*|k|*1.6e-8, far below eps*|r|. Using +# float(pi) as the leading term ("Cody-Waite" with a 53-bit head) does not +# help: k*float(pi) already rounds by eps*|u| (2.3e-10 at u = 1e6), which +# Jacobi Zeta and E inherit. Residual pi - A - B - C = 1.3e-24. +_PI_A = 3.1415926218032837 # 0x1.921fb5p+1 +_PI_B = 1.5893254712295857e-08 # 0x1.110b46p-26 +_PI_C = 1.5893254834760535e-08 + + +def sub_kpi(u, k): + """u - k*pi for integer-valued k, accurate to eps*|result| (any backend).""" + return ((u - k * _PI_A) - k * _PI_B) - k * _PI_C + + def is_numpy(xp): """True for the eager numpy namespace, whichever module object represents it: ``numpy`` itself, or ``array_api_compat.numpy`` (what diff --git a/python/elliptic/applications.py b/python/elliptic/applications.py index 42ca2fc..7aefbd5 100644 --- a/python/elliptic/applications.py +++ b/python/elliptic/applications.py @@ -32,7 +32,7 @@ def arclength_ellipse(a, b, theta0=0.0, theta1=None): -------- Full perimeter of ellipse with a=5, b=10 (matches Mathematica): - >>> arclength_ellipse(5, 10) # doctest: +ELLIPSIS + >>> float(arclength_ellipse(5, 10)) # doctest: +ELLIPSIS 48.4422... Notes diff --git a/python/elliptic/carlson.py b/python/elliptic/carlson.py index 0cef695..fd237c7 100644 --- a/python/elliptic/carlson.py +++ b/python/elliptic/carlson.py @@ -213,7 +213,7 @@ def _rj_xp(xp, x, y, z, p): Z = (A - z) / A P = -(X + Y + Z) / 2.0 E2 = X*Y + X*Z + Y*Z - 3.0*P**2 - E3 = X*Y*Z + 2.0*E2*P + 3.0*P**3 + E3 = X*Y*Z + 2.0*E2*P + 4.0*P**3 # DLMF 19.36.2 (3P^3 was wrong; masked here by the fixed 100 duplications) E4 = (2.0*X*Y*Z + E2*P + 3.0*P**3) * P E5 = X*Y*Z * P**2 poly = (1.0 - 3.0*E2/14.0 + E3/6.0 + 9.0*E2**2/88.0 diff --git a/python/elliptic/elliptic12.py b/python/elliptic/elliptic12.py index 644c44d..e271f8b 100644 --- a/python/elliptic/elliptic12.py +++ b/python/elliptic/elliptic12.py @@ -17,10 +17,8 @@ import math import numpy as np -from ._xputils import get_xp, check_range +from ._xputils import get_xp, check_range, sub_kpi -_PI_HI = 3.141592653589793 # double nearest pi -_PI_LO = 1.2246467991473532e-16 # pi - _PI_HI from .carlson import _rf_xp, _rd_xp, _rf_numpy, _rd_numpy @@ -51,7 +49,7 @@ def _elliptic12_xp(xp, u, m): # Period reduction: F(u+kπ|m) = F(u|m) + 2k·K(m), Z period π k = xp.round(u / math.pi) # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| - u_r = (u - k * _PI_HI) - k * _PI_LO # reduced to (-π/2, π/2] + u_r = sub_kpi(u, k) # reduced to (-π/2, π/2], error eps*|u_r| # Complete integrals K(m), E(m) via Carlson z0 = xp.zeros_like(m) diff --git a/python/elliptic/elliptic3.py b/python/elliptic/elliptic3.py index ee622e5..0a82ebc 100644 --- a/python/elliptic/elliptic3.py +++ b/python/elliptic/elliptic3.py @@ -10,7 +10,7 @@ import math import numpy as np -from ._xputils import get_xp, is_numpy +from ._xputils import get_xp, is_numpy, sub_kpi from .carlson import _rf_xp, _rj_xp @@ -63,7 +63,7 @@ def elliptic3(u, m, n): ua = xp.abs(u) k_per = xp.floor(ua / math.pi) # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| - r = (ua - k_per * 3.141592653589793) - k_per * 1.2246467991473532e-16 # in [0, pi) + r = sub_kpi(ua, k_per) # in [0, pi), error eps*|r| refl = r > math.pi * 0.5 u_red = xp.where(refl, math.pi - r, r) # in [0, pi/2] s = xp.sin(u_red) diff --git a/python/elliptic/ellipticBDJ.py b/python/elliptic/ellipticBDJ.py index b096cf5..18d0278 100644 --- a/python/elliptic/ellipticBDJ.py +++ b/python/elliptic/ellipticBDJ.py @@ -19,7 +19,7 @@ import math -from ._xputils import get_xp +from ._xputils import get_xp, is_numpy, sub_kpi from .carlson import _rf_xp, _rd_xp, _rj_xp @@ -56,7 +56,7 @@ def ellipticBDJ(phi, m, n=None): # J(phi+k*pi,n|m) = J(phi,n|m) + 2k*J(n|m) k = xp.ceil(phi / math.pi - 0.5) # Cody-Waite split of pi: (u - k*PI_HI) - k*PI_LO keeps the reduction error at eps*|u_r| instead of eps*|u| - phi = (phi - k * 3.141592653589793) - k * 1.2246467991473532e-16 # now in (-pi/2, pi/2] + phi = sub_kpi(phi, k) # now in (-pi/2, pi/2], error eps*|phi| s = xp.sin(phi) c = xp.cos(phi) @@ -86,11 +86,27 @@ def ellipticBDJ(phi, m, n=None): if compute_J: p = 1.0 - n * s * s - RJ = _rj_xp(xp, c * c, d2, one, p) + # n > 1 with the phase beyond the pole at arcsin(1/sqrt(n)) is a + # Cauchy principal-value integral (DLMF 19.7.3): R_J needs p > 0. + # The private _rj_xp silently returned garbage there (1.147 for + # J(1, 1.5|0.5); the principal value is 0.859). The complete J(n|m) + # is only needed when a period was removed (k != 0); at n = 1 it is + # a pole, and 0 * inf turned J(phi, 1|m) into NaN for |phi| <= pi/2. + bad_inc = p <= 0.0 + bad_cpl = (k != 0.0) & (n >= 1.0) + if is_numpy(xp) and bool(xp.any(bad_inc | bad_cpl)): + raise ValueError( + "ellipticBDJ: n > 1 with phase beyond the pole at arcsin(1/sqrt(n)) " + "(or n >= 1 with |phi| > pi/2) is a Cauchy principal-value integral " + "(DLMF 19.7.3); not supported.") + p_safe = xp.where(bad_inc, one, p) + RJ = _rj_xp(xp, c * c, d2, one, p_safe) J_val = s3o3 * RJ J_val = xp.where(zero, xp.zeros_like(J_val), J_val) - J_cpl = _rj_xp(xp, zed, 1.0 - m, one, 1.0 - n) / 3.0 # J(n|m) - J_val = J_val + 2.0 * k * J_cpl + n_safe = xp.where(bad_cpl | (n == 1.0), xp.zeros_like(n), n) + J_cpl = _rj_xp(xp, zed, 1.0 - m, one, 1.0 - n_safe) / 3.0 # J(n|m) + J_val = J_val + xp.where(k == 0.0, xp.zeros_like(J_val), 2.0 * k * J_cpl) + J_val = xp.where(bad_inc | bad_cpl, xp.full_like(J_val, math.nan), J_val) else: J_val = None diff --git a/python/octave-workspace b/python/octave-workspace new file mode 100644 index 0000000000000000000000000000000000000000..e151ea36724d451311cc7755cf984d59682c3489 GIT binary patch literal 447275 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", "scipy>=1.8", "mpmath>=1.3"] packages = ["elliptic"] [tool.pytest.ini_options] -testpaths = ["tests"] -addopts = "-v --tb=short" +testpaths = ["tests", "elliptic"] +addopts = "-v --tb=short --doctest-modules" diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 35287e6..6d430a1 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -640,3 +640,45 @@ def test_weierstrass_root_order_is_enforced(self): # equal neighbours are the legitimate degenerate lattices (m = 1 / m = 0) assert math.isfinite(_s(elliptic.weierstrassP(0.5, 1.0, 1.0, -2.0))) assert math.isfinite(_s(elliptic.weierstrassP(0.5, 1.0, 0.0, 0.0))) + + +# ===================================================================== +# U. Round 6: cross-port parity sweep at extreme m (anchors: mpmath at the +# exact double inputs). The k*pi reduction now uses a 25-bit split of pi +# (k*float(pi) rounded by eps*|u|), the RJ series E3 coefficient is +# 4 P^3 (DLMF 19.36.2), ellipticBDJ rejects n > 1 beyond the pole and +# handles n = 1, and the Python-only tiny-m / theta-nome paths are pinned. +# ===================================================================== +class TestAdversarialRound6: + def test_tiny_m_band(self): + F, E, _ = elliptic.elliptic12(np.array([1.0, 1.0]), np.array([3e-16, 5e-16])) + assert np.all(np.isfinite(F)) and np.all(np.isfinite(E)) + assert abs(F[0] - 1.0) < 5e-16 and abs(E[0] - 0.99999999999999996) < 5e-16 + assert abs(F[1] - 1.0000000000000001) < 5e-16 and abs(E[1] - 0.99999999999999993) < 5e-16 + + def test_theta_nome_from_exact_m(self): + assert abs(_s(elliptic.theta(1, 34401.9, 1.6e-16)) - 0.00011178415088289534) < 1e-13 * 1.1e-4 + assert abs(_s(elliptic.theta_prime(1, 6577.39, 1.5e-16)[0]) - (-9.8878892558450512e-5)) < 1e-13 * 1e-4 + + def test_E_near_m1_and_large_u_pi_split(self): + F, E, _ = elliptic.elliptic12(-1.65181, 0.99999999999999578) + assert abs(_s(E) - (-1.0032798131910099)) < 5e-14 and abs(_s(F) - (-32.666065762173088)) < 1e-14 * 33 + u, m = 80101.48788857895, 0.9999533239086507 # 25497*pi + 0.3 + F, E, Z = elliptic.elliptic12(u, m) + assert abs(_s(Z) - 0.2477141143165845) < 2e-14 # eps*|u| = 1.8e-11 before the split + assert abs(_s(E) - 51001.284415600044) < 1e-14 * 51001 + assert abs(_s(F) - 324959.38078465716) < 1e-14 * 324959 + _, _, Z = elliptic.elliptic12(1000000.123, 1 - 2**-53) + assert abs(_s(Z) - (-0.220434859492317)) < 2e-14 + assert abs(_s(elliptic.elliptic3(-2.70143, 1 - 2**-53, 0.9723)) - (-1249.3300419938347)) < 1e-14 * 1249 + + def test_RJ_series_and_characteristic_domain(self): + assert abs(_s(elliptic.carlsonRJ(0.1, 0.2, 1, 3.0)) - 1.1311524759367163) < 5e-15 * 1.13 + assert abs(_s(elliptic.carlsonRJ(0.292, 0.646, 1, 1.354)) - 1.2806109121365949) < 5e-15 * 1.28 + assert abs(_s(elliptic.ellipticBDJ(1.0, 0.5, 1.0)[2]) - 0.64877476917835824) < 5e-15 # was NaN (0 * inf) + assert abs(_s(elliptic.ellipticBDJ(0.5, 0.5, 1.5)[2]) - 0.052791966372572887) < 5e-16 + with pytest.raises(ValueError, match="principal"): + elliptic.ellipticBDJ(1.0, 0.5, 1.5) # beyond the pole: returned 1.147 silently + for c, ref in [(-0.5, 0.9560406633267465), (-3.0, 0.66684868942035313), (-100.0, 0.1523863772236308)]: + assert abs(_s(elliptic.elliptic3(1.0, 0.5, c)) - ref) < 5e-16 + assert abs(_s(elliptic.elliptic3(4.0, 0.9, -100.0)) - 0.4921742710224714) < 5e-15 From a2f7164ac811edc3fe365d84279e4cf40da6b066 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 19:09:50 +0400 Subject: [PATCH 15/23] fix(cel): Bulirsch's kc-native algorithm in both ports cel evaluated through m = 1 - kc^2, which loses kc entirely below ~1e-8: cel1(1e-9) was Inf (MATLAB) / 2e6 (Python) against ln(4/kc) = 22.1. MATLAB also rejected kc > 1 (m < 0) and both ports returned Inf for p < 0, where Bulirsch defines the Cauchy principal value. Both cores are now Bulirsch's algorithm (Numer. Math. 13 (1969) 305): any real kc (the integral depends on kc^2 only), p < 0 gives the principal value (= Re Pi(1-p | m), checked against mpmath), p = 0 gives Inf, kc = 0 gives +-Inf unless b = 0 (finite limit). MATLAB iterates with a per-element active mask; Python runs a fixed 40-step ascent with converged elements frozen (backend-native). Cross-port results are bit-identical on 400 random points spanning kc = 1e-300 .. 100 and p = +-1e-3 .. 1e6, within 1e-16 of mpmath / the identity cel(1,0) + p cel(0,1) = K. Tests: testEdgeCases block V, TestAdversarialRound6.test_bulirsch_cel_is_kc_native. Docs: round 6.9 in docs/specs/post-0d09740-regression-audit.md. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 2 + matlab/src/cel.m | 96 +++++++++++---------- matlab/tests/testEdgeCases.m | 20 +++++ python/elliptic/bulirsch.py | 71 ++++++++++----- python/tests/test_edge_cases.py | 16 ++++ 5 files changed, 138 insertions(+), 67 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index 0370066..09c05cd 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -184,6 +184,8 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` (1e-11): the `4K` period is not a constant, so `u - 4kK` rounds by `eps*|u|` in both ports; this is the documented limit of `ellipj`. +| 6.9 | `cel` (both ports) | evaluated through `m = 1 - kc^2`, which loses `kc` entirely below ~1e-8: `cel1(1e-9)` was Inf (MATLAB) / 2e6 (Python) against `ln(4/kc) = 22.1`; MATLAB also rejected `kc > 1` (`m < 0`) and both returned Inf for `p < 0` | Bulirsch's own kc-native algorithm (Numer. Math. 13, 1969) in both ports: any real `kc`, `p < 0` is the Cauchy principal value (`= Re Pi(1-p | m)`, checked against mpmath), bit-identical across ports, 1e-16 from `kc = 1e-300` to 100 | + Also in this round: every MATLAB docstring `Example:` block now runs as a test (`testDocExamples.m`) and the Python docstrings run under `pytest --doctest-modules` (one example printed a 0-d array and was fixed). diff --git a/matlab/src/cel.m b/matlab/src/cel.m index 2fe3c7a..4d0db64 100644 --- a/matlab/src/cel.m +++ b/matlab/src/cel.m @@ -55,51 +55,59 @@ % ----------------------------------------------------------------------- function C = cel_core(kc, p, a, b) -%CEL_CORE Vectorised evaluation (row-vector inputs). -N = numel(kc); -C = zeros(1, N); - -bad = (kc < 0); -C(bad) = NaN; -pole = (p <= 0); -C(pole) = Inf; - -ok = ~bad & ~pole; -if ~any(ok), return; end - -m = 1 - kc(ok).^2; -[K, ~] = ellipke(m); -[B, D, ~] = ellipticBD(m); - -pp = p(ok); aa = a(ok); bb = b(ok); - -% Case p ≈ 1: use a*B + b*D -% Case p ≠ 1: use Carlson/Pi formula -p1 = abs(pp - 1) < 1e-12; -pn = ~p1; - -Cv = zeros(1, sum(ok)); -Cv(p1) = aa(p1) .* B(p1) + bb(p1) .* D(p1); - -if any(pn) - pn_m = m(pn); pn_p = pp(pn); pn_a = aa(pn); pn_b = bb(pn); pn_K = K(pn); - n_val = 1 - pn_p; % n for Π(n|m) = Π(1-p|m) - - % Compute J_complete(n|m) via Carlson at φ=π/2 (s=1, c=0, d=kc): - % J = (1/3) * RJ(0, kc², 1, 1-n) where 1-n = p - kc_pn = sqrt(1 - pn_m); - RJ_val = carlsonRJ(zeros(size(pn_m)), kc_pn.^2, ones(size(pn_m)), pn_p); - J_n = RJ_val ./ 3; % J_complete(n|m) = s³/3 * RJ at s=1 - - Pi_n = pn_K + n_val .* J_n; % Π(1-p|m) = K + (1-p)*J - - % Formula (DLMF §19.25, decomposition): - % cel = a*K + (b - a*p)*(Pi - K)/(1-p) - Cv(pn) = pn_a .* pn_K + (pn_b - pn_a .* pn_p) .* (Pi_n - pn_K) ./ n_val; +%CEL_CORE Bulirsch's algorithm (Numer. Math. 13 (1969) 305, "cel"), vectorised. +% Works directly with kc, so kc ~ 1e-9 (where 1 - kc^2 rounds to 1 and the +% previous ellipke/ellipticBD route returned Inf or garbage: cel1(1e-9) came +% out 2e6 instead of ln(4/kc) = 22.1) and kc > 1 (m < 0) are exact, and p < 0 +% yields the Cauchy principal value. Quadratically convergent Landen ascent; +% the stopping test |g - k| <= g*CA leaves an error of order CA^2. +CA = 1e-9; +N = numel(kc); +C = nan(1, N); +k = abs(kc); % the integral depends on kc^2 only +zero_kc = (k == 0); +% kc = 0: the integrand ~ b/(p cos) at pi/2 diverges unless b = 0; for b = 0 +% the limit kc -> 0 is finite and the ascent evaluates it from realmin. +k(zero_kc & (b == 0)) = realmin; +run = ~zero_kc | (b == 0); +if any(zero_kc & (b ~= 0)) + C(zero_kc & (b ~= 0)) = sign(b(zero_kc & (b ~= 0)) ./ p(zero_kc & (b ~= 0))) .* Inf; end - -C(ok) = Cv; - +if ~any(run), return; end +k = k(run); p = p(run); a = a(run); b = b(run); +n = numel(k); +e = k; em = ones(1, n); +pos = p > 0; +% p > 0 +pp = sqrt(p(pos)); p(pos) = pp; b(pos) = b(pos) ./ pp; +% p <= 0: transform to the p > 0 case (principal value); p = 0 gives Inf below +neg = ~pos; +if any(neg) + f = k(neg).^2; q = 1 - f; g = 1 - p(neg); f = f - p(neg); + q = q .* (b(neg) - a(neg) .* p(neg)); + pn = sqrt(f ./ g); + an = (a(neg) - b(neg)) ./ g; + b(neg) = -q ./ (g.^2 .* pn) + an .* pn; + a(neg) = an; p(neg) = pn; +end +active = true(1, n); +for it = 1:60 + f = a(active); + a(active) = a(active) + b(active) ./ p(active); + g = e(active) ./ p(active); + b(active) = 2 .* (b(active) + f .* g); + p(active) = p(active) + g; + g = em(active); + em(active) = em(active) + k(active); + conv = abs(g - k(active)) <= g .* CA; + idx = find(active); + kk = 2 .* sqrt(e(idx(~conv))); + k(idx(~conv)) = kk; + e(idx(~conv)) = kk .* em(idx(~conv)); + active(idx(conv)) = false; + if ~any(active), break; end +end +C(run) = pi / 2 .* (b + a .* em) ./ (em .* (em + p)); % ----------------------------------------------------------------------- function [kc, p, a, b] = cel_broadcast(kc, p, a, b) diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 2a8a91a..7b73a05 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -771,3 +771,23 @@ %! assert(abs(elliptic3(1, 0.5, -3.0) - 0.66684868942035313) < 5e-16, 'elliptic3 with c = -3'); %! assert(abs(elliptic3(1, 0.5, -100.0) - 0.1523863772236308) < 5e-16, 'elliptic3 with c = -100 (Carlson branch)'); %! assert(abs(elliptic3(4, 0.9, -100.0) - 0.4921742710224714) < 5e-15, 'elliptic3 with c = -100, reduced phase'); + +%% --------------------------------------------------------------------- +%% V. Bulirsch cel (round 6b). The old route through m = 1 - kc^2 lost kc +%% entirely below ~1e-8 (cel1(1e-9) was Inf here, 2e6 in Python; the value +%% is ln(4/kc) = 22.1), rejected kc > 1 (m < 0) and returned Inf for p < 0. +%% Bulirsch's own algorithm is kc-native; p < 0 is the Cauchy principal +%% value, equal to Re Pi(1-p | 1-kc^2) (mpmath). +%% --------------------------------------------------------------------- +%!test +%! clear +%! assert(abs(cel1(1e-9) - (22.109560198066302)) < 1e-15 * 22, 'cel1(1e-9) = ln(4/kc)+...'); +%! assert(abs(cel1(1e-300) - (692.1618222593336)) < 1e-15 * 692, 'cel1(1e-300)'); +%! assert(abs(cel1(2) - (1.0782578237498216)) < 1e-15, 'cel1(2): kc > 1 is m = -3'); +%! assert(abs(cel(0.5, -0.5, 1, 1) - (-1.0782578237498216)) < 1e-15 * 1.1, 'cel with p < 0 = principal value Re Pi(1.5|0.75)'); +%! assert(abs(cel(0.7, -5, 1, 1) - (-0.092277884964284496)) < 1e-15, 'cel with p = -5'); +%! assert(abs(cel(1e-9, 0.3, 1.5, -0.7) - (-46.045402351061091)) < 1e-15 * 47, 'general cel at kc = 1e-9'); +%! assert(cel(0.3, 1, 1, 1) == cel1(0.3) && cel(-0.3, 1, 1, 1) == cel1(0.3), 'cel depends on kc^2 only'); +%! assert(isinf(cel1(0)) && cel1(0) > 0, 'cel1(0) = K(1) = Inf'); +%! kc = [1e-12 0.3 0.9 2.5]; p = [-0.4 0.7 -3 1e-3]; +%! assert(all(abs(cel(kc, p, 1, 0) + p .* cel(kc, p, 0, 1) - cel1(kc)) < 1e-14 .* max(1, cel1(kc))), 'cel(1,0) + p cel(0,1) = K'); diff --git a/python/elliptic/bulirsch.py b/python/elliptic/bulirsch.py index a86026c..12bd3cb 100644 --- a/python/elliptic/bulirsch.py +++ b/python/elliptic/bulirsch.py @@ -15,9 +15,6 @@ import numpy as np from ._xputils import get_xp -from .ellipticBD import _bd_xp -from .elliptic12 import _elliptic12_xp -from .carlson import _rj_xp def cel(kc, p, a, b): @@ -32,26 +29,54 @@ def cel(kc, p, a, b): def _cel_xp(xp, kc, p, a, b): - m = 1.0 - kc * kc - phi = xp.full_like(m, math.pi * 0.5) - K, _, _ = _elliptic12_xp(xp, phi, m) - B, D, _ = _bd_xp(xp, m) - - # p ≈ 1 branch: C = a*B + b*D - C_p1 = a * B + b * D - - # p ≠ 1 branch: C = a*K + (b - a*p)*(Pi - K)/(1-p) - n_val = 1.0 - p - mc = 1.0 - m - n_safe = xp.where(xp.abs(n_val) < 1e-14, xp.ones_like(n_val), n_val) - RJ = _rj_xp(xp, xp.zeros_like(m), mc, xp.ones_like(m), p) - J_n = RJ / 3.0 - Pi_n = K + n_val * J_n - C_pn = a * K + (b - a * p) * (Pi_n - K) / n_safe - - C = xp.where(xp.abs(p - 1.0) < 1e-12, C_p1, C_pn) - C = xp.where(kc < 0.0, xp.full_like(C, math.nan), C) - C = xp.where(p <= 0.0, xp.full_like(C, math.inf), C) + """Bulirsch's algorithm (Numer. Math. 13 (1969) 305), backend-native. + + Works directly with kc: the previous route through m = 1 - kc**2 lost kc + entirely below ~1e-8 (cel1(1e-9) returned 2e6; ln(4/kc) = 22.1). Any + real kc (kc > 1 is m < 0); p < 0 is the Cauchy principal value; p = 0 + gives inf. Quadratic Landen ascent run for a fixed number of steps with + converged elements frozen (no data-dependent branching). + """ + CA = 1e-9 + k = xp.abs(kc) + zero_kc = k == 0.0 + # kc = 0: divergent unless b = 0, where the kc -> 0 limit is finite and + # the ascent evaluates it from the smallest normal number. + k = xp.where(zero_kc, xp.full_like(k, 2.2250738585072014e-308), k) + e = k + em = xp.ones_like(k) + pos = p > 0.0 + p_safe = xp.where(pos, p, xp.ones_like(p)) + # p > 0 + sp = xp.sqrt(p_safe) + # p <= 0: transform to the p > 0 case (principal value) + g0 = 1.0 - p + g0_safe = xp.where(pos, xp.ones_like(g0), g0) + f0 = k * k - p + q0 = (1.0 - k * k) * (b - a * p) + pn = xp.sqrt(xp.where(pos, xp.ones_like(f0), f0 / g0_safe)) + an = (a - b) / g0_safe + bn = -q0 / (g0_safe * g0_safe * pn) + an * pn + p = xp.where(pos, sp, pn) + b = xp.where(pos, b / sp, bn) + a = xp.where(pos, a, an) + active = xp.ones_like(k, dtype=xp.bool) + for _ in range(40): + f = a + a = xp.where(active, a + b / p, a) + g = e / p + b = xp.where(active, 2.0 * (b + f * g), b) + p = xp.where(active, p + g, p) + g = em + em = xp.where(active, em + k, em) + conv = xp.abs(g - k) <= g * CA + step = active & ~conv + kk = 2.0 * xp.sqrt(e) + e = xp.where(step, kk * em, e) + k = xp.where(step, kk, k) + active = step + C = math.pi / 2.0 * (b + a * em) / (em * (em + p)) + C = xp.where(zero_kc & (b != 0.0), xp.sign(b / xp.where(p == 0, xp.ones_like(p), p)) * math.inf, C) return C diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 6d430a1..089bca2 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -682,3 +682,19 @@ def test_RJ_series_and_characteristic_domain(self): for c, ref in [(-0.5, 0.9560406633267465), (-3.0, 0.66684868942035313), (-100.0, 0.1523863772236308)]: assert abs(_s(elliptic.elliptic3(1.0, 0.5, c)) - ref) < 5e-16 assert abs(_s(elliptic.elliptic3(4.0, 0.9, -100.0)) - 0.4921742710224714) < 5e-15 + + def test_bulirsch_cel_is_kc_native(self): + """Bulirsch's algorithm: the old route through m = 1 - kc**2 lost kc + below ~1e-8 (cel1(1e-9) returned 2e6; ln(4/kc) = 22.1). p < 0 is the + Cauchy principal value = Re Pi(1-p | 1-kc^2) (mpmath).""" + assert abs(_s(elliptic.cel1(1e-9)) - (22.109560198066302)) < 1e-15 * 22 + assert abs(_s(elliptic.cel1(1e-300)) - (692.1618222593336)) < 1e-15 * 692 + assert abs(_s(elliptic.cel1(2.0)) - (1.0782578237498216)) < 1e-15 # kc > 1 is m = -3 + assert abs(_s(elliptic.cel(0.5, -0.5, 1.0, 1.0)) - (-1.0782578237498216)) < 1e-15 * 1.1 + assert abs(_s(elliptic.cel(0.7, -5.0, 1.0, 1.0)) - (-0.092277884964284496)) < 1e-15 + assert abs(_s(elliptic.cel(1e-9, 0.3, 1.5, -0.7)) - (-46.045402351061091)) < 1e-15 * 47 + assert _s(elliptic.cel(-0.3, 1.0, 1.0, 1.0)) == _s(elliptic.cel1(0.3)) # depends on kc^2 only + assert math.isinf(_s(elliptic.cel1(0.0))) and _s(elliptic.cel1(0.0)) > 0 + kc = np.array([1e-12, 0.3, 0.9, 2.5]); p = np.array([-0.4, 0.7, -3.0, 1e-3]) + K = np.asarray(elliptic.cel1(kc)) + assert np.all(np.abs(np.asarray(elliptic.cel(kc, p, 1.0, 0.0)) + p * np.asarray(elliptic.cel(kc, p, 0.0, 1.0)) - K) < 1e-14 * np.maximum(1, K)) From 21474223f734d053d5de1c38587ea80978d2fb74 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 19:18:57 +0400 Subject: [PATCH 16/23] fix: four more parity-sweep defects (complex Z, Delta^2, jacobiEDJ, arclength) Sweep of the remaining outputs (complex E/Z, jacobiEDJ, ellipticBD, agm, arclength_ellipse) on 1000 random points against mpmath; rounds 6.10-6.13 in docs/specs/post-0d09740-regression-audit.md. * elliptic12i.py (Jacobi Zeta output): the complete K, E were taken as F(double(pi/2)|m); cos(double(pi/2)) = 6e-17 leaves K 5.8e-9 relative short at m = 1-eps/2 and Z inherited 1.8e-11. Exact Carlson complete forms now (3.5e-16). * ellipticBDJ (both ports): Delta^2 = 1 - m sin^2 cancels near phi = pi/2 as m -> 1 (relative 2.5e-9 at m = 1-1e-8), which R_D turned into 3e-10 in D(phi|m). Delta^2 = (1-m) + m cos^2 now. * jacobiEDJ (both ports): took am(u) at |u| ~ 1e3 before reducing, where the map phi -> D is steep (1/sqrt(1-m)); D_u(1520|1-1e-8) was off by 3e-10. Reduce u by 2K first, amplitude of the reduced argument, add 2k complete integrals; now at the eps*|u| floor in both ports. * arclength_ellipse.m: if(ab) on arrays has all-elements semantics, so any mixed array fell through to the circle formula for every element. Elementwise masks after broadcasting scalars. Tests: testEdgeCases block W, TestAdversarialRound6.test_round6c_*. Octave: 17 files, 0 failures; pytest green. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 4 +++ matlab/src/arclength_ellipse.m | 38 +++++++++++++-------- matlab/src/ellipticBDJ.m | 5 ++- matlab/src/jacobiEDJ.m | 35 ++++++++++++++----- matlab/tests/testEdgeCases.m | 18 ++++++++++ python/elliptic/complex_elliptic.py | 8 ++++- python/elliptic/ellipticBDJ.py | 5 ++- python/elliptic/jacobi_edj.py | 29 ++++++++++++---- python/tests/test_edge_cases.py | 12 +++++++ 9 files changed, 122 insertions(+), 32 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index 09c05cd..eb47957 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -185,6 +185,10 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` `eps*|u|` in both ports; this is the documented limit of `ellipj`. | 6.9 | `cel` (both ports) | evaluated through `m = 1 - kc^2`, which loses `kc` entirely below ~1e-8: `cel1(1e-9)` was Inf (MATLAB) / 2e6 (Python) against `ln(4/kc) = 22.1`; MATLAB also rejected `kc > 1` (`m < 0`) and both returned Inf for `p < 0` | Bulirsch's own kc-native algorithm (Numer. Math. 13, 1969) in both ports: any real `kc`, `p < 0` is the Cauchy principal value (`= Re Pi(1-p | m)`, checked against mpmath), bit-identical across ports, 1e-16 from `kc = 1e-300` to 100 | +| 6.10 | `elliptic12i.py` (Jacobi Zeta output) | complete `K`, `E` taken as `F(double(pi/2)|m)`: `cos(double(pi/2)) = 6e-17`, K 5.8e-9 relative short at `m = 1-eps/2`, Z off by 1.8e-11 | exact Carlson complete forms `R_F(0,1-m,1)`, `R_F - (m/3) R_D` | +| 6.11 | `ellipticBDJ` (both ports) | `Delta^2 = 1 - m sin^2 phi` cancels near `phi = pi/2` as `m -> 1` (relative 2.5e-9 at `m = 1-1e-8`), which `R_D` turned into 3e-10 in `D(phi|m)` | `Delta^2 = (1-m) + m cos^2 phi` | +| 6.12 | `jacobiEDJ` (both ports) | took `am(u)` at `|u| ~ 1e3` (rounding `eps*|am|`) and only then reduced, where the map `phi -> D` is steep (`1/sqrt(1-m)`): `D_u(1520|1-1e-8)` off by 3e-10 on top of 6.11 | reduce `u` by `2K` first, amplitude of the reduced argument, add `2k` times the complete integrals; now at the `eps*|u|` floor (`dD_u/du = sn^2 <= 1`) | +| 6.13 | `arclength_ellipse.m` | `if (a < b) ... elseif (a > b)` on arrays uses all-elements semantics: any mixed array fell through to the circle formula `a (theta1 - theta0)` for every element | elementwise masks after broadcasting scalars (the Python port was already elementwise) | Also in this round: every MATLAB docstring `Example:` block now runs as a test (`testDocExamples.m`) and the Python docstrings run under diff --git a/matlab/src/arclength_ellipse.m b/matlab/src/arclength_ellipse.m index 3c88e49..dd5450f 100644 --- a/matlab/src/arclength_ellipse.m +++ b/matlab/src/arclength_ellipse.m @@ -96,23 +96,33 @@ theta1 = 2*pi; end +% Broadcast scalars to a common size, then branch ELEMENTWISE. The previous +% if(ab) on arrays used all-elements semantics, so any mixed +% array fell through to the circle formula for every element. +sz = size(a); +for x = {b, theta0, theta1} + if numel(x{1}) > 1, sz = size(x{1}); end +end +a = a + zeros(sz); b = b + zeros(sz); theta0 = theta0 + zeros(sz); theta1 = theta1 + zeros(sz); + % Default solution for a==b (circles) arclength = a.*(theta1-theta0); -% Ellipses (ab) -if(ab) - % Theta measured from a axis = semi-MAJOR axis - % Standard formulation will not work ((1-(a/b)^2) < 0); instead use PI/2 - phi and b/a instead of a/b - [F1, E1] = elliptic12( pi/2 - theta1, 1 - (b./a).^2 ); - [F0, E0] = elliptic12( pi/2 - theta0, 1 - (b./a).^2 ); - % d(PI/2 - phi)/dphi = -1, so reverse operands in this difference to flip sign: - arclength = a.*(E0 - E1); +% Ellipses: theta measured from the a axis +lt = a < b; % a is the semi-MINOR axis: standard E(phi|m) +if any(lt(:)) + m = 1 - (a(lt)./b(lt)).^2; + [~, E1] = elliptic12(theta1(lt), m); + [~, E0] = elliptic12(theta0(lt), m); + arclength(lt) = b(lt).*(E1 - E0); +end +gt = a > b; % a is the semi-MAJOR axis: (1-(a/b)^2) < 0, use pi/2 - phi and b/a +if any(gt(:)) + m = 1 - (b(gt)./a(gt)).^2; + [~, E1] = elliptic12(pi/2 - theta1(gt), m); + [~, E0] = elliptic12(pi/2 - theta0(gt), m); + % d(pi/2 - phi)/dphi = -1, so reverse the operands to flip the sign: + arclength(gt) = a(gt).*(E0 - E1); end return; diff --git a/matlab/src/ellipticBDJ.m b/matlab/src/ellipticBDJ.m index 50e3fe5..d4f22d7 100644 --- a/matlab/src/ellipticBDJ.m +++ b/matlab/src/ellipticBDJ.m @@ -97,7 +97,10 @@ s = sin(phi); c = cos(phi); -d2 = 1 - m .* s.^2; % Δ² +% Δ² = (1-m) + m cos² instead of 1 - m sin²: the latter cancels near +% phi = pi/2 as m -> 1 (relative 2.5e-9 at m = 1-1e-8, which R_D turned into +% 3e-10 in D(phi|m) and hence in jacobiEDJ at large u). +d2 = (1 - m) + m .* c.^2; % Δ² d = sqrt(d2); % Δ % s³/3 factor diff --git a/matlab/src/jacobiEDJ.m b/matlab/src/jacobiEDJ.m index 75280a5..5038768 100644 --- a/matlab/src/jacobiEDJ.m +++ b/matlab/src/jacobiEDJ.m @@ -39,16 +39,33 @@ % Get amplitude phi = am(u|m) via ellipj % ellipj returns [sn, cn, dn, am] +% Reduce u by the period 2K BEFORE taking the amplitude. am(u) at |u| ~ 1e3 +% carries eps*|am| ~ 5e-14 of rounding, and near phi = pi/2 with m -> 1 the +% map phi -> D(phi|m) is steep (dD/dphi = sin^2/Delta ~ 1/sqrt(1-m)), so +% D_u(1520|1-1e-8) was off by 3e-10. As functions of u the integrals are +% perfectly conditioned (dD_u/du = sn^2 <= 1): reduce u, take the amplitude +% of the reduced argument (|am_r| <= pi/2), add 2k times the complete +% integrals. +if isscalar(m) && ~isscalar(u), m = m + zeros(size(u)); end +if isscalar(u) && ~isscalar(m), u = u + zeros(size(m)); end +if compute_J && isscalar(n) && ~isscalar(u), n = n + zeros(size(u)); end +one = ones(size(m)); zed = zeros(size(m)); +K = carlsonRF(zed, 1 - m, one); +k = floor((u + K) ./ (2 .* K)); +u_r = u - 2 .* k .* K; +[~, ~, ~, phi_r] = ellipj(u_r, m); +D_cpl = carlsonRD(zed, 1 - m, one) ./ 3; % D(m) if compute_J - [~, ~, ~, phi] = ellipj(u, m); - [B, D, J] = ellipticBDJ(phi, m, n); - Eu = u - m .* D; % E_u = u - m*D_u - Du = D; - Ju = J; + [~, D_r, J_r] = ellipticBDJ(phi_r, m, n); + J_cpl = zed; + kk = find(k ~= 0); + if ~isempty(kk) + J_cpl(kk) = carlsonRJ(zed(kk), 1 - m(kk), one(kk), 1 - n(kk)) ./ 3; % J(n|m) + end + Ju = J_r + 2 .* k .* J_cpl; else - [~, ~, ~, phi] = ellipj(u, m); - [~, D] = ellipticBDJ(phi, m); - Eu = u - m .* D; - Du = D; + [~, D_r] = ellipticBDJ(phi_r, m); Ju = []; end +Du = D_r + 2 .* k .* D_cpl; +Eu = u - m .* Du; % E_u = u - m*D_u (error eps*|u|, the conditioning floor) diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 7b73a05..c005f2d 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -791,3 +791,21 @@ %! assert(isinf(cel1(0)) && cel1(0) > 0, 'cel1(0) = K(1) = Inf'); %! kc = [1e-12 0.3 0.9 2.5]; p = [-0.4 0.7 -3 1e-3]; %! assert(all(abs(cel(kc, p, 1, 0) + p .* cel(kc, p, 0, 1) - cel1(kc)) < 1e-14 .* max(1, cel1(kc))), 'cel(1,0) + p cel(0,1) = K'); + +%% --------------------------------------------------------------------- +%% W. Round 6c (sweep of the remaining outputs). mpmath at exact doubles. +%% - ellipticBDJ formed Delta^2 = 1 - m sin^2, which cancels near phi = pi/2 +%% as m -> 1; jacobiEDJ also took am(u) at |u| ~ 1e3 before reducing, so +%% D_u(1520|1-1e-8) was off by 3e-10. Both are at the eps*|u| floor now. +%% - arclength_ellipse branched with if(a 1 (relative 2.5e-9 at m = 1-1e-8; R_D turned it + # into 3e-10 in D(phi|m) and hence in jacobiEDJ at large u). + d2 = (1.0 - m) + m * c * c s3o3 = s * s * s / 3.0 one = xp.ones_like(phi) diff --git a/python/elliptic/jacobi_edj.py b/python/elliptic/jacobi_edj.py index 8d2ec03..a219226 100644 --- a/python/elliptic/jacobi_edj.py +++ b/python/elliptic/jacobi_edj.py @@ -12,6 +12,7 @@ from ._xputils import get_xp from .ellipj import ellipj +from .carlson import _rf_xp, _rd_xp, _rj_xp from .ellipticBDJ import ellipticBDJ @@ -42,10 +43,26 @@ def jacobiEDJ(u, m, n=None): n = xp.asarray(n, dtype=xp.float64) u_arr, m_arr, n = xp.broadcast_arrays(u_arr, m_arr, n) - _, _, _, phi = ellipj(u_arr, m_arr) - B, D, J = ellipticBDJ(phi, m_arr, n) - - Du = D - Eu = u_arr - m_arr * D - Ju = J + # Reduce u by the period 2K BEFORE taking the amplitude. am(u) at + # |u| ~ 1e3 carries eps*|am| ~ 5e-14 of rounding, and near phi = pi/2 + # with m -> 1 the map phi -> D(phi|m) is steep (dD/dphi = sin^2/Delta + # ~ 1/sqrt(1-m)): D_u(1520|1-1e-8) was off by 3e-10. As functions of + # u the integrals are perfectly conditioned (dD_u/du = sn^2 <= 1). + zed = xp.zeros_like(m_arr) + one = xp.ones_like(m_arr) + K = _rf_xp(xp, zed, 1.0 - m_arr, one) + k = xp.floor((u_arr + K) / (2.0 * K)) + u_r = u_arr - 2.0 * k * K + _, _, _, phi_r = ellipj(u_r, m_arr) # |phi_r| <= pi/2 + B_r, D_r, J_r = ellipticBDJ(phi_r, m_arr, n) + D_cpl = _rd_xp(xp, zed, 1.0 - m_arr, one) / 3.0 # D(m) + Du = D_r + 2.0 * k * D_cpl + Eu = u_arr - m_arr * Du # error eps*|u|: the conditioning floor + if n is not None: + # J(n|m) only where a period was removed (n = 1 is a pole there) + n_safe = xp.where(k == 0.0, zed, n) + J_cpl = _rj_xp(xp, zed, 1.0 - m_arr, one, 1.0 - n_safe) / 3.0 + Ju = J_r + xp.where(k == 0.0, zed, 2.0 * k * J_cpl) + else: + Ju = None return Eu, Du, Ju diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 089bca2..16de5df 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -698,3 +698,15 @@ def test_bulirsch_cel_is_kc_native(self): kc = np.array([1e-12, 0.3, 0.9, 2.5]); p = np.array([-0.4, 0.7, -3.0, 1e-3]) K = np.asarray(elliptic.cel1(kc)) assert np.all(np.abs(np.asarray(elliptic.cel(kc, p, 1.0, 0.0)) + p * np.asarray(elliptic.cel(kc, p, 0.0, 1.0)) - K) < 1e-14 * np.maximum(1, K)) + + def test_round6c_bdj_delta_jacobiEDJ_complexZ_arclength(self): + """mpmath at exact doubles: Delta^2 = (1-m) + m cos^2 in ellipticBDJ, + jacobiEDJ reduces u before the amplitude, complex Z uses the exact + complete integrals, arclength on mixed arrays.""" + assert abs(_s(elliptic.ellipticBDJ(math.pi/2 - 2e-4, 1 - 1e-8)[1]) - (8.1529993379146678)) < 2e-12 * 9 + Eu, Du, _ = elliptic.jacobiEDJ(1520.3427441800743, 0.999999990458008) + assert abs(_s(Eu) - 143.00000694616405) < 2e-12 and abs(_s(Du) - 1377.3427503765037) < 2e-12 + Z = _s(elliptic.elliptic12i(complex(-1.053, -0.8215), 1 - 2**-53)[2]) + assert abs(Z - complex(-1.1405612347714637, -0.39945642654606886)) < 1e-14 # was 1e-11: K taken at double(pi/2) + v = np.asarray(elliptic.arclength_ellipse(np.array([5, 785.9, 3]), np.array([10, 495.8, 3]), np.array([0, 5.279, 0]), np.array([1, -6.134, 2]))) + assert np.all(np.abs(v - np.array([8.8662512353670695, -7494.1448816975323, 6])) < 1e-14 * np.array([9, 7495, 6])) From d3883687ab397645d0358136259b49039bf0b793 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 19:33:59 +0400 Subject: [PATCH 17/23] fix: elliptic12i period term below pi/2, elliptic123 m>1, inversenomeq near q=1, nome2m Rounds 6.14-6.16 in docs/specs/post-0d09740-regression-audit.md. * elliptic12i (both ports): the period term pi*ceil(phi/pi - 0.5 + eps) (Python: + 1e-14) was counted from a separately rounded quantity, so for phi a few ulps (Python: 3e-14) below pi/2 it added a period that the sign term (-1)^floor(2phi/pi) had not crossed: Re F came out 3K instead of K. The period is now pi*ceil(k/2) from the same k = floor(2phi/pi). asin(sqrt(3)) is 2 ulps below pi/2, so elliptic123 returned K(3) = 3.003 (mpmath: 1.001) -- how the defect was found. * elliptic123.m, complete m > 1: evaluated elliptic12i exactly on the branch point of F(.|1/m), where the decomposition is sqrt(eps)-conditioned (1e-8 even after the fix). DLMF 19.7.3 closed forms now, also for elliptic123(pi/2, m); K(3), E(3), K(5), E(5) match mpmath to 2e-16. * inversenomeq (both ports): above q_max = 0.7789534 the 30-term series is not converged; MATLAB returned m > 1 (1.034 at q = 0.999), Python raised. The true 1-m is below eps/2 there, so both return exactly 1 (clamped <= 1 below q_max). * nome2m.m: captured its whole input array in the fzero objective (errored on any array) and the bracket covered only q < 0.62. Now an alias of inversenomeq. Tests: testEdgeCases blocks X, Y; TestAdversarialRound6 additions; the Python q_max test now asserts the rounded value. Octave 17 files, 0 failures; pytest 481 passed. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 11 ++++++ matlab/src/elliptic123.m | 30 ++++++++++++--- matlab/src/elliptic12i.m | 10 ++++- matlab/src/inversenomeq.m | 12 ++++-- matlab/src/nome2m.m | 18 ++++++--- matlab/tests/testEdgeCases.m | 41 +++++++++++++++++++++ python/elliptic/bulirsch.py | 2 +- python/elliptic/complex_elliptic.py | 12 +++--- python/elliptic/nome.py | 15 ++++---- python/tests/test_edge_cases.py | 8 ++++ python/tests/test_numerical_precision.py | 11 ++++-- 11 files changed, 136 insertions(+), 34 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index eb47957..a6c1ade 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -189,6 +189,17 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` | 6.11 | `ellipticBDJ` (both ports) | `Delta^2 = 1 - m sin^2 phi` cancels near `phi = pi/2` as `m -> 1` (relative 2.5e-9 at `m = 1-1e-8`), which `R_D` turned into 3e-10 in `D(phi|m)` | `Delta^2 = (1-m) + m cos^2 phi` | | 6.12 | `jacobiEDJ` (both ports) | took `am(u)` at `|u| ~ 1e3` (rounding `eps*|am|`) and only then reduced, where the map `phi -> D` is steep (`1/sqrt(1-m)`): `D_u(1520|1-1e-8)` off by 3e-10 on top of 6.11 | reduce `u` by `2K` first, amplitude of the reduced argument, add `2k` times the complete integrals; now at the `eps*|u|` floor (`dD_u/du = sn^2 <= 1`) | | 6.13 | `arclength_ellipse.m` | `if (a < b) ... elseif (a > b)` on arrays uses all-elements semantics: any mixed array fell through to the circle formula `a (theta1 - theta0)` for every element | elementwise masks after broadcasting scalars (the Python port was already elementwise) | +| 6.14 | `elliptic12i` (both ports) | the period term `pi*ceil(phi/pi - 0.5 + eps)` (Python: `+ 1e-14`) was counted from a separately rounded quantity: for `phi` a few ulps (Python: 3e-14) below `pi/2` it added a period the sign term `(-1)^floor(2phi/pi)` had not crossed, and `Re F` came out `3K` instead of `K`. `asin(sqrt(3))` lands 2 ulps below `pi/2`, so `elliptic123` inherited `K(3) = 3.003` (mpmath 1.001) | period `pi*ceil(k/2)` from the same `k = floor(2phi/pi)` | +| 6.15 | `elliptic123.m` (complete `m > 1`) | evaluated `elliptic12i(asin(sqrt(m)), 1/m)`, i.e. exactly on the branch point of `F(.|1/m)`, where the decomposition is `sqrt(eps)`-conditioned (1e-8 after 6.14) | DLMF 19.7.3 closed forms `K(m) = (K(1/m) - i K(1-1/m))/sqrt(m)` and the matching `E`; `elliptic123(pi/2, m)` routes there too | +| 6.16 | `inversenomeq` (both ports), `nome2m.m` | above `q_max = 0.7789534` the 30-term theta series is not converged: MATLAB returned `m > 1` (1.034 at `q = 0.999`), Python raised; `nome2m` captured its whole input array in the `fzero` objective and errored on any array (bracket also covered only `q < 0.62`) | the true `1 - m` is below `eps/2` there, so both ports return exactly 1 (clamped `<= 1` below); `nome2m` is an alias of `inversenomeq` | + +Deliberate limits found in this sweep: complex `sn, cn, dn` near the poles +`u = iK'` and Weierstrass functions near lattice points carry the +conditioning `eps*K'/|u - iK'|` of the rounded half-period (1e-11 at a +distance 0.05); `elliptic12i` exactly at its branch point +`pi/2 + i acosh(1/sqrt(m))` is `sqrt(eps)`-conditioned (1e-8) because the +function has a square-root singularity there; `theta` at `|v| ~ 1e3` and +`jacobiThetaEta` at `|u| ~ 1e4` carry `eps*|v|` from the argument itself. Also in this round: every MATLAB docstring `Example:` block now runs as a test (`testDocExamples.m`) and the Python docstrings run under diff --git a/matlab/src/elliptic123.m b/matlab/src/elliptic123.m index f972146..65d60e2 100644 --- a/matlab/src/elliptic123.m +++ b/matlab/src/elliptic123.m @@ -139,10 +139,15 @@ end -% Reciprocal-modulus transformation: http://dlmf.nist.gov/19.7#E4 +% Reciprocal-modulus transformation, complete case (DLMF 19.7.3): +% K(m) = (K(1/m) - i K(1-1/m)) / sqrt(m), m > 1 +% evaluated from the real complete integrals. Going through +% elliptic12i(asin(sqrt(m)), 1/m) puts the argument exactly on the branch +% point of F(.|1/m), where the decomposition is sqrt(eps)-conditioned and the +% result was off by 1e-8 (and, before the period fix in elliptic12i, by 2K). if any(m>1) mm=m(m>1); - F(m>1)=(1./sqrt(mm)).*(elliptic12i(asin(sqrt(mm)),1./mm)); + F(m>1)=(ellipke(1./mm) - 1i*ellipke(1-1./mm))./sqrt(mm); end if any(m<=1&m>=0) @@ -161,11 +166,14 @@ E(m<0)=sqrt(1-mm).*EE; end - % Reciprocal-modulus transformation: http://dlmf.nist.gov/19.7#E4 + % Complete case of DLMF 19.7.4 at the branch point, where (A&S 17.4.16 with + % lambda = mu = pi/2) F_b = K(m') - i K(1-m'), E_b = E(m') - i (K(1-m') - E(1-m')), + % m' = 1/m, and E(m) = sqrt(m) E_b - ((m-1)/sqrt(m)) F_b. if any(m>1) - mm=m(m>1); - [FF,EE]=elliptic12i(asin(sqrt(mm)),1./mm); - E(m>1)=((1./sqrt(mm))-sqrt(mm)).*FF+sqrt(mm).*EE; + mm=m(m>1); mp=1./mm; + [Kp, Ep] = ellipke(mp); [Kq, Eq] = ellipke(1-mp); + Fb = Kp - 1i*Kq; Eb = Ep - 1i*(Kq - Eq); + E(m>1)=((1./sqrt(mm))-sqrt(mm)).*Fb+sqrt(mm).*Eb; end if any(m<=1&m>=0) @@ -237,6 +245,12 @@ bb=b(mpos_ind); F(mpos_ind)=(1./sqrt(mm)).*(elliptic12i(asin(sqrt(mm).*sin(bb)),1./mm)); + % sqrt(m) sin b = 1 is the branch point of F(.|1/m) (sqrt(eps)-conditioned + % there): that is the complete integral, take the closed form instead. + cpl = abs(sin(bb)) >= 1 - eps; % b = pi/2 (mod pi): asin(sqrt(m)) is that branch point + if any(cpl) + Fc = elliptic12c(mm(cpl)); Fi = F(mpos_ind); Fi(cpl) = sign(sin(bb(cpl))).*Fc; F(mpos_ind) = Fi; + end warning('elliptic123:F_bm_largem','Complex part may be missing and/or incorrect for ellipticF(b,m>1).'); end @@ -293,6 +307,10 @@ [FF,EE]=elliptic12i(asin(sqrt(mm).*sin(bb)),1./mm); %cannot display complex part E(mpos_ind)=((1./sqrt(mm))-sqrt(mm)).*FF+sqrt(mm).*EE; + cpl = abs(sin(bb)) >= 1 - eps; % complete: closed form (see elliptic12c) + if any(cpl) + [~, Ec] = elliptic12c(mm(cpl)); Ei = E(mpos_ind); Ei(cpl) = sign(sin(bb(cpl))).*Ec; E(mpos_ind) = Ei; + end warning('elliptic123:BadComplex','Complex part may be missing'); end diff --git a/matlab/src/elliptic12i.m b/matlab/src/elliptic12i.m index 7b36443..f7f9c27 100644 --- a/matlab/src/elliptic12i.m +++ b/matlab/src/elliptic12i.m @@ -115,8 +115,14 @@ lambda = acot( sqrt(X1) ); mu = atan( sqrt(tan2mu) ); -% change of variables taking into account periodicity ceil to the right -lambda = (-1).^floor(phi/pi*2).*lambda + pi*ceil(phi/pi-0.5+eps); +% Periodicity: with k = floor(2 phi/pi) the quadrant sign is (-1)^k and the +% period term is pi*ceil(k/2), derived from the SAME k. The previous +% pi*ceil(phi/pi - 0.5 + eps) counted the period from a separately rounded +% quantity, and for phi within a few ulps below pi/2 (e.g. asin(sqrt(3)) as +% used by elliptic123 for m > 1) it added a period the sign term had not +% crossed: Re F came out 3K instead of K. +kq = floor(phi/pi*2); +lambda = (-1).^kq.*lambda + pi*ceil(kq/2); mu = sign(psi).*real(mu); [F1(:),E1(:)] = elliptic12(lambda, m, tol); diff --git a/matlab/src/inversenomeq.m b/matlab/src/inversenomeq.m index afed261..7565a02 100644 --- a/matlab/src/inversenomeq.m +++ b/matlab/src/inversenomeq.m @@ -55,12 +55,18 @@ % This replaces the old interpolation tables, which were documented as % unreliable for q < 1e-5 and q > 0.76; the series is exact at every scale % down to m(1e-30) = 1.6e-29. +% Above q_max = 0.778953424877990 the true 1-m = m(exp(pi^2/ln q)) ~ 16 exp(-pi^2/ln(1/q)) +% is below eps/2, so the correctly rounded double is exactly 1; the 30-term +% series is not converged there and returned m > 1 (1.034 at q = 0.999). +q_max = 0.778953424877990; s2 = ones(size(q)); % sum q^(n(n+1)), n >= 0 s3 = ones(size(q)); % theta3 = 1 + 2*sum q^(n^2) +qs = min(q, q_max); for n = 1:30 - s2 = s2 + q.^(n*(n+1)); - s3 = s3 + 2*q.^(n^2); + s2 = s2 + qs.^(n*(n+1)); + s3 = s3 + 2*qs.^(n^2); end -m(:) = 16*q .* (s2./s3).^4; +m(:) = min(16*qs .* (s2./s3).^4, 1); +m(q > q_max) = 1; % END FUNCTION inversenomeq() diff --git a/matlab/src/nome2m.m b/matlab/src/nome2m.m index 13a62c0..ba71279 100644 --- a/matlab/src/nome2m.m +++ b/matlab/src/nome2m.m @@ -1,8 +1,14 @@ function m = nome2m(q) -%NOME2M Inverse of Moiseev's nomeq: q -> m (00),'q must satisfy 0 q - m = arrayfun(@(qq) fzero(f, [1e-8 1-1e-8]), q); % vectorised +%NOME2M Inverse of NOMEQ: q -> m (0 < q < 1). Alias of INVERSENOMEQ. +% M = NOME2M(Q) returns the parameter m whose nome is Q, elementwise, via +% the closed theta form m = (theta2(0,q)/theta3(0,q))^4 (DLMF 20.9.1), which +% is exact on the whole open interval. The previous fzero bracket +% [1e-8, 1-1e-8] covered only q in (6e-10, 0.62), and its objective captured +% the whole input array, so any array input errored inside fzero. +% +% See also INVERSENOMEQ, NOMEQ. +if ~isreal(q) || any(~(q > 0 & q < 1)) + error('nome2m: q must satisfy 0 < q < 1.'); +end +m = inversenomeq(q); end - diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index c005f2d..de4a8f1 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -809,3 +809,44 @@ %! v = arclength_ellipse([5 785.9 3], [10 495.8 3], [0 5.279 0], [1 -6.134 2]); %! assert(all(abs(v - [8.8662512353670695 -7494.1448816975323 6]) < 1e-14 * [9 7495 6]), 'arclength_ellipse elementwise on mixed arrays'); %! assert(abs(arclength_ellipse(5, 10, [0 0.5], [1 1.5])(1) - 8.8662512353670695) < 1e-14, 'scalar axes with array angles'); + +%% --------------------------------------------------------------------- +%% X. inversenomeq / nome2m near q = 1. Above q_max = 0.7789534 the true +%% 1-m = m(exp(pi^2/ln q)) is below eps/2, so the correctly rounded double +%% is exactly 1; the unconverged 30-term series returned m > 1 (1.034 at +%% q = 0.999, 1+1.6e-15 at q = 0.9). nome2m captured its whole input array +%% in the fzero objective and errored on any array. +%% --------------------------------------------------------------------- +%!test +%! clear +%! assert(isequal(inversenomeq([0.78 0.9 0.999]), [1 1 1]), 'inversenomeq rounds to exactly 1 above q_max'); +%! assert(all(inversenomeq([0.5 0.7 0.7789]) <= 1), 'no overshoot below q_max'); +%! assert(abs(inversenomeq(0.5) - 0.99998952213731039) < 4e-16, 'inversenomeq(0.5) (mpmath mfrom)'); +%! q = [1e-12 0.05 0.3 0.5]; % q > 0.5 puts 1-m below 1e-5, where q(m) is ill-conditioned in double +%! m = nome2m(q); +%! assert(isequal(size(m), size(q)) && max(abs(nomeq(m) - q) ./ q) < 1e-11, 'nome2m on an array, round trip'); +%! assert(nome2m(0.999) == 1, 'nome2m near q = 1'); + +%% --------------------------------------------------------------------- +%% Y. elliptic12i period term just below pi/2, and elliptic123 for m > 1. +%% lambda = (-1)^k lambda + pi*ceil(phi/pi - 0.5 + eps) counted the period +%% from a separately rounded quantity: for phi within a few ulps below +%% pi/2 (asin(sqrt(3)) is 2 ulps below) Re F came out 3K instead of K, +%% which elliptic123 inherited as K(3) = 3.003 (mpmath: 1.001). The +%% complete m > 1 case now uses the DLMF 19.7.3 closed forms instead of +%% evaluating elliptic12i exactly on its branch point (sqrt(eps)-conditioned). +%% --------------------------------------------------------------------- +%!test +%! clear +%! F = elliptic12i(1.5707963267948961 + 0.5i, 1/3); +%! assert(abs(F - (1.7339168852579344 + 0.62666316872107993i)) < 2e-15 * 2, 'F two ulps below pi/2 (was 3K + ...)'); +%! F = elliptic12i(asin(sqrt(3)), 1/3); +%! assert(abs(real(F) - 1.73391688525794) < 1e-7 && abs(imag(F) + 2.02895910274881) < 1e-7, 'F at the branch point (sqrt(eps)-conditioned there)'); +%! [K, E] = elliptic123(3); +%! assert(abs(K - (1.0010773804561062 - 1.1714200841467699i)) < 1e-15 * 2 && abs(E - (0.47522393535101711 + 1.0130180585994313i)) < 1e-15 * 2, 'K(3), E(3) closed forms (were 3x off in the real part)'); +%! [K, E] = elliptic123(pi/2, 3); +%! assert(abs(K - (1.0010773804561062 - 1.1714200841467699i)) < 1e-15 * 2 && abs(E - (0.47522393535101711 + 1.0130180585994313i)) < 1e-15 * 2, 'elliptic123(pi/2, 3) routes to the complete closed form'); +%! [K, E] = elliptic123(5); +%! assert(abs(K - (0.74220623671119323 - 1.0094529099892116i)) < 3e-16 && abs(E - (0.36075866393790281 + 1.6257306716064185i)) < 3e-15, 'K(5), E(5)'); +%! [F, E] = elliptic123(1.2, 3); +%! assert(abs(F - (1.0010773804561062 - 0.89956974520736591i)) < 1e-14 && abs(E - (0.47522393535101711 + 0.50673122232331459i)) < 1e-14, 'F(1.2|3), E(1.2|3) (m > 1, real part was 3x off)'); diff --git a/python/elliptic/bulirsch.py b/python/elliptic/bulirsch.py index 12bd3cb..0603004 100644 --- a/python/elliptic/bulirsch.py +++ b/python/elliptic/bulirsch.py @@ -60,7 +60,7 @@ def _cel_xp(xp, kc, p, a, b): p = xp.where(pos, sp, pn) b = xp.where(pos, b / sp, bn) a = xp.where(pos, a, an) - active = xp.ones_like(k, dtype=xp.bool) + active = k == k # all-True boolean of the right backend/shape for _ in range(40): f = a a = xp.where(active, a + b / p, a) diff --git a/python/elliptic/complex_elliptic.py b/python/elliptic/complex_elliptic.py index 597960b..d0f9671 100644 --- a/python/elliptic/complex_elliptic.py +++ b/python/elliptic/complex_elliptic.py @@ -78,11 +78,13 @@ def elliptic12i(u, m): lam = xp.arctan(1.0 / xp.sqrt(X + 1e-300)) mu = xp.arctan(xp.sqrt(tan2mu)) - # Account for periodicity - lam = ( - (-1.0) ** xp.floor(phi / np.pi * 2.0) * lam - + np.pi * xp.ceil(phi / np.pi - 0.5 + 1e-14) - ) + # Periodicity: with k = floor(2 phi/pi) the quadrant sign is (-1)^k and + # the period term is pi*ceil(k/2), derived from the SAME k. The previous + # pi*ceil(phi/pi - 0.5 + 1e-14) counted the period from a separately + # rounded quantity, and for phi within 3e-14 below pi/2 it added a period + # the sign term had not crossed: Re F came out 3K instead of K. + kq = xp.floor(phi / np.pi * 2.0) + lam = (-1.0) ** kq * lam + np.pi * xp.ceil(kq / 2.0) mu = xp.sign(psi) * xp.real(mu) F1, E1, _ = _elliptic12_xp(xp, lam, m_f) diff --git a/python/elliptic/nome.py b/python/elliptic/nome.py index 6265848..0187d56 100644 --- a/python/elliptic/nome.py +++ b/python/elliptic/nome.py @@ -53,11 +53,9 @@ def inversenomeq(q): if is_numpy(xp): if np.any((q < 0.0) | (q >= 1.0)): raise ValueError("q must be in [0, 1)") - if np.any(q > q_max): - raise ValueError( - f"inversenomeq: q must be <= {q_max:.15f} in double precision " - "(the essential singularity of m(q) at q=1 cannot be resolved in f64)" - ) + # Above q_max the true 1-m = m(exp(pi^2/ln q)) ~ 16 exp(-pi^2/ln(1/q)) is + # below eps/2, so the correctly rounded double is exactly 1.0 (the series + # is not converged there; this used to raise, MATLAB returned m > 1). # Closed form, DLMF 20.9.1: m = (theta2(0,q) / theta3(0,q))^4. # Exact at every scale -- the previous 64-step bisection in m had an @@ -65,12 +63,13 @@ def inversenomeq(q): # instead of 1.6e-29 (nine orders of magnitude off). # theta2(0,q) = 2 q^(1/4) sum q^(n(n+1)), theta3(0,q) = 1 + 2 sum q^(n^2) # The q^(1/4) factor is kept outside the ratio so tiny q cannot underflow. - valid = (q >= 0.0) & (q <= q_max) - q_safe = xp.where(valid, q, xp.zeros_like(q)) + valid = (q >= 0.0) & (q < 1.0) + q_safe = xp.where(valid, xp.minimum(q, xp.full_like(q, q_max)), xp.zeros_like(q)) s2 = xp.ones_like(q_safe) # sum q^(n(n+1)), n >= 0 s3 = xp.ones_like(q_safe) # theta3 = 1 + 2 sum q^(n^2) for n in range(1, 31): s2 = s2 + q_safe ** (n * (n + 1)) s3 = s3 + 2.0 * q_safe ** (n * n) - result = 16.0 * q_safe * (s2 / s3) ** 4 + result = xp.minimum(16.0 * q_safe * (s2 / s3) ** 4, xp.ones_like(q_safe)) + result = xp.where(q > q_max, xp.ones_like(result), result) return xp.where(valid, result, xp.full_like(result, math.nan)) diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 16de5df..6065b7c 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -710,3 +710,11 @@ def test_round6c_bdj_delta_jacobiEDJ_complexZ_arclength(self): assert abs(Z - complex(-1.1405612347714637, -0.39945642654606886)) < 1e-14 # was 1e-11: K taken at double(pi/2) v = np.asarray(elliptic.arclength_ellipse(np.array([5, 785.9, 3]), np.array([10, 495.8, 3]), np.array([0, 5.279, 0]), np.array([1, -6.134, 2]))) assert np.all(np.abs(v - np.array([8.8662512353670695, -7494.1448816975323, 6])) < 1e-14 * np.array([9, 7495, 6])) + + def test_elliptic12i_period_just_below_half_pi(self): + """pi*ceil(phi/pi - 0.5 + 1e-14) added a period for phi within 3e-14 + below pi/2: Re F came out 3K instead of K (mpmath at the exact double).""" + F = _s(elliptic.elliptic12i(complex(1.5707963267948961, 0.5), 1/3)[0]) + assert abs(F - complex(1.7339168852579344, 0.62666316872107993)) < 4e-15 + F = _s(elliptic.elliptic12i(complex(math.pi/2 - 1e-12, -2.0), 0.5)[0]) + assert abs(F.real - 0.3901536583) < 1e-9 # left of the cut, not 2K - ... diff --git a/python/tests/test_numerical_precision.py b/python/tests/test_numerical_precision.py index 5abb56e..4d81063 100644 --- a/python/tests/test_numerical_precision.py +++ b/python/tests/test_numerical_precision.py @@ -304,10 +304,15 @@ def test_issue_jacobi_scalar_inputs(self): Eu, Du, _ = jacobiEDJ(0.5, 0.5) assert math.isfinite(_f(Eu)) and math.isfinite(_f(Du)) - def test_issue_inversenomeq_clear_error_above_qmax(self): - """inversenomeq formerly raised brentq's cryptic 'f(a) f(b) same sign'.""" + def test_issue_inversenomeq_rounds_to_one_above_qmax(self): + """Above q_max = 0.7789534 the true 1-m is below eps/2, so the correctly + rounded double is exactly 1.0 (formerly a ValueError; MATLAB returned + m > 1 from the unconverged series).""" + assert _f(inversenomeq(0.9)) == 1.0 and _f(inversenomeq(0.999)) == 1.0 + assert _f(inversenomeq(0.78)) == 1.0 + assert 0.0 < 1.0 - _f(inversenomeq(0.7)) < 1e-10 # true 1-m = 1.5e-11 with pytest.raises(ValueError, match="q must be"): - inversenomeq(0.9) + inversenomeq(1.0) def test_issue_inversenomeq_round_trip(self): """Round-trip nomeq → inversenomeq for valid q.""" From 5b3c561376c6d3a213600483031f46bb304372a7 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 19:42:50 +0400 Subject: [PATCH 18/23] fix(matlab): input-shape defects, GPU host/device mixing, strict device stub Rounds 6.17-6.18 in docs/specs/post-0d09740-regression-audit.md. Shape sweep (matrix / column / row / mixed scalar-array calls of every function against scalar loops; the Python port passed, pinned by TestInputShapes): * ellipj: re-read cn(I) from its column-shaped output against the row m(I): 6x6 broadcast error for any column u (and hence jacobiEDJ). * jacobiThetaEta: returned a row for a matrix input. * inverselliptic2: the vector-wide Newton stop made values depend on the batch by an ulp; per-element mask now. * elliptic123: failed on any matrix; inputs flattened to rows (its legacy kernels preallocate rows), shape restored. GPU: * elliptic12 GPU path: K_per = 2 .* gpuArray(k_per) .* K_vals mixed a device array with a host matrix (K_vals became host in the per-element AGM change); ocl refuses that and the Octave section of the L4 run failed. Host product, then gpuArray. * tests/gpu_stub/gpuArray.m: strict stand-in for an ocl device array that errors on device-by-host-matrix operators and on logical indexing, plus testGpuStrict.m running every GPU path under it against the CPU path (agreement <= 2.8e-16). The identity stubs used before let three host/device defects through to hardware. L4 hardware run (image from d388368): torch and JAX 88/88 OK on device (max rel diff 3.6e-14, x50 over NumPy), backend pytest 10/10; the Octave OpenCL section failed on the K_per mixing fixed here. Octave: 18 files, 0 failures; pytest 483 passed. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 2 + matlab/src/ellipj.m | 5 +- matlab/src/elliptic12.m | 2 +- matlab/src/elliptic123.m | 13 +- matlab/src/inverselliptic2.m | 14 +- matlab/src/jacobiThetaEta.m | 3 + matlab/tests/gpu_stub/README.md | 17 +++ matlab/tests/gpu_stub/gather.m | 3 + matlab/tests/gpu_stub/gpuArray.m | 145 ++++++++++++++++++++ matlab/tests/gpu_stub/has_gpu.m | 5 + matlab/tests/testEdgeCases.m | 33 +++++ matlab/tests/testGpuStrict.m | 75 ++++++++++ python/tests/test_edge_cases.py | 31 +++++ 13 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 matlab/tests/gpu_stub/README.md create mode 100644 matlab/tests/gpu_stub/gather.m create mode 100644 matlab/tests/gpu_stub/gpuArray.m create mode 100644 matlab/tests/gpu_stub/has_gpu.m create mode 100644 matlab/tests/testGpuStrict.m diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index a6c1ade..ee8717f 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -192,6 +192,8 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` | 6.14 | `elliptic12i` (both ports) | the period term `pi*ceil(phi/pi - 0.5 + eps)` (Python: `+ 1e-14`) was counted from a separately rounded quantity: for `phi` a few ulps (Python: 3e-14) below `pi/2` it added a period the sign term `(-1)^floor(2phi/pi)` had not crossed, and `Re F` came out `3K` instead of `K`. `asin(sqrt(3))` lands 2 ulps below `pi/2`, so `elliptic123` inherited `K(3) = 3.003` (mpmath 1.001) | period `pi*ceil(k/2)` from the same `k = floor(2phi/pi)` | | 6.15 | `elliptic123.m` (complete `m > 1`) | evaluated `elliptic12i(asin(sqrt(m)), 1/m)`, i.e. exactly on the branch point of `F(.|1/m)`, where the decomposition is `sqrt(eps)`-conditioned (1e-8 after 6.14) | DLMF 19.7.3 closed forms `K(m) = (K(1/m) - i K(1-1/m))/sqrt(m)` and the matching `E`; `elliptic123(pi/2, m)` routes there too | | 6.16 | `inversenomeq` (both ports), `nome2m.m` | above `q_max = 0.7789534` the 30-term theta series is not converged: MATLAB returned `m > 1` (1.034 at `q = 0.999`), Python raised; `nome2m` captured its whole input array in the `fzero` objective and errored on any array (bracket also covered only `q < 0.62`) | the true `1 - m` is below `eps/2` there, so both ports return exactly 1 (clamped `<= 1` below); `nome2m` is an alias of `inversenomeq` | +| 6.17 | `ellipj.m`, `jacobiThetaEta.m`, `inverselliptic2.m`, `elliptic123.m` | input-shape defects from a sweep of matrix / column / row / mixed-scalar calls against scalar loops: `ellipj` re-read `cn(I)` from its column-shaped output against the row `m(I)` (6x6 broadcast error for any column `u`, hence also `jacobiEDJ`); `jacobiThetaEta` returned a row for a matrix; `inverselliptic2`'s vector-wide Newton stop made values batch-dependent by an ulp; `elliptic123` failed on any matrix | keep the row-shaped value; reshape to the input shape; per-element Newton mask; flatten to rows and restore the shape. The Python port passed the same sweep; `TestInputShapes` pins it | +| 6.18 | `elliptic12.m` GPU path | `K_per = 2 .* gpuArray(k_per) .* K_vals` multiplied a device array by a host matrix (K_vals became a host array in 6.4); `ocl` refuses that and the L4 run's Octave section failed. The identity stubs used locally cannot see host/device mixing | host product, then `gpuArray`. New `tests/gpu_stub/gpuArray.m`: a strict device-array stand-in that errors on device-by-host-matrix operators and on logical indexing exactly like `ocl`; `testGpuStrict.m` runs every GPU path under it (would have caught all three host/device defects of the earlier hardware rounds on a laptop) | Deliberate limits found in this sweep: complex `sn, cn, dn` near the poles `u = iK'` and Weierstrass functions near lattice points carry the diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index 116b9ac..538099d 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -138,8 +138,9 @@ quasi_sign = 1 - 2 .* mod(period, 2); am(I) = phin + period .* pi; sn(I) = quasi_sign .* sin(phin); - cn(I) = quasi_sign .* cos(phin); - dn(I) = sqrt((1 - m(I)) + m(I).*cn(I).^2); + cn_v = quasi_sign .* cos(phin); % keep the row: cn(I) re-read from a column-shaped + cn(I) = cn_v; % output broadcast against the row m(I) (6x6 error) + dn(I) = sqrt((1 - m(I)) + m(I).*cn_v.^2); end % Special cases: m = {0, 1} diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index e6cf747..98a6a2e 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -278,7 +278,7 @@ u_work = signU .* u(I); k_per = floor(u_work ./ pi); phin = gpuArray(sub_kpi(u_work, k_per)); % exact k*pi split, as in the CPU path - K_per = 2 .* gpuArray(k_per) .* K_vals; + K_per = gpuArray(2 .* k_per .* K_vals); % host product, then to the device (ocl refuses ocl .* host) C = gpuArray(zeros(mmax, 1)); Cp = gpuArray(zeros(mmax, 1)); c2 = c .^ 2; diff --git a/matlab/src/elliptic123.m b/matlab/src/elliptic123.m index 65d60e2..e529df5 100644 --- a/matlab/src/elliptic123.m +++ b/matlab/src/elliptic123.m @@ -86,6 +86,13 @@ % Everyone is permitted to copy and distribute verbatim copies of this % script under terms and conditions of GNU GENERAL PUBLIC LICENSE. +% The legacy kernels below preallocate row vectors and index with logical +% masks; give them rows (any input shape) and restore the shape at the end. +sz = size(a1); +a1 = a1(:).'; +if nargin >= 2, a2 = a2(:).'; end +if nargin >= 3, a3 = a3(:).'; end + if nargout<3 if nargin==1 @@ -113,9 +120,9 @@ end % multidimensional input reshape -F = reshape(F,size(a1)); -E = reshape(E,size(a1)); -if nargin==3, P = reshape(P,size(a1)); end +F = reshape(F,sz); +E = reshape(E,sz); +if nargout==3, P = reshape(P,sz); end end diff --git a/matlab/src/inverselliptic2.m b/matlab/src/inverselliptic2.m index 3fc3bf3..847b88a 100644 --- a/matlab/src/inverselliptic2.m +++ b/matlab/src/inverselliptic2.m @@ -117,11 +117,19 @@ % Newton on E(phi|m) = z_red; dE/dphi = sqrt(1 - m sin^2 phi). % Iterate to convergence rather than a fixed count (issue #12): four steps % are not enough near m -> 1, where the initial guess can be off by ~1. +% Per-element convergence: a vector-wide break let converged elements take +% extra Newton steps that depended on their batch mates (1 ulp). +active = true(numel(invE), 1); % column, like res +mc = m(:); for iter=1:100 [~, Ecur] = elliptic12(invE(:),m,tol); - res = Ecur - z_red; - if all(abs(res) <= 4*eps*max(abs(z_red), realmin)), break; end % relative - invE(:) = invE(:) - res./max(sqrt( 1-m.*sin(invE(:)).^2 ), 1e-15); + res = Ecur(:) - z_red(:); + active = active & ~(abs(res) <= 4*eps*max(abs(z_red(:)), realmin)); % relative + if ~any(active), break; end + iv = invE(:); + step = zeros(numel(iv), 1); + step(active) = res(active)./max(sqrt( 1-mc(active).*sin(iv(active)).^2 ), 1e-15); + invE(:) = iv - step; invE(:) = min(max(invE(:), 0), pi/2); end diff --git a/matlab/src/jacobiThetaEta.m b/matlab/src/jacobiThetaEta.m index e46dd32..256505a 100644 --- a/matlab/src/jacobiThetaEta.m +++ b/matlab/src/jacobiThetaEta.m @@ -50,6 +50,7 @@ if length(u)==1, u = u(ones(size(m))); end if ~isequal(size(m),size(u)), error('U and M must be the same size.'); end +origSize = size(u); Th = zeros(size(u)); H = Th; @@ -103,6 +104,8 @@ Th(m1) = NaN; H(m1) = NaN; end +Th = reshape(Th, origSize); % the series works on rows; give back the input shape +H = reshape(H, origSize); function [Th,H] = parallel_jacobiThetaEta(u, m, tol, nWorkers, minChunk) diff --git a/matlab/tests/gpu_stub/README.md b/matlab/tests/gpu_stub/README.md new file mode 100644 index 0000000..5c62158 --- /dev/null +++ b/matlab/tests/gpu_stub/README.md @@ -0,0 +1,17 @@ +# Strict device stub for the GPU code paths + +`gpuArray.m` is a stand-in for an `ocl` (OpenCL) device array that behaves +like the real one where it matters for correctness of the kernels: + +- elementwise operators work between two device arrays or with a plain + scalar; mixing a device array with a host **matrix** errors exactly like + `ocl` does (`binary operator not implemented for 'ocl matrix' by 'matrix'`), +- logical indexing and indexed assignment with logical/device masks error + (`ocl` has no logical indexing), +- `isreal` is false, `gather` rejects host arrays. + +`testGpuStrict.m` puts this directory in front of the path, switches +`elliptic_config('gpu', true)` and compares every GPU path with the CPU +path. The identity stubs used before (plain `gpuArray = @(x) x`) let three +host/device mixing defects through to the L4 hardware runs; this stub +catches them on a laptop. diff --git a/matlab/tests/gpu_stub/gather.m b/matlab/tests/gpu_stub/gather.m new file mode 100644 index 0000000..3617123 --- /dev/null +++ b/matlab/tests/gpu_stub/gather.m @@ -0,0 +1,3 @@ +function x = gather(g) + if isa(g, 'gpuArray'), x = g.d; else, error('gather: argument is not a device array (ocl errors here too)'); end +end diff --git a/matlab/tests/gpu_stub/gpuArray.m b/matlab/tests/gpu_stub/gpuArray.m new file mode 100644 index 0000000..f1a5ccc --- /dev/null +++ b/matlab/tests/gpu_stub/gpuArray.m @@ -0,0 +1,145 @@ +classdef gpuArray + % Strict stand-in for an ocl/OpenCL device array: elementwise ops work only + % between gpuArrays or with plain scalars; mixing with a host matrix errors + % exactly like the ocl package does ("not implemented for 'ocl matrix' by + % 'matrix'"), logical indexing errors, isreal is false. + properties + d + end + methods + function g = gpuArray(x) + if isa(x, 'gpuArray'), g.d = x.d; else, g.d = double(x); end + end + function x = gather(g), x = g.d; end + function x = double(g), error('gpuArray: double() of a device array; use gather()'); end + function r = isreal(~), r = false; end + function r = isa_gpu(~), r = true; end + function s = size(g, varargin), s = size(g.d, varargin{:}); end + function n = numel(g), n = numel(g.d); end + function n = length(g), n = length(g.d); end + function r = isempty(g), r = isempty(g.d); end + function r = isscalar(g), r = isscalar(g.d); end + function r = ndims(g), r = ndims(g.d); end + function r = columns(g), r = columns(g.d); end + function r = rows(g), r = rows(g.d); end + function r = isnumeric(~), r = true; end + function r = isfloat(~), r = true; end + function r = islogical(~), r = false; end + function disp(g), disp('gpuArray (strict stub)'); disp(g.d); end + function display(g), disp(g); end + % ---- binary elementwise ---- + function r = plus(a, b), r = gpuArray(gpuArray.bin(a, b, @plus)); end + function r = minus(a, b), r = gpuArray(gpuArray.bin(a, b, @minus)); end + function r = times(a, b), r = gpuArray(gpuArray.bin(a, b, @times)); end + function r = rdivide(a, b), r = gpuArray(gpuArray.bin(a, b, @rdivide)); end + function r = ldivide(a, b), r = gpuArray(gpuArray.bin(a, b, @ldivide)); end + function r = power(a, b), r = gpuArray(gpuArray.bin(a, b, @power)); end + function r = mtimes(a, b) + if isscalar(a) || isscalar(b), r = times(a, b); else, error('gpuArray: matrix product not supported by ocl'); end + end + function r = mrdivide(a, b) + if isscalar(b), r = rdivide(a, b); else, error('gpuArray: mrdivide not supported'); end + end + function r = mpower(a, b) + if isscalar(a) && isscalar(b), r = power(a, b); else, error('gpuArray: mpower not supported'); end + end + function r = lt(a, b), r = gpuArray(gpuArray.bin(a, b, @lt)); end + function r = gt(a, b), r = gpuArray(gpuArray.bin(a, b, @gt)); end + function r = le(a, b), r = gpuArray(gpuArray.bin(a, b, @le)); end + function r = ge(a, b), r = gpuArray(gpuArray.bin(a, b, @ge)); end + function r = eq(a, b), r = gpuArray(gpuArray.bin(a, b, @eq)); end + function r = ne(a, b), r = gpuArray(gpuArray.bin(a, b, @ne)); end + function r = and(a, b), r = gpuArray(gpuArray.bin(a, b, @and)); end + function r = or(a, b), r = gpuArray(gpuArray.bin(a, b, @or)); end + function r = max(a, b, varargin) + if nargin == 1, r = gpuArray(max(a.d)); else, r = gpuArray(gpuArray.bin(a, b, @max)); end + end + function r = min(a, b, varargin) + if nargin == 1, r = gpuArray(min(a.d)); else, r = gpuArray(gpuArray.bin(a, b, @min)); end + end + function r = atan2(a, b), r = gpuArray(gpuArray.bin(a, b, @atan2)); end + function r = mod(a, b), r = gpuArray(gpuArray.bin(a, b, @mod)); end + % ---- unary ---- + function r = uminus(g), r = gpuArray(-g.d); end + function r = uplus(g), r = g; end + function r = not(g), r = gpuArray(~g.d); end + function r = sqrt(g), r = gpuArray(sqrt(g.d)); end + function r = sin(g), r = gpuArray(sin(g.d)); end + function r = cos(g), r = gpuArray(cos(g.d)); end + function r = tan(g), r = gpuArray(tan(g.d)); end + function r = atan(g), r = gpuArray(atan(g.d)); end + function r = asin(g), r = gpuArray(asin(g.d)); end + function r = acos(g), r = gpuArray(acos(g.d)); end + function r = sinh(g), r = gpuArray(sinh(g.d)); end + function r = cosh(g), r = gpuArray(cosh(g.d)); end + function r = tanh(g), r = gpuArray(tanh(g.d)); end + function r = exp(g), r = gpuArray(exp(g.d)); end + function r = log(g), r = gpuArray(log(g.d)); end + function r = log1p(g), r = gpuArray(log1p(g.d)); end + function r = abs(g), r = gpuArray(abs(g.d)); end + function r = sign(g), r = gpuArray(sign(g.d)); end + function r = floor(g), r = gpuArray(floor(g.d)); end + function r = ceil(g), r = gpuArray(ceil(g.d)); end + function r = round(g), r = gpuArray(round(g.d)); end + function r = fix(g), r = gpuArray(fix(g.d)); end + function r = real(g), r = gpuArray(real(g.d)); end + function r = imag(g), r = gpuArray(imag(g.d)); end + function r = isnan(g), r = gpuArray(isnan(g.d)); end + function r = isinf(g), r = gpuArray(isinf(g.d)); end + function r = isfinite(g), r = gpuArray(isfinite(g.d)); end + function r = transpose(g), r = gpuArray(g.d.'); end + function r = ctranspose(g), r = gpuArray(g.d'); end + function r = sum(g, varargin), r = gpuArray(sum(g.d, varargin{:})); end + function r = prod(g, varargin), r = gpuArray(prod(g.d, varargin{:})); end + function r = any(g, varargin), r = any(g.d, varargin{:}); end + function r = all(g, varargin), r = all(g.d, varargin{:}); end + function r = reshape(g, varargin), r = gpuArray(reshape(g.d, varargin{:})); end + function r = repmat(g, varargin), r = gpuArray(repmat(g.d, varargin{:})); end + function r = horzcat(varargin), r = gpuArray(horzcat(gpuArray.cellu(varargin){:})); end + function r = vertcat(varargin), r = gpuArray(vertcat(gpuArray.cellu(varargin){:})); end + function r = cat(dim, varargin), r = gpuArray(cat(dim, gpuArray.cellu(varargin){:})); end + % ---- indexing: numeric/colon only (ocl has no logical indexing) ---- + function r = subsref(g, s) + switch s(1).type + case '()' + for k = 1:numel(s(1).subs) + ix = s(1).subs{k}; + if islogical(ix) || isa(ix, 'gpuArray'), error('gpuArray: logical / device-array indexing not supported by ocl'); end + end + r = gpuArray(g.d(s(1).subs{:})); + if numel(s) > 1, r = subsref(r, s(2:end)); end + case '.' + if strcmp(s(1).subs, 'd'), r = g.d; else, error('gpuArray: no field %s', s(1).subs); end + otherwise, error('gpuArray: unsupported indexing'); + end + end + function g = subsasgn(g, s, v) + if ~strcmp(s(1).type, '()'), error('gpuArray: unsupported assignment'); end + for k = 1:numel(s(1).subs) + ix = s(1).subs{k}; + if islogical(ix) || isa(ix, 'gpuArray'), error('gpuArray: logical / device-array indexed assignment not supported by ocl'); end + end + if isa(v, 'gpuArray'), v = v.d; elseif ~isscalar(v), error('gpuArray: assigning a host matrix into a device array'); end + g.d(s(1).subs{:}) = v; + end + function n = end(g, k, n_), if n_ == 1, n = numel(g.d); else, n = size(g.d, k); end, end + end + methods (Static) + function x = bin(a, b, op) + ga = isa(a, 'gpuArray'); gb = isa(b, 'gpuArray'); + if ga, av = a.d; else, av = a; end + if gb, bv = b.d; else, bv = b; end + if ga && ~gb && ~isscalar(bv) && ~isempty(bv) + error('binary operator not implemented for ''ocl matrix'' by ''matrix'' operations (strict gpuArray stub)'); + elseif gb && ~ga && ~isscalar(av) && ~isempty(av) + error('binary operator not implemented for ''matrix'' by ''ocl matrix'' operations (strict gpuArray stub)'); + end + if islogical(av), av = double(av); end + if islogical(bv), bv = double(bv); end + x = op(av, bv); + end + function c = cellu(c) + for k = 1:numel(c), if isa(c{k}, 'gpuArray'), c{k} = c{k}.d; end, end + end + end +end diff --git a/matlab/tests/gpu_stub/has_gpu.m b/matlab/tests/gpu_stub/has_gpu.m new file mode 100644 index 0000000..b23ed75 --- /dev/null +++ b/matlab/tests/gpu_stub/has_gpu.m @@ -0,0 +1,5 @@ +function r = has_gpu() +%HAS_GPU Test stub: the strict device stub is "available" whenever the +% configuration flag is on (the real has_gpu also needs the ocl package). +r = elliptic_config('gpu'); +end diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index de4a8f1..53c358c 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -850,3 +850,36 @@ %! assert(abs(K - (0.74220623671119323 - 1.0094529099892116i)) < 3e-16 && abs(E - (0.36075866393790281 + 1.6257306716064185i)) < 3e-15, 'K(5), E(5)'); %! [F, E] = elliptic123(1.2, 3); %! assert(abs(F - (1.0010773804561062 - 0.89956974520736591i)) < 1e-14 && abs(E - (0.47522393535101711 + 0.50673122232331459i)) < 1e-14, 'F(1.2|3), E(1.2|3) (m > 1, real part was 3x off)'); + +%% --------------------------------------------------------------------- +%% Z. Input shapes (round 6d). Every function must give the same values for +%% a 2x3 matrix, a column, a row and a mixed scalar/array call as for the +%% scalar loop, and keep the input shape. Found: ellipj re-read cn(I) +%% from its column-shaped output against the row m(I) (6x6 broadcast +%% error for column u), jacobiThetaEta returned a row for a matrix, +%% inverselliptic2's vector-wide Newton stop made values batch-dependent, +%% elliptic123 failed on any matrix. +%% --------------------------------------------------------------------- +%!test +%! clear +%! u = reshape(linspace(-2.5, 7.3, 6), 2, 3); m = reshape([0.1 0.5 0.9 0.999 0.3 0.7], 2, 3); n = reshape([0.2 -0.5 0.9 0.0 0.5 0.3], 2, 3); +%! [sn, cn, dn, am] = ellipj(u(:), 0.5); +%! assert(isequal(size(sn), [6 1]), 'ellipj column input'); +%! [s1, c1, d1, a1] = ellipj(u(3), 0.5); +%! assert(sn(3) == s1 && cn(3) == c1 && dn(3) == d1 && am(3) == a1, 'ellipj column == scalar'); +%! [Th, H] = jacobiThetaEta(u, m); +%! assert(isequal(size(Th), [2 3]) && isequal(size(H), [2 3]), 'jacobiThetaEta keeps the input shape'); +%! [t1, h1] = jacobiThetaEta(u(4), m(4)); +%! assert(Th(4) == t1 && H(4) == h1, 'jacobiThetaEta matrix == scalar'); +%! z = reshape(linspace(0.2, 3.1, 6), 2, 3); +%! v = inverselliptic2(z, m); +%! for i = 1:6, assert(v(i) == inverselliptic2(z(i), m(i)), 'inverselliptic2 must be batch-independent'); end +%! [F, E] = elliptic123(u, m); +%! assert(isequal(size(F), [2 3]), 'elliptic123 matrix input'); +%! for i = 1:6, [f, e] = elliptic123(u(i), m(i)); assert(abs(F(i) - f) < 1e-15 && abs(E(i) - e) < 1e-15, 'elliptic123 matrix == scalar'); end +%! [F, E, P] = elliptic123(u(:), m(:), 0.3); +%! assert(isequal(size(P), [6 1]), 'elliptic123 three outputs, column input'); +%! [Eu, Du, Ju] = jacobiEDJ(u(:), 0.5, 0.3); +%! assert(isequal(size(Ju), [6 1]), 'jacobiEDJ column u, scalar m, n'); +%! [Eu1, Du1, Ju1] = jacobiEDJ(u(2), 0.5, 0.3); +%! assert(Eu(2) == Eu1 && Du(2) == Du1 && Ju(2) == Ju1, 'jacobiEDJ column == scalar'); diff --git a/matlab/tests/testGpuStrict.m b/matlab/tests/testGpuStrict.m new file mode 100644 index 0000000..3c99c5c --- /dev/null +++ b/matlab/tests/testGpuStrict.m @@ -0,0 +1,75 @@ +function testGpuStrict() +%TESTGPUSTRICT Every GPU code path under a strict device-array stub. +% See gpu_stub/README.md. Each function is evaluated on the CPU path and +% on the GPU path (with the stub) on the same inputs, including the +% large-u / m -> 1 / tiny-m cases that broke earlier GPU kernels, and the +% two must agree to 1e-13 relative (they are bit-identical for most). +end + +%!test +%! here = fileparts(mfilename('fullpath')); +%! addpath(fullfile(here, '..', 'src')); +%! addpath(fullfile(here, 'gpu_stub'), '-begin'); +%! unwind_protect +%! rand('seed', 5); N = 300; +%! u = rand(1,N)*20 - 10; m = rand(1,N)*(1-2e-6) + 1e-6; n = rand(1,N)*0.9; z = rand(1,N)*3; +%! u = [u, 1e6+0.123, 9.375, pi/2-1e-9, -4.9, 0, 1000000.123]; m = [m, 1-eps/2, 1-eps/2, 1-eps/2, 1e-12, 0.5, 3e-16]; +%! n = [n 0.3 0.3 0.3 0.3 0.3 0.3]; z = [z 0.7 0.7 0.7 0.7 0.7 0.7]; +%! tests = { +%! 'elliptic12', @() nthargout(1:3, @elliptic12, u, m); +%! 'ellipj', @() nthargout(1:4, @ellipj, u, m); +%! 'elliptic3', @() elliptic3(u, m, n); +%! 'ellipticBDJ', @() nthargout(1:3, @ellipticBDJ, u, m, n); +%! 'ellipticBD', @() nthargout(1:3, @ellipticBD, m); +%! 'jacobiEDJ', @() nthargout(1:3, @jacobiEDJ, u, m, n); +%! 'theta', @() theta(1, u, m); +%! 'theta_prime', @() nthargout(1:2, @theta_prime, 2, u, m); +%! 'jacobiThetaEta', @() nthargout(1:2, @jacobiThetaEta, u, m); +%! 'nomeq', @() nomeq(m); +%! 'inversenomeq',@() inversenomeq(m*0.7); +%! 'elliptic12i', @() nthargout(1:3, @elliptic12i, u + 1i*z, m); +%! 'ellipji', @() nthargout(1:3, @ellipji, u + 1i*z, m); +%! 'weierstrassP',@() weierstrassP(z, 1.5, -0.25, -1.25); +%! 'weierstrassZeta', @() weierstrassZeta(z, 1.5, -0.25, -1.25); +%! 'weierstrassSigma', @() weierstrassSigma(z, 1.5, -0.25, -1.25); +%! 'weierstrassPPrime', @() weierstrassPPrime(z, 1.5, -0.25, -1.25); +%! 'inverselliptic2', @() inverselliptic2(z, m); +%! 'cel', @() cel(sqrt(1-m), n, 1, 0.5); +%! 'arclength_ellipse', @() arclength_ellipse(z+0.1, z+0.5, u, z); +%! }; +%! for t = 1:rows(tests) +%! name = tests{t,1}; f = tests{t,2}; +%! elliptic_config('gpu', false); ref = f(); +%! elliptic_config('gpu', true); got = f(); +%! elliptic_config('gpu', false); +%! if ~iscell(ref), ref = {ref}; got = {got}; end +%! for k = 1:numel(ref) +%! g = got{k}; +%! assert(~isa(g, 'gpuArray'), sprintf('%s: output %d is still a device array', name, k)); +%! r = ref{k}; +%! assert(isequal(isfinite(r), isfinite(g)), sprintf('%s: finite pattern differs on the GPU path', name)); +%! ok = isfinite(r); +%! d = max([0; abs(g(ok)(:) - r(ok)(:)) ./ max(1, abs(r(ok)(:)))]); +%! assert(d < 1e-13, sprintf('%s: GPU path differs from CPU by %.2e', name, d)); +%! end +%! end +%! unwind_protect_cleanup +%! elliptic_config('gpu', false); +%! rmpath(fullfile(here, 'gpu_stub')); +%! end_unwind_protect + +%!test +%! % the stub itself must reject what ocl rejects, or the test above proves nothing +%! here = fileparts(mfilename('fullpath')); +%! addpath(fullfile(here, 'gpu_stub'), '-begin'); +%! unwind_protect +%! caught = false; +%! try, x = gpuArray([1 2 3]) .* [1 2 3]; catch, caught = true; end +%! assert(caught, 'stub must reject device .* host matrix'); +%! caught = false; +%! try, g = gpuArray([1 2 3]); y = g(logical([1 0 1])); catch, caught = true; end +%! assert(caught, 'stub must reject logical indexing'); +%! assert(isequal(gather(gpuArray([1 2 3]) .* 2 + gpuArray([1 1 1])), [3 5 7])); +%! unwind_protect_cleanup +%! rmpath(fullfile(here, 'gpu_stub')); +%! end_unwind_protect diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 6065b7c..4e8d395 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -718,3 +718,34 @@ def test_elliptic12i_period_just_below_half_pi(self): assert abs(F - complex(1.7339168852579344, 0.62666316872107993)) < 4e-15 F = _s(elliptic.elliptic12i(complex(math.pi/2 - 1e-12, -2.0), 0.5)[0]) assert abs(F.real - 0.3901536583) < 1e-9 # left of the cut, not 2K - ... + + +class TestInputShapes: + """Every function on a 2x3 array, on a flat vector with scalar partners, + and elementwise against scalar calls (the Octave port had four shape + defects of this kind; the NumPy port passes and this pins it).""" + def test_shapes_match_scalar_calls(self): + u = np.linspace(-2.5, 7.3, 6).reshape(2, 3); m = np.array([0.1, 0.5, 0.9, 0.999, 0.3, 0.7]).reshape(2, 3) + n = np.array([0.2, -0.5, 0.9, 0.0, 0.5, 0.3]).reshape(2, 3); q = np.array([0.01, 0.1, 0.3, 0.5, 0.05, 0.2]).reshape(2, 3) + z = np.linspace(0.2, 3.1, 6).reshape(2, 3); zc = u + 1j * np.linspace(-1.5, 1.5, 6).reshape(2, 3) + kc = np.array([0.1, 0.5, 0.9, 2.0, 1e-9, 0.3]).reshape(2, 3); p = np.array([1, 0.5, -0.5, 2, 0.3, 1.]).reshape(2, 3) + a = np.array([1, 0.5, -1, 2, 0.3, 1.]).reshape(2, 3); b = np.array([1, 2, 0.5, -1, 0.7, 0.2]).reshape(2, 3) + cases = [(elliptic.elliptic12, (u, m)), (elliptic.elliptic12i, (zc, m)), (elliptic.ellipj, (u, m)), (elliptic.ellipji, (zc, m)), + (elliptic.elliptic3, (u, m, n)), (elliptic.ellipticBDJ, (u, m, n)), (elliptic.ellipticBD, (m,)), (elliptic.jacobiEDJ, (u, m, n)), + (lambda v, mm: elliptic.theta(1, v, mm), (u, m)), (lambda v, mm: elliptic.theta_prime(3, v, mm), (u, m)), (elliptic.jacobiThetaEta, (u, m)), + (elliptic.nomeq, (m,)), (elliptic.inversenomeq, (q,)), (elliptic.cel, (kc, p, a, b)), (elliptic.cel1, (kc,)), + (elliptic.weierstrassP, (z, 1.5, -0.25, -1.25)), (elliptic.weierstrassZeta, (z, 1.5, -0.25, -1.25)), (elliptic.weierstrassSigma, (z, 1.5, -0.25, -1.25)), + (elliptic.arclength_ellipse, (np.abs(a) + 0.1, np.abs(b) + 0.1, u, z)), (elliptic.inverselliptic2, (z, m)), + (elliptic.carlsonRF, (np.abs(a), np.abs(b) + 0.1, z)), (elliptic.carlsonRJ, (np.abs(a), np.abs(b) + 0.1, z, np.abs(p) + 0.1))] + tup = lambda x: x if isinstance(x, tuple) else (x,) + for fn, args in cases: + outs = [np.asarray(o) for o in tup(fn(*args)) if o is not None] + assert all(o.shape == (2, 3) for o in outs) + for i in range(6): + sa = [(np.asarray(x).ravel()[i] if np.ndim(x) else x) for x in args] + so = [np.asarray(o) for o in tup(fn(*[complex(x) if np.iscomplexobj(x) else float(x) for x in sa])) if o is not None] + for o, s in zip(outs, so): + x, y = o.ravel()[i], s.item() + assert x == y or (np.isnan(x) and np.isnan(y)) or abs(x - y) <= 4e-16 * max(1, abs(y)) + ca = [np.asarray(args[0]).ravel()] + [(np.asarray(x).ravel()[0] if np.ndim(x) else x) for x in args[1:]] + assert np.asarray(tup(fn(*ca))[0]).shape == (6,) From fcc7983ff80b502538514273a216b0371e985a64 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 19:49:10 +0400 Subject: [PATCH 19/23] fix: empty, NaN and Inf inputs (both ports); R_J rejects p <= 0 in MATLAB Round 6.19 in docs/specs/post-0d09740-regression-audit.md. A sweep of [] / NaN / +-Inf / [0.3 NaN 0.7] through every function: MATLAB * empty input -> empty output of the same shape in ellipticBDJ, theta_prime, cel, weierstrassP/Zeta/Sigma/PPrime, carlsonRF/RD/RJ/RC, arclength_ellipse, elliptic123 (they rejected [] against a scalar). * nomeq aborted inside ellipke ("algorithm did not converge") on a single NaN element; inversenomeq rejected NaN as out of [0,1); both now isolate NaN and keep the domain error for finite out-of-range values. * elliptic12i raised "Input arguments must be real" for NaN or Inf phases because (-1)^NaN is complex NaN in Octave. * Carlson wrappers: NaN, Inf and negative arguments give real NaN (were complex NaN); carlsonRJ errors for p <= 0 like the Python port instead of returning complex garbage from sqrt of a negative. Python * cel(NaN, ...) returned pi/2: a NaN kc never became active in the ascent. Tests: testEdgeCases block AA; TestEmptyNaNInf. Octave 18 files, 0 failures; pytest 484 passed. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 1 + matlab/src/arclength_ellipse.m | 11 ++++++++ matlab/src/carlsonRC.m | 13 ++++++++++ matlab/src/carlsonRD.m | 14 +++++++++++ matlab/src/carlsonRF.m | 15 +++++++++++ matlab/src/carlsonRJ.m | 21 ++++++++++++++++ matlab/src/cel.m | 11 ++++++++ matlab/src/elliptic123.m | 10 ++++++++ matlab/src/elliptic12i.m | 1 + matlab/src/ellipticBDJ.m | 11 ++++++++ matlab/src/inversenomeq.m | 5 +++- matlab/src/nomeq.m | 10 +++++++- matlab/src/theta_prime.m | 11 ++++++++ matlab/src/weierstrassP.m | 11 ++++++++ matlab/src/weierstrassPPrime.m | 11 ++++++++ matlab/src/weierstrassSigma.m | 11 ++++++++ matlab/src/weierstrassZeta.m | 11 ++++++++ matlab/tests/testEdgeCases.m | 28 +++++++++++++++++++++ python/elliptic/bulirsch.py | 4 ++- python/tests/test_edge_cases.py | 16 ++++++++++++ 20 files changed, 223 insertions(+), 3 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index ee8717f..4d61cbe 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -194,6 +194,7 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` | 6.16 | `inversenomeq` (both ports), `nome2m.m` | above `q_max = 0.7789534` the 30-term theta series is not converged: MATLAB returned `m > 1` (1.034 at `q = 0.999`), Python raised; `nome2m` captured its whole input array in the `fzero` objective and errored on any array (bracket also covered only `q < 0.62`) | the true `1 - m` is below `eps/2` there, so both ports return exactly 1 (clamped `<= 1` below); `nome2m` is an alias of `inversenomeq` | | 6.17 | `ellipj.m`, `jacobiThetaEta.m`, `inverselliptic2.m`, `elliptic123.m` | input-shape defects from a sweep of matrix / column / row / mixed-scalar calls against scalar loops: `ellipj` re-read `cn(I)` from its column-shaped output against the row `m(I)` (6x6 broadcast error for any column `u`, hence also `jacobiEDJ`); `jacobiThetaEta` returned a row for a matrix; `inverselliptic2`'s vector-wide Newton stop made values batch-dependent by an ulp; `elliptic123` failed on any matrix | keep the row-shaped value; reshape to the input shape; per-element Newton mask; flatten to rows and restore the shape. The Python port passed the same sweep; `TestInputShapes` pins it | | 6.18 | `elliptic12.m` GPU path | `K_per = 2 .* gpuArray(k_per) .* K_vals` multiplied a device array by a host matrix (K_vals became a host array in 6.4); `ocl` refuses that and the L4 run's Octave section failed. The identity stubs used locally cannot see host/device mixing | host product, then `gpuArray`. New `tests/gpu_stub/gpuArray.m`: a strict device-array stand-in that errors on device-by-host-matrix operators and on logical indexing exactly like `ocl`; `testGpuStrict.m` runs every GPU path under it (would have caught all three host/device defects of the earlier hardware rounds on a laptop) | +| 6.19 | empty / NaN / Inf inputs | MATLAB: nine functions rejected `[]` against a scalar partner (`ellipticBDJ`, `theta_prime`, `cel`, the four Weierstrass functions, the four Carlson functions, `arclength_ellipse`, `elliptic123`); `nomeq` aborted inside `ellipke` on a single NaN; `inversenomeq` rejected NaN as out of range; `elliptic12i` raised "must be real" because `(-1)^NaN` is complex in Octave; the Carlson wrappers returned complex NaN for `-Inf` and complex garbage for `R_J` with `p < 0`. Python: `cel(NaN, ...)` returned `pi/2` (a NaN `kc` never became active) | empty in, empty out of the same shape; NaN isolated elementwise; `R_J` with `p <= 0` errors like the Python port; `cel` propagates NaN | Deliberate limits found in this sweep: complex `sn, cn, dn` near the poles `u = iK'` and Weierstrass functions near lattice points carry the diff --git a/matlab/src/arclength_ellipse.m b/matlab/src/arclength_ellipse.m index dd5450f..2322d5f 100644 --- a/matlab/src/arclength_ellipse.m +++ b/matlab/src/arclength_ellipse.m @@ -86,6 +86,17 @@ % Moiseev Igor %arguments +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 2 && (isempty(a) || isempty(b) || (nargin == 4 && (isempty(theta0) || isempty(theta1)))) + sz = size(a); + if isempty(b), sz = size(b); end + if nargin == 4 && isempty(theta0), sz = size(theta0); end + if nargin == 4 && isempty(theta1), sz = size(theta1); end + arclength = zeros(sz); + return; +end + if nargin ~= 2 && nargin ~= 4, error('ARCLENGTH_ELLIPSE: Requires two or four inputs.') return diff --git a/matlab/src/carlsonRC.m b/matlab/src/carlsonRC.m index f341f3d..f691e5d 100644 --- a/matlab/src/carlsonRC.m +++ b/matlab/src/carlsonRC.m @@ -29,6 +29,15 @@ % [2] B.C. Carlson, "Computing Elliptic Integrals by Duplication," % Numer. Math. 33 (1979), 1–16. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 2 && (isempty(x) || isempty(y)) + sz = size(x); + if isempty(y), sz = size(y); end + RC = zeros(sz); + return; +end + if nargin < 2, error('carlsonRC: requires two arguments (x, y).'); end if ~isreal(x) || ~isreal(y) error('carlsonRC: all input arguments must be real.'); @@ -38,7 +47,11 @@ origSize = size(x); x = x(:).'; y = y(:).'; +% NaN, Inf and negative x give NaN (y < 0 is the principal value, handled by the core). +bad = ~(x >= 0) | isinf(x) | isnan(y) | isinf(y); +x(bad) = 1; y(bad) = 1; RC = carlsonRC_core(x, y); +RC(bad) = NaN; RC = reshape(RC, origSize); diff --git a/matlab/src/carlsonRD.m b/matlab/src/carlsonRD.m index 7449192..aa4a534 100644 --- a/matlab/src/carlsonRD.m +++ b/matlab/src/carlsonRD.m @@ -23,6 +23,16 @@ % [2] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 3 && (isempty(x) || isempty(y) || isempty(z)) + sz = size(x); + if isempty(y), sz = size(y); end + if isempty(z), sz = size(z); end + RD = zeros(sz); + return; +end + if nargin < 3, error('carlsonRD: requires three arguments (x, y, z).'); end if ~isreal(x) || ~isreal(y) || ~isreal(z) error('carlsonRD: all input arguments must be real.'); @@ -32,7 +42,11 @@ origSize = size(x); x = x(:).'; y = y(:).'; z = z(:).'; +% NaN, Inf and negative arguments give NaN (see carlsonRF). +bad = ~(x >= 0 & y >= 0 & z > 0) | isinf(x) | isinf(y) | isinf(z); +x(bad) = 1; y(bad) = 1; z(bad) = 1; RD = carlsonRD_core(x, y, z); +RD(bad) = NaN; RD((x == 0) & (y == 0)) = Inf; % diverges (DLMF 19.16.5) RD = reshape(RD, origSize); diff --git a/matlab/src/carlsonRF.m b/matlab/src/carlsonRF.m index cb8143f..a8ca257 100644 --- a/matlab/src/carlsonRF.m +++ b/matlab/src/carlsonRF.m @@ -27,6 +27,16 @@ % [2] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 3 && (isempty(x) || isempty(y) || isempty(z)) + sz = size(x); + if isempty(y), sz = size(y); end + if isempty(z), sz = size(z); end + RF = zeros(sz); + return; +end + if nargin < 3, error('carlsonRF: requires three arguments (x, y, z).'); end if ~isreal(x) || ~isreal(y) || ~isreal(z) error('carlsonRF: all input arguments must be real.'); @@ -36,7 +46,12 @@ origSize = size(x); x = x(:).'; y = y(:).'; z = z(:).'; +% NaN, Inf and negative arguments give NaN (R_F is defined for x, y, z >= 0); +% they used to reach the duplication and come back as complex NaN. +bad = ~(x >= 0 & y >= 0 & z >= 0) | isinf(x) | isinf(y) | isinf(z); +x(bad) = 1; y(bad) = 1; z(bad) = 1; RF = carlsonRF_core(x, y, z); +RF(bad) = NaN; % Two zero arguments: the integral diverges (DLMF 19.16.1); the duplication % loop just stalls and returned a finite 2e6 for R_F(0, 0, 1). RF((x == 0) + (y == 0) + (z == 0) >= 2) = Inf; diff --git a/matlab/src/carlsonRJ.m b/matlab/src/carlsonRJ.m index 953db05..4180ea5 100644 --- a/matlab/src/carlsonRJ.m +++ b/matlab/src/carlsonRJ.m @@ -25,6 +25,17 @@ % [2] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(x) || isempty(y) || isempty(z) || isempty(p)) + sz = size(x); + if isempty(y), sz = size(y); end + if isempty(z), sz = size(z); end + if isempty(p), sz = size(p); end + RJ = zeros(sz); + return; +end + if nargin < 4, error('carlsonRJ: requires four arguments (x, y, z, p).'); end if ~isreal(x) || ~isreal(y) || ~isreal(z) || ~isreal(p) error('carlsonRJ: all input arguments must be real.'); @@ -34,7 +45,17 @@ origSize = size(x); x = x(:).'; y = y(:).'; z = z(:).'; p = p(:).'; +% p <= 0 is the Cauchy principal value (DLMF 19.20.14), not implemented -- the +% duplication took sqrt of a negative and returned complex garbage. +if any(p <= 0) + error(['carlsonRJ: p must be > 0. For p < 0 the integral is a Cauchy principal ' ... + 'value (DLMF 19.20.14); use the transformation to a p > 0 argument before calling.']); +end +% NaN, Inf and negative x, y, z give NaN (see carlsonRF). +bad = ~(x >= 0 & y >= 0 & z >= 0) | isinf(x) | isinf(y) | isinf(z) | isnan(p) | isinf(p); +x(bad) = 1; y(bad) = 1; z(bad) = 1; p(bad) = 1; RJ = carlsonRJ_core(x, y, z, p); +RJ(bad) = NaN; RJ((x == 0) + (y == 0) + (z == 0) >= 2) = Inf; % diverges (DLMF 19.16.2) RJ = reshape(RJ, origSize); diff --git a/matlab/src/cel.m b/matlab/src/cel.m index 4d0db64..5e163e5 100644 --- a/matlab/src/cel.m +++ b/matlab/src/cel.m @@ -40,6 +40,17 @@ % elliptic functions," Numer. Math. 7 (1965), 78–90. % [2] NIST DLMF §19.25 https://dlmf.nist.gov/19.25 +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(kc) || isempty(p) || isempty(a) || isempty(b)) + sz = size(kc); + if isempty(p), sz = size(p); end + if isempty(a), sz = size(a); end + if isempty(b), sz = size(b); end + C = zeros(sz); + return; +end + if nargin < 4, error('cel: requires four arguments (kc, p, a, b).'); end if ~isreal(kc) || ~isreal(p) || ~isreal(a) || ~isreal(b) error('cel: all arguments must be real.'); diff --git a/matlab/src/elliptic123.m b/matlab/src/elliptic123.m index e529df5..5864b35 100644 --- a/matlab/src/elliptic123.m +++ b/matlab/src/elliptic123.m @@ -88,6 +88,16 @@ % The legacy kernels below preallocate row vectors and index with logical % masks; give them rows (any input shape) and restore the shape at the end. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 1 && (isempty(a1)) + sz = size(a1); + F = zeros(sz); + E = zeros(sz); + P = zeros(sz); + return; +end + sz = size(a1); a1 = a1(:).'; if nargin >= 2, a2 = a2(:).'; end diff --git a/matlab/src/elliptic12i.m b/matlab/src/elliptic12i.m index f7f9c27..70ef9cf 100644 --- a/matlab/src/elliptic12i.m +++ b/matlab/src/elliptic12i.m @@ -122,6 +122,7 @@ % used by elliptic123 for m > 1) it added a period the sign term had not % crossed: Re F came out 3K instead of K. kq = floor(phi/pi*2); +kq(~isfinite(kq)) = 0; % (-1)^NaN, (-1)^Inf are complex NaN in Octave; keep lambda real so NaN propagates lambda = (-1).^kq.*lambda + pi*ceil(kq/2); mu = sign(psi).*real(mu); diff --git a/matlab/src/ellipticBDJ.m b/matlab/src/ellipticBDJ.m index d4f22d7..904a6a0 100644 --- a/matlab/src/ellipticBDJ.m +++ b/matlab/src/ellipticBDJ.m @@ -43,6 +43,17 @@ % [3] B.C. Carlson, "Numerical Computation of Real or Complex Elliptic % Integrals," Numer. Algorithms 10 (1995), 13–26. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 2 && (isempty(phi) || isempty(m)) + sz = size(phi); + if isempty(m), sz = size(m); end + B = zeros(sz); + D = zeros(sz); + J = zeros(sz); + return; +end + compute_J = (nargin >= 3); if nargin < 2, error('ellipticBDJ: requires at least two arguments (phi, m).'); end diff --git a/matlab/src/inversenomeq.m b/matlab/src/inversenomeq.m index 7565a02..dec763e 100644 --- a/matlab/src/inversenomeq.m +++ b/matlab/src/inversenomeq.m @@ -45,9 +45,11 @@ m = zeros(size(q)); q = q(:).'; % make a row vector -if ~all(q >= 0) || ~all(q < 1) +bad = isnan(q); +if ~all(q(~bad) >= 0) || ~all(q(~bad) < 1) error('Input arguments must be from the interval [0,1).') end +q(bad) = 0; % computed as m(0) = 0, overwritten with NaN below % Closed form, DLMF 20.9.1: m = (theta2(0,q)/theta3(0,q))^4 % theta2(0,q) = 2*q^(1/4) * sum q^(n(n+1)), theta3(0,q) = 1 + 2*sum q^(n^2) @@ -68,5 +70,6 @@ end m(:) = min(16*qs .* (s2./s3).^4, 1); m(q > q_max) = 1; +m(bad) = NaN; % END FUNCTION inversenomeq() diff --git a/matlab/src/nomeq.m b/matlab/src/nomeq.m index 5f6ef85..24eedd7 100644 --- a/matlab/src/nomeq.m +++ b/matlab/src/nomeq.m @@ -36,6 +36,14 @@ % K'(m) = K(1-m) = R_F(0, m, 1) evaluated from the EXACT argument m: % ellipke(1-m) rounds 1-m first and lost ~eps/m relative digits % (q(1e-16) was 11% off, q(1e-17) came back 0). -NomeQ = exp(-pi*carlsonRF(zeros(size(m)), m, ones(size(m)))./ellipke(m,tol)); +% NaN elements propagate; finite elements outside [0, 1] are a domain error +% (ellipke used to abort with 'algorithm did not converge' on a single NaN). +bad = isnan(m); +if any(m(~bad) < 0) || any(m(~bad) > 1) + error('nomeq: m must be in the range 0 <= m <= 1.'); +end +NomeQ = nan(size(m)); +mv = m(~bad); +NomeQ(~bad) = exp(-pi*carlsonRF(zeros(size(mv)), mv, ones(size(mv)))./ellipke(mv,tol)); % END FUNCTION nomeq() \ No newline at end of file diff --git a/matlab/src/theta_prime.m b/matlab/src/theta_prime.m index 6a06493..58d43ae 100644 --- a/matlab/src/theta_prime.m +++ b/matlab/src/theta_prime.m @@ -63,6 +63,17 @@ % Moiseev Igor, % 34106, SISSA, via Beirut n. 2-4, Trieste, Italy +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 3 && (isempty(j) || isempty(z) || isempty(m)) + sz = size(j); + if isempty(z), sz = size(z); end + if isempty(m), sz = size(m); end + th = zeros(sz); + thp = zeros(sz); + return; +end + if nargin < 4, tol = eps; end if nargin < 3, error('Not enough input arguments.'); end diff --git a/matlab/src/weierstrassP.m b/matlab/src/weierstrassP.m index 6c892e0..103f21d 100644 --- a/matlab/src/weierstrassP.m +++ b/matlab/src/weierstrassP.m @@ -25,6 +25,17 @@ % Functions", Dover, 1965, §18.9. % [2] NIST DLMF §23.6. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + P = zeros(sz); + return; +end + if nargin < 4, error('weierstrassP: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassP: all input arguments must be real.'); diff --git a/matlab/src/weierstrassPPrime.m b/matlab/src/weierstrassPPrime.m index 55490f6..40a293c 100644 --- a/matlab/src/weierstrassPPrime.m +++ b/matlab/src/weierstrassPPrime.m @@ -25,6 +25,17 @@ % [1] M. Abramowitz and I.A. Stegun, "Handbook of Mathematical % Functions", Dover, 1965, §18.9. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + dP = zeros(sz); + return; +end + if nargin < 4, error('weierstrassPPrime: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassPPrime: all input arguments must be real.'); diff --git a/matlab/src/weierstrassSigma.m b/matlab/src/weierstrassSigma.m index c8f5fa1..75b8b8e 100644 --- a/matlab/src/weierstrassSigma.m +++ b/matlab/src/weierstrassSigma.m @@ -27,6 +27,17 @@ % Functions", Dover, 1965, §18.3, 18.5. % [2] NIST DLMF §23.2. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + S = zeros(sz); + return; +end + if nargin < 4, error('weierstrassSigma: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassSigma: all input arguments must be real.'); diff --git a/matlab/src/weierstrassZeta.m b/matlab/src/weierstrassZeta.m index 8ac99bd..a3073ed 100644 --- a/matlab/src/weierstrassZeta.m +++ b/matlab/src/weierstrassZeta.m @@ -25,6 +25,17 @@ % Functions", Dover, 1965, §18.3, 18.10. % [2] NIST DLMF §23.6. +% Empty input -> empty output of the same shape (elementwise semantics; the +% size checks below would otherwise reject [] against a scalar). +if nargin >= 4 && (isempty(z) || isempty(e1) || isempty(e2) || isempty(e3)) + sz = size(z); + if isempty(e1), sz = size(e1); end + if isempty(e2), sz = size(e2); end + if isempty(e3), sz = size(e3); end + Z = zeros(sz); + return; +end + if nargin < 4, error('weierstrassZeta: requires four arguments (z, e1, e2, e3).'); end if ~isreal(z) || ~isreal(e1) || ~isreal(e2) || ~isreal(e3) error('weierstrassZeta: all input arguments must be real.'); diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 53c358c..f386056 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -883,3 +883,31 @@ %! assert(isequal(size(Ju), [6 1]), 'jacobiEDJ column u, scalar m, n'); %! [Eu1, Du1, Ju1] = jacobiEDJ(u(2), 0.5, 0.3); %! assert(Eu(2) == Eu1 && Du(2) == Du1 && Ju(2) == Ju1, 'jacobiEDJ column == scalar'); + +%% --------------------------------------------------------------------- +%% AA. Empty, NaN and Inf inputs (round 6e). Empty in -> empty out of the +%% same shape; a NaN element must come back as NaN without disturbing +%% its neighbours or aborting the call. Found: nine functions rejected +%% [] against a scalar partner; nomeq aborted inside ellipke on a NaN +%% ("algorithm did not converge"); inversenomeq rejected NaN as "out of +%% [0,1)"; elliptic12i raised "must be real" because (-1)^NaN is complex +%% in Octave; the Carlson wrappers returned complex NaN for -Inf and +%% complex garbage for R_J with p < 0. +%% --------------------------------------------------------------------- +%!test +%! clear +%! assert(isempty(ellipticBDJ([], 0.5, 0.3)) && isempty(theta_prime(2, [], 0.5)) && isempty(cel([], 1, 1, 1)), 'empty inputs (1)'); +%! assert(isempty(weierstrassP([], 1.5, -0.25, -1.25)) && isempty(weierstrassZeta([], 1.5, -0.25, -1.25)) && isempty(weierstrassSigma([], 1.5, -0.25, -1.25)), 'empty inputs (2)'); +%! assert(isempty(carlsonRF([], 0.5, 1)) && isempty(carlsonRJ(1, 2, 3, [])) && isempty(carlsonRC([], 1)) && isempty(arclength_ellipse(2, 3, 0, [])) && isempty(elliptic123([], 0.5)), 'empty inputs (3)'); +%! assert(isequal(size(carlsonRD(zeros(0, 3), 1, 2)), [0 3]), 'empty keeps its shape'); +%! q = nomeq([0.3 NaN 0.7]); +%! assert(isnan(q(2)) && ~any(isnan(q([1 3]))) && q(1) == nomeq(0.3), 'nomeq isolates NaN'); +%! m = inversenomeq([0.05 NaN 0.3]); +%! assert(isnan(m(2)) && m(1) == inversenomeq(0.05) && m(3) == inversenomeq(0.3), 'inversenomeq isolates NaN'); +%! F = elliptic12i([0.3 NaN 0.7 Inf] + 0.2i, 0.5); +%! assert(isnan(F(2)) && isnan(F(4)) && ~isnan(F(1)) && F(1) == elliptic12i(0.3 + 0.2i, 0.5), 'elliptic12i isolates NaN / Inf'); +%! assert(isnan(carlsonRF(-Inf, 0.5, 1)) && isreal(carlsonRF(-Inf, 0.5, 1)) && isnan(carlsonRF(-1, 2, 3)), 'Carlson: -Inf / negative -> real NaN'); +%! v = carlsonRF([1 NaN 2], 2, 3); +%! assert(isnan(v(2)) && v(1) == carlsonRF(1, 2, 3), 'Carlson isolates NaN'); +%! err = ''; try, carlsonRJ(1, 2, 3, -1); catch e, err = e.message; end +%! assert(~isempty(strfind(err, 'principal')), 'carlsonRJ p < 0 must error, not return complex'); diff --git a/python/elliptic/bulirsch.py b/python/elliptic/bulirsch.py index 0603004..df50001 100644 --- a/python/elliptic/bulirsch.py +++ b/python/elliptic/bulirsch.py @@ -77,7 +77,9 @@ def _cel_xp(xp, kc, p, a, b): active = step C = math.pi / 2.0 * (b + a * em) / (em * (em + p)) C = xp.where(zero_kc & (b != 0.0), xp.sign(b / xp.where(p == 0, xp.ones_like(p), p)) * math.inf, C) - return C + # kc = NaN never became active above (NaN == NaN is False) and returned + # the untouched pi/2; propagate it like every other input + return xp.where(k != k, xp.full_like(C, math.nan), C) def cel1(kc): diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 4e8d395..349c88e 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -749,3 +749,19 @@ def test_shapes_match_scalar_calls(self): assert x == y or (np.isnan(x) and np.isnan(y)) or abs(x - y) <= 4e-16 * max(1, abs(y)) ca = [np.asarray(args[0]).ravel()] + [(np.asarray(x).ravel()[0] if np.ndim(x) else x) for x in args[1:]] assert np.asarray(tup(fn(*ca))[0]).shape == (6,) + + +class TestEmptyNaNInf: + def test_cel_propagates_nan_kc(self): + """kc = NaN never became active in the Landen ascent (NaN == NaN is + False) and cel returned the untouched pi/2.""" + assert math.isnan(_s(elliptic.cel(np.nan, 1.0, 1.0, 1.0))) + v = np.asarray(elliptic.cel(np.array([0.3, np.nan, 0.7]), 1.0, 1.0, 1.0)) + assert np.isnan(v[1]) and not np.isnan(v[[0, 2]]).any() + + def test_empty_and_nan_isolation(self): + for fn in (lambda x: elliptic.ellipticBDJ(x, 0.5, 0.3)[0], lambda x: elliptic.cel(x, 1.0, 1.0, 1.0), lambda x: elliptic.weierstrassP(x, 1.5, -0.25, -1.25), + lambda x: elliptic.carlsonRF(x, 0.5, 1.0), lambda x: elliptic.nomeq(x), lambda x: elliptic.elliptic12i(x + 0.2j, 0.5)[0]): + assert np.asarray(fn(np.array([]))).size == 0 + v = np.asarray(fn(np.array([0.3, np.nan, 0.7]))) + assert np.isnan(v[1]) and not np.isnan(v[[0, 2]]).any() From aab8c1081499a7c39a82d4670cc87a8a56d2bd1f Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 19:56:57 +0400 Subject: [PATCH 20/23] fix(matlab): NaN on the GPU branches and in the theta family; MATLAB-portable par_worker Round 6.20 in docs/specs/post-0d09740-regression-audit.md. * L4 run (image from 5b3c561): torch/JAX 88/88 OK on device, backend pytest 10/10, Octave OpenCL parity <= 4.8e-16 (x2.7) -- and the NaN block of testEdgeCases failed under the GPU flag: a NaN m fell through find(m ~= 1 & m ~= 0) in gpu_elliptic12 / gpu_ellipj, the AGM loop exited at once and F came back equal to u. Reproduced locally with the strict device stub (testGpuStrict now carries NaN cases); NaN masks added. * Octave's ellipke aborts ("algorithm did not converge") when any element is NaN, taking theta, theta_prime, jacobiThetaEta, elliptic12i and inverselliptic2 down with it. New ellipke_safe.m (NaN-propagating) at the six call sites; NaN m gives NaN after the q = 0 stand-in. * par_worker.m: try/catch instead of the Octave-only unwind_protect, so the file also parses in MATLAB. The whole Octave suite also passes under --traditional (MATLAB-compatibility mode). * complex_elliptic.py: silence a masked 0/0 in the complex E assembly. * Audit doc: "Automated verification" refreshed for the state after round 6. Octave: 18 files, 0 failures; pytest 484 passed. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 28 ++++++++++++++------- matlab/src/ellipj.m | 4 ++- matlab/src/ellipke_safe.m | 13 ++++++++++ matlab/src/elliptic12.m | 6 ++++- matlab/src/elliptic12i.m | 2 +- matlab/src/inverselliptic2.m | 2 +- matlab/src/jacobiThetaEta.m | 6 ++--- matlab/src/par_worker.m | 9 ++++--- matlab/src/theta.m | 4 +-- matlab/src/theta_prime.m | 4 +-- matlab/tests/testEdgeCases.m | 7 ++++++ matlab/tests/testGpuStrict.m | 4 +++ python/elliptic/complex_elliptic.py | 5 +++- 13 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 matlab/src/ellipke_safe.m diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index 4d61cbe..c26cfa4 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -56,15 +56,24 @@ regression tests. No public function was intentionally removed. ## Automated verification -- Python default environment: **451 passed, 1 skipped**. The skip is the - optional JAX-only test when JAX is not installed. -- Python with both JAX and PyTorch installed: **458 passed**. -- Dedicated backend matrix: **10 passed**, covering NumPy, PyTorch, and JAX; - JAX compiles the core paths and traces the larger fixed-iteration graphs. -- Octave: **220/220 test blocks passed** across all 16 `test*.m` files. -- Python byte-compilation and whitespace/error checks passed. -- A base-dependency-only public API exercise verifies that SciPy is absent and - not imported at runtime. +State after round 6 (2026-09-02): + +- Python (NumPy + PyTorch CPU): **484 passed, 1 skipped** (the optional + JAX-only test); docstring examples run under `pytest --doctest-modules`. +- Backend matrix on an NVIDIA L4 (Cloud Run job): **10 passed**; the 88 + function/backend device checks of `gpu_verify.py` all within 3.6e-14 of the + NumPy reference, torch x50 over NumPy, JAX jitted 2e6 points in 18 ms. +- Octave: **18 `test*.m` files, 0 failures**, also under `--traditional` + (MATLAB-compatibility mode). New files: `testDocExamples.m` (every + docstring `Example:` block), `testGpuStrict.m` (every GPU path under the + strict device stub in `tests/gpu_stub/`, including NaN inputs), + `testParallel.m` chunking block (bit-identical chunked vs serial). +- Octave on the L4 (`ocl` over OpenCL 3.0): GPU/CPU parity <= 4.8e-16 on + `elliptic12`, `ellipj`, `elliptic3`, `ellipticBDJ`; x2.7 over CPU. +- Cross-port sweeps (kept under `scratchpad`, reproducible from the audit + text): 3000 + 1500 + 800 + 1000 random points at extreme parameters, every + disagreement above 1e-13 adjudicated with mpmath at the exact doubles; + matrix/column/mixed-shape sweep; empty/NaN/Inf sweep. ## Adversarial review round (2026-08-16) @@ -195,6 +204,7 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` | 6.17 | `ellipj.m`, `jacobiThetaEta.m`, `inverselliptic2.m`, `elliptic123.m` | input-shape defects from a sweep of matrix / column / row / mixed-scalar calls against scalar loops: `ellipj` re-read `cn(I)` from its column-shaped output against the row `m(I)` (6x6 broadcast error for any column `u`, hence also `jacobiEDJ`); `jacobiThetaEta` returned a row for a matrix; `inverselliptic2`'s vector-wide Newton stop made values batch-dependent by an ulp; `elliptic123` failed on any matrix | keep the row-shaped value; reshape to the input shape; per-element Newton mask; flatten to rows and restore the shape. The Python port passed the same sweep; `TestInputShapes` pins it | | 6.18 | `elliptic12.m` GPU path | `K_per = 2 .* gpuArray(k_per) .* K_vals` multiplied a device array by a host matrix (K_vals became a host array in 6.4); `ocl` refuses that and the L4 run's Octave section failed. The identity stubs used locally cannot see host/device mixing | host product, then `gpuArray`. New `tests/gpu_stub/gpuArray.m`: a strict device-array stand-in that errors on device-by-host-matrix operators and on logical indexing exactly like `ocl`; `testGpuStrict.m` runs every GPU path under it (would have caught all three host/device defects of the earlier hardware rounds on a laptop) | | 6.19 | empty / NaN / Inf inputs | MATLAB: nine functions rejected `[]` against a scalar partner (`ellipticBDJ`, `theta_prime`, `cel`, the four Weierstrass functions, the four Carlson functions, `arclength_ellipse`, `elliptic123`); `nomeq` aborted inside `ellipke` on a single NaN; `inversenomeq` rejected NaN as out of range; `elliptic12i` raised "must be real" because `(-1)^NaN` is complex in Octave; the Carlson wrappers returned complex NaN for `-Inf` and complex garbage for `R_J` with `p < 0`. Python: `cel(NaN, ...)` returned `pi/2` (a NaN `kc` never became active) | empty in, empty out of the same shape; NaN isolated elementwise; `R_J` with `p <= 0` errors like the Python port; `cel` propagates NaN | +| 6.20 | GPU branches of `elliptic12.m`, `ellipj.m`; `theta`, `theta_prime`, `jacobiThetaEta`, `elliptic12i`, `inverselliptic2` (MATLAB) | on the L4 the Octave section failed the NaN block: a NaN `m` fell through `find(m ~= 1 & m ~= 0)` on the GPU path, the AGM loop exited at once and `F` came back equal to `u`. Separately, Octave's `ellipke` aborts with "algorithm did not converge" as soon as one element is NaN, taking the whole theta family down | NaN masks in both GPU branches (reproduced locally by the strict stub, which now includes NaN cases); `ellipke_safe.m` (NaN-propagating `ellipke`) at the six call sites, and NaN `m` mapped back to NaN after the `q = 0` stand-in | Deliberate limits found in this sweep: complex `sn, cn, dn` near the poles `u = iK'` and Weierstrass functions near lattice points carry the diff --git a/matlab/src/ellipj.m b/matlab/src/ellipj.m index 538099d..b728d1d 100644 --- a/matlab/src/ellipj.m +++ b/matlab/src/ellipj.m @@ -210,7 +210,9 @@ if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end - I = find(m ~= 1 & m ~= 0); + bad = isnan(m) | isnan(u); % NaN in, NaN out (see gpu_elliptic12) + sn(bad) = NaN; cn(bad) = NaN; dn(bad) = NaN; am(bad) = NaN; + I = find(m ~= 1 & m ~= 0 & ~bad); if ~isempty(I) mmax = length(I); mu = m(I); diff --git a/matlab/src/ellipke_safe.m b/matlab/src/ellipke_safe.m new file mode 100644 index 0000000..de7f988 --- /dev/null +++ b/matlab/src/ellipke_safe.m @@ -0,0 +1,13 @@ +function [K, E] = ellipke_safe(m, tol) +%ELLIPKE_SAFE Complete elliptic integrals K(m), E(m) with NaN propagation. +% [K, E] = ELLIPKE_SAFE(M) is ELLIPKE(M) except that NaN elements of M +% give NaN instead of aborting: Octave's ellipke raises "algorithm did not +% converge" as soon as one element is NaN, which took every theta and nome +% function down with it. TOL is passed through when given. +if nargin < 2, tol = eps; end +K = nan(size(m)); E = K; +ok = ~isnan(m); +if any(ok(:)) + [K(ok), E(ok)] = ellipke(m(ok), tol); +end +end diff --git a/matlab/src/elliptic12.m b/matlab/src/elliptic12.m index 98a6a2e..94f3691 100644 --- a/matlab/src/elliptic12.m +++ b/matlab/src/elliptic12.m @@ -239,7 +239,11 @@ if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end m(m < eps) = 0; - I = find(m ~= 1 & m ~= 0); + % NaN in, NaN out (a NaN m fell through the selection below and the AGM + % loop exited at once, so the GPU path returned F = u on real hardware) + bad = isnan(m) | isnan(u); + F(bad) = NaN; E(bad) = NaN; Z(bad) = NaN; + I = find(m ~= 1 & m ~= 0 & ~bad); if ~isempty(I) mmax = length(I); mu = m(I); diff --git a/matlab/src/elliptic12i.m b/matlab/src/elliptic12i.m index 70ef9cf..c0e76be 100644 --- a/matlab/src/elliptic12i.m +++ b/matlab/src/elliptic12i.m @@ -144,7 +144,7 @@ Ei(:) = (b1 + sqrt(-1)*b2)./b3; Ei(:) = Ei(:) + E1(:) + sqrt(-1)*(-E2(:) + F2(:)); -[K,Ee] = ellipke(m); +[K,Ee] = ellipke_safe(m); % complex values of zeta function Zi(:) = Ei(:) - Ee(:)./K(:).*Fi(:); diff --git a/matlab/src/inverselliptic2.m b/matlab/src/inverselliptic2.m index 847b88a..c294aeb 100644 --- a/matlab/src/inverselliptic2.m +++ b/matlab/src/inverselliptic2.m @@ -91,7 +91,7 @@ z = E; mu = 1-m; % complete integral initialization -[~,E1] = ellipke(m,tol); +[~,E1] = ellipke_safe(m,tol); % Boyd's initialisation and the Newton iteration below only converge on % phi in [0, pi/2]. Reduce first, using diff --git a/matlab/src/jacobiThetaEta.m b/matlab/src/jacobiThetaEta.m index 256505a..242a72d 100644 --- a/matlab/src/jacobiThetaEta.m +++ b/matlab/src/jacobiThetaEta.m @@ -76,7 +76,7 @@ m = m(:).'; % make a row vector u = u(:).'; -KK = ellipke(m); +KK = ellipke_safe(m); % Theta functions from their q-series (A&S 16.27, 16.38): % Th(u|m) = theta_4(v, q), H(u|m) = theta_1(v, q), v = pi*u/(2K) @@ -99,7 +99,7 @@ H(m0) = sqrt(sqrt(m(m0))).* sin(u(m0)); end -m1 = find(abs(m-1) < 10*eps); +m1 = find(abs(m-1) < 10*eps | isnan(m)); % NaN m: q was set to 0 above if ( ~isempty(m1) ) Th(m1) = NaN; H(m1) = NaN; @@ -154,7 +154,7 @@ m = m(:); u = u(:); if any(m < 0) || any(m > 1), error('M must be in the range 0 <= M <= 1.'); end - KK = ellipke(m); + KK = ellipke_safe(m); q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ KK); % K(1-m) from the exact m (see NOMEQ) q(~(q < 1)) = 0; v = pi .* u ./ (2 .* KK); diff --git a/matlab/src/par_worker.m b/matlab/src/par_worker.m index 99d569d..72fe71c 100644 --- a/matlab/src/par_worker.m +++ b/matlab/src/par_worker.m @@ -13,11 +13,14 @@ was_parallel = elliptic_config('parallel'); elliptic_config('parallel', false); - unwind_protect + % try/catch rather than unwind_protect so the file also parses in MATLAB + try result = par_worker_dispatch(func_name, varargin{:}); - unwind_protect_cleanup + catch err elliptic_config('parallel', was_parallel); - end_unwind_protect + rethrow(err); + end + elliptic_config('parallel', was_parallel); end diff --git a/matlab/src/theta.m b/matlab/src/theta.m index 95d36e5..f16a290 100644 --- a/matlab/src/theta.m +++ b/matlab/src/theta.m @@ -66,10 +66,10 @@ % (2e-10 at v ~ 1e8); THETA_SERIES also avoids the k*v product rounding. % K'(m) = R_F(0, m, 1) from the exact m: ellipke(1-m) rounds 1-m first and the % nome was 30% off at m ~ 1e-16 (theta1 off by 1e-5); see NOMEQ. -q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ ellipke(m)); +q = exp(-pi .* carlsonRF(zeros(size(m)), m, ones(size(m))) ./ ellipke_safe(m)); q(~(q < 1)) = 0; % m == 1: series diverges -> NaN below Th(:) = theta_series(type, v, q, tol); -Th(m == 1) = NaN; +Th(m == 1 | isnan(m)) = NaN; % q = 0 stands in for NaN m above; give NaN back if type == 1 Th(m == 0) = 0; % theta_1(v, 0) = 0 exactly end diff --git a/matlab/src/theta_prime.m b/matlab/src/theta_prime.m index 58d43ae..5f64ea2 100644 --- a/matlab/src/theta_prime.m +++ b/matlab/src/theta_prime.m @@ -99,12 +99,12 @@ error('M must be in the range 0 <= M <= 1.'); end -K = ellipke(m); +K = ellipke_safe(m); Kp = carlsonRF(zeros(size(m)), m, ones(size(m))); % K(1-m) from the exact m (see NOMEQ) q = exp(-pi .* Kp ./ K); q(~(q < 1)) = 0; % m == 1 guard [th, thp] = theta_series(j, z, q, tol); -th(m == 1) = NaN; thp(m == 1) = NaN; +th(m == 1 | isnan(m)) = NaN; thp(m == 1 | isnan(m)) = NaN; % q = 0 stood in for NaN m if j == 1, th(m == 0) = 0; end end diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index f386056..6040741 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -911,3 +911,10 @@ %! assert(isnan(v(2)) && v(1) == carlsonRF(1, 2, 3), 'Carlson isolates NaN'); %! err = ''; try, carlsonRJ(1, 2, 3, -1); catch e, err = e.message; end %! assert(~isempty(strfind(err, 'principal')), 'carlsonRJ p < 0 must error, not return complex'); +%! t = theta(1, [0.3 0.5 0.7], [0.2 NaN 0.4]); +%! assert(isnan(t(2)) && t(1) == theta(1, 0.3, 0.2), 'theta isolates NaN in m (Octave ellipke aborts on NaN)'); +%! [th, thp] = theta_prime(2, 0.4, [0.2 NaN]); +%! assert(isnan(th(2)) && isnan(thp(2)) && ~isnan(th(1)), 'theta_prime isolates NaN in m'); +%! [Th, H] = jacobiThetaEta([0.3 0.5], [0.2 NaN]); +%! assert(isnan(Th(2)) && isnan(H(2)) && Th(1) == jacobiThetaEta(0.3, 0.2), 'jacobiThetaEta isolates NaN in m'); +%! assert(isnan(inverselliptic2(0.4, NaN)) && isnan(elliptic12i(0.3 + 0.2i, NaN)), 'inverselliptic2 / elliptic12i NaN in m'); diff --git a/matlab/tests/testGpuStrict.m b/matlab/tests/testGpuStrict.m index 3c99c5c..4c68806 100644 --- a/matlab/tests/testGpuStrict.m +++ b/matlab/tests/testGpuStrict.m @@ -36,6 +36,10 @@ function testGpuStrict() %! 'inverselliptic2', @() inverselliptic2(z, m); %! 'cel', @() cel(sqrt(1-m), n, 1, 0.5); %! 'arclength_ellipse', @() arclength_ellipse(z+0.1, z+0.5, u, z); +%! 'elliptic12 NaN', @() nthargout(1:3, @elliptic12, [0.3 0.5 0.7 NaN], [0.2 NaN 0.4 0.5]); +%! 'ellipj NaN', @() nthargout(1:4, @ellipj, [0.3 0.5 0.7 NaN], [0.2 NaN 0.4 0.5]); +%! 'theta NaN', @() theta(1, [0.3 0.5], [0.2 NaN]); +%! 'elliptic3 NaN', @() elliptic3([0.3 0.5], [0.2 NaN], 0.3); %! }; %! for t = 1:rows(tests) %! name = tests{t,1}; f = tests{t,2}; diff --git a/python/elliptic/complex_elliptic.py b/python/elliptic/complex_elliptic.py index d0f9671..60db7db 100644 --- a/python/elliptic/complex_elliptic.py +++ b/python/elliptic/complex_elliptic.py @@ -100,7 +100,10 @@ def elliptic12i(u, m): den = cm**2 + m_f * sl**2 * sm**2 b1 = m_f * sl * cl * sm**2 * xp.sqrt(d2l) b2 = sm * cm * d2l * xp.sqrt(d2m) - Ei = (b1 + 1j * b2) / den + E1 + 1j * (-E2 + F2) + # den = 0 only where the small-m series (below) replaces the result; keep + # the division silent instead of raising a RuntimeWarning on 0/0 + den_safe = xp.where(den == 0.0, xp.ones_like(den), den) + Ei = (b1 + 1j * b2) / den_safe + E1 + 1j * (-E2 + F2) # Z = E - (E_complete / K) * F # Complete integrals from the exact Carlson forms, not F(double(pi/2)|m): From bf1b2b8296c67092d7648e085f6ad2ea036fef72 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 20:00:54 +0400 Subject: [PATCH 21/23] fix(matlab): scalar phase with a parameter vector in elliptic3, theta, elliptic123 Round 6.21: elliptic3 expanded c from the still-scalar u before u was expanded from m and rejected elliptic3(0.3, [0.2 0.5 0.9], 0.3) as "must be the same size"; theta preallocated its output before broadcasting and hit a size error for theta(1, 0.3, [..]); elliptic123 restored the shape of the scalar phase. Broadcast to the largest input first, preallocate after. Empty inputs give empty outputs in elliptic3 and theta as elsewhere. Tests: testEdgeCases block AB. Octave 18 files, 0 failures. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 1 + matlab/src/elliptic123.m | 2 ++ matlab/src/elliptic3.m | 12 +++++++++--- matlab/src/theta.m | 6 +++--- matlab/tests/testEdgeCases.m | 20 ++++++++++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index c26cfa4..b943107 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -205,6 +205,7 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` | 6.18 | `elliptic12.m` GPU path | `K_per = 2 .* gpuArray(k_per) .* K_vals` multiplied a device array by a host matrix (K_vals became a host array in 6.4); `ocl` refuses that and the L4 run's Octave section failed. The identity stubs used locally cannot see host/device mixing | host product, then `gpuArray`. New `tests/gpu_stub/gpuArray.m`: a strict device-array stand-in that errors on device-by-host-matrix operators and on logical indexing exactly like `ocl`; `testGpuStrict.m` runs every GPU path under it (would have caught all three host/device defects of the earlier hardware rounds on a laptop) | | 6.19 | empty / NaN / Inf inputs | MATLAB: nine functions rejected `[]` against a scalar partner (`ellipticBDJ`, `theta_prime`, `cel`, the four Weierstrass functions, the four Carlson functions, `arclength_ellipse`, `elliptic123`); `nomeq` aborted inside `ellipke` on a single NaN; `inversenomeq` rejected NaN as out of range; `elliptic12i` raised "must be real" because `(-1)^NaN` is complex in Octave; the Carlson wrappers returned complex NaN for `-Inf` and complex garbage for `R_J` with `p < 0`. Python: `cel(NaN, ...)` returned `pi/2` (a NaN `kc` never became active) | empty in, empty out of the same shape; NaN isolated elementwise; `R_J` with `p <= 0` errors like the Python port; `cel` propagates NaN | | 6.20 | GPU branches of `elliptic12.m`, `ellipj.m`; `theta`, `theta_prime`, `jacobiThetaEta`, `elliptic12i`, `inverselliptic2` (MATLAB) | on the L4 the Octave section failed the NaN block: a NaN `m` fell through `find(m ~= 1 & m ~= 0)` on the GPU path, the AGM loop exited at once and `F` came back equal to `u`. Separately, Octave's `ellipke` aborts with "algorithm did not converge" as soon as one element is NaN, taking the whole theta family down | NaN masks in both GPU branches (reproduced locally by the strict stub, which now includes NaN cases); `ellipke_safe.m` (NaN-propagating `ellipke`) at the six call sites, and NaN `m` mapped back to NaN after the `q = 0` stand-in | +| 6.21 | `elliptic3.m`, `theta.m`, `elliptic123.m` | a scalar phase with a parameter vector: `elliptic3` expanded `c` from the still-scalar `u` before `u` was expanded from `m` and rejected the call ("must be the same size"); `theta` preallocated its output before broadcasting (size error); `elliptic123` restored the scalar phase's shape | broadcast to the largest input first; preallocate after; shape of the largest input | Deliberate limits found in this sweep: complex `sn, cn, dn` near the poles `u = iK'` and Weierstrass functions near lattice points carry the diff --git a/matlab/src/elliptic123.m b/matlab/src/elliptic123.m index 5864b35..0da1279 100644 --- a/matlab/src/elliptic123.m +++ b/matlab/src/elliptic123.m @@ -99,6 +99,8 @@ end sz = size(a1); +if nargin >= 2 && numel(a2) > numel(a1), sz = size(a2); end +if nargin >= 3 && numel(a3) > max(numel(a1), numel(a2)), sz = size(a3); end a1 = a1(:).'; if nargin >= 2, a2 = a2(:).'; end if nargin >= 3, a3 = a3(:).'; end diff --git a/matlab/src/elliptic3.m b/matlab/src/elliptic3.m index 46fbfcb..3ae9bb9 100644 --- a/matlab/src/elliptic3.m +++ b/matlab/src/elliptic3.m @@ -68,9 +68,15 @@ [mm,nm] = size(m); [mu,nu] = size(u); -if length(m)==1, m = m(ones(size(u))); end -if length(c)==1, c = c(ones(size(u))); end -if length(u)==1, u = u(ones(size(m))); end +% Broadcast scalars to the largest input (the old order expanded c from the +% still-scalar u before u itself was expanded from m, so a scalar phase with a +% parameter vector was rejected as 'must be the same size'). +sz = size(u); +if numel(m) > 1, sz = size(m); elseif numel(c) > 1, sz = size(c); end +if isempty(u) || isempty(m) || isempty(c), Pi = zeros(0, 0); if isempty(u), Pi = zeros(size(u)); elseif isempty(m), Pi = zeros(size(m)); else, Pi = zeros(size(c)); end; return; end +if length(m)==1, m = m(ones(sz)); end +if length(c)==1, c = c(ones(sz)); end +if length(u)==1, u = u(ones(sz)); end if ~isequal(size(m), size(c), size(u)), error('U, M and C must be the same size.'); end diff --git a/matlab/src/theta.m b/matlab/src/theta.m index f16a290..42eadb8 100644 --- a/matlab/src/theta.m +++ b/matlab/src/theta.m @@ -47,11 +47,11 @@ error('Input arguments must be real.') end -Th = zeros(size(v)); -H = Th; - +if isempty(v) || isempty(m), Th = zeros(size(v)); if isempty(m), Th = zeros(size(m)); end; return; end if length(m)==1, m = m(ones(size(v))); end if length(v)==1, v = v(ones(size(m))); end +Th = zeros(size(v)); % after broadcasting (a scalar v with a vector m used to hit a size error) +H = Th; if ~isequal(size(m),size(v)), error('V and M must be the same size.'); end % m = m(:).'; % make a row vector diff --git a/matlab/tests/testEdgeCases.m b/matlab/tests/testEdgeCases.m index 6040741..571aa4d 100644 --- a/matlab/tests/testEdgeCases.m +++ b/matlab/tests/testEdgeCases.m @@ -918,3 +918,23 @@ %! [Th, H] = jacobiThetaEta([0.3 0.5], [0.2 NaN]); %! assert(isnan(Th(2)) && isnan(H(2)) && Th(1) == jacobiThetaEta(0.3, 0.2), 'jacobiThetaEta isolates NaN in m'); %! assert(isnan(inverselliptic2(0.4, NaN)) && isnan(elliptic12i(0.3 + 0.2i, NaN)), 'inverselliptic2 / elliptic12i NaN in m'); + +%% --------------------------------------------------------------------- +%% AB. Scalar first argument with a parameter vector (round 6f). elliptic3 +%% expanded c from the still-scalar u before u was expanded from m and +%% rejected the call as "must be the same size"; theta preallocated its +%% output before broadcasting; elliptic123 restored the shape of the +%% scalar phase. Each result must equal the scalar-loop values. +%% --------------------------------------------------------------------- +%!test +%! clear +%! mv = [0.2 0.5 0.9]; +%! P = elliptic3(0.3, mv, 0.3); +%! assert(isequal(size(P), [1 3]) && P(2) == elliptic3(0.3, 0.5, 0.3), 'elliptic3 scalar u, vector m'); +%! P = elliptic3(0.3, 0.5, [0.1 0.2 0.3]); +%! assert(isequal(size(P), [1 3]) && P(3) == elliptic3(0.3, 0.5, 0.3), 'elliptic3 scalar u, m; vector c'); +%! t = theta(1, 0.3, mv); +%! assert(isequal(size(t), [1 3]) && t(2) == theta(1, 0.3, 0.5), 'theta scalar v, vector m'); +%! [F, E] = elliptic123(0.3, mv); +%! assert(isequal(size(F), [1 3]) && F(2) == elliptic123(0.3, 0.5), 'elliptic123 scalar b, vector m'); +%! assert(isempty(elliptic3([], 0.5, 0.3)) && isempty(theta(1, [], 0.5)) && isempty(elliptic3(0.3, [], 0.3)), 'empty inputs'); From dfb10b057793b435c2cf11d70f0e16ada8762d2a Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 20:13:35 +0400 Subject: [PATCH 22/23] fix(matlab): elliptic3 GPU path falls back to the Carlson form like the CPU core Round 6.22: the OpenCL branch of elliptic3 was the 20-node Gauss-Legendre rule only. The serial core switches to the Carlson form (DLMF 19.25.14) where the rule loses digits -- endpoint denominators below 0.25, and c < 0 since round 6.8 -- but the GPU path had no such fallback: on the L4, Pi(1|0.5,-100) came back 3.8e-9 off and Pi(4|0.9,-100) 5.4e-10, failing the new block U anchors under the GPU flag. The strict device stub reproduces it locally. The Carlson block is now the shared subfunction elliptic3_carlson; the GPU path evaluates the "danger" subset (and NaN elements) on the host with it and sends only the regular elements to the kernel. GPU and CPU paths are bit-identical on the c < 0 / near-pole cases (testGpuStrict extended). Octave: 18 files, 0 failures; pytest green. Co-Authored-By: Claude Fable 5.1 --- docs/specs/post-0d09740-regression-audit.md | 1 + matlab/src/elliptic3.m | 53 +++++++++++++++------ matlab/tests/testGpuStrict.m | 1 + 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/docs/specs/post-0d09740-regression-audit.md b/docs/specs/post-0d09740-regression-audit.md index b943107..27d9f66 100644 --- a/docs/specs/post-0d09740-regression-audit.md +++ b/docs/specs/post-0d09740-regression-audit.md @@ -206,6 +206,7 @@ for theta1). The remaining cross-port gap is `sn, cn, dn` at `|u| ~ 1e5` | 6.19 | empty / NaN / Inf inputs | MATLAB: nine functions rejected `[]` against a scalar partner (`ellipticBDJ`, `theta_prime`, `cel`, the four Weierstrass functions, the four Carlson functions, `arclength_ellipse`, `elliptic123`); `nomeq` aborted inside `ellipke` on a single NaN; `inversenomeq` rejected NaN as out of range; `elliptic12i` raised "must be real" because `(-1)^NaN` is complex in Octave; the Carlson wrappers returned complex NaN for `-Inf` and complex garbage for `R_J` with `p < 0`. Python: `cel(NaN, ...)` returned `pi/2` (a NaN `kc` never became active) | empty in, empty out of the same shape; NaN isolated elementwise; `R_J` with `p <= 0` errors like the Python port; `cel` propagates NaN | | 6.20 | GPU branches of `elliptic12.m`, `ellipj.m`; `theta`, `theta_prime`, `jacobiThetaEta`, `elliptic12i`, `inverselliptic2` (MATLAB) | on the L4 the Octave section failed the NaN block: a NaN `m` fell through `find(m ~= 1 & m ~= 0)` on the GPU path, the AGM loop exited at once and `F` came back equal to `u`. Separately, Octave's `ellipke` aborts with "algorithm did not converge" as soon as one element is NaN, taking the whole theta family down | NaN masks in both GPU branches (reproduced locally by the strict stub, which now includes NaN cases); `ellipke_safe.m` (NaN-propagating `ellipke`) at the six call sites, and NaN `m` mapped back to NaN after the `q = 0` stand-in | | 6.21 | `elliptic3.m`, `theta.m`, `elliptic123.m` | a scalar phase with a parameter vector: `elliptic3` expanded `c` from the still-scalar `u` before `u` was expanded from `m` and rejected the call ("must be the same size"); `theta` preallocated its output before broadcasting (size error); `elliptic123` restored the scalar phase's shape | broadcast to the largest input first; preallocate after; shape of the largest input | +| 6.22 | `elliptic3.m` GPU path | the OpenCL branch was the 20-node rule only, with no Carlson fallback for the elements the rule cannot resolve (endpoint denominators below 0.25, `c < 0`): on the L4, `Pi(1|0.5,-100)` was 3.8e-9 off and `Pi(4|0.9,-100)` 5.4e-10 (the strict stub reproduces it) | the Carlson block is now the shared subfunction `elliptic3_carlson`; the GPU path evaluates the "danger" subset on the host with it and sends only the regular elements to the kernel | Deliberate limits found in this sweep: complex `sn, cn, dn` near the poles `u = iK'` and Weierstrass functions near lattice points carry the diff --git a/matlab/src/elliptic3.m b/matlab/src/elliptic3.m index 3ae9bb9..5c21dfc 100644 --- a/matlab/src/elliptic3.m +++ b/matlab/src/elliptic3.m @@ -151,16 +151,7 @@ % are replaced by Inf below. near = find(danger); if ~isempty(near) - c_eval = c(near); - d2_eval = d2(near); - p_eval = p(near); - endpoint = (u(near)==pi/2 & (m(near)==1 | c(near)==1)); - c_eval(endpoint) = 0; - d2_eval(endpoint) = 1; - p_eval(endpoint) = 1; - RF = carlsonRF(co(near).^2, d2_eval, ones(size(near))); - RJ = carlsonRJ(co(near).^2, d2_eval, ones(size(near)), p_eval); - P(near) = s(near).*RF + c_eval.*s(near).^3.*RJ./3; + P(near) = elliptic3_carlson(u(near), m(near), c(near)); end P(s == 0) = 0; Pi(:) = P; @@ -170,6 +161,23 @@ return; +function P = elliptic3_carlson(u, m, c) +%ELLIPTIC3_CARLSON Pi(u|m,c) by DLMF 19.25.14 for 0 <= u <= pi/2 (row inputs). +% Used for the elements the 20-node rule cannot resolve (denominators +% below 0.25 at the endpoint, or c < 0) by BOTH the serial core and the +% GPU path: the OpenCL kernel is the quadrature only, and on the L4 it +% returned Pi(1|0.5,-100) 3.8e-9 off because it had no such fallback. +s = sin(u); co = cos(u); +d2 = (1 - m) + m.*co.^2; +p = (1 - c) + c.*co.^2; +endpoint = (u == pi/2 & (m == 1 | c == 1)); % keep the eager evaluation finite; caller sets Inf +c(endpoint) = 0; d2(endpoint) = 1; p(endpoint) = 1; +RF = carlsonRF(co.^2, d2, ones(size(u))); +RJ = carlsonRJ(co.^2, d2, ones(size(u)), p); +P = s.*RF + c.*s.^3.*RJ./3; +P(s == 0) = 0; + + function g = g(u,m,c) % g = 1/((1 - c*sin(u)^2)*sqrt(1 - m*sin(u)^2)); @@ -216,9 +224,25 @@ origSize = size(u); I_inf = find(u(:).' == pi/2 & m(:).' == 1 | u(:).' == pi/2 & c(:).' == 1); - u_g = gpuArray(u(:).'); - m_g = gpuArray(m(:).'); - c_g = gpuArray(c(:).'); + uu = u(:).'; mm = m(:).'; cc = c(:).'; + % Same hybrid as the serial core: the kernel is the 20-node rule, which is + % full precision only while both endpoint denominators stay >= 0.25 and + % c >= 0; the rest goes through the Carlson form on the host. + co2 = cos(uu).^2; + danger = ((1 - mm) + mm.*co2 < 0.25) | ((1 - cc) + cc.*co2 < 0.25) | (cc < 0) | isnan(uu) | isnan(mm) | isnan(cc); + Pi = zeros(origSize); + if any(danger) + Pi(danger) = elliptic3_carlson(uu(danger), mm(danger), cc(danger)); + Pi(isnan(uu) | isnan(mm) | isnan(cc)) = NaN; + end + if ~any(~danger) + Pi(I_inf) = inf; + return; + end + reg = ~danger; + u_g = gpuArray(uu(reg)); + m_g = gpuArray(mm(reg)); + c_g = gpuArray(cc(reg)); t = [ 0.9931285991850949, 0.9639719272779138, ... 0.9122344282513259, 0.8391169718222188, ... @@ -238,8 +262,7 @@ end P = u_g/2 .* P; - Pi = zeros(origSize); - Pi(:) = gather(P); + Pi(reg) = gather(P); Pi(I_inf) = inf; diff --git a/matlab/tests/testGpuStrict.m b/matlab/tests/testGpuStrict.m index 4c68806..c41dc25 100644 --- a/matlab/tests/testGpuStrict.m +++ b/matlab/tests/testGpuStrict.m @@ -40,6 +40,7 @@ function testGpuStrict() %! 'ellipj NaN', @() nthargout(1:4, @ellipj, [0.3 0.5 0.7 NaN], [0.2 NaN 0.4 0.5]); %! 'theta NaN', @() theta(1, [0.3 0.5], [0.2 NaN]); %! 'elliptic3 NaN', @() elliptic3([0.3 0.5], [0.2 NaN], 0.3); +%! 'elliptic3 c<0 / near pole', @() elliptic3([1 1 1 4 1.5707 1.2 0.4], [0.5 0.5 0.5 0.9 1-1e-9 0.999999 0.3], [-0.5 -3 -100 -100 0.3 0.999999 0.5]); %! }; %! for t = 1:rows(tests) %! name = tests{t,1}; f = tests{t,2}; From d22bdc646a54c1a579ebd517c9ccf1819bb768aa Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 2 Sep 2026 21:43:34 +0400 Subject: [PATCH 23/23] ci: path-independent GPU-stub and doc-example tests; Linux SIMD tolerance in the shape test * testGpuStrict.m / testDocExamples.m: mfilename('fullpath') is empty inside test blocks when test() is called with a full path from the repo root (CircleCI), so the relative '../src' and 'gpu_stub' lookups failed (1/2 and a 0-example run). Locate the directories from which('elliptic12') instead. The suite now passes with CircleCI's exact invocation from the repo root. * TestInputShapes: batch-vs-scalar tolerance 4e-15 instead of 4e-16 -- NumPy's SIMD sin/cos on Linux differ from the scalar libm path by an ulp (ubuntu 3.13 and the torch job failed on 1.8e-15). Co-Authored-By: Claude Fable 5.1 --- matlab/tests/testDocExamples.m | 3 +-- matlab/tests/testGpuStrict.m | 8 +++++--- python/tests/test_edge_cases.py | 4 +++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/matlab/tests/testDocExamples.m b/matlab/tests/testDocExamples.m index d8fe942..88b13c4 100644 --- a/matlab/tests/testDocExamples.m +++ b/matlab/tests/testDocExamples.m @@ -15,8 +15,7 @@ function testDocExamples() %! end %!test -%! src = fullfile(fileparts(mfilename('fullpath')), '..', 'src'); -%! addpath(src); +%! src = fileparts(which('elliptic12')); % mfilename is empty inside test blocks under test() %! files = dir(fullfile(src, '*.m')); %! nrun = 0; %! for f = 1:numel(files) diff --git a/matlab/tests/testGpuStrict.m b/matlab/tests/testGpuStrict.m index c41dc25..590a25e 100644 --- a/matlab/tests/testGpuStrict.m +++ b/matlab/tests/testGpuStrict.m @@ -7,8 +7,10 @@ function testGpuStrict() end %!test -%! here = fileparts(mfilename('fullpath')); -%! addpath(fullfile(here, '..', 'src')); +%! % locate the stub from the source directory: mfilename is empty inside test +%! % blocks when test() is called with a full path (CI), so relative paths fail +%! src = fileparts(which('elliptic12')); +%! here = fullfile(src, '..', 'tests'); %! addpath(fullfile(here, 'gpu_stub'), '-begin'); %! unwind_protect %! rand('seed', 5); N = 300; @@ -65,7 +67,7 @@ function testGpuStrict() %!test %! % the stub itself must reject what ocl rejects, or the test above proves nothing -%! here = fileparts(mfilename('fullpath')); +%! here = fullfile(fileparts(which('elliptic12')), '..', 'tests'); %! addpath(fullfile(here, 'gpu_stub'), '-begin'); %! unwind_protect %! caught = false; diff --git a/python/tests/test_edge_cases.py b/python/tests/test_edge_cases.py index 349c88e..c6582f7 100644 --- a/python/tests/test_edge_cases.py +++ b/python/tests/test_edge_cases.py @@ -746,7 +746,9 @@ def test_shapes_match_scalar_calls(self): so = [np.asarray(o) for o in tup(fn(*[complex(x) if np.iscomplexobj(x) else float(x) for x in sa])) if o is not None] for o, s in zip(outs, so): x, y = o.ravel()[i], s.item() - assert x == y or (np.isnan(x) and np.isnan(y)) or abs(x - y) <= 4e-16 * max(1, abs(y)) + # 4e-15, not 4e-16: NumPy's SIMD sin/cos on Linux differ from the + # scalar libm path by an ulp, which a batch-vs-scalar comparison sees + assert x == y or (np.isnan(x) and np.isnan(y)) or abs(x - y) <= 4e-15 * max(1, abs(y)) ca = [np.asarray(args[0]).ravel()] + [(np.asarray(x).ravel()[0] if np.ndim(x) else x) for x in args[1:]] assert np.asarray(tup(fn(*ca))[0]).shape == (6,)