Files
tensor/internal/core/special_test.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

384 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package core
import (
"math"
"testing"
)
func mustFloats(t *testing.T, vals []float64, shape ...int) *Array {
t.Helper()
if len(shape) == 0 {
shape = []int{len(vals)}
}
a, err := FromFloats(vals, shape...)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
return a
}
// TestGammaFamily checks Γ, ln|Γ| and B against exact values.
func TestGammaFamily(t *testing.T) {
x := mustFloats(t, []float64{1, 5, 0.5, -0.5})
g, err := Gamma(x)
if err != nil {
t.Fatalf("Gamma: %v", err)
}
// Γ(1)=1, Γ(5)=24, Γ(½)=√π, Γ(−½)=−2√π.
want := []float64{1, 24, math.SqrtPi, -2 * math.SqrtPi}
for i := range want {
if math.Abs(g.FloatAt(i)-want[i]) > 1e-12*(1+math.Abs(want[i])) {
t.Fatalf("Gamma(%v) = %v, want %v", x.FloatAt(i), g.FloatAt(i), want[i])
}
}
lg, err := LnGamma(x)
if err != nil {
t.Fatalf("LnGamma: %v", err)
}
if math.Abs(lg.FloatAt(1)-math.Log(24)) > 1e-12 {
t.Fatalf("LnGamma(5) = %v, want ln 24", lg.FloatAt(1))
}
bx := mustFloats(t, []float64{1, 2})
by := mustFloats(t, []float64{1, 3})
b, err := Beta(bx, by)
if err != nil {
t.Fatalf("Beta: %v", err)
}
// B(1,1) = 1 and B(2,3) = Γ(2)Γ(3)/Γ(5) = 2/24 = 1/12.
if math.Abs(b.FloatAt(0)-1) > 1e-12 || math.Abs(b.FloatAt(1)-1.0/12) > 1e-12 {
t.Fatalf("Beta = [%v, %v], want [1, 1/12]", b.FloatAt(0), b.FloatAt(1))
}
if _, err := Beta(mustFloats(t, []float64{1}), by); err == nil {
t.Fatal("expected a shape-mismatch error for Beta")
}
}
// TestErrorFunctions checks the error function family through the
// defining identity erf(x) + erfc(x) = 1 and known values.
func TestErrorFunctions(t *testing.T) {
x := mustFloats(t, []float64{0, 0.5, 1, 3})
e, err := Erf(x)
if err != nil {
t.Fatalf("Erf: %v", err)
}
c, err := Erfc(x)
if err != nil {
t.Fatalf("Erfc: %v", err)
}
for i := range x.Len() {
if math.Abs(e.FloatAt(i)+c.FloatAt(i)-1) > 1e-12 {
t.Fatalf("erf(%v)+erfc(%v) = %v, want 1",
x.FloatAt(i), x.FloatAt(i), e.FloatAt(i)+c.FloatAt(i))
}
}
if e.FloatAt(0) != 0 {
t.Fatalf("erf(0) = %v, want 0", e.FloatAt(0))
}
if math.Abs(e.FloatAt(2)-0.8427007929497149) > 1e-12 {
t.Fatalf("erf(1) = %v, want 0.8427007929497149", e.FloatAt(2))
}
}
// TestSinc checks the normalised sinc: value 1 at the origin, zeros at
// non-zero integers, 2/π at a half.
func TestSinc(t *testing.T) {
x := mustFloats(t, []float64{0, 0.5, 1, 2})
s, err := Sinc(x)
if err != nil {
t.Fatalf("Sinc: %v", err)
}
if s.FloatAt(0) != 1 {
t.Fatalf("Sinc(0) = %v, want 1", s.FloatAt(0))
}
if math.Abs(s.FloatAt(1)-2/math.Pi) > 1e-12 {
t.Fatalf("Sinc(0.5) = %v, want 2/π", s.FloatAt(1))
}
for _, i := range []int{2, 3} {
if math.Abs(s.FloatAt(i)) > 1e-15 {
t.Fatalf("Sinc(%v) = %v, want 0", x.FloatAt(i), s.FloatAt(i))
}
}
}
// TestLegendrePolynomials checks P_l against closed forms and the
// Bonnet recurrence across degrees.
func TestLegendrePolynomials(t *testing.T) {
x := mustFloats(t, []float64{-0.8, -0.3, 0, 0.42, 0.9})
closed := []struct {
l int
want func(v float64) float64
}{
{0, func(v float64) float64 { return 1 }},
{1, func(v float64) float64 { return v }},
{2, func(v float64) float64 { return 0.5 * (3*v*v - 1) }},
{3, func(v float64) float64 { return 0.5 * (5*v*v*v - 3*v) }},
// P_7's explicit expansion: (429v⁷ − 693v⁵ + 315v³ − 35v)/16.
{7, func(v float64) float64 {
return (429*math.Pow(v, 7) - 693*math.Pow(v, 5) + 315*v*v*v - 35*v) / 16
}},
}
for _, tc := range closed {
p, err := Legendre(tc.l, x)
if err != nil {
t.Fatalf("Legendre(%d): %v", tc.l, err)
}
for i := range x.Len() {
v := x.FloatAt(i)
want := tc.want(v)
if math.Abs(p.FloatAt(i)-want) > 1e-12*(1+math.Abs(want)) {
t.Fatalf("P_%d(%v) = %v, want %v", tc.l, v, p.FloatAt(i), want)
}
}
}
if _, err := Legendre(-1, x); err == nil {
t.Fatal("expected an error for a negative degree")
}
}
// TestLegendreAssociated checks the associated functions against
// closed forms, the negative-order relation and orthogonality.
func TestLegendreAssociated(t *testing.T) {
x := mustFloats(t, []float64{-0.6, 0, 0.35, 0.77})
// P_1^1 = −√(1−x²) with the Condon-Shortley phase.
p11, err := LegendreAssociated(1, 1, x)
if err != nil {
t.Fatalf("LegendreAssociated(1,1): %v", err)
}
for i := range x.Len() {
v := x.FloatAt(i)
want := -math.Sqrt(1 - v*v)
if math.Abs(p11.FloatAt(i)-want) > 1e-12 {
t.Fatalf("P_1^1(%v) = %v, want %v", v, p11.FloatAt(i), want)
}
}
// P_2^1 = −3x√(1−x²).
p21, err := LegendreAssociated(2, 1, x)
if err != nil {
t.Fatalf("LegendreAssociated(2,1): %v", err)
}
for i := range x.Len() {
v := x.FloatAt(i)
want := -3 * v * math.Sqrt(1-v*v)
if math.Abs(p21.FloatAt(i)-want) > 1e-12 {
t.Fatalf("P_2^1(%v) = %v, want %v", v, p21.FloatAt(i), want)
}
}
// P_1^0 must equal P_1.
p10, err := LegendreAssociated(1, 0, x)
if err != nil {
t.Fatalf("LegendreAssociated(1,0): %v", err)
}
p1, _ := Legendre(1, x)
for i := range x.Len() {
if p10.FloatAt(i) != p1.FloatAt(i) {
t.Fatalf("P_1^0(%v) = %v, want P_1 = %v", x.FloatAt(i), p10.FloatAt(i), p1.FloatAt(i))
}
}
// Negative order: P_1^(−1) = −(0)!/(2)!·P_1^1 = −½ P_1^1.
pm1, err := LegendreAssociated(1, -1, x)
if err != nil {
t.Fatalf("LegendreAssociated(1,−1): %v", err)
}
for i := range x.Len() {
if math.Abs(pm1.FloatAt(i)+0.5*p11.FloatAt(i)) > 1e-12 {
t.Fatalf("P_1^−1(%v) = %v, want %v", x.FloatAt(i), pm1.FloatAt(i), -0.5*p11.FloatAt(i))
}
}
// Orthogonality on a coarse Simpson grid: ∫₋₁¹ P_2 P_3 dx = 0.
const n = 2001
grid := make([]float64, n)
for i := range n {
grid[i] = -1 + 2*float64(i)/float64(n-1)
}
g := mustFloats(t, grid, n)
p2, _ := Legendre(2, g)
p3, _ := Legendre(3, g)
sum := 0.0
for i := range n {
w := 1.0
if i == 0 || i == n-1 {
w = 1
} else if i%2 == 1 {
w = 4
} else {
w = 2
}
sum += w * p2.FloatAt(i) * p3.FloatAt(i)
}
sum *= 2.0 / 3.0 / float64(n-1)
if math.Abs(sum) > 1e-6 {
t.Fatalf("∫P_2 P_3 = %v, want 0", sum)
}
if _, err := LegendreAssociated(2, 3, x); err == nil {
t.Fatal("expected an error for |m| > l")
}
}
// TestSphericalHarmonics checks Y_lm against closed forms and the
// conjugation symmetry Y_{l,−m} = (−1)^m conj(Y_{l,m}).
func TestSphericalHarmonics(t *testing.T) {
theta := mustFloats(t, []float64{0.3, 1.1, 2.4})
phi := mustFloats(t, []float64{0, 0.9, -1.3})
// Y_0^0 = 1/√(4π).
y00, err := SphericalHarmonic(0, 0, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonic(0,0): %v", err)
}
want := 1 / math.Sqrt(4*math.Pi)
for i := range theta.Len() {
if d := cmplxAbsDiff(y00.ComplexAt(i), complex(want, 0)); d > 1e-12 {
t.Fatalf("Y_0^0 = %v, want %v", y00.ComplexAt(i), want)
}
}
// Y_1^0 = √(3/4π) cosθ.
y10, err := SphericalHarmonic(1, 0, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonic(1,0): %v", err)
}
for i := range theta.Len() {
w := math.Sqrt(3/(4*math.Pi)) * math.Cos(theta.FloatAt(i))
if d := cmplxAbsDiff(y10.ComplexAt(i), complex(w, 0)); d > 1e-12 {
t.Fatalf("Y_1^0 = %v, want %v", y10.ComplexAt(i), w)
}
}
// Y_1^1 = −√(3/8π) sinθ e^{iφ}.
y11, err := SphericalHarmonic(1, 1, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonic(1,1): %v", err)
}
for i := range theta.Len() {
mod := math.Sqrt(3/(8*math.Pi)) * math.Sin(theta.FloatAt(i))
w := -complex(mod, 0) * cmplxExpStd(complex(0, phi.FloatAt(i)))
if d := cmplxAbsDiff(y11.ComplexAt(i), w); d > 1e-12 {
t.Fatalf("Y_1^1 = %v, want %v", y11.ComplexAt(i), w)
}
}
// Conjugation symmetry.
ym1, err := SphericalHarmonic(1, -1, theta, phi)
if err != nil {
t.Fatalf("SphericalHarmonic(1,−1): %v", err)
}
for i := range theta.Len() {
if d := cmplxAbsDiff(ym1.ComplexAt(i), -cmplxConjStd(y11.ComplexAt(i))); d > 1e-12 {
t.Fatalf("Y_1^−1 symmetry broken: %v vs %v",
ym1.ComplexAt(i), -cmplxConjStd(y11.ComplexAt(i)))
}
}
if _, err := SphericalHarmonic(1, 2, theta, phi); err == nil {
t.Fatal("expected an error for |m| > l")
}
if _, err := SphericalHarmonic(0, 0, mustFloats(t, []float64{1}), phi); err == nil {
t.Fatal("expected a shape-mismatch error")
}
}
// cmplxAbsDiff returns |a − b| for two complex values.
func cmplxAbsDiff(a, b complex128) float64 {
d := real(a) - real(b)
e := imag(a) - imag(b)
return math.Sqrt(d*d + e*e)
}
// cmplxExpStd is e^{i·z}.
func cmplxExpStd(z complex128) complex128 {
return complex(math.Cos(imag(z)), math.Sin(imag(z)))
}
// cmplxConjStd conjugates z.
func cmplxConjStd(z complex128) complex128 {
return complex(real(z), -imag(z))
}
// TestSphericalBessel checks j_l and y_l against their closed forms
// for l ≤ 2, the recurrence linking three consecutive degrees, and
// the small-x limits of j.
func TestSphericalBessel(t *testing.T) {
x := mustFloats(t, []float64{0.05, 0.5, 1, 3.7, 20})
j0, err := SphericalBesselJ(0, x)
if err != nil {
t.Fatalf("SphericalBesselJ(0): %v", err)
}
y0, err := SphericalBesselY(0, x)
if err != nil {
t.Fatalf("SphericalBesselY(0): %v", err)
}
for i := range x.Len() {
xv := x.FloatAt(i)
if math.Abs(j0.FloatAt(i)-math.Sin(xv)/xv) > 1e-12 {
t.Fatalf("j_0(%v) = %v, want %v", xv, j0.FloatAt(i), math.Sin(xv)/xv)
}
if math.Abs(y0.FloatAt(i)-(-math.Cos(xv)/xv)) > 1e-12 {
t.Fatalf("y_0(%v) = %v, want %v", xv, y0.FloatAt(i), -math.Cos(xv)/xv)
}
}
j1, _ := SphericalBesselJ(1, x)
j2, _ := SphericalBesselJ(2, x)
y1, _ := SphericalBesselY(1, x)
y2, _ := SphericalBesselY(2, x)
for i := range x.Len() {
xv := x.FloatAt(i)
// j_1 = sin/x² − cos/x and y_1 = −cos/x² − sin/x.
j1w := math.Sin(xv)/(xv*xv) - math.Cos(xv)/xv
y1w := -math.Cos(xv)/(xv*xv) - math.Sin(xv)/xv
if math.Abs(j1.FloatAt(i)-j1w) > 1e-11*(1+math.Abs(j1w)) {
t.Fatalf("j_1(%v) = %v, want %v", xv, j1.FloatAt(i), j1w)
}
if math.Abs(y1.FloatAt(i)-y1w) > 1e-11*(1+math.Abs(y1w)) {
t.Fatalf("y_1(%v) = %v, want %v", xv, y1.FloatAt(i), y1w)
}
// Three-term recurrence j_{l+1} = (2l+1)/x·j_l − j_{l−1} at l=1.
if d := j2.FloatAt(i) - (3/xv*j1.FloatAt(i) - j0.FloatAt(i)); math.Abs(d) > 1e-10*(1+math.Abs(j2.FloatAt(i))) {
t.Fatalf("j recurrence at %v broken: %v", xv, d)
}
if d := y2.FloatAt(i) - (3/xv*y1.FloatAt(i) - y0.FloatAt(i)); math.Abs(d) > 1e-10*(1+math.Abs(y2.FloatAt(i))) {
t.Fatalf("y recurrence at %v broken: %v", xv, d)
}
}
// Small-x limits: j_l(0) = δ_{l0}.
atZero := mustFloats(t, []float64{0})
for l := range 4 {
jl, err := SphericalBesselJ(l, atZero)
if err != nil {
t.Fatalf("SphericalBesselJ(%d, 0): %v", l, err)
}
want := 0.0
if l == 0 {
want = 1
}
if jl.FloatAt(0) != want {
t.Fatalf("j_%d(0) = %v, want %v", l, jl.FloatAt(0), want)
}
}
// j_l below its degree is tiny compared to j_0 at the same point:
// the downward recurrence must keep the small roots, not flood
// them with round-off. Reference: the small-x asymptote
// j_l(x) tends to x^l/(2l+1)!!; at x = 0.01 the first correction
// term x²/(2(2l+3)) is 6.4e-7 relative, well inside the tolerance.
small := mustFloats(t, []float64{0.01})
j5, err := SphericalBesselJ(5, small)
if err != nil {
t.Fatalf("SphericalBesselJ(5): %v", err)
}
asymptote := math.Pow(0.01, 5) / 10395 // 11!! = 10395
if d := math.Abs(j5.FloatAt(0) - asymptote); d > 1e-5*math.Abs(asymptote) {
t.Fatalf("j_5(0.01) = %v, want ≈ %v (downward recurrence lost the root)",
j5.FloatAt(0), asymptote)
}
if _, err := SphericalBesselJ(-1, small); err == nil {
t.Fatal("expected an error for a negative degree")
}
}