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) } }