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
318 lines
10 KiB
Go
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
|
|
}
|