Files

338 lines
8.8 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// 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))
}
}