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
412 lines
14 KiB
Go
412 lines
14 KiB
Go
package analysis
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// pathGraph builds a directed path a0 -> a1 -> ... -> a(n-1) over
|
|
// EdgeCalls. On a path of length n the interior node at index i has
|
|
// an analytic betweenness of i * (n-1-i): every source at index < i
|
|
// reaches every target at index > i through it.
|
|
func pathGraph(n int) *graph.Graph {
|
|
g := graph.New()
|
|
for i := 0; i < n; i++ {
|
|
id := fmt.Sprintf("p%d", i)
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
}
|
|
for i := 0; i < n-1; i++ {
|
|
g.AddEdge(&graph.Edge{
|
|
From: fmt.Sprintf("p%d", i),
|
|
To: fmt.Sprintf("p%d", i+1),
|
|
Kind: graph.EdgeCalls,
|
|
})
|
|
}
|
|
return g
|
|
}
|
|
|
|
// relayStar builds a directed star where every leaf calls the hub and
|
|
// the hub calls every leaf. The only path between two distinct leaves
|
|
// runs leaf -> hub -> leaf, so the hub's analytic betweenness is
|
|
// k*(k-1) for k leaves and every leaf scores 0.
|
|
func relayStar(leaves int) *graph.Graph {
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{ID: "hub", Kind: graph.KindFunction, Name: "hub"})
|
|
for i := 0; i < leaves; i++ {
|
|
id := fmt.Sprintf("leaf%d", i)
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
g.AddEdge(&graph.Edge{From: id, To: "hub", Kind: graph.EdgeCalls})
|
|
g.AddEdge(&graph.Edge{From: "hub", To: id, Kind: graph.EdgeCalls})
|
|
}
|
|
return g
|
|
}
|
|
|
|
func TestComputeBetweenness_EmptyGraph(t *testing.T) {
|
|
r := ComputeBetweenness(graph.New())
|
|
if len(r.Scores) != 0 {
|
|
t.Errorf("empty graph should yield no scores, got %d", len(r.Scores))
|
|
}
|
|
if r.Max != 0 {
|
|
t.Errorf("Max = %f, want 0", r.Max)
|
|
}
|
|
if r.Sampled {
|
|
t.Errorf("empty graph should not report Sampled")
|
|
}
|
|
}
|
|
|
|
func TestComputeBetweenness_NilGraph(t *testing.T) {
|
|
r := ComputeBetweenness(nil)
|
|
if r == nil || len(r.Scores) != 0 {
|
|
t.Fatalf("nil graph should yield an empty result, got %+v", r)
|
|
}
|
|
}
|
|
|
|
// TestComputeBetweenness_ExactPathGraph checks exact Brandes' against
|
|
// the closed-form betweenness of a directed path. Every node's score
|
|
// is hand-checkable: index i on a path of n nodes scores i*(n-1-i).
|
|
func TestComputeBetweenness_ExactPathGraph(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
n int
|
|
want map[string]float64
|
|
}{
|
|
{
|
|
name: "path of 5",
|
|
n: 5,
|
|
// p0,p4 are endpoints (0). p1: 1*3=3. p2: 2*2=4. p3: 3*1=3.
|
|
want: map[string]float64{"p0": 0, "p1": 3, "p2": 4, "p3": 3, "p4": 0},
|
|
},
|
|
{
|
|
name: "path of 4",
|
|
n: 4,
|
|
// p1: 1*2=2. p2: 2*1=2.
|
|
want: map[string]float64{"p0": 0, "p1": 2, "p2": 2, "p3": 0},
|
|
},
|
|
{
|
|
name: "path of 3",
|
|
n: 3,
|
|
// only p1 is interior: 1*1=1.
|
|
want: map[string]float64{"p0": 0, "p1": 1, "p2": 0},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := ComputeBetweenness(pathGraph(tt.n))
|
|
if r.Sampled {
|
|
t.Fatalf("small graph (%d nodes) must use the exact path", tt.n)
|
|
}
|
|
if r.Pivots != tt.n {
|
|
t.Errorf("exact path should run from every node: Pivots=%d, want %d", r.Pivots, tt.n)
|
|
}
|
|
for id, want := range tt.want {
|
|
if got := r.ScoreOf(id); math.Abs(got-want) > 1e-9 {
|
|
t.Errorf("betweenness(%s) = %v, want %v", id, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestComputeBetweenness_ExactStarGraph checks exact Brandes' against
|
|
// the closed-form betweenness of a relay star: the hub scores
|
|
// k*(k-1), every leaf scores 0.
|
|
func TestComputeBetweenness_ExactStarGraph(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
leaves int
|
|
wantHub float64
|
|
}{
|
|
{name: "3 leaves", leaves: 3, wantHub: 6}, // 3*2
|
|
{name: "4 leaves", leaves: 4, wantHub: 12}, // 4*3
|
|
{name: "6 leaves", leaves: 6, wantHub: 30}, // 6*5
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := ComputeBetweenness(relayStar(tt.leaves))
|
|
if r.Sampled {
|
|
t.Fatalf("small graph must use the exact path")
|
|
}
|
|
if got := r.ScoreOf("hub"); math.Abs(got-tt.wantHub) > 1e-9 {
|
|
t.Errorf("hub betweenness = %v, want %v", got, tt.wantHub)
|
|
}
|
|
if r.Max != r.ScoreOf("hub") {
|
|
t.Errorf("hub should hold the max score: max=%v hub=%v", r.Max, r.ScoreOf("hub"))
|
|
}
|
|
for i := 0; i < tt.leaves; i++ {
|
|
leaf := fmt.Sprintf("leaf%d", i)
|
|
if got := r.ScoreOf(leaf); math.Abs(got) > 1e-9 {
|
|
t.Errorf("leaf %s should have zero betweenness, got %v", leaf, got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestComputeBetweenness_AdaptiveThreshold verifies the fast path
|
|
// switch: at or below betweennessExactThreshold every node is a
|
|
// source; above it the sampled path runs from a bounded pivot set.
|
|
func TestComputeBetweenness_AdaptiveThreshold(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
nodes int
|
|
wantSampled bool
|
|
wantPivots int
|
|
}{
|
|
{name: "below threshold stays exact", nodes: betweennessExactThreshold - 1, wantSampled: false, wantPivots: betweennessExactThreshold - 1},
|
|
{name: "at threshold stays exact", nodes: betweennessExactThreshold, wantSampled: false, wantPivots: betweennessExactThreshold},
|
|
{name: "above threshold goes sampled", nodes: betweennessExactThreshold + 1, wantSampled: true, wantPivots: betweennessPivots},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := ComputeBetweenness(pathGraph(tt.nodes))
|
|
if r.Sampled != tt.wantSampled {
|
|
t.Errorf("Sampled = %v, want %v (nodes=%d)", r.Sampled, tt.wantSampled, tt.nodes)
|
|
}
|
|
if r.Pivots != tt.wantPivots {
|
|
t.Errorf("Pivots = %d, want %d (nodes=%d)", r.Pivots, tt.wantPivots, tt.nodes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestComputeBetweenness_SampledApproximatesExact builds a graph past
|
|
// the exact threshold and checks the sampled estimate tracks the
|
|
// analytic betweenness of a long directed path. On a path the score
|
|
// of index i is i*(n-1-i); the sampled, V/k-rescaled estimate should
|
|
// land within a modest relative tolerance for the high-centrality
|
|
// interior nodes.
|
|
func TestComputeBetweenness_SampledApproximatesExact(t *testing.T) {
|
|
n := betweennessExactThreshold + 1500
|
|
g := pathGraph(n)
|
|
|
|
r := ComputeBetweenness(g)
|
|
if !r.Sampled {
|
|
t.Fatalf("graph of %d nodes should use the sampled path", n)
|
|
}
|
|
|
|
// Check the middle of the path, where betweenness is largest and
|
|
// the relative sampling error is smallest.
|
|
mid := n / 2
|
|
id := fmt.Sprintf("p%d", mid)
|
|
want := float64(mid) * float64(n-1-mid)
|
|
got := r.ScoreOf(id)
|
|
|
|
relErr := math.Abs(got-want) / want
|
|
const tolerance = 0.20 // 20% — a 256-pivot sample on a 3500-node path
|
|
if relErr > tolerance {
|
|
t.Errorf("sampled betweenness(%s) = %.0f, want ~%.0f (rel err %.3f > %.2f)",
|
|
id, got, want, relErr, tolerance)
|
|
}
|
|
|
|
// The endpoints are never intermediate — they must stay at zero
|
|
// regardless of which pivots were sampled.
|
|
if got := r.ScoreOf("p0"); got != 0 {
|
|
t.Errorf("path endpoint p0 betweenness = %v, want 0", got)
|
|
}
|
|
if got := r.ScoreOf(fmt.Sprintf("p%d", n-1)); got != 0 {
|
|
t.Errorf("path endpoint p%d betweenness = %v, want 0", n-1, got)
|
|
}
|
|
}
|
|
|
|
// TestComputeBetweenness_SampledIsDeterministic verifies the
|
|
// fixed-seed pivot sampling produces byte-identical scores across
|
|
// repeated runs on the same graph.
|
|
func TestComputeBetweenness_SampledIsDeterministic(t *testing.T) {
|
|
n := betweennessExactThreshold + 800
|
|
g := pathGraph(n)
|
|
|
|
first := ComputeBetweenness(g)
|
|
if !first.Sampled {
|
|
t.Fatalf("graph of %d nodes should use the sampled path", n)
|
|
}
|
|
|
|
for run := 0; run < 5; run++ {
|
|
again := ComputeBetweenness(g)
|
|
if again.Pivots != first.Pivots {
|
|
t.Fatalf("run %d: Pivots = %d, want %d", run, again.Pivots, first.Pivots)
|
|
}
|
|
if again.Max != first.Max {
|
|
t.Errorf("run %d: Max = %v, want %v", run, again.Max, first.Max)
|
|
}
|
|
for id, want := range first.Scores {
|
|
if got := again.Scores[id]; got != want {
|
|
t.Errorf("run %d: score(%s) = %v, want %v — sampling not deterministic", run, id, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestComputeBetweenness_LargeGraphCompletes builds a graph well past
|
|
// the exact threshold and asserts the sampled fast path returns a
|
|
// well-formed result. This exercises the O(k*E) structural fast path
|
|
// without a wall-clock bound.
|
|
func TestComputeBetweenness_LargeGraphCompletes(t *testing.T) {
|
|
n := 12000
|
|
g := graph.New()
|
|
for i := 0; i < n; i++ {
|
|
id := fmt.Sprintf("n%d", i)
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
}
|
|
// A wide directed mesh: each node calls the next three. Plenty of
|
|
// shortest paths cross the interior so betweenness is non-trivial.
|
|
for i := 0; i < n; i++ {
|
|
for d := 1; d <= 3 && i+d < n; d++ {
|
|
g.AddEdge(&graph.Edge{
|
|
From: fmt.Sprintf("n%d", i),
|
|
To: fmt.Sprintf("n%d", i+d),
|
|
Kind: graph.EdgeCalls,
|
|
})
|
|
}
|
|
}
|
|
|
|
r := ComputeBetweenness(g)
|
|
if !r.Sampled {
|
|
t.Fatalf("graph of %d nodes should use the sampled path", n)
|
|
}
|
|
if r.Pivots != betweennessPivots {
|
|
t.Errorf("Pivots = %d, want %d", r.Pivots, betweennessPivots)
|
|
}
|
|
if len(r.Scores) != n {
|
|
t.Errorf("Scores should cover every node: got %d, want %d", len(r.Scores), n)
|
|
}
|
|
if r.Max <= 0 {
|
|
t.Errorf("a connected mesh should have a positive max betweenness, got %v", r.Max)
|
|
}
|
|
}
|
|
|
|
// TestComputeBetweenness_OnlyCallAndReferenceEdges verifies that
|
|
// structural edges are ignored — a path wired with EdgeDefines
|
|
// carries no betweenness.
|
|
func TestComputeBetweenness_OnlyCallAndReferenceEdges(t *testing.T) {
|
|
g := graph.New()
|
|
for _, id := range []string{"x", "y", "z"} {
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
}
|
|
g.AddEdge(&graph.Edge{From: "x", To: "y", Kind: graph.EdgeDefines})
|
|
g.AddEdge(&graph.Edge{From: "y", To: "z", Kind: graph.EdgeDefines})
|
|
|
|
r := ComputeBetweenness(g)
|
|
if r.Max != 0 {
|
|
t.Errorf("structural edges should carry no betweenness, max=%v", r.Max)
|
|
}
|
|
|
|
// References participate exactly like calls.
|
|
g2 := graph.New()
|
|
for _, id := range []string{"x", "y", "z"} {
|
|
g2.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
}
|
|
g2.AddEdge(&graph.Edge{From: "x", To: "y", Kind: graph.EdgeReferences})
|
|
g2.AddEdge(&graph.Edge{From: "y", To: "z", Kind: graph.EdgeReferences})
|
|
r2 := ComputeBetweenness(g2)
|
|
if got := r2.ScoreOf("y"); math.Abs(got-1) > 1e-9 {
|
|
t.Errorf("reference-edge path: betweenness(y) = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestFindHotspots_BetweennessComponent verifies the hotspot scorer
|
|
// surfaces a pure bottleneck. The relay hub has modest fan-in/out
|
|
// relative to a separately wired high-fan-in node, but it sits on
|
|
// every leaf-to-leaf shortest path — its Betweenness field must be
|
|
// populated and non-zero, and it must rank as a hotspot.
|
|
func TestFindHotspots_BetweennessComponent(t *testing.T) {
|
|
g := relayStar(8)
|
|
// Pad with extra unrelated functions so the graph clears the
|
|
// 10-symbol floor the MCP handler enforces.
|
|
for i := 0; i < 6; i++ {
|
|
id := fmt.Sprintf("extra%d", i)
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
}
|
|
|
|
communities := &CommunityResult{NodeToComm: map[string]string{}}
|
|
result := FindHotspots(g, communities, 0)
|
|
|
|
var hub *HotspotEntry
|
|
for i := range result {
|
|
if result[i].ID == "hub" {
|
|
hub = &result[i]
|
|
break
|
|
}
|
|
}
|
|
if hub == nil {
|
|
t.Fatalf("relay hub should be reported as a hotspot, got %d entries", len(result))
|
|
}
|
|
if hub.Betweenness <= 0 {
|
|
t.Errorf("relay hub should carry a positive betweenness component, got %v", hub.Betweenness)
|
|
}
|
|
// The hub is the single highest-betweenness node, so its
|
|
// normalized betweenness should be the 0-100 ceiling.
|
|
if math.Abs(hub.Betweenness-100) > 0.01 {
|
|
t.Errorf("relay hub betweenness = %v, want 100 (it holds the graph max)", hub.Betweenness)
|
|
}
|
|
// A leaf is never an intermediate vertex — if it surfaces at all
|
|
// its betweenness component is zero.
|
|
for i := range result {
|
|
if result[i].ID == "leaf0" && result[i].Betweenness != 0 {
|
|
t.Errorf("leaf0 betweenness = %v, want 0", result[i].Betweenness)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFindHotspots_BetweennessRaisesRank verifies the betweenness
|
|
// term augments — not replaces — the legacy fan-in/out signal: adding
|
|
// a bottleneck role to a node strictly raises its complexity score.
|
|
func TestFindHotspots_BetweennessRaisesRank(t *testing.T) {
|
|
// Baseline: a plain 3-hop chain bridge -> via -> sink, plus
|
|
// padding to clear the symbol floor.
|
|
build := func(withBottleneck bool) []HotspotEntry {
|
|
g := graph.New()
|
|
ids := []string{"src", "via", "sink"}
|
|
for _, id := range ids {
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
}
|
|
g.AddEdge(&graph.Edge{From: "src", To: "via", Kind: graph.EdgeCalls})
|
|
g.AddEdge(&graph.Edge{From: "via", To: "sink", Kind: graph.EdgeCalls})
|
|
if withBottleneck {
|
|
// Route extra callers and callees through `via` so it
|
|
// becomes a genuine shortest-path bottleneck.
|
|
for i := 0; i < 4; i++ {
|
|
in := fmt.Sprintf("in%d", i)
|
|
out := fmt.Sprintf("out%d", i)
|
|
g.AddNode(&graph.Node{ID: in, Kind: graph.KindFunction, Name: in})
|
|
g.AddNode(&graph.Node{ID: out, Kind: graph.KindFunction, Name: out})
|
|
g.AddEdge(&graph.Edge{From: in, To: "via", Kind: graph.EdgeCalls})
|
|
g.AddEdge(&graph.Edge{From: "via", To: out, Kind: graph.EdgeCalls})
|
|
}
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
id := fmt.Sprintf("pad%d", i)
|
|
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
|
|
}
|
|
return FindHotspots(g, &CommunityResult{NodeToComm: map[string]string{}}, 0)
|
|
}
|
|
|
|
scoreOf := func(entries []HotspotEntry, id string) (float64, bool) {
|
|
for _, e := range entries {
|
|
if e.ID == id {
|
|
return e.ComplexityScore, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
withBottleneck := build(true)
|
|
viaScore, ok := scoreOf(withBottleneck, "via")
|
|
if !ok {
|
|
t.Fatalf("bottleneck node `via` should be reported as a hotspot")
|
|
}
|
|
if viaScore <= 0 {
|
|
t.Errorf("bottleneck node should have a positive complexity score, got %v", viaScore)
|
|
}
|
|
// The bottleneck node carries both fan-in/out and betweenness, so
|
|
// it must outrank the inert padding functions.
|
|
if padScore, ok := scoreOf(withBottleneck, "pad0"); ok && viaScore <= padScore {
|
|
t.Errorf("bottleneck node (%.2f) should outrank inert padding (%.2f)", viaScore, padScore)
|
|
}
|
|
}
|