Files
tensor/linalg/sparsefill_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

338 lines
8.8 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 linalg
import (
"math/rand/v2"
"slices"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// The reference side of the minimum degree tests: the selection scan
// the frontier replaced, kept verbatim so the equivalence test can
// hold the frontier to the exact sequence of choices the scan makes,
// tie breaks included, and the ordering benchmark can put a number on
// the difference.
// minimumDegreeScan returns the elimination order the original scan
// selects: every step walks the surviving vertices, counts each one's
// remaining neighbours and keeps the first vertex of the smallest
// count, so ties break to the smallest index.
func minimumDegreeScan(c *SparseCSC) ([]int, error) {
adj, err := symmetrisedAdjacency(c)
if err != nil {
return nil, err
}
// The absorption below merges the lists as plain ascending index
// sets, so the breadth first search's (degree, index) order has to
// go: re-sort by index, exactly as the production order does.
for i := range adj {
slices.Sort(adj[i])
}
n := c.Cols
eliminated := make([]bool, n)
order := make([]int, 0, n)
for range n {
p := -1
best := 0
for i := range n {
if eliminated[i] {
continue
}
d := 0
for _, u := range adj[i] {
if !eliminated[u] {
d++
}
}
if p == -1 || d < best {
p = i
best = d
}
}
if p == -1 {
return nil, base.Errf("minimumDegree: no vertex left to eliminate")
}
order = append(order, p)
eliminated[p] = true
absorbElementScan(adj, eliminated, p)
}
return order, nil
}
// absorbElementScan merges the element the eliminated vertex p leaves
// behind into every surviving neighbour's adjacency list, the scan's
// form of the absorption: adj(j) becomes adj(j) ∪ adj(p) \ {j}.
func absorbElementScan(adj [][]int, eliminated []bool, p int) {
for _, j := range adj[p] {
if eliminated[j] {
continue
}
adj[j] = sortedSetUnionScan(adj[j], adj[p])
if at, found := slices.BinarySearch(adj[j], j); found {
adj[j] = slices.Delete(adj[j], at, at+1)
}
}
}
// sortedSetUnionScan merges two sorted unique slices into one sorted
// unique slice.
func sortedSetUnionScan(a, b []int) []int {
out := make([]int, 0, len(a)+len(b))
i, j := 0, 0
for i < len(a) && j < len(b) {
switch {
case a[i] < b[j]:
out = append(out, a[i])
i++
case b[j] < a[i]:
out = append(out, b[j])
j++
default:
out = append(out, a[i])
i++
j++
}
}
out = append(out, a[i:]...)
out = append(out, b[j:]...)
return out
}
// patternCOO assembles a symmetric matrix from off-diagonal edges plus
// a diagonal heavy enough to keep the matrix positive definite, though
// the ordering reads the pattern alone.
func patternCOO(t *testing.T, n int, edges [][2]int) *core.SparseCOO {
t.Helper()
idx := make([]int64, 0, 2*len(edges)+2*n)
vals := make([]float64, 0, 2*len(edges)+2*n)
add := func(r, c int) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, 1)
}
for _, e := range edges {
add(e[0], e[1])
add(e[1], e[0])
}
for i := range n {
idx = append(idx, int64(i), int64(i))
vals = append(vals, float64(n)+2)
}
indices, err := core.FromInts(idx, len(vals), 2)
if err != nil {
t.Fatalf("FromInts: %v", err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{len(vals)}), []int{n, n})
if err != nil {
t.Fatalf("NewSparseCOO: %v", err)
}
return coo
}
// gridEdges returns the edges of the w×h grid graph.
func gridEdges(w, h int) [][2]int {
at := func(x, y int) int { return y*w + x }
edges := make([][2]int, 0, 2*w*h)
for y := range h {
for x := range w {
if x+1 < w {
edges = append(edges, [2]int{at(x, y), at(x+1, y)})
}
if y+1 < h {
edges = append(edges, [2]int{at(x, y), at(x, y+1)})
}
}
}
return edges
}
// cubeEdges returns the edges of the w×h×d grid graph.
func cubeEdges(w, h, d int) [][2]int {
at := func(x, y, z int) int { return (z*h+y)*w + x }
edges := make([][2]int, 0, 3*w*h*d)
for z := range d {
for y := range h {
for x := range w {
if x+1 < w {
edges = append(edges, [2]int{at(x, y, z), at(x+1, y, z)})
}
if y+1 < h {
edges = append(edges, [2]int{at(x, y, z), at(x, y+1, z)})
}
if z+1 < d {
edges = append(edges, [2]int{at(x, y, z), at(x, y, z+1)})
}
}
}
}
return edges
}
// starEdges returns the edges of the star graph: a centre joined to
// every other vertex, the shape whose eliminations hand the centre its
// degree one neighbour at a time.
func starEdges(n int) [][2]int {
edges := make([][2]int, 0, n-1)
for i := 1; i < n; i++ {
edges = append(edges, [2]int{0, i})
}
return edges
}
// completeEdges returns the edges of the complete graph on n vertices,
// the shape whose first elimination fills everything.
func completeEdges(n int) [][2]int {
edges := make([][2]int, 0, n*(n-1)/2)
for i := range n {
for j := i + 1; j < n; j++ {
edges = append(edges, [2]int{i, j})
}
}
return edges
}
// relabelledEdges renames every vertex through a random permutation:
// the same pattern under an index order chosen to scatter the degree
// ties the tie break has to survive.
func relabelledEdges(rng *rand.Rand, edges [][2]int) [][2]int {
label := make(map[int]int)
for _, e := range edges {
label[e[0]] = 0
label[e[1]] = 0
}
names := make([]int, 0, len(label))
for v := range label {
names = append(names, v)
}
slices.Sort(names)
order := rng.Perm(len(names))
for i, v := range names {
label[v] = order[i]
}
out := make([][2]int, len(edges))
for i, e := range edges {
out[i] = [2]int{label[e[0]], label[e[1]]}
}
return out
}
// randomEdges draws m distinct off-diagonal edges of an n-vertex
// graph, the irregular patterns the ordering exists for. A random
// clique rides along every few calls to force the fill the absorptions
// have to keep up with.
func randomEdges(rng *rand.Rand, n, m int) [][2]int {
if n < 2 {
return nil
}
largest := n * (n - 1) / 2
m = min(m, largest)
seen := make(map[[2]int]bool)
edges := make([][2]int, 0, m)
for len(edges) < m {
i := rng.IntN(n)
j := rng.IntN(n)
if i == j {
continue
}
e := [2]int{min(i, j), max(i, j)}
if seen[e] {
continue
}
seen[e] = true
edges = append(edges, e)
}
if rng.IntN(4) == 0 {
clique := min(n, 2+rng.IntN(n/4+1))
start := rng.IntN(n - clique + 1)
for i := start; i < start+clique; i++ {
for j := i + 1; j < start+clique; j++ {
e := [2]int{i, j}
if !seen[e] {
seen[e] = true
edges = append(edges, e)
}
}
}
}
return edges
}
// checkOrderMatchesScan factors the pattern's adjacency once for each
// side and requires the frontier order to equal the scan order entry
// for entry, and to be a permutation at all.
func checkOrderMatchesScan(t *testing.T, name string, coo *core.SparseCOO) {
t.Helper()
c, err := CSCFromCOO(coo)
if err != nil {
t.Fatalf("%s: CSCFromCOO: %v", name, err)
}
want, err := minimumDegreeScan(c)
if err != nil {
t.Fatalf("%s: scan: %v", name, err)
}
got, err := minimumDegree(c)
if err != nil {
t.Fatalf("%s: frontier: %v", name, err)
}
for i := range min(len(got), len(want)) {
if got[i] != want[i] {
t.Fatalf("%s: step %d eliminates %d, the scan eliminates %d", name, i, got[i], want[i])
}
}
if len(got) != len(want) {
t.Fatalf("%s: order lengths differ: %d vs %d", name, len(got), len(want))
}
sorted := slices.Clone(got)
slices.Sort(sorted)
for i := range sorted {
if sorted[i] != i {
t.Fatalf("%s: order entry %d holds %d; not a permutation", name, i, sorted[i])
}
}
}
// TestMinimumDegreeMatchesScan holds the frontier ordering to the
// reference scan: on every pattern below, both must return the same
// elimination order, tie breaks included. The permutation decides the
// factorisation's fill, so a single differing choice is a failure.
func TestMinimumDegreeMatchesScan(t *testing.T) {
rng := rand.New(rand.NewPCG(2026, 9))
structured := []struct {
name string
n int
edges [][2]int
}{
{"empty", 12, nil},
{"path-50", 50, gridEdges(50, 1)},
{"star-50", 50, starEdges(50)},
{"complete-30", 30, completeEdges(30)},
{"grid-12x12", 144, gridEdges(12, 12)},
{"grid-15x15-shuffled", 225, relabelledEdges(rng, gridEdges(15, 15))},
{"grid-64x64", 4096, gridEdges(64, 64)},
{"cube-6x6x6", 216, cubeEdges(6, 6, 6)},
{"two-grids", 64 + 36, append(gridEdges(8, 8), func() [][2]int {
shifted := gridEdges(6, 6)
for i := range shifted {
shifted[i][0] += 64
shifted[i][1] += 64
}
return shifted
}()...)},
}
for _, tc := range structured {
checkOrderMatchesScan(t, tc.name, patternCOO(t, tc.n, tc.edges))
}
for range 400 {
n := 1 + rng.IntN(90)
edges := randomEdges(rng, n, rng.IntN(3*n+1))
if rng.IntN(2) == 0 {
edges = relabelledEdges(rng, edges)
}
checkOrderMatchesScan(t, "random", patternCOO(t, n, edges))
}
}