feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+410
View File
@@ -0,0 +1,410 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package core
import (
"math"
"math/cmplx"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// Special functions for physics, cosmology and chemistry: the gamma
// and beta families, the error function family, Legendre polynomials,
// spherical harmonics and spherical Bessel functions. Element-wise
// functions run on real arrays and promote ints and float32 per the
// ladder; complex inputs are refused. Values outside a function's
// domain follow the IEEE behaviour of the underlying implementation
// (math.Gamma, for example, returns +Inf at non-positive integers and
// NaN where undefined).
// Gamma returns the gamma function Γ(x) of each element.
func Gamma(a *Array) (*Array, error) { return a.realFunc("Gamma", math.Gamma) }
// LnGamma returns the natural logarithm of |Γ(x)| of each element. The
// sign of Γ for negative arguments is dropped; callers that need it
// evaluate Gamma directly.
func LnGamma(a *Array) (*Array, error) {
return a.realFunc("LnGamma", func(x float64) float64 {
lg, _ := math.Lgamma(x)
return lg
})
}
// Beta returns the Euler beta function B(x, y) = Γ(x)Γ(y)/Γ(x+y)
// element-wise. Both arrays must have the same shape.
func Beta(x, y *Array) (*Array, error) {
if !sameShape(x.shape, y.shape) {
return nil, errf("Beta: shape mismatch %s vs %s", shapeText(x.shape), shapeText(y.shape))
}
if x.dt == Complex || y.dt == Complex {
return nil, errf("Beta: 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, yv := x.floatAt(i), y.floatAt(i)
// In log space: the Γ product overflows to Inf well before
// B itself leaves the float64 range (B(100, 80) ≈ 3e-67),
// and Inf/Inf would turn the answer into NaN.
lx, sx := math.Lgamma(xv)
ly, sy := math.Lgamma(yv)
lxy, sxy := math.Lgamma(xv + yv)
sign := sx * sy * sxy
out.floats[i] = float64(sign) * math.Exp(lx+ly-lxy)
}
})
return out, nil
}
// Erf and Erfc return the error function and the complementary error
// function of each element.
func Erf(a *Array) (*Array, error) { return a.realFunc("Erf", math.Erf) }
func Erfc(a *Array) (*Array, error) { return a.realFunc("Erfc", math.Erfc) }
// Sinc returns the normalised sinc sin(πx)/(πx) of each element, with
// Sinc(0) = 1. The π-normalised convention is the one signal
// processing and interpolation use.
func Sinc(a *Array) (*Array, error) {
return a.realFunc("Sinc", func(x float64) float64 {
if x == 0 {
return 1
}
return math.Sin(math.Pi*x) / (math.Pi * x)
})
}
// Cosm1 returns cos(x) − 1 of each element, kept accurate where the
// direct subtraction loses: as a float64 subtraction cos(x) − 1 has
// no correct significant bit under about |x| ≈ 1e-8, while the factored
// even series in x² holds full precision down to the smallest
// representable argument, where the answer is exactly −x²/2 as far as
// the format can see it. The standard library carries log1p and expm1
// for the logarithmic and exponential side of the same problem and no
// cosinusoidal counterpart, which is the gap this fills.
func Cosm1(a *Array) (*Array, error) {
return a.realFunc("Cosm1", cosm1)
}
// cosm1Crossover splits the series and the direct subtraction: below
// it the factored series ends well past the rounding floor, above it
// the cancellation the subtraction suffers still leaves every bit the
// answer has.
const cosm1Crossover = math.Pi / 4
// cosm1 evaluates cos(x) − 1 at one point. With t = x² the series
// cos x − 1 = −(t/2)·(1 − t/12·(1 − t/30·(1 − t/56·(1 − t/90·
// (1 − t/132·(1 − t/182·(1 − t/240))))))) truncates around 2e-18
// relative at the crossover and falls quadratically below it; the
// leading −t/2 factor carries the sign, so negative arguments need no
// branch.
func cosm1(x float64) float64 {
t := x * x
if math.Abs(x) >= cosm1Crossover {
return math.Cos(x) - 1
}
return -0.5 * t * (1 - t/12*(1-t/30*(1-t/56*(1-t/90*(1-t/132*(1-t/182*(1-t/240)))))))
}
// Legendre returns the Legendre polynomial P_l(x) of each element,
// computed by the Bonnet recurrence. The degree l must be ≥ 0; the
// convention is P_0 = 1, P_1 = x, no extra scaling.
func Legendre(l int, x *Array) (*Array, error) {
if l < 0 {
return nil, errf("Legendre: degree must be ≥ 0, got %d", l)
}
if x.dt == Complex {
return nil, errf("Legendre: 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)
p0, p1 := 1.0, xv
for k := 1; k < l; k++ {
p0, p1 = p1, ((2*float64(k)+1)*xv*p1-float64(k)*p0)/float64(k+1)
}
if l == 0 {
p1 = 1
}
out.floats[i] = p1
}
})
return out, nil
}
// LegendreAssociated returns the associated Legendre function
// P_l^m(x) of each element, with the Condon-Shortley phase (−1)^m
// folded in and no extra normalisation. Requires |m| ≤ l; a negative m
// uses the standard relation P_l^(−m) = (−1)^m (l−m)!/(l+m)! P_l^m.
func LegendreAssociated(l, m int, x *Array) (*Array, error) {
if l < 0 {
return nil, errf("LegendreAssociated: degree must be ≥ 0, got %d", l)
}
if m < -l || m > l {
return nil, errf("LegendreAssociated: order %d out of range for degree %d", m, l)
}
if x.dt == Complex {
return nil, errf("LegendreAssociated: complex arrays are not supported")
}
am, neg := m, false
if am < 0 {
am, neg = -m, true
}
mm := float64(am)
// Seed sign from the Condon-Shortley phase (−1)^m.
seedSign := 1.0
if am%2 == 1 {
seedSign = -1
}
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)
// P_m^m = (−1)^m (2m−1)!! (1−x²)^(m/2).
fact := 1.0
for k := 1; k <= am; k++ {
fact *= float64(2*k - 1)
}
pmm := seedSign * fact * math.Pow(1-xv*xv, mm/2)
if l == am {
out.floats[i] = pmm
continue
}
// P_{m+1}^m = x (2m+1) P_m^m, then the fixed-order
// recurrence up the degree.
pmp1 := xv * (2*mm + 1) * pmm
pl := pmp1
for k := am + 2; k <= l; k++ {
pl = ((2*float64(k)-1)*xv*pmp1 - float64(k+am-1)*pmm) / float64(k-am)
pmm, pmp1 = pmp1, pl
}
out.floats[i] = pl
}
})
if neg {
// P_l^(−m) = (−1)^m (l−m)!/(l+m)! P_l^m.
ratio := math.Exp(LnFactorial(l-am) - LnFactorial(l+am))
engine.Parallel(out.Len(), func(s, e int) {
for i := s; i < e; i++ {
out.floats[i] *= seedSign * ratio
}
})
}
return out, nil
}
// LnFactorial returns the natural logarithm of n!.
func LnFactorial(n int) float64 {
if n < 0 {
return math.NaN()
}
lg, _ := math.Lgamma(float64(n + 1))
return lg
}
// SphericalHarmonic returns the complex spherical harmonic
// Y_l^m(θ, φ) element-wise over the polar angle θ and azimuth φ,
// using the Condon-Shortley phase and the orthonormal convention
// N·P_l^m(cos θ)·e^{imφ} with N = √((2l+1)/(4π)·(l−m)!/(l+m)!).
// theta and phi must have the same shape.
func SphericalHarmonic(l, m int, theta, phi *Array) (*Array, error) {
if l < 0 {
return nil, errf("SphericalHarmonic: degree must be ≥ 0, got %d", l)
}
if m < -l || m > l {
return nil, errf("SphericalHarmonic: order %d out of range for degree %d", m, l)
}
if !sameShape(theta.shape, phi.shape) {
return nil, errf("SphericalHarmonic: shape mismatch %s vs %s",
shapeText(theta.shape), shapeText(phi.shape))
}
if theta.dt == Complex || phi.dt == Complex {
return nil, errf("SphericalHarmonic: complex arrays are not supported")
}
am, conj := m, false
if am < 0 {
am, conj = -m, true
}
// P_l^m takes the cosine of the polar angle, not the angle.
cosTheta, err := Cos(theta)
if err != nil {
return nil, err
}
p, err := LegendreAssociated(l, am, cosTheta)
if err != nil {
return nil, err
}
norm := math.Sqrt((2*float64(l) + 1) / (4 * math.Pi) *
math.Exp(LnFactorial(l-am)-LnFactorial(l+am)))
// The Condon-Shortley phase (−1)^m is already folded into the
// associated Legendre function; the normalisation carries no
// extra sign.
out := &Array{shape: append([]int{}, theta.shape...), dt: Complex}
out.alloc(theta.Len())
engine.Parallel(theta.Len(), func(s, e int) {
for i := s; i < e; i++ {
y := complex(norm*p.floatAt(i), 0) *
cmplx.Exp(complex(0, float64(am)*phi.floatAt(i)))
if conj {
// Y_{l,−m} = (−1)^m conj(Y_{l,m}).
phase := 1.0
if am%2 == 1 {
phase = -1
}
y = complex(phase, 0) * cmplx.Conj(y)
}
out.complexes[i] = y
}
})
return out, nil
}
// SphericalBesselJ returns the spherical Bessel function of the first
// kind j_l(x) of each element. Orders at or below |x| climb the upward
// recurrence from the exact closed forms j₀ = sin x/x and
// j₁ = sin x/x² − cos x/x, the stable direction there at O(l) cost;
// higher orders run the downward Miller recurrence, which is what the
// small roots need and whose start sits above the turning point at
// order l + |x|.
func SphericalBesselJ(l int, x *Array) (*Array, error) {
if l < 0 {
return nil, errf("SphericalBesselJ: degree must be ≥ 0, got %d", l)
}
if x.dt == Complex {
return nil, errf("SphericalBesselJ: 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++ {
out.floats[i] = sphericalBesselJ(l, x.floatAt(i))
}
})
return out, nil
}
// SphericalBesselY returns the spherical Bessel function of the second
// kind y_l(x) of each element, evaluated by the upward recurrence from
// y_0 = −cos(x)/x, the stable direction. Every y_l diverges to −Inf at
// x = 0.
func SphericalBesselY(l int, x *Array) (*Array, error) {
if l < 0 {
return nil, errf("SphericalBesselY: degree must be ≥ 0, got %d", l)
}
if x.dt == Complex {
return nil, errf("SphericalBesselY: 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)
if xv == 0 {
// Divergence at the origin: fill this element and carry
// on, the rest of the chunk still needs its values.
out.floats[i] = math.Inf(-1)
continue
}
y0 := -math.Cos(xv) / xv
if l == 0 {
// Assign and carry on: a return here would abandon the
// rest of the worker's chunk at zero.
out.floats[i] = y0
continue
}
y1 := -math.Cos(xv)/(xv*xv) - math.Sin(xv)/xv
for k := 1; k < l; k++ {
y0, y1 = y1, (2*float64(k)+1)/xv*y1-y0
}
out.floats[i] = y1
}
})
return out, nil
}
// sphericalBesselJ evaluates j_l at one point. Below the argument the
// upward recurrence climbs from the exact j₀ and j₁ at O(l) cost; at
// and above it the downward Miller recurrence, started well above the
// turning point, is the stable direction.
func sphericalBesselJ(l int, x float64) float64 {
if x == 0 {
if l == 0 {
return 1
}
return 0
}
if math.Abs(x) < 1e-4 {
// Small-x power series. Below ~1e-6 the unscaled Miller seed
// overflows on the way down and the renormalisation turns into
// Inf/Inf = NaN; the two-term series is relative ~1e-8 exact
// over the whole branch and underflows to the true limit for
// large l.
num := math.Pow(x, float64(l))
dbl := 1.0 // (2l+1)!!
for k := 3; k <= 2*l+1; k += 2 {
dbl *= float64(k)
}
return num / dbl * (1 - x*x/(2*float64(2*l+3)))
}
if float64(l) <= math.Abs(x) {
return sphericalJUpward(l, x)
}
// |x| keeps the start above l for negative arguments too: a start
// at or below l would never pass the order on the way down and
// renormalise against a garbage seed.
ceiling := l + int(math.Abs(x)) + 40
jp1, j := 0.0, 1.0 // j_{k+1}, j_k, seeded at k = ceiling
jl := 0.0
for k := ceiling; k >= 1; k-- {
if k == l {
jl = j
}
jp1, j = j, (2*float64(k)+1)/x*j-jp1
if aj := math.Abs(j); aj > 1e200 {
// The unscaled walk overflows for degree-argument
// combinations whose true value is representable; the
// renormalisation cancels any common factor.
jp1 /= aj
j /= aj
jl /= aj
}
}
// j now holds the unscaled j_0, which for l = 0 is the answer.
if l == 0 {
jl = j
}
return sphericalJ0(x) * jl / j
}
// sphericalJUpward climbs the spherical Bessel functions of the first
// kind from the exact closed forms j₀ = sin x/x and
// j₁ = sin x/x² − cos x/x by the upward recurrence
// jₖ₊₁ = (2k+1)/x·jₖ − jₖ₋₁, the stable direction while the order
// stays at or below the argument. Both seeds carry the absolute scale,
// so no renormalisation is needed, and they carry the parity for free:
// the recurrence reproduces j_l(−x) = (−1)^l j_l(x). The caller
// guarantees x ≠ 0 and 0 ≤ l ≤ |x|.
func sphericalJUpward(l int, x float64) float64 {
j0 := sphericalJ0(x)
j1 := math.Sin(x)/(x*x) - math.Cos(x)/x
if l == 0 {
return j0
}
for k := 1; k < l; k++ {
j0, j1 = j1, (2*float64(k)+1)/x*j1-j0
}
return j1
}
// sphericalJ0 is the exact j_0 = sin(x)/x with the x = 0 limit.
func sphericalJ0(x float64) float64 {
if x == 0 {
return 1
}
return math.Sin(x) / x
}