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
198 lines
6.7 KiB
Go
198 lines
6.7 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|