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
+370
View File
@@ -0,0 +1,370 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package signal
import (
"math"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// daubechiesFamilies lists every family the machinery carries, with
// its vanishing-moment count.
var daubechiesFamilies = []struct {
family Daubechies
moments int
coeffs []float64
}{
{DB2, 2, db2Coeffs},
{DB3, 3, db3Coeffs},
{DB4, 4, db4Coeffs},
{DB5, 5, db5Coeffs},
{DB6, 6, db6Coeffs},
{DB7, 7, db7Coeffs},
{DB8, 8, db8Coeffs},
}
// TestDaubechiesPerfectReconstruction pins the defining property of
// the orthonormal bank: inverse(forward(x)) == x to machine
// precision, for every family, at every level count the length
// allows.
func TestDaubechiesPerfectReconstruction(t *testing.T) {
for _, f := range daubechiesFamilies {
L := 2 * f.moments
for _, mult := range []int{4, 8} {
n := L * mult
g := core.NewGenerator(int64(f.moments)*100 + int64(mult))
vals := make([]float64, n)
for i := range vals {
vals[i] = g.NormalUnit()
}
x := mustFloats(t, vals)
for _, levels := range []int{1, 2, 3} {
c, err := DaubechiesDWT(x, f.family, levels, DWTPeriodic)
if err != nil {
t.Fatalf("%s n=%d levels=%d: %v", f.family, n, levels, err)
}
back, err := DaubechiesIDWT(c, f.family, levels, DWTPeriodic)
if err != nil {
t.Fatalf("%s n=%d levels=%d inverse: %v", f.family, n, levels, err)
}
for i := range vals {
if math.Abs(back.FloatAt(i)-vals[i]) > 1e-12 {
t.Fatalf("%s n=%d levels=%d: reconstruction off at %d: %v vs %v",
f.family, n, levels, i, back.FloatAt(i), vals[i])
}
}
}
}
}
}
// TestDaubechiesEnergyPreservation pins Parseval: the periodic
// transform moves no energy between the signal and its coefficients.
func TestDaubechiesEnergyPreservation(t *testing.T) {
for _, f := range []struct {
family Daubechies
moments int
}{
{DB4, 4},
{DB7, 7},
} {
L := 2 * f.moments
n := 8 * L
g := core.NewGenerator(int64(f.moments) + 50)
vals := make([]float64, n)
for i := range vals {
vals[i] = g.NormalUnit()
}
x := mustFloats(t, vals)
for _, levels := range []int{1, 2, 4} {
c, err := DaubechiesDWT(x, f.family, levels, DWTPeriodic)
if err != nil {
t.Fatalf("%s levels=%d: %v", f.family, levels, err)
}
eIn, eC := 0.0, 0.0
for i := range n {
eIn += vals[i] * vals[i]
eC += c.FloatAt(i) * c.FloatAt(i)
}
if math.Abs(eIn-eC) > 1e-11*eIn {
t.Fatalf("%s levels=%d: energy %g vs %g", f.family, levels, eC, eIn)
}
}
}
}
// TestDaubechiesFilterTables certifies every coefficient table
// against the defining conditions of the family: the √2 sum, the
// unit norm, the shift-2 orthogonality, the vanishing moments, and
// the spectral identity against Daubechies' polynomial P, so a
// mistyped digit cannot survive.
func TestDaubechiesFilterTables(t *testing.T) {
for _, f := range daubechiesFamilies {
h := f.coeffs
L := len(h)
sum, norm := 0.0, 0.0
for _, v := range h {
sum += v
norm += v * v
}
if math.Abs(sum-math.Sqrt2) > 1e-14 {
t.Fatalf("%s: sum %v, want √2", f.family, sum)
}
if math.Abs(norm-1) > 1e-14 {
t.Fatalf("%s: norm %v, want 1", f.family, norm)
}
for l := 1; 2*l < L; l++ {
s := 0.0
for k := 0; k+2*l < L; k++ {
s += h[k] * h[k+2*l]
}
if math.Abs(s) > 1e-13 {
t.Fatalf("%s: shift-2 orthogonality at l=%d: %v", f.family, l, s)
}
}
// The vanishing moments, judged relatively: the raw sums of
// k^j-weighted terms reach magnitudes where the arithmetic
// noise floor alone is around 1e-9.
for j := 0; j < f.moments; j++ {
var s, scale float64
for k, v := range h {
s += math.Pow(-1, float64(k)) * math.Pow(float64(k), float64(j)) * v
scale += math.Pow(float64(k), float64(j)) * math.Abs(v)
}
if math.Abs(s) > 1e-12*scale {
t.Fatalf("%s: vanishing moment %d: %v (scale %v)", f.family, j, s, scale)
}
}
// The spectral identity |H(ω)|² = 2·cos^{2N}(ω/2)·P(sin²(ω/2))
// with P(y) = Σ C(N−1+k, k)·y^k.
P := make([]float64, f.moments)
for k := range P {
P[k] = binomialCoeffs(f.moments - 1 + k)[k]
}
for i := 0; i <= 512; i++ {
w := math.Pi * float64(i) / 512
var hr, hi float64
for k, v := range h {
hr += v * math.Cos(float64(k)*w)
hi -= v * math.Sin(float64(k)*w)
}
s := math.Sin(w / 2)
y := s * s
pv := 0.0
pow := 1.0
for k := range P {
pv += P[k] * pow
pow *= y
}
c := math.Cos(w / 2)
want := 2 * math.Pow(c*c, float64(f.moments)) * pv
if got := hr*hr + hi*hi; math.Abs(got-want) > 1e-12 {
t.Fatalf("%s: spectral identity at ω %d/512: %v vs %v", f.family, i, got, want)
}
}
}
}
// TestDaubechiesKnownTwoLevel pins a whole 2-level decomposition,
// db2 over [1..8], against independently computed reference values
// (an explicit analysis matrix built row by row), layout included.
func TestDaubechiesKnownTwoLevel(t *testing.T) {
x := mustFloats(t, []float64{1, 2, 3, 4, 5, 6, 7, 8})
c, err := DaubechiesDWT(x, DB2, 2, DWTPeriodic)
if err != nil {
t.Fatalf("DaubechiesDWT: %v", err)
}
want := []float64{
5.9019237886466849, 12.098076211353316,
0.36602540378444015, -3.8301270189221936,
0, 0, 0, -2.8284271247461907,
}
for i, w := range want {
got := c.FloatAt(i)
if math.Abs(got-w) > 1e-12 {
t.Fatalf("coefficient %d: %.15g, want %.15g", i, got, w)
}
}
}
// TestDaubechiesConstantDetails pins the vanishing moments end to
// end: the detail bands of a constant signal are zero at every level
// and the approximation scales by 2^(levels/2), the Σh = √2 gain.
func TestDaubechiesConstantDetails(t *testing.T) {
for _, f := range []struct {
family Daubechies
moments int
}{
{DB2, 2},
{DB5, 5},
{DB8, 8},
} {
L := 2 * f.moments
n := 4 * L
for _, levels := range []int{1, 2} {
vals := make([]float64, n)
for i := range vals {
vals[i] = 3.5
}
c, err := DaubechiesDWT(mustFloats(t, vals), f.family, levels, DWTPeriodic)
if err != nil {
t.Fatalf("%s levels=%d: %v", f.family, levels, err)
}
block := n >> levels
wantA := 3.5 * math.Pow(math.Sqrt2, float64(levels))
for i := range block {
if got := c.FloatAt(i); math.Abs(got-wantA) > 1e-12 {
t.Fatalf("%s levels=%d: approximation %d: %v, want %v",
f.family, levels, i, got, wantA)
}
}
for i := block; i < n; i++ {
if got := c.FloatAt(i); math.Abs(got) > 1e-12 {
t.Fatalf("%s levels=%d: detail coefficient %d = %v, want 0",
f.family, levels, i, got)
}
}
}
}
}
// TestDaubechiesZeroPad pins the padding mode: a length no level
// tree would accept transforms on the next valid padded length, the
// padded signal's energy is conserved, and the inverse returns the
// padded length whose head is the original signal.
func TestDaubechiesZeroPad(t *testing.T) {
g := core.NewGenerator(77)
const n = 13
vals := make([]float64, n)
var eIn float64
for i := range vals {
vals[i] = g.NormalUnit()
eIn += vals[i] * vals[i]
}
x := mustFloats(t, vals)
// db2 has 4 taps: two levels need a multiple of 8, so 13 pads
// to 16.
c, err := DaubechiesDWT(x, DB2, 2, DWTZeroPad)
if err != nil {
t.Fatalf("DaubechiesDWT zero-pad: %v", err)
}
if c.Len() != 16 {
t.Fatalf("padded coefficient length %d, want 16", c.Len())
}
eC := 0.0
for i := range 16 {
eC += c.FloatAt(i) * c.FloatAt(i)
}
if math.Abs(eIn-eC) > 1e-11*eIn {
t.Fatalf("padded energy %g vs %g", eC, eIn)
}
back, err := DaubechiesIDWT(c, DB2, 2, DWTZeroPad)
if err != nil {
t.Fatalf("DaubechiesIDWT zero-pad: %v", err)
}
if back.Len() != 16 {
t.Fatalf("reconstruction length %d, want the padded 16", back.Len())
}
for i := range vals {
if math.Abs(back.FloatAt(i)-vals[i]) > 1e-12 {
t.Fatalf("reconstruction off at %d: %v vs %v", i, back.FloatAt(i), vals[i])
}
}
}
// TestDaubechiesErrors pins the input gates: family names, level
// counts, the length contract of both modes, and the shapes.
func TestDaubechiesErrors(t *testing.T) {
x := mustFloats(t, []float64{1, 2, 3, 4, 5, 6, 7, 8})
if _, err := DaubechiesDWT(x, "db1", 1, DWTPeriodic); err == nil {
t.Error("db1 accepted; Haar keeps its own transform")
}
if _, err := DaubechiesDWT(x, "db9", 1, DWTPeriodic); err == nil {
t.Error("db9 accepted")
}
if _, err := DaubechiesDWT(x, "", 1, DWTPeriodic); err == nil {
t.Error("an empty family name accepted")
}
if _, err := DaubechiesDWT(x, DB2, 0, DWTPeriodic); err == nil {
t.Error("zero levels accepted")
}
// db2 has 4 taps: 8 samples support 2 levels, not 3.
if _, err := DaubechiesDWT(x, DB2, 3, DWTPeriodic); err == nil {
t.Error("levels deeper than the filter length accepted")
}
// 12 samples hold three 4-tap blocks at level 1 but 6 are not a
// multiple of 4 at level 2.
twelve := mustFloats(t, make([]float64, 12))
if _, err := DaubechiesDWT(twelve, DB2, 1, DWTPeriodic); err != nil {
t.Errorf("12 samples at 1 level refused: %v", err)
}
if _, err := DaubechiesDWT(twelve, DB2, 2, DWTPeriodic); err == nil {
t.Error("a block of 6 accepted against a 4-tap filter")
}
// 14 samples are not a multiple of 4 even at level 1.
if _, err := DaubechiesDWT(mustFloats(t, make([]float64, 14)), DB2, 1, DWTPeriodic); err == nil {
t.Error("14 samples accepted against a 4-tap filter")
}
// The zero-padded mode takes exactly those.
if _, err := DaubechiesDWT(mustFloats(t, make([]float64, 14)), DB2, 1, DWTZeroPad); err != nil {
t.Errorf("zero-pad refused a short length: %v", err)
}
if _, err := DaubechiesDWT(mustFloats(t, make([]float64, 14)), DB2, 61, DWTZeroPad); err == nil {
t.Error("61 zero-pad levels accepted")
}
// db8's 16 taps at 60 levels would need a padded length of
// 16·2^59, which wraps past the addressable range: refused.
if _, err := DaubechiesDWT(mustFloats(t, make([]float64, 8)), DB8, 60, DWTZeroPad); err == nil {
t.Error("a padded length past the addressable range accepted")
}
// The band between the wrapped corner and the shallow depths holds
// spans the shift survives but no allocator does: each must refuse
// rather than panic in the work-buffer make.
for _, tc := range []struct {
family Daubechies
levels int
why string
}{
{DB2, 60, "2^61 elements"},
{DB8, 59, "2^62 elements"},
{DB8, 50, "2^53 elements"},
{DB8, 43, "2^46 elements, one past the makeslice ceiling"},
} {
if _, err := DaubechiesDWT(mustFloats(t, make([]float64, 8)), tc.family, tc.levels, DWTZeroPad); err == nil {
t.Errorf("%s at %d levels accepted a padded span of %s", tc.family, tc.levels, tc.why)
}
}
if _, err := DaubechiesIDWT(x, DB2, 0, DWTPeriodic); err == nil {
t.Error("zero levels accepted by the inverse")
}
if _, err := DaubechiesIDWT(x, DB2, 1, DWTMode(9)); err == nil {
t.Error("unknown boundary mode accepted by the inverse")
}
if _, err := DaubechiesDWT(x, DB2, 1, DWTMode(9)); err == nil {
t.Error("unknown boundary mode accepted")
}
if _, err := DaubechiesDWT(mustFloats(t, []float64{1, 2, 3, 4}, 2, 2), DB2, 1, DWTPeriodic); err == nil {
t.Error("rank 2 accepted")
}
if _, err := DaubechiesDWT(mustComplexes(t, []complex128{1, 2, 3, 4, 5, 6, 7, 8}, 8), DB2, 1, DWTPeriodic); err == nil {
t.Error("complex accepted")
}
if _, err := DaubechiesDWT(mustFloats(t, nil), DB2, 1, DWTPeriodic); err == nil {
t.Error("empty accepted")
}
// The inverse repeats the length contract on its own input.
c, err := DaubechiesDWT(x, DB2, 1, DWTPeriodic)
if err != nil {
t.Fatal(err)
}
bad := mustFloats(t, make([]float64, 10))
if _, err := DaubechiesIDWT(bad, DB2, 1, DWTPeriodic); err == nil {
t.Error("a length-10 coefficient vector accepted")
}
if _, err := DaubechiesIDWT(c, "db7", 1, DWTPeriodic); err == nil {
t.Error("inverting db2 coefficients as db7 accepted")
}
}