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
275 lines
9.0 KiB
Go
275 lines
9.0 KiB
Go
package analysis
|
|
|
|
import (
|
|
"math/rand"
|
|
"sort"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// BetweennessResult holds per-node betweenness-centrality scores.
|
|
//
|
|
// Betweenness measures how often a node lies on a shortest path
|
|
// between two other nodes — a high score marks a structural
|
|
// bottleneck that load flows through. It complements PageRank
|
|
// (which measures being depended-on) by measuring being a conduit.
|
|
type BetweennessResult struct {
|
|
// Scores maps node ID to its betweenness value. Larger means the
|
|
// node sits on more shortest paths; values are best read relative
|
|
// to Max.
|
|
Scores map[string]float64
|
|
// Max is the largest score in Scores — the normaliser callers use
|
|
// to project centrality onto a 0..1 / 0..100 scale.
|
|
Max float64
|
|
// Sampled reports whether the sampled-pivot fast path was used.
|
|
// False means every node was a source (exact Brandes').
|
|
Sampled bool
|
|
// Pivots is the number of source nodes the accumulation ran from.
|
|
// Equals the node count on the exact path.
|
|
Pivots int
|
|
}
|
|
|
|
// ScoreOf returns the betweenness score for a node, or 0 when absent.
|
|
func (r *BetweennessResult) ScoreOf(id string) float64 {
|
|
if r == nil {
|
|
return 0
|
|
}
|
|
return r.Scores[id]
|
|
}
|
|
|
|
// Betweenness tuning.
|
|
//
|
|
// betweennessExactThreshold is the node-count cutoff between the two
|
|
// paths: at or below it every node is a source (exact Brandes',
|
|
// O(V*E)); above it the sampled fast path runs from a bounded number
|
|
// of pivots (O(k*E)) and rescales by V/k.
|
|
//
|
|
// betweennessPivots bounds k on the sampled path. ~256 source pivots
|
|
// give a stable ranking on graphs into the hundreds of thousands of
|
|
// nodes — betweenness is needed for ordering hotspots, not for an
|
|
// exact path count, and the sampling error shrinks with graph size.
|
|
//
|
|
// betweennessSeed fixes the pivot-sampling RNG so a given graph
|
|
// yields the same scores on every run.
|
|
const (
|
|
betweennessExactThreshold = 2000
|
|
betweennessPivots = 256
|
|
betweennessSeed = 0x6f7274
|
|
)
|
|
|
|
// ComputeBetweenness runs betweenness centrality over the call /
|
|
// reference graph. It adapts to graph size: small graphs get exact
|
|
// Brandes' (every node a source); large graphs switch to the
|
|
// sampled-pivot fast path — accumulate from k randomly chosen
|
|
// sources and scale the result by V/k. Both paths share the same
|
|
// single-source accumulation kernel, so the only difference is which
|
|
// sources feed it.
|
|
//
|
|
// Only EdgeCalls and EdgeReferences participate, matching
|
|
// ComputePageRank: structural edges (defines, member_of, imports)
|
|
// would swamp the dependency signal. Edges are treated as unweighted
|
|
// and directed — shortest paths are hop counts found by BFS.
|
|
//
|
|
// Pivot sampling is seeded with a fixed seed, so results are
|
|
// reproducible run to run.
|
|
func ComputeBetweenness(g graph.Store) *BetweennessResult {
|
|
if g == nil {
|
|
return &BetweennessResult{Scores: map[string]float64{}}
|
|
}
|
|
// Betweenness measures shortest-path centrality across the
|
|
// call / reference subgraph; only function and method nodes carry
|
|
// those edges. The scoring kernel only ever touches node IDs, so
|
|
// the unfiltered AllNodes() pull was wasted on the other 90% of
|
|
// the node table AND on the 9 unused columns of every retained
|
|
// row. NodeIDsByKinds returns just the id column from a single
|
|
// query; NodesByKindsScanner is the legacy fallback for
|
|
// backends that haven't shipped the id projection yet.
|
|
betweennessKinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
|
|
bcNodeKinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod}
|
|
var ids []string
|
|
if scan, ok := g.(graph.NodeIDsByKinds); ok {
|
|
ids = scan.NodeIDsByKinds(bcNodeKinds)
|
|
} else if scan, ok := g.(graph.NodesByKindsScanner); ok {
|
|
ns := scan.NodesByKinds(bcNodeKinds)
|
|
ids = make([]string, 0, len(ns))
|
|
for _, nd := range ns {
|
|
ids = append(ids, nd.ID)
|
|
}
|
|
} else {
|
|
all := g.AllNodes()
|
|
ids = make([]string, 0, len(all))
|
|
for _, nd := range all {
|
|
if nd.Kind == graph.KindFunction || nd.Kind == graph.KindMethod {
|
|
ids = append(ids, nd.ID)
|
|
}
|
|
}
|
|
}
|
|
// Federation proxy nodes carry real kinds, so the kind filter above
|
|
// keeps them — drop them here so a remote stub is never a pivot.
|
|
ids = excludeProxyIDs(ids)
|
|
n := len(ids)
|
|
if n == 0 {
|
|
return &BetweennessResult{Scores: map[string]float64{}}
|
|
}
|
|
|
|
// Stable node ordering: betweenness itself is order-independent,
|
|
// but a deterministic order makes the sampled pivot pick
|
|
// reproducible regardless of the iteration order
|
|
// NodeIDsByKinds happens to return.
|
|
sort.Strings(ids)
|
|
|
|
// Forward adjacency over the call / reference subgraph.
|
|
// EdgeAdjacencyForKinds returns only the (from, to) projection of
|
|
// function/method endpoints — the disk path collapses to one
|
|
// join with both endpoint kinds enforced in the store, so
|
|
// neither the cross-kind edges nor the ~10 unused columns are
|
|
// ever materialized. Falls back to EdgesByKinds (and then
|
|
// EdgesByKind per kind) on backends that don't implement the
|
|
// adjacency capability.
|
|
adj := make(map[string][]string, n)
|
|
if adjScan, ok := g.(graph.EdgeAdjacencyForKinds); ok {
|
|
for pair := range adjScan.EdgeAdjacencyForKinds(betweennessKinds, bcNodeKinds) {
|
|
adj[pair[0]] = append(adj[pair[0]], pair[1])
|
|
}
|
|
} else if es, ok := g.(graph.EdgesByKindsScanner); ok {
|
|
for e := range es.EdgesByKinds(betweennessKinds) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
adj[e.From] = append(adj[e.From], e.To)
|
|
}
|
|
} else {
|
|
for _, kind := range betweennessKinds {
|
|
for e := range g.EdgesByKind(kind) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
adj[e.From] = append(adj[e.From], e.To)
|
|
}
|
|
}
|
|
}
|
|
|
|
score := make(map[string]float64, n)
|
|
for _, id := range ids {
|
|
score[id] = 0
|
|
}
|
|
|
|
// Adaptive fast path: exact for small graphs, sampled otherwise.
|
|
sources := ids
|
|
sampled := false
|
|
if n > betweennessExactThreshold {
|
|
sampled = true
|
|
sources = samplePivots(ids, betweennessPivots)
|
|
}
|
|
|
|
for _, src := range sources {
|
|
brandesAccumulate(src, ids, adj, score)
|
|
}
|
|
|
|
// Rescale the sampled estimate up to a full-source equivalent so
|
|
// the magnitude is comparable to the exact path.
|
|
if sampled && len(sources) > 0 {
|
|
scale := float64(n) / float64(len(sources))
|
|
for id := range score {
|
|
score[id] *= scale
|
|
}
|
|
}
|
|
|
|
var max float64
|
|
for _, v := range score {
|
|
if v > max {
|
|
max = v
|
|
}
|
|
}
|
|
return &BetweennessResult{
|
|
Scores: score,
|
|
Max: max,
|
|
Sampled: sampled,
|
|
Pivots: len(sources),
|
|
}
|
|
}
|
|
|
|
// samplePivots picks k distinct source nodes from ids using a
|
|
// fixed-seed RNG, so the chosen pivots — and therefore the resulting
|
|
// scores — are identical on every run. When k is at or above the
|
|
// node count the whole set is returned (the caller would not be on
|
|
// the sampled path in that case, but the guard keeps samplePivots
|
|
// total).
|
|
func samplePivots(ids []string, k int) []string {
|
|
if k >= len(ids) {
|
|
out := make([]string, len(ids))
|
|
copy(out, ids)
|
|
return out
|
|
}
|
|
rng := rand.New(rand.NewSource(betweennessSeed))
|
|
perm := rng.Perm(len(ids))
|
|
out := make([]string, k)
|
|
for i := range k {
|
|
out[i] = ids[perm[i]]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// brandesAccumulate runs one single-source pass of Brandes'
|
|
// algorithm: a BFS from src counts shortest paths, then a reverse
|
|
// dependency-accumulation sweep folds this source's contribution
|
|
// into score. Intermediate vertices (everything but src and the
|
|
// target) collect the credit, which is exactly betweenness.
|
|
//
|
|
// The graph is unweighted, so a plain BFS yields shortest paths and
|
|
// the per-source cost is O(V+E); summed over all sources this is the
|
|
// O(V*E) exact bound, or O(k*E) when only k sources feed it.
|
|
func brandesAccumulate(src string, ids []string, adj map[string][]string, score map[string]float64) {
|
|
// sigma: number of shortest paths from src to a node.
|
|
// dist: hop distance from src (-1 = unreached).
|
|
// preds: shortest-path predecessors of a node.
|
|
// order: nodes in non-decreasing distance — the BFS visitation
|
|
// order, replayed in reverse for the accumulation sweep.
|
|
sigma := make(map[string]float64, len(ids))
|
|
dist := make(map[string]int, len(ids))
|
|
preds := make(map[string][]string, len(ids))
|
|
for _, id := range ids {
|
|
dist[id] = -1
|
|
}
|
|
sigma[src] = 1
|
|
dist[src] = 0
|
|
|
|
queue := []string{src}
|
|
order := make([]string, 0, len(ids))
|
|
for len(queue) > 0 {
|
|
v := queue[0]
|
|
queue = queue[1:]
|
|
order = append(order, v)
|
|
for _, w := range adj[v] {
|
|
// First time w is reached — record its distance and
|
|
// enqueue it.
|
|
if dist[w] < 0 {
|
|
dist[w] = dist[v] + 1
|
|
queue = append(queue, w)
|
|
}
|
|
// w found again along another shortest path — add v's
|
|
// path count and register v as a predecessor.
|
|
if dist[w] == dist[v]+1 {
|
|
sigma[w] += sigma[v]
|
|
preds[w] = append(preds[w], v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reverse sweep: pop nodes farthest-first and push their
|
|
// accumulated dependency back onto their predecessors.
|
|
delta := make(map[string]float64, len(ids))
|
|
for i := len(order) - 1; i >= 0; i-- {
|
|
w := order[i]
|
|
for _, v := range preds[w] {
|
|
if sigma[w] != 0 {
|
|
delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w])
|
|
}
|
|
}
|
|
// The source itself is never an intermediate vertex.
|
|
if w != src {
|
|
score[w] += delta[w]
|
|
}
|
|
}
|
|
}
|