// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package core import ( "math" "math/big" "sourcedock.dev/petrbalvin/tensor/internal/engine" ) // Classical orthogonal polynomials on the real line, each element-wise // over real arrays with integer degree or order. The recurrences are // the stable ones for every family, and the normalisations follow the // standard physics conventions: Hermite physicists' Hₙ with leading // coefficient 2ⁿ, Laguerre Lₙ (α = 0) and generalised Lₙ^α, Chebyshev // of the first and second kind Tₙ and Uₙ on [−1, 1]. // Hermite returns the physicists' Hermite polynomial Hₙ(x) of each // element, by the recurrence Hₙ₊₁ = 2x·Hₙ − 2n·Hₙ₋₁. func Hermite(n int, x *Array) (*Array, error) { if n < 0 { return nil, errf("Hermite: degree must be ≥ 0, got %d", n) } if x.dt == Complex { return nil, errf("Hermite: complex arrays are not supported") } out := &Array{shape: append([]int{}, x.shape...), dt: Float} out.alloc(x.Len()) engine.Parallel(x.Len(), func(s, e int) { for i := s; i < e; i++ { xv := x.floatAt(i) h0, h1 := 1.0, 2*xv for k := 1; k < n; k++ { h0, h1 = h1, 2*xv*h1-2*float64(k)*h0 } if n == 0 { h1 = 1 } out.floats[i] = h1 } }) return out, nil } // Laguerre returns the generalised Laguerre polynomial Lₙ^α(x) of each // element, by the recurrence (n+1)Lₙ₊₁ = (2n+1+α−x)Lₙ − (n+α)Lₙ₋₁. // The degree n must be ≥ 0. func Laguerre(n int, alpha float64, x *Array) (*Array, error) { if n < 0 { return nil, errf("Laguerre: degree must be ≥ 0, got %d", n) } if x.dt == Complex { return nil, errf("Laguerre: complex arrays are not supported") } if n == 0 { out := &Array{shape: append([]int{}, x.shape...), dt: Float} out.alloc(x.Len()) for i := range x.Len() { out.floats[i] = 1 } return out, nil } out := &Array{shape: append([]int{}, x.shape...), dt: Float} out.alloc(x.Len()) engine.Parallel(x.Len(), func(s, e int) { for i := s; i < e; i++ { xv := x.floatAt(i) l0, l1 := 1.0, 1.0+float64(alpha)-xv for k := 1; k < n; k++ { kf := float64(k) l0, l1 = l1, ((2*kf+1+alpha-xv)*l1-(kf+alpha)*l0)/(kf+1) } out.floats[i] = l1 } }) return out, nil } // ChebyshevT returns the Chebyshev polynomial of the first kind // Tₙ(x) = cos(n·arccos x) of each element, by the recurrence // Tₙ₊₁ = 2x·Tₙ − Tₙ₋₁. func ChebyshevT(n int, x *Array) (*Array, error) { if n < 0 { return nil, errf("ChebyshevT: degree must be ≥ 0, got %d", n) } if x.dt == Complex { return nil, errf("ChebyshevT: complex arrays are not supported") } out := &Array{shape: append([]int{}, x.shape...), dt: Float} out.alloc(x.Len()) engine.Parallel(x.Len(), func(s, e int) { for i := s; i < e; i++ { xv := x.floatAt(i) t0, t1 := 1.0, xv for k := 1; k < n; k++ { t0, t1 = t1, 2*xv*t1-t0 } if n == 0 { t1 = 1 } out.floats[i] = t1 } }) return out, nil } // ChebyshevU returns the Chebyshev polynomial of the second kind // Uₙ(x) of each element, by the recurrence Uₙ₊₁ = 2x·Uₙ − Uₙ₋₁ with // U₀ = 1, U₁ = 2x. func ChebyshevU(n int, x *Array) (*Array, error) { if n < 0 { return nil, errf("ChebyshevU: degree must be ≥ 0, got %d", n) } if x.dt == Complex { return nil, errf("ChebyshevU: complex arrays are not supported") } out := &Array{shape: append([]int{}, x.shape...), dt: Float} out.alloc(x.Len()) engine.Parallel(x.Len(), func(s, e int) { for i := s; i < e; i++ { xv := x.floatAt(i) u0, u1 := 1.0, 2*xv for k := 1; k < n; k++ { u0, u1 = u1, 2*xv*u1-u0 } if n == 0 { u1 = 1 } out.floats[i] = u1 } }) return out, nil } // SphericalHarmonicReal returns the real spherical harmonic // Y_l^m(θ, φ) built from the complex one, both branches under the // standard convention Y_real = √2·(−1)^m·Re(Y_l^m) for m > 0 and // Y_real = √2·(−1)^m·Im(Y_l^|m|) for m < 0, and for m = 0 the plain // Y_l⁰. Values are real and element-wise over theta and phi, which // must share a shape. func SphericalHarmonicReal(l, m int, theta, phi *Array) (*Array, error) { // m = 0 is purely real in the complex form, but the contract here // is a Float array, so the value is extracted rather than handing // the complex Y_l⁰ through unchanged. yc, err := SphericalHarmonic(l, absInt(m), theta, phi) if err != nil { return nil, err } out := &Array{shape: append([]int{}, theta.shape...), dt: Float} out.alloc(theta.Len()) root2 := math.Sqrt2 // The standard real forms carry the (−1)^m phase on both sides of // zero, mirroring the Condon-Shortley factor the complex harmonics // carry. sign := 1.0 if m%2 != 0 { sign = -1 } for i := range theta.Len() { z := yc.complexAt(i) switch { case m == 0: out.floats[i] = real(z) case m > 0: out.floats[i] = sign * root2 * real(z) default: out.floats[i] = sign * root2 * imag(z) } } return out, nil } // Airy returns the Airy functions of the first and second kind at // each element, Ai in the first return slot and Bi in the second. // The power series about zero is used, which converges for every // real x but is numerically dependable only within |x| ≤ 8; outside // that window the functions return NaN rather than a silently wrong // value. Positive arguments above 2 sum the series in extended // precision, where the float64 reconstruction of Ai would cancel the // growing solution and lose digits. The scaling convention is the // standard Ai, Bi with Ai(0) = 1/(3^{2/3}Γ(2/3)), // Bi(0) = 1/(3^{1/6}Γ(1/3)). func Airy(a *Array) (ai, bi *Array, err error) { if a.dt == Complex { return nil, nil, errf("Airy: complex arrays are not supported") } ai = &Array{shape: append([]int{}, a.shape...), dt: Float} ai.alloc(a.Len()) bi = &Array{shape: append([]int{}, a.shape...), dt: Float} bi.alloc(a.Len()) engine.Parallel(a.Len(), func(s, e int) { for i := s; i < e; i++ { x := a.floatAt(i) if math.Abs(x) > 8 { ai.floats[i] = math.NaN() bi.floats[i] = math.NaN() continue } aiv, biv := airySeries(x) ai.floats[i] = aiv bi.floats[i] = biv } }) return ai, bi, nil } // airySeries evaluates the Maclaurin pair // Ai(x) = c1·f(x) − c2·g(x), Bi(x) = √3·(c1·f(x) + c2·g(x)) // with f = Σ 3ᵏ(1/3)ₖx^{3k}/(3k)!, g = Σ 3ᵏ(2/3)ₖx^{3k+1}/(3k+1)!, // summed by the term-ratio recurrence to machine precision. For // positive arguments above airyBigCut the Ai reconstruction runs in // extended precision instead: Bi is a sum of like-signed terms and // keeps the float64 series. func airySeries(x float64) (aiv, biv float64) { const ( c1 = 0.35502805388781723926 c2 = 0.25881940379280679840 ) tf, tg := 1.0, x sumF, sumG := 1.0, x for k := range 400 { tf *= 3 * x * x * x * (float64(k) + 1.0/3) / float64((3*k+1)*(3*k+2)*(3*k+3)) tg *= 3 * x * x * x * (float64(k) + 2.0/3) / float64((3*k+2)*(3*k+3)*(3*k+4)) sumF += tf sumG += tg if math.Abs(tf) < 1e-18*math.Abs(sumF) && math.Abs(tg) < 1e-18*math.Abs(sumG) { break } } biv = math.Sqrt(3) * (c1*sumF + c2*sumG) if x > airyBigCut { aiv = airyBig(x) return aiv, biv } aiv = c1*sumF - c2*sumG return aiv, biv } // airyBigCut is the positive argument above which Ai is reconstructed // in extended precision. At the cut the float64 difference c1·f − c2·g // has lost 2ζ/ln2 ≈ 5 bits to the cancellation, which caps its // relative accuracy at about 1e-14; below the cut the float64 series // still holds fourteen digits. const airyBigCut = 2.0 // The Maclaurin constants of the extended-precision branch, Ai(0) and // −Ai'(0) to 50 significant digits: at the working sizes the // cancellation needs, the float64 roundings of airySeries would // reintroduce the very error the branch removes. The regression test // pins their float64 roundings against the float64 constants above. const ( airyC1Digits = "0.35502805388781723926006318600418317639797917419918" airyC2Digits = "0.25881940379280679840518356018920396347909113835493" ) // airyBig evaluates Ai(x) for x > 0 in extended precision. The // reconstruction c1·f − c2·g cancels e^{2ζ}, ζ = 2x^{3/2}/3: both // products carry the growing solution e^{ζ} while Ai itself is the // decaying e^{−ζ} one, so the float64 difference loses 2ζ/ln2 ≈ // 1.92·x^{3/2} bits (4.5e-3 relative at x = 8). The same two series are // therefore summed in math/big with a working size scaled to that loss, // following the fresnelBig pattern; the term ratios here are the // simplified forms x³/((3k+2)(3k+3)) and x³/((3k+3)(3k+4)) the float64 // recurrence above evaluates with the (3k+1) and (3k+2) factors still // in place. func airyBig(x float64) float64 { bits := uint(64 + int(1.9235*x*math.Sqrt(x)) + 96) xf := new(big.Float).SetPrec(bits).SetFloat64(x) x3 := new(big.Float).SetPrec(bits).Mul(xf, xf) x3.Mul(x3, xf) c1, _, err := big.ParseFloat(airyC1Digits, 10, bits, big.ToNearestEven) if err != nil { return math.NaN() } c2, _, err := big.ParseFloat(airyC2Digits, 10, bits, big.ToNearestEven) if err != nil { return math.NaN() } tf := new(big.Float).SetPrec(bits).SetInt64(1) tg := new(big.Float).SetPrec(bits).Set(xf) sumF := new(big.Float).SetPrec(bits).Set(tf) sumG := new(big.Float).SetPrec(bits).Set(tg) // The terms are all positive for x > 0, so a cut in absolute size // relative to a sum that stays above 1 is a relative cut. tiny := new(big.Float).SetPrec(bits).SetMantExp(big.NewFloat(1), -int(bits)+20) for k := range 400 { r1 := new(big.Float).SetPrec(bits).Quo(x3, big.NewFloat(float64((3*k+2)*(3*k+3)))) r2 := new(big.Float).SetPrec(bits).Quo(x3, big.NewFloat(float64((3*k+3)*(3*k+4)))) tf.Mul(tf, r1) tg.Mul(tg, r2) sumF.Add(sumF, tf) sumG.Add(sumG, tg) if tf.Cmp(tiny) < 0 && tg.Cmp(tiny) < 0 { break } } term := new(big.Float).SetPrec(bits).Mul(c2, sumG) aiv := new(big.Float).SetPrec(bits).Mul(c1, sumF) aiv.Sub(aiv, term) out, _ := aiv.Float64() return out } // absInt returns |n|. func absInt(n int) int { if n < 0 { return -n } return n }