diff --git a/kdtree_gonum_test.go b/kdtree_gonum_test.go new file mode 100644 index 0000000..43a0c81 --- /dev/null +++ b/kdtree_gonum_test.go @@ -0,0 +1,625 @@ +//go:build gonum + +package poindexter + +import ( + "math" + "testing" +) + +func equalish(a, b []float64, tol float64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if math.Abs(a[i]-b[i]) > tol { + return false + } + } + return true +} + +func TestGonumKnnHeap(t *testing.T) { + h := knnHeap{} + + h.push(knnItem{idx: 1, dist: 1.0}) + h.push(knnItem{idx: 2, dist: 2.0}) + h.push(knnItem{idx: 3, dist: 0.5}) + + if h.Len() != 3 { + t.Errorf("expected heap length 3, got %d", h.Len()) + } + + item := h.pop() + if item.idx != 2 || item.dist != 2.0 { + t.Errorf("expected item with index 2 and dist 2.0, got idx %d dist %f", item.idx, item.dist) + } + + item = h.pop() + if item.idx != 1 || item.dist != 1.0 { + t.Errorf("expected item with index 1 and dist 1.0, got idx %d dist %f", item.idx, item.dist) + } + + item = h.pop() + if item.idx != 3 || item.dist != 0.5 { + t.Errorf("expected item with index 3 and dist 0.5, got idx %d dist %f", item.idx, item.dist) + } + + if h.Len() != 0 { + t.Errorf("expected heap length 0, got %d", h.Len()) + } +} + +func TestGonumNearest(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{2, 2}}, + {ID: "3", Coords: []float64{3, 3}}, + } + + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + + p, dist, ok := tree.Nearest([]float64{1.1, 1.1}) + if !ok || p.ID != "1" || math.Abs(dist-0.1414213562373095) > 1e-9 { + t.Errorf("expected point 1 with dist ~0.14, got point %s with dist %f", p.ID, dist) + } +} + +func TestGonumKNearest(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{2, 2}}, + {ID: "3", Coords: []float64{3, 3}}, + } + + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + + ps, dists := tree.KNearest([]float64{1.1, 1.1}, 2) + if len(ps) != 2 || ps[0].ID != "1" || ps[1].ID != "2" { + t.Errorf("expected points 1 and 2, got %v", ps) + } + + expectedDists := []float64{0.1414213562373095, 1.2727922061357854} + if !equalish(dists, expectedDists, 1e-9) { + t.Errorf("expected dists %v, got %v", expectedDists, dists) + } +} + +func TestGonumRadius(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{2, 2}}, + {ID: "3", Coords: []float64{3, 3}}, + } + + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + + ps, dists := tree.Radius([]float64{1.1, 1.1}, 1.5) + if len(ps) != 2 || ps[0].ID != "1" || ps[1].ID != "2" { + t.Errorf("expected points 1 and 2, got %v", ps) + } + + expectedDists := []float64{0.1414213562373095, 1.2727922061357854} + if !equalish(dists, expectedDists, 1e-9) { + t.Errorf("expected dists %v, got %v", expectedDists, dists) + } +} + +func TestBuildGonumBackendWithNonSupportedMetric(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum), WithMetric(CosineDistance{})) + if err != nil { + t.Fatal(err) + } + if tree.backend != BackendLinear { + t.Errorf("expected fallback to linear backend, but got %s", tree.backend) + } +} + +func TestGonumNearestWithEmptyTree(t *testing.T) { + _, err := NewKDTree([]KDPoint[int]{}, WithBackend(BackendGonum)) + if err != ErrEmptyPoints { + t.Fatalf("expected ErrEmptyPoints, got %v", err) + } +} + +func TestAxisStdWithNoPoints(t *testing.T) { + stds := axisStd(nil, nil, 2) + if len(stds) != 2 || stds[0] != 0 || stds[1] != 0 { + t.Errorf("expected [0, 0], got %v", stds) + } +} + +func TestGonumNearestWithNilRoot(t *testing.T) { + backend := &kdBackend{root: nil, dim: 2} + _, _, ok := gonumNearest[int](backend, []float64{1, 1}) + if ok { + t.Error("expected no point found, but got one") + } +} + +func TestGonumNearestWithMismatchedDimensions(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + + _, _, ok := tree.Nearest([]float64{1, 1, 1}) + if ok { + t.Error("expected no point found, but got one") + } +} + +func TestGonumKNearestWithEmptyTree(t *testing.T) { + _, err := NewKDTree([]KDPoint[int]{}, WithBackend(BackendGonum)) + if err != ErrEmptyPoints { + t.Fatalf("expected ErrEmptyPoints, got %v", err) + } +} + +func TestGonumRadiusWithEmptyTree(t *testing.T) { + _, err := NewKDTree([]KDPoint[int]{}, WithBackend(BackendGonum)) + if err != ErrEmptyPoints { + t.Fatalf("expected ErrEmptyPoints, got %v", err) + } +} + +func TestGonumKNearestWithZeroK(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.KNearest([]float64{1, 1}, 0) + if len(ps) != 0 { + t.Error("expected 0 points, got some") + } +} + +func TestGonumRadiusWithNegativeRadius(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.Radius([]float64{1, 1}, -1) + if len(ps) != 0 { + t.Error("expected 0 points, got some") + } +} + +func TestGonumNearestWithSinglePoint(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, ok := tree.Nearest([]float64{1, 1}) + if !ok || p.ID != "1" { + t.Errorf("expected point 1, got %v", p) + } +} +func TestGonumKnnHeapPop(t *testing.T) { + h := knnHeap{} + h.push(knnItem{idx: 1, dist: 1.0}) + h.push(knnItem{idx: 2, dist: 2.0}) + h.push(knnItem{idx: 3, dist: 0.5}) + + if h.Len() != 3 { + t.Errorf("expected heap length 3, got %d", h.Len()) + } + + item := h.pop() + if item.idx != 2 || item.dist != 2.0 { + t.Errorf("expected item with index 2 and dist 2.0, got idx %d dist %f", item.idx, item.dist) + } +} + +func TestGonumKNearestWithSmallK(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{2, 2}}, + {ID: "3", Coords: []float64{3, 3}}, + } + + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + + ps, _ := tree.KNearest([]float64{1.1, 1.1}, 1) + if len(ps) != 1 || ps[0].ID != "1" { + t.Errorf("expected point 1, got %v", ps) + } +} +func TestGonumNearestReturnsFalseForNoPoints(t *testing.T) { + tree, err := NewKDTreeFromDim[int](2, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + _, _, ok := tree.Nearest([]float64{0, 0}) + if ok { + t.Errorf("expected ok to be false, but it was true") + } +} +func TestBuildKDRecursiveWithSinglePoint(t *testing.T) { + idxs := []int{0} + coords := func(i int) []float64 { return []float64{1, 1} } + node := buildKDRecursive(idxs, coords, 2, 0) + if node == nil { + t.Fatal("expected a node, got nil") + } + if node.idx != 0 { + t.Errorf("expected index 0, got %d", node.idx) + } +} + +func TestGonumKNearestWithLargeK(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{2, 2}}, + {ID: "3", Coords: []float64{3, 3}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.KNearest([]float64{1.1, 1.1}, 5) + if len(ps) != 3 { + t.Errorf("expected 3 points, got %d", len(ps)) + } +} +func TestGonumNearestWithIdenticalPoints(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{1, 1}) + if p.ID != "1" && p.ID != "2" { + t.Errorf("expected point 1 or 2, got %v", p) + } +} +func TestGonumKNearestPrefersCloserPoints(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{1.1, 1.1}}, + {ID: "3", Coords: []float64{1.2, 1.2}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.KNearest([]float64{0.9, 0.9}, 2) + if len(ps) != 2 || ps[0].ID != "1" || ps[1].ID != "2" { + t.Errorf("expected points 1 and 2, got %v", ps) + } +} +func TestGonumKNearestWithFewerPointsThanK(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.KNearest([]float64{1, 1}, 2) + if len(ps) != 1 { + t.Errorf("expected 1 point, got %d", len(ps)) + } +} +func TestGonumKNearestReturnsCorrectOrder(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{3, 3}}, + {ID: "2", Coords: []float64{1, 1}}, + {ID: "3", Coords: []float64{2, 2}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.KNearest([]float64{0, 0}, 3) + if ps[0].ID != "2" || ps[1].ID != "3" || ps[2].ID != "1" { + t.Errorf("expected points in order 2, 3, 1, got %v", ps) + } +} + +func TestGonumRadiusReturnsAllWithinRadius(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{2, 2}}, + {ID: "3", Coords: []float64{10, 10}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.Radius([]float64{0, 0}, 3) + if len(ps) != 2 { + t.Errorf("expected 2 points, got %d", len(ps)) + } +} + +func TestGonumRadiusReturnsEmptyForLargeRadiusWithNoPoints(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{10, 10}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.Radius([]float64{0, 0}, 1) + if len(ps) != 0 { + t.Errorf("expected 0 points, got %d", len(ps)) + } +} +func TestGonumNearestWithNegativeCoords(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{-1, -1}}, + {ID: "2", Coords: []float64{-2, -2}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{-1.1, -1.1}) + if p.ID != "1" { + t.Errorf("expected point 1, got %v", p) + } +} +func TestGonumRadiusWithOverlappingPoints(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.Radius([]float64{1, 1}, 0.1) + if len(ps) != 2 { + t.Errorf("expected 2 points, got %d", len(ps)) + } +} + +func TestGonumNearestWithFurtherPoints(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{10, 10}}, + {ID: "2", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{0, 0}) + if p.ID != "2" { + t.Errorf("expected point 2, got %v", p) + } +} + +func TestGonumNearestWithZeroDistance(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + _, dist, _ := tree.Nearest([]float64{1, 1}) + if dist != 0 { + t.Errorf("expected distance 0, got %f", dist) + } +} +func TestGonumNearestWithRightChild(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{5, 5}}, + {ID: "2", Coords: []float64{1, 1}}, + {ID: "3", Coords: []float64{8, 8}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{9, 9}) + if p.ID != "3" { + t.Errorf("expected point 3, got %v", p) + } +} + +func TestGonumKNearestHeapBehavior(t *testing.T) { + h := knnHeap{} + h.push(knnItem{idx: 1, dist: 1.0}) + h.push(knnItem{idx: 2, dist: 3.0}) + h.push(knnItem{idx: 3, dist: 2.0}) + + if h.peek().dist != 3.0 { + t.Errorf("expected max dist to be 3.0, got %f", h.peek().dist) + } + + h.push(knnItem{idx: 4, dist: 0.5}) + if h.peek().dist != 3.0 { + t.Errorf("expected max dist to be 3.0, got %f", h.peek().dist) + } +} + +func TestGonumNearestWithUnsortedPoints(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{10, 0}}, + {ID: "2", Coords: []float64{0, 10}}, + {ID: "3", Coords: []float64{5, 5}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{4, 4}) + if p.ID != "3" { + t.Errorf("expected point 3, got %v", p) + } +} +func TestGonumKNearestWithThreshold(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{10, 10}}, + {ID: "3", Coords: []float64{2, 2}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.KNearest([]float64{0, 0}, 2) + if len(ps) != 2 { + t.Fatalf("expected 2 points, got %d", len(ps)) + } + if ps[0].ID != "1" || ps[1].ID != "3" { + t.Errorf("expected points 1 and 3, got %v and %v", ps[0].ID, ps[1].ID) + } +} + +func TestGonumRadiusSearch(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{1.5, 1.5}}, + {ID: "3", Coords: []float64{3, 3}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.Radius([]float64{1.2, 1.2}, 0.5) + if len(ps) != 2 { + t.Errorf("expected 2 points, got %d", len(ps)) + } +} +func TestGonumNearestWithFloatMinMax(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1e150, 1e150}}, + {ID: "2", Coords: []float64{-1e150, -1e150}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, ok := tree.Nearest([]float64{0, 0}) + if !ok { + t.Fatal("expected to find a point, but didn't") + } + if p.ID != "1" && p.ID != "2" { + t.Errorf("expected point 1 or 2, got %v", p) + } +} +func TestGonumKnnHeapWithDuplicateDistances(t *testing.T) { + h := knnHeap{} + h.push(knnItem{idx: 1, dist: 1.0}) + h.push(knnItem{idx: 2, dist: 1.0}) + if h.Len() != 2 { + t.Errorf("expected heap length 2, got %d", h.Len()) + } +} +func TestGonumNearestToPointOnAxis(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{0, 10}}, + {ID: "2", Coords: []float64{0, -10}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{0, 0}) + if p.ID != "1" && p.ID != "2" { + t.Errorf("expected point 1 or 2, got %v", p) + } +} +func TestGonumNearestReturnsCorrectlyWhenPointsAreCollinear(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 1}}, + {ID: "2", Coords: []float64{2, 2}}, + {ID: "3", Coords: []float64{3, 3}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{1.9, 1.9}) + if p.ID != "2" { + t.Errorf("expected point 2, got %v", p) + } +} +func TestGonumKNearestWithMorePoints(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{0, 0}}, + {ID: "2", Coords: []float64{1, 1}}, + {ID: "3", Coords: []float64{2, 2}}, + {ID: "4", Coords: []float64{3, 3}}, + {ID: "5", Coords: []float64{4, 4}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.KNearest([]float64{0.5, 0.5}, 3) + if len(ps) != 3 { + t.Fatalf("expected 3 points, got %d", len(ps)) + } + if !((ps[0].ID == "1" && ps[1].ID == "2") || (ps[0].ID == "2" && ps[1].ID == "1")) { + t.Errorf("expected first two points to be 1 and 2, got %s and %s", ps[0].ID, ps[1].ID) + } + if ps[2].ID != "3" { + t.Errorf("expected third point to be 3, got %s", ps[2].ID) + } +} +func TestGonumRadiusReturnsSorted(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1.2, 1.2}}, + {ID: "2", Coords: []float64{1.1, 1.1}}, + {ID: "3", Coords: []float64{1.0, 1.0}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + ps, _ := tree.Radius([]float64{0, 0}, 2) + if len(ps) != 3 { + t.Errorf("expected 3 points, got %d", len(ps)) + } + if ps[0].ID != "3" || ps[1].ID != "2" || ps[2].ID != "1" { + t.Errorf("expected order 3, 2, 1, got %v, %v, %v", ps[0].ID, ps[1].ID, ps[2].ID) + } +} +func TestGonumNearestWithMultipleDimensions(t *testing.T) { + points := []KDPoint[int]{ + {ID: "1", Coords: []float64{1, 2, 3, 4}}, + {ID: "2", Coords: []float64{5, 6, 7, 8}}, + } + tree, err := NewKDTree(points, WithBackend(BackendGonum)) + if err != nil { + t.Fatal(err) + } + p, _, _ := tree.Nearest([]float64{1.1, 2.1, 3.1, 4.1}) + if p.ID != "1" { + t.Errorf("expected point 1, got %v", p) + } +} diff --git a/kdtree_helpers_test.go b/kdtree_helpers_test.go index 48ee724..753af41 100644 --- a/kdtree_helpers_test.go +++ b/kdtree_helpers_test.go @@ -223,3 +223,61 @@ func TestBuild4DWithStats_DynamicUpdateExample(t *testing.T) { t.Fatalf("dim != 4") } } + +func TestComputeNormStats3D(t *testing.T) { + type rec struct{ x, y, z float64 } + items := []rec{{1, 10, 100}, {2, 20, 200}, {3, 30, 300}} + stats := ComputeNormStats3D(items, + func(r rec) float64 { return r.x }, + func(r rec) float64 { return r.y }, + func(r rec) float64 { return r.z }, + ) + expected := NormStats{ + Stats: []AxisStats{ + {Min: 1, Max: 3}, + {Min: 10, Max: 30}, + {Min: 100, Max: 300}, + }, + } + if stats.Stats[0] != expected.Stats[0] || stats.Stats[1] != expected.Stats[1] || stats.Stats[2] != expected.Stats[2] { + t.Fatalf("expected %v, got %v", expected, stats) + } +} + +func TestBuildND(t *testing.T) { + type rec struct{ a, b, c float64 } + items := []rec{{1, 2, 3}, {4, 5, 6}} + extractors := []func(rec) float64{ + func(r rec) float64 { return r.a }, + func(r rec) float64 { return r.b }, + func(r rec) float64 { return r.c }, + } + weights := []float64{1, 1, 1} + invert := []bool{false, false, false} + pts, err := BuildND(items, func(r rec) string { return "" }, extractors, weights, invert) + if err != nil { + t.Fatal(err) + } + if len(pts) != 2 { + t.Fatalf("expected 2 points, got %d", len(pts)) + } + if len(pts[0].Coords) != 3 { + t.Fatalf("expected 3 dimensions, got %d", len(pts[0].Coords)) + } +} + +func TestBuildNDError(t *testing.T) { + type rec struct{ a, b, c float64 } + items := []rec{{1, 2, 3}, {4, 5, 6}} + extractors := []func(rec) float64{ + func(r rec) float64 { return r.a }, + func(r rec) float64 { return r.b }, + func(r rec) float64 { return r.c }, + } + weights := []float64{1, 1} // Mismatched length + invert := []bool{false, false, false} + _, err := BuildND(items, func(r rec) string { return "" }, extractors, weights, invert) + if err == nil { + t.Fatal("expected an error, but got nil") + } +}