Files
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

318 lines
10 KiB
Go

package analysis
import (
"sort"
"github.com/zzet/gortex/internal/graph"
)
// AdjacencySnapshot is a compact, immutable CSR-style view of the
// call / reference graph, built once per analysis pass and reused by
// seeded random-walk queries so they never re-scan AllNodes / AllEdges.
//
// Only EdgeCalls and EdgeReferences participate — the same edge set
// ComputePageRank walks — and each edge rides its graph.ProvenanceWeight
// so a seeded walk attenuates over-represented LSP-dispatch fan-outs
// identically to the global PageRank.
//
// Layout (forward adjacency, From -> To):
//
// - ids[i] the node ID at dense index i
// - offsets[i]..[i+1] the slice of out-neighbours of node i
// - neighbors[k] dense index of the k-th out-neighbour
// - weights[k] provenance weight of the edge to neighbors[k]
// - outWeight[i] sum of weights of node i's out-edges
//
// The snapshot is read-only after construction; PersonalizedPageRank
// allocates only its own score vectors, so concurrent walks over one
// snapshot are safe without locking.
type AdjacencySnapshot struct {
ids []string
index map[string]int
offsets []int32
neighbors []int32
weights []float64
outWeight []float64
// pkgRoots maps a package directory (the dir of a node's file path,
// ≈ a Go package) to a content hash of that package's contribution
// to the walk: every member node's stable ID plus its out-edges
// (neighbour IDs + weights). The hash is index-shift invariant — it
// uses string IDs, not dense indices — so a package whose subgraph
// did not change keeps the same root even when nodes are added or
// removed in OTHER packages. This is the per-package Merkle root the
// walk cache keys on, so only walks touching a changed package miss.
// Empty when the snapshot is empty.
pkgRoots map[string]uint64
}
// NodeCount returns the number of nodes in the snapshot.
func (a *AdjacencySnapshot) NodeCount() int {
if a == nil {
return 0
}
return len(a.ids)
}
// EdgeCount returns the number of directed call / reference edges
// captured in the snapshot.
func (a *AdjacencySnapshot) EdgeCount() int {
if a == nil {
return 0
}
return len(a.neighbors)
}
// BuildAdjacencySnapshot constructs the CSR adjacency over the call /
// reference graph. Nodes are densely indexed in sorted ID order so the
// snapshot — and therefore every seeded walk over it — is deterministic
// regardless of the backend's node / edge enumeration order. An edge
// whose endpoint is not a real graph node (an unresolved or dangling
// target) is skipped so the dense index stays consistent.
func BuildAdjacencySnapshot(g graph.Store) *AdjacencySnapshot {
snap := &AdjacencySnapshot{index: map[string]int{}}
if g == nil {
return snap
}
nodes := g.AllNodes()
if len(nodes) == 0 {
return snap
}
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
if n == nil || n.ID == "" {
continue
}
ids = append(ids, n.ID)
}
sort.Strings(ids)
index := make(map[string]int, len(ids))
for i, id := range ids {
index[id] = i
}
// First pass: bucket out-edges per source so the CSR offsets can be
// laid out contiguously. Only call / reference edges with both
// endpoints in the dense index participate.
type link struct {
to int
w float64
}
adj := make([][]link, len(ids))
// Meta-less kind-scoped scan (see LightEdgeScanner): the CSR build reads only
// e.Kind, endpoints, and graph.ProvenanceWeight.
for _, e := range graph.EdgesForKindsLight(g, graph.EdgeCalls, graph.EdgeReferences) {
if e == nil {
continue
}
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
continue
}
from, ok := index[e.From]
if !ok {
continue
}
to, ok := index[e.To]
if !ok {
continue
}
adj[from] = append(adj[from], link{to: to, w: graph.ProvenanceWeight(e)})
}
offsets := make([]int32, len(ids)+1)
var total int
for i := range adj {
offsets[i] = int32(total)
total += len(adj[i])
}
offsets[len(ids)] = int32(total)
neighbors := make([]int32, 0, total)
weights := make([]float64, 0, total)
outWeight := make([]float64, len(ids))
for i := range adj {
// Sort each node's out-neighbours by dense index so the CSR row
// order is deterministic (AllEdges order is backend-specific).
row := adj[i]
sort.Slice(row, func(a, b int) bool { return row[a].to < row[b].to })
for _, l := range row {
neighbors = append(neighbors, int32(l.to))
weights = append(weights, l.w)
outWeight[i] += l.w
}
}
snap.ids = ids
snap.index = index
snap.offsets = offsets
snap.neighbors = neighbors
snap.weights = weights
snap.outWeight = outWeight
snap.pkgRoots = computePackageRoots(ids, offsets, neighbors, weights)
return snap
}
// pprDefaultRestart is the restart probability a seeded walk uses when
// the caller passes a non-positive value. 0.15 mirrors the canonical
// 0.85 PageRank damping (restart = 1 - damping).
const pprDefaultRestart = 0.15
// pprIterations is fixed rather than convergence-tested: the graph is
// small enough that the ranking order stabilises well within this many
// power-iteration steps, and a fixed count keeps the result
// deterministic and the cost bounded at O(iters * edges).
const pprIterations = 40
// PersonalizedPageRank runs a seeded random-walk-with-restart over the
// snapshot. Restart mass returns to the seed set (not uniformly across
// the graph), so the stationary distribution concentrates on nodes that
// are reachable from the seeds along many short, high-provenance paths.
//
// restart is the per-step restart probability; a non-positive value
// uses pprDefaultRestart. Score flows along an edge in proportion to
// its weight / the source's out-weight — identical to ComputePageRank —
// and dangling mass (a node with no out-edges) is returned to the seed
// set so no probability leaks. The result maps node ID to its proximity
// score; an empty map is returned when no seed resolves to a snapshot
// node.
func (a *AdjacencySnapshot) PersonalizedPageRank(seeds []string, restart float64) map[string]float64 {
return a.PersonalizedPageRankTopK(seeds, restart, 0)
}
// PersonalizedPageRankTopK is PersonalizedPageRank restricted to the k
// nodes with the highest stationary score. The seeded walk concentrates
// almost all of its mass on a small neighbourhood of the seeds, so the
// long tail of near-floor scores carries no usable ranking signal: every
// consumer either looks a candidate's score up by ID (absent → 0, exactly
// as a zero score already is) or max-normalises against the top entry,
// which top-k always retains. Capping the result turns a cached walk from
// a full-graph-sized map (~len(ids) entries) into at most k entries, which
// is what bounds the PPR walk cache's memory. k <= 0 (or k >= the number
// of scored nodes) returns the full dense map, identical to
// PersonalizedPageRank.
func (a *AdjacencySnapshot) PersonalizedPageRankTopK(seeds []string, restart float64, k int) map[string]float64 {
score := a.personalizedPageRankScores(seeds, restart)
if score == nil {
return map[string]float64{}
}
if k <= 0 {
out := make(map[string]float64, len(score))
for i, v := range score {
if v != 0 {
out[a.ids[i]] = v
}
}
return out
}
// Gather the non-zero (index, score) pairs, then keep the k largest.
pairs := make([]pprScore, 0, len(score))
for i, v := range score {
if v != 0 {
pairs = append(pairs, pprScore{idx: i, score: v})
}
}
if k >= len(pairs) {
out := make(map[string]float64, len(pairs))
for _, p := range pairs {
out[a.ids[p.idx]] = p.score
}
return out
}
// Partial selection by descending score; ties broken by index so the
// retained set is deterministic across runs.
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].score != pairs[j].score {
return pairs[i].score > pairs[j].score
}
return pairs[i].idx < pairs[j].idx
})
out := make(map[string]float64, k)
for _, p := range pairs[:k] {
out[a.ids[p.idx]] = p.score
}
return out
}
// pprScore pairs a snapshot node index with its stationary score for the
// top-k partial selection in PersonalizedPageRankTopK.
type pprScore struct {
idx int
score float64
}
// personalizedPageRankScores runs the seeded random-walk-with-restart and
// returns the raw stationary score array aligned to a.ids (score[i] is the
// proximity of a.ids[i]). It returns nil when the snapshot is empty or no
// seed resolves to a snapshot node — the public wrappers then return an
// empty map.
func (a *AdjacencySnapshot) personalizedPageRankScores(seeds []string, restart float64) []float64 {
if a == nil || len(a.ids) == 0 {
return nil
}
if restart <= 0 || restart >= 1 {
restart = pprDefaultRestart
}
n := len(a.ids)
// Restart distribution: uniform over the in-snapshot seeds. A seed
// absent from the snapshot contributes nothing.
seedIdx := make([]int, 0, len(seeds))
seen := make(map[int]bool, len(seeds))
for _, s := range seeds {
if i, ok := a.index[s]; ok && !seen[i] {
seen[i] = true
seedIdx = append(seedIdx, i)
}
}
if len(seedIdx) == 0 {
return nil
}
restartVec := make([]float64, n)
seedMass := 1.0 / float64(len(seedIdx))
for _, i := range seedIdx {
restartVec[i] = seedMass
}
// Initialise the walk at the seed distribution.
score := make([]float64, n)
copy(score, restartVec)
for iter := 0; iter < pprIterations; iter++ {
next := make([]float64, n)
// Push each node's score forward along its out-edges, weighted
// by w/outWeight. Dangling nodes pool their score and return it
// to the seed set, conserving total mass.
var dangling float64
for i := 0; i < n; i++ {
ow := a.outWeight[i]
if ow == 0 {
dangling += score[i]
continue
}
s := score[i]
if s == 0 {
continue
}
start, end := a.offsets[i], a.offsets[i+1]
for k := start; k < end; k++ {
next[a.neighbors[k]] += s * a.weights[k] / ow
}
}
// Combine the walk step, the restart, and the dangling pool.
// (1-restart) of the walked mass plus restart mass to the seeds;
// dangling mass also returns to the seeds so it never leaks.
for i := 0; i < n; i++ {
next[i] = (1-restart)*next[i] + (restart+(1-restart)*dangling)*restartVec[i]
}
score = next
}
return score
}