package tree import ( "math" "testing" ) // cosEmbeddings places points on the unit circle so that the cosine similarity // between consecutive points equals sims[i]. It lets tests assert exact // watershed cut points from a known similarity profile. func cosEmbeddings(sims []float64) [][]float64 { angles := make([]float64, len(sims)+1) for i, c := range sims { if c > 1 { c = 1 } if c < -1 { c = -1 } angles[i+1] = angles[i] + math.Acos(c) } emb := make([][]float64, len(angles)) for i, a := range angles { emb[i] = []float64{math.Cos(a), math.Sin(a)} } return emb } func uniqueCount(labels []int) int { seen := map[int]struct{}{} for _, l := range labels { seen[l] = struct{}{} } return len(seen) } func TestWatershedEdgeCases(t *testing.T) { if _, err := watershed(nil, 4); err == nil { t.Fatalf("expected error for empty input") } labels, err := watershed([][]float64{{1, 0}}, 4) if err != nil || len(labels) != 1 || labels[0] != 0 { t.Fatalf("n=1: got %v err %v", labels, err) } // n=2: the Python reference always splits two points into two clusters. labels, err = watershed([][]float64{{1, 0}, {0, 1}}, 4) if err != nil || len(labels) != 2 || labels[0] != 0 || labels[1] != 1 { t.Fatalf("n=2: got %v err %v", labels, err) } } // TestWatershedTreeOrderControlsCount verifies that the tree_order branching // factor drives the expected cluster count: for a monotone similarity drop the // number of clusters is non-increasing as the order grows (larger order => fewer, // bigger clusters). The expected count is approximately 1 + (N-1)/order. func TestWatershedTreeOrderControlsCount(t *testing.T) { emb := cosEmbeddings([]float64{0.2, 0.4, 0.6, 0.8}) // N = 5 points cases := []struct { order int want int }{ {2, 3}, {3, 2}, {4, 2}, {10, 2}, } prev := 1 << 30 for _, c := range cases { labels, err := watershed(emb, c.order) if err != nil { t.Fatalf("order %v: %v", c.order, err) } got := uniqueCount(labels) if got != c.want { t.Fatalf("order %v: got %d clusters, want %d", c.order, got, c.want) } if got > prev { t.Fatalf("order %v: clusters %d > previous %d (not non-increasing)", c.order, got, prev) } prev = got } } func TestWatershedDeterministic(t *testing.T) { emb := cosEmbeddings([]float64{0.1, 0.9, 0.2, 0.8, 0.3}) a, err := watershed(emb, 4) if err != nil { t.Fatal(err) } b, err := watershed(emb, 4) if err != nil { t.Fatal(err) } for i := range a { if a[i] != b[i] { t.Fatalf("non-deterministic at %d: %v vs %v", i, a, b) } } } func TestWatershedScaleInvariant(t *testing.T) { emb := cosEmbeddings([]float64{0.2, 0.4, 0.6, 0.8}) scaled := make([][]float64, len(emb)) for i, r := range emb { scaled[i] = []float64{r[0] * 5, r[1] * 5} } a, _ := watershed(emb, 2) b, _ := watershed(scaled, 2) for i := range a { if a[i] != b[i] { t.Fatalf("scale changed labels at %d: %v vs %v", i, a, b) } } } // TestWatershedTreeOrderDefault verifies the exported default is honored when // the parameter is absent. func TestWatershedTreeOrderDefault(t *testing.T) { emb := cosEmbeddings([]float64{0.2, 0.4, 0.6, 0.8}) a, _ := watershed(emb, DefaultTreeOrder) b, _ := watershed(emb, 4) for i := range a { if a[i] != b[i] { t.Fatalf("default order != 4 at %d: %v vs %v", i, a, b) } } }