// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package signal import ( "math" "math/rand/v2" "testing" "sourcedock.dev/petrbalvin/tensor/internal/core" ) func TestConv2DBadBias(t *testing.T) { // Conv2D must reject a bias whose length does not match the output // channels, like the other convolution entry points. in := mustFromFloats(t, []float64{1, 2, 3, 4}, 1, 1, 2, 2) ker := mustFromFloats(t, []float64{1, 0, 0, 1}, 1, 1, 2, 2) bad := mustFromFloats(t, []float64{1, 2, 3}, 3) // one output channel, 3 biases if _, err := Conv2D(in, ker, bad, 1, 0); err == nil { t.Error("Conv2D: expected error for mismatched bias") } good := mustFromFloats(t, []float64{1}, 1) if _, err := Conv2D(in, ker, good, 1, 0); err != nil { t.Fatalf("Conv2D with valid bias: %v", err) } } func TestConv1D(t *testing.T) { // 1×1×5 input, 1×1×3 kernel. in := mustFromFloats(t, []float64{1, 2, 3, 4, 5}, 1, 1, 5) ker := mustFromFloats(t, []float64{1, 0, -1}, 1, 1, 3) got, err := Conv1D(in, ker, nil, 1, 0, 1) if err != nil { t.Fatal(err) } if got.Shape()[2] != 3 { t.Errorf("Conv1D output L: %d, want 3", got.Shape()[2]) } // (1,2,3) convolved with (1,0,-1) = [1*1 + 2*0 + 3*(-1), 2*1 + 3*0 + 4*(-1), 3*1 + 4*0 + 5*(-1)] // = [-2, -2, -2] for i, w := range []float64{-2, -2, -2} { v, _ := core.FloatAt(got, 0, 0, i) if v != w { t.Errorf("Conv1D [%d]: got %v, want %v", i, v, w) } } } func TestConv3D(t *testing.T) { // 1×1×1×2×2 input, 1×1×1×2×2 kernel (basic case). in := mustFromFloats(t, []float64{1, 2, 3, 4}, 1, 1, 1, 2, 2) ker := mustFromFloats(t, []float64{1, 1, 1, 1}, 1, 1, 1, 2, 2) got, err := Conv3D(in, ker, nil, 1, [3]int{0, 0, 0}, [3]int{1, 1, 1}) if err != nil { t.Fatal(err) } if got.Shape()[0] != 1 || got.Shape()[1] != 1 || got.Shape()[2] != 1 || got.Shape()[3] != 1 || got.Shape()[4] != 1 { t.Errorf("Conv3D shape: %v", got.Shape()) } // Sum of 1+2+3+4 = 10. v, _ := core.FloatAt(got, 0, 0, 0, 0, 0) if v != 10 { t.Errorf("Conv3D: got %v, want 10", v) } } func TestConv2DGroups(t *testing.T) { // 2 channels, groups=2 gives depthwise convolution. in := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, }, 1, 2, 2, 2) ker := mustFromFloats(t, []float64{ 1, 0, // kernel for channel 0 0, 1, // kernel for channel 1 }, 2, 1, 1, 2) got, err := Conv2DGroups(in, ker, nil, 1, 0, 2) if err != nil { t.Fatal(err) } if got.Shape()[1] != 2 { t.Errorf("Conv2DGroups C_out: %d, want 2", got.Shape()[1]) } // Channel 0: kernel [[1,0],[0,1]] applied to [[1,2],[3,4]] = [[1+4, 2+0],[3+0, 4+0]] = [[5,2],[3,4]] // Wait: Conv2D with kH=1,kW=2 means 1-row × 2-col kernel. Output H=2, W=1. // Channel 0 input [[1,2],[3,4]] conv with [[1,0]] (kH=1, kW=2) gives [[1·1+2·0, ...], [3·1+4·0, ...]] = [[1, ?], [3, ?]]. v00, _ := core.FloatAt(got, 0, 0, 0, 0) v10, _ := core.FloatAt(got, 0, 0, 1, 0) // Channel 1: kernel [[0,1]] applied to [[5,6],[7,8]] = [[5·0+6·1, ...], [7·0+8·1, ...]] = [[6, ?], [8, ?]]. v01, _ := core.FloatAt(got, 0, 1, 0, 0) v11, _ := core.FloatAt(got, 0, 1, 1, 0) if v00 != 1 || v10 != 3 || v01 != 6 || v11 != 8 { t.Errorf("Conv2DGroups: got ch0 [[%v,?],[%v,?]], ch1 [[%v,?],[%v,?]], want [[1,_],[3,_]], [[6,_],[8,_]]", v00, v10, v01, v11) } } func TestConvTranspose2D(t *testing.T) { // 1×1×1×1 input, 1×1×2×2 kernel: output should be 2×2. in := mustFromFloats(t, []float64{1}, 1, 1, 1, 1) ker := mustFromFloats(t, []float64{ 1, 2, 3, 4, }, 1, 1, 2, 2) got, err := ConvTranspose2D(in, ker, nil, 1, 0) if err != nil { t.Fatal(err) } if got.Shape()[2] != 2 || got.Shape()[3] != 2 { t.Errorf("ConvTranspose2D shape: %v", got.Shape()) } // With single input and identity-position kernel, output == kernel. for i, w := range []float64{1, 2, 3, 4} { v, _ := core.FloatAt(got, 0, 0, i/2, i%2) if math.Abs(v-w) > 1e-9 { t.Errorf("ConvTranspose2D [%d]: got %v, want %v", i, v, w) } } } func TestMaxPool1D(t *testing.T) { in := mustFromFloats(t, []float64{1, 3, 2, 4, 1}, 1, 1, 5) got, err := MaxPool1D(in, 2, 2, 0) if err != nil { t.Fatal(err) } // 2 windows: [1,3] gives 3, [2,4] gives 4. v0, _ := core.FloatAt(got, 0, 0, 0) v1, _ := core.FloatAt(got, 0, 0, 1) if v0 != 3 || v1 != 4 { t.Errorf("MaxPool1D: got %v %v, want 3 4", v0, v1) } } func TestAvgPool3D(t *testing.T) { in := mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6, 7, 8}, 1, 1, 2, 2, 2) got, err := AvgPool3D(in, [3]int{2, 2, 2}, [3]int{2, 2, 2}, [3]int{0, 0, 0}, true) if err != nil { t.Fatal(err) } if got.Shape()[2] != 1 || got.Shape()[3] != 1 || got.Shape()[4] != 1 { t.Errorf("AvgPool3D shape: %v", got.Shape()) } v, _ := core.FloatAt(got, 0, 0, 0, 0, 0) // Average of all 8 = 36 / 8 = 4.5. if math.Abs(v-4.5) > 1e-9 { t.Errorf("AvgPool3D: got %v, want 4.5", v) } } func TestAdaptiveMaxPool2D(t *testing.T) { in := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, }, 1, 1, 4, 4) got, err := AdaptiveMaxPool2D(in, 2, 2) if err != nil { t.Fatal(err) } v00, _ := core.FloatAt(got, 0, 0, 0, 0) v01, _ := core.FloatAt(got, 0, 0, 0, 1) v11, _ := core.FloatAt(got, 0, 0, 1, 1) if v00 != 6 || v01 != 8 || v11 != 16 { t.Errorf("AdaptiveMaxPool2D: got %v %v %v, want 6 8 16", v00, v01, v11) } } func TestGlobalAvgPool2D(t *testing.T) { in := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, }, 1, 1, 2, 4) got, err := GlobalAvgPool2D(in) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0, 0, 0, 0) // Average of 1..8 = 36/8 = 4.5. if math.Abs(v-4.5) > 1e-9 { t.Errorf("GlobalAvgPool2D: got %v, want 4.5", v) } } func TestGlobalMaxPool2D(t *testing.T) { in := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, }, 1, 1, 2, 4) got, err := GlobalMaxPool2D(in) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0, 0, 0, 0) if v != 8 { t.Errorf("GlobalMaxPool2D: got %v, want 8", v) } } func TestGlobalAvgPool1D(t *testing.T) { in := mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6}, 1, 1, 6) got, err := GlobalAvgPool1D(in) if err != nil { t.Fatal(err) } if got.Shape()[2] != 1 { t.Errorf("GlobalAvgPool1D L_out: %d", got.Shape()[2]) } v, _ := core.FloatAt(got, 0, 0, 0) if math.Abs(v-3.5) > 1e-9 { t.Errorf("GlobalAvgPool1D: got %v, want 3.5", v) } } func TestGlobalMaxPool1D(t *testing.T) { in := mustFromFloats(t, []float64{1, 5, 3, 4, 2, 6}, 1, 1, 6) got, err := GlobalMaxPool1D(in) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0, 0, 0) if v != 6 { t.Errorf("GlobalMaxPool1D: got %v, want 6", v) } } func TestGlobalAvgPool3D(t *testing.T) { in := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, }, 1, 1, 2, 2, 2) got, err := GlobalAvgPool3D(in) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0, 0, 0, 0, 0) // Average of 1..8 = 4.5. if math.Abs(v-4.5) > 1e-9 { t.Errorf("GlobalAvgPool3D: got %v, want 4.5", v) } } func TestGlobalMaxPool3D(t *testing.T) { in := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, }, 1, 1, 2, 2, 2) got, err := GlobalMaxPool3D(in) if err != nil { t.Fatal(err) } v, _ := core.FloatAt(got, 0, 0, 0, 0, 0) if v != 8 { t.Errorf("GlobalMaxPool3D: got %v, want 8", v) } } func TestAdaptiveMaxPool1D(t *testing.T) { in := mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6}, 1, 1, 6) got, err := AdaptiveMaxPool1D(in, 2) if err != nil { t.Fatal(err) } if got.Shape()[2] != 2 { t.Errorf("AdaptiveMaxPool1D L_out: %d", got.Shape()[2]) } // Window [0..3) gives max(1,2,3) = 3; [3..6) gives max(4,5,6) = 6. v0, _ := core.FloatAt(got, 0, 0, 0) v1, _ := core.FloatAt(got, 0, 0, 1) if v0 != 3 || v1 != 6 { t.Errorf("AdaptiveMaxPool1D: got %v %v, want 3 6", v0, v1) } } func TestAdaptiveAvgPool1D(t *testing.T) { in := mustFromFloats(t, []float64{1, 2, 3, 4, 5, 6}, 1, 1, 6) got, err := AdaptiveAvgPool1D(in, 2) if err != nil { t.Fatal(err) } // [1..4) gives 6/3 = 2; [4..7) gives 15/3 = 5. v0, _ := core.FloatAt(got, 0, 0, 0) v1, _ := core.FloatAt(got, 0, 0, 1) if math.Abs(v0-2) > 1e-9 || math.Abs(v1-5) > 1e-9 { t.Errorf("AdaptiveAvgPool1D: got %v %v, want 2 5", v0, v1) } } // TestAdaptivePoolCeilWindows pins the floor-start, ceil-end window // convention on sizes that do not divide: window o covers // floor(o·in/out) to ceil((o+1)·in/out), so every input sample lands // in some window (the old floor ends silently dropped input samples). func TestAdaptivePoolCeilWindows(t *testing.T) { in := mustFromFloats(t, []float64{1, 2, 3, 4, 5}, 1, 1, 5) maxGot, err := AdaptiveMaxPool1D(in, 2) if err != nil { t.Fatal(err) } // Windows [0,3) and [2,5): max 3 and 5 (the floor convention gave 2). v0, _ := core.FloatAt(maxGot, 0, 0, 0) v1, _ := core.FloatAt(maxGot, 0, 0, 1) if v0 != 3 || v1 != 5 { t.Errorf("AdaptiveMaxPool1D 5 to 2: got %v %v, want 3 5", v0, v1) } avgGot, err := AdaptiveAvgPool1D(in, 2) if err != nil { t.Fatal(err) } // (1+2+3)/3 and (3+4+5)/3. a0, _ := core.FloatAt(avgGot, 0, 0, 0) a1, _ := core.FloatAt(avgGot, 0, 0, 1) if math.Abs(a0-2) > 1e-9 || math.Abs(a1-4) > 1e-9 { t.Errorf("AdaptiveAvgPool1D 5 to 2: got %v %v, want 2 4", a0, a1) } // Upsampling repeats inputs: 2 to 4 gives [0,1),[0,1),[1,2),[1,2). two := mustFromFloats(t, []float64{7, 9}, 1, 1, 2) up, err := AdaptiveMaxPool1D(two, 4) if err != nil { t.Fatal(err) } wantUp := []float64{7, 7, 9, 9} for i, w := range wantUp { if v, _ := core.FloatAt(up, 0, 0, i); v != w { t.Errorf("AdaptiveMaxPool1D 2 to 4 [%d]: got %v, want %v", i, v, w) } } // The 2-D twins follow the same ceil ends. sq := mustFromFloats(t, []float64{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, }, 1, 1, 5, 5) max2D, err := AdaptiveMaxPool2D(sq, 2, 2) if err != nil { t.Fatal(err) } // Windows are rows/cols [0,3) and [2,5): the quadrant maxima are // 13, 15, 23, 25. want2D := []float64{13, 15, 23, 25} for i, w := range want2D { oy, ox := i/2, i%2 if v, _ := core.FloatAt(max2D, 0, 0, oy, ox); v != w { t.Errorf("AdaptiveMaxPool2D 5 to 2 [%d,%d]: got %v, want %v", oy, ox, v, w) } } avg2D, err := AdaptiveAvgPool2D(sq, 2, 2) if err != nil { t.Fatal(err) } // Top-left window average (1+2+3+6+7+8+11+12+13)/9 = 7. if v, _ := core.FloatAt(avg2D, 0, 0, 0, 0); math.Abs(v-7) > 1e-9 { t.Errorf("AdaptiveAvgPool2D 5 to 2 [0,0]: got %v, want 7", v) } } // TestPoolAndConvNumeratorGuard pins the output-size contract: a // kernel that exceeds the padded input must be an error, not a // 1-wide output from a negative numerator truncating toward zero. func TestPoolAndConvNumeratorGuard(t *testing.T) { in4 := mustFromFloats(t, make([]float64, 16), 1, 1, 4, 4) if _, err := MaxPool2D(in4, 5, 2, 0); err == nil { t.Error("MaxPool2D: expected an error when the kernel exceeds the input") } if _, err := AvgPool2D(in4, 5, 2, 0, true); err == nil { t.Error("AvgPool2D: expected an error when the kernel exceeds the input") } lane := mustFromFloats(t, make([]float64, 4), 1, 1, 4) if _, err := MaxPool1D(lane, 5, 2, 0); err == nil { t.Error("MaxPool1D: expected an error when the kernel exceeds the input") } cube := mustFromFloats(t, make([]float64, 8), 1, 1, 2, 2, 2) if _, err := MaxPool3D(cube, [3]int{5, 1, 1}, [3]int{2, 1, 1}, [3]int{0, 0, 0}); err == nil { t.Error("MaxPool3D: expected an error when the kernel exceeds the input") } kernel, _ := core.FromFloats(make([]float64, 25), 1, 1, 5, 5) if _, err := Conv2D(in4, kernel, nil, 2, 0); err == nil { t.Error("Conv2D: expected an error when the kernel exceeds the input") } k1, _ := core.FromFloats(make([]float64, 5), 1, 1, 5) if _, err := Conv1D(lane, k1, nil, 2, 0, 1); err == nil { t.Error("Conv1D: expected an error when the kernel exceeds the input") } k3, _ := core.FromFloats(make([]float64, 125), 1, 1, 5, 5, 5) if _, err := Conv3D(cube, k3, nil, 2, [3]int{0, 0, 0}, [3]int{1, 1, 1}); err == nil { t.Error("Conv3D: expected an error when the kernel exceeds the input") } } // TestConv2DGroupsPartialChannelBlock pins the 2-D kernel's // output-channel blocking when the last block of a group is partial: // six output channels in two groups of three, on a row wide enough that // one block covers two channels, so the group's third channel is a // block of its own. Reading the block count as a floor instead of a // ceiling drops that trailing block and leaves the channel at the zero // the output array was initialised with, which the scalar walk below // sees at once. func TestConv2DGroupsPartialChannelBlock(t *testing.T) { const ( n, cIn, hIn, wIn = 1, 2, 3, 2000 groups = 2 cOut, kH, kW = 6, 2, 3 stride, padding = 1, 1 ) cInPerGroup := cIn / groups cOutPerGroup := cOut / groups if block := convAccCap / wIn; block < 1 || block >= cOutPerGroup { t.Fatalf("the case needs a partial block: a block of %d against %d channels per group", block, cOutPerGroup) } rng := rand.New(rand.NewPCG(11, 13)) in := make([]float64, n*cIn*hIn*wIn) for i := range in { in[i] = rng.NormFloat64() } ker := make([]float64, cOut*cInPerGroup*kH*kW) for i := range ker { ker[i] = rng.NormFloat64() } bias := make([]float64, cOut) for i := range bias { bias[i] = rng.NormFloat64() } x := mustFromFloats(t, in, n, cIn, hIn, wIn) k := mustFromFloats(t, ker, cOut, cInPerGroup, kH, kW) b := mustFromFloats(t, bias, cOut) got, err := Conv2DGroups(x, k, b, stride, padding, groups) if err != nil { t.Fatalf("Conv2DGroups: %v", err) } hOut := (hIn+2*padding-kH)/stride + 1 wOut := (wIn+2*padding-kW)/stride + 1 if got.Shape()[1] != cOut || got.Shape()[2] != hOut || got.Shape()[3] != wOut { t.Fatalf("shape %v, want [%d %d %d %d]", got.Shape(), n, cOut, hOut, wOut) } // A scalar walk in the same (input channel, tap row, tap column) // order the kernel accumulates its taps in. worst, scale := 0.0, 0.0 for oc := range cOut { g := oc / cOutPerGroup live := false for oh := range hOut { for ow := range wOut { want := 0.0 for ic := range cInPerGroup { for kh := range kH { ih := oh*stride + kh - padding if ih < 0 || ih >= hIn { continue } for kw := range kW { iw := ow*stride + kw - padding if iw < 0 || iw >= wIn { continue } want += in[((g*cInPerGroup+ic)*hIn+ih)*wIn+iw] * ker[((oc*cInPerGroup+ic)*kH+kh)*kW+kw] } } } want += bias[oc] gv := got.FloatAt(oc*hOut*wOut + oh*wOut + ow) if gv != 0 { live = true } if d := math.Abs(gv - want); d > worst { worst = d } if a := math.Abs(want); a > scale { scale = a } } } if !live { t.Errorf("output channel %d (group %d, the group's last channel) is identically zero", oc, g) } } if worst > 1e-12*scale { t.Fatalf("worst deviation from the scalar walk %.6g (scale %.6g), want the blocked sweep to agree with it", worst, scale) } }