Files
zzet--gortex/internal/analysis/leiden_resolution_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

198 lines
6.7 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.
package analysis
import (
"fmt"
"sort"
"testing"
"github.com/zzet/gortex/internal/graph"
)
// buildTieredGraph builds a graph with a clear two-level hierarchy so
// the Leiden resolution knob (γ) has something to expose:
//
// - nm modules, each holding cpm tight cliques of cs nodes;
// - every intra-clique pair is a strong EdgeCalls edge;
// - clique hubs inside a module are stitched together with `within`
// EdgeReferences edges (so a module is cohesive but looser than a
// clique);
// - modules form a ring joined by `inter` EdgeCalls edges (the
// weakest scale).
//
// Sweeping γ walks the hierarchy: low γ merges modules into a few
// blobs, the default γ = 1.0 lands on the per-module scale, and high γ
// fragments modules down to their individual cliques.
func buildTieredGraph(nm, cpm, cs, within, inter int) *graph.Graph {
g := graph.New()
add := func(id string) {
g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction, FilePath: id + ".go"})
}
edge := func(from, to string, k graph.EdgeKind) {
g.AddEdge(&graph.Edge{From: from, To: to, Kind: k})
}
node := func(m, c, i int) string { return fmt.Sprintf("m%d_c%d_n%d", m, c, i) }
for m := 0; m < nm; m++ {
for c := 0; c < cpm; c++ {
for i := 0; i < cs; i++ {
add(node(m, c, i))
}
for i := 0; i < cs; i++ {
for j := i + 1; j < cs; j++ {
edge(node(m, c, i), node(m, c, j), graph.EdgeCalls)
}
}
}
// within-module bridges across distinct clique-hub pairs.
placed := 0
for c := 0; c < cpm && placed < within; c++ {
for d := c + 1; d < cpm && placed < within; d++ {
edge(node(m, c, 0), node(m, d, 0), graph.EdgeReferences)
placed++
}
}
}
// inter-module ring; `inter` distinct node pairs per adjacency.
for m := 0; m < nm; m++ {
next := (m + 1) % nm
for k := 0; k < inter; k++ {
edge(node(m, 0, k%cs), node(next, 0, k%cs), graph.EdgeCalls)
}
}
return g
}
// partitionStats summarises a CommunityResult: number of communities,
// the largest community size, and the mean community size.
func partitionStats(cr *CommunityResult) (count, maxSize int, avgSize float64) {
count = len(cr.Communities)
total := 0
for _, c := range cr.Communities {
if c.Size > maxSize {
maxSize = c.Size
}
total += c.Size
}
if count > 0 {
avgSize = float64(total) / float64(count)
}
return
}
// TestLeidenResolutionGradient is the acceptance test for the γ knob.
// γ = 2.0 must yield MORE and (on average) smaller communities than the
// default; γ = 0.5 must yield FEWER and (on average) larger ones.
func TestLeidenResolutionGradient(t *testing.T) {
g := buildTieredGraph(4, 3, 4, 3, 2)
def := DetectCommunitiesLeiden(g)
hi := DetectCommunitiesLeidenWith(g, LeidenOptions{Resolution: 2.0})
lo := DetectCommunitiesLeidenWith(g, LeidenOptions{Resolution: 0.5})
defN, defMax, defAvg := partitionStats(def)
hiN, hiMax, hiAvg := partitionStats(hi)
loN, loMax, loAvg := partitionStats(lo)
t.Logf("gamma=0.5 -> %d communities, maxSize=%d, avgSize=%.2f", loN, loMax, loAvg)
t.Logf("gamma=1.0 -> %d communities, maxSize=%d, avgSize=%.2f", defN, defMax, defAvg)
t.Logf("gamma=2.0 -> %d communities, maxSize=%d, avgSize=%.2f", hiN, hiMax, hiAvg)
// Higher resolution -> more, smaller communities.
if hiN <= defN {
t.Errorf("gamma=2.0 should produce MORE communities than default: got %d vs %d", hiN, defN)
}
if hiAvg >= defAvg {
t.Errorf("gamma=2.0 should produce smaller communities than default: avg %.2f vs %.2f", hiAvg, defAvg)
}
// Lower resolution -> fewer, larger communities.
if loN >= defN {
t.Errorf("gamma=0.5 should produce FEWER communities than default: got %d vs %d", loN, defN)
}
if loAvg <= defAvg {
t.Errorf("gamma=0.5 should produce larger communities than default: avg %.2f vs %.2f", loAvg, defAvg)
}
}
// nodeToCommSignature renders NodeToComm as a stable, comparable string
// so two partitions can be checked for exact equality.
func nodeToCommSignature(cr *CommunityResult) string {
ids := make([]string, 0, len(cr.NodeToComm))
for id := range cr.NodeToComm {
ids = append(ids, id)
}
sort.Strings(ids)
var b []byte
for _, id := range ids {
b = append(b, id...)
b = append(b, '=')
b = append(b, cr.NodeToComm[id]...)
b = append(b, ';')
}
return string(b)
}
// asymResolutionGraph builds three differently-sized, asymmetrically
// bridged clusters so the modularity optimum is unique — the full
// Leiden path breaks exact gain ties by map-iteration order, so a
// byte-identical assertion can only be made on a graph with no
// symmetric ties (where the partition is the same on every run).
func asymResolutionGraph() *graph.Graph {
g := graph.New()
add := func(id string) {
g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction, FilePath: id + ".go"})
}
e := func(from, to string, k graph.EdgeKind) { g.AddEdge(&graph.Edge{From: from, To: to, Kind: k}) }
clusters := [][]string{
{"a1", "a2", "a3", "a4", "a5"},
{"b1", "b2", "b3", "b4"},
{"c1", "c2", "c3"},
}
for _, ids := range clusters {
for _, id := range ids {
add(id)
}
for i := 0; i < len(ids); i++ {
for j := i + 1; j < len(ids); j++ {
e(ids[i], ids[j], graph.EdgeCalls)
}
}
}
e("a1", "b1", graph.EdgeReferences)
e("b2", "c1", graph.EdgeImports)
e("a3", "c2", graph.EdgeImports)
return g
}
// TestLeidenResolutionDefaultByteIdentical proves the γ knob is a true
// no-op at its default: the historical entry point, an explicit
// γ = 1.0, the zero-value options (normalised to 1.0), and
// defaultLeidenOptions() must all produce byte-identical partitions —
// multiplying the null-model penalty by exactly 1.0 is the IEEE-754
// identity, so the default path is unchanged from the pre-resolution
// implementation. Both fixtures have a unique modularity optimum, so
// the full Leiden path is deterministic on them.
func TestLeidenResolutionDefaultByteIdentical(t *testing.T) {
graphs := map[string]*graph.Graph{
"toy": buildTestGraph(),
"asym": asymResolutionGraph(),
}
for name, g := range graphs {
t.Run(name, func(t *testing.T) {
base := nodeToCommSignature(DetectCommunitiesLeiden(g))
explicit := nodeToCommSignature(DetectCommunitiesLeidenWith(g, LeidenOptions{Resolution: 1.0}))
zero := nodeToCommSignature(DetectCommunitiesLeidenWith(g, LeidenOptions{}))
defOpts := nodeToCommSignature(DetectCommunitiesLeidenWith(g, defaultLeidenOptions()))
if base != explicit {
t.Errorf("default differs from explicit gamma=1.0:\n base=%s\n 1.0=%s", base, explicit)
}
if base != zero {
t.Errorf("default differs from zero-value options (should normalise to 1.0):\n base=%s\n zero=%s", base, zero)
}
if base != defOpts {
t.Errorf("default differs from defaultLeidenOptions():\n base=%s\n def=%s", base, defOpts)
}
})
}
}