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

165 lines
5.1 KiB
Go

package analysis
import (
"math"
"github.com/zzet/gortex/internal/graph"
)
// HITSResult holds per-node HITS scores -- the authority and hub
// values from Kleinberg's Hyperlink-Induced Topic Search.
//
// In a call/reference graph the two roles read as:
// - Authority: a symbol called by many *hubs* -- a load-bearing
// definition that coordinator code converges on.
// - Hub: a symbol that calls many *authorities* -- an orchestrator
// / dispatcher that pulls many load-bearing pieces together.
//
// A pure utility called by everything scores HIGH on the in-degree
// count fan-in already captures, but only MODERATELY on authority
// unless its callers are themselves authoritative -- which is exactly
// the discrimination the rerank pipeline wants on top of raw fan-in.
type HITSResult struct {
// Authorities maps node ID to its authority score.
Authorities map[string]float64
// Hubs maps node ID to its hub score.
Hubs map[string]float64
// MaxAuth / MaxHub are the largest values in each map -- the
// normalisers callers use to project onto a 0..1 scale.
MaxAuth float64
MaxHub float64
}
// AuthorityOf returns the authority score for a node, or 0 when
// absent or the result is nil.
func (r *HITSResult) AuthorityOf(id string) float64 {
if r == nil {
return 0
}
return r.Authorities[id]
}
// HubOf returns the hub score for a node, or 0 when absent or the
// result is nil.
func (r *HITSResult) HubOf(id string) float64 {
if r == nil {
return 0
}
return r.Hubs[id]
}
// hitsIterations fixes the power-iteration count. HITS converges
// fast; 40 steps mirror ComputePageRank and are well past the point
// the authority/hub ordering stabilises on a graph of this size.
const hitsIterations = 40
// ComputeHITS runs the HITS algorithm over the call / reference
// graph. Only EdgeCalls and EdgeReferences participate -- the same
// edge set ComputePageRank uses, so structural edges (defines,
// member_of, imports) do not drown the dependency signal.
//
// Each iteration applies the mutual recursion
//
// auth(p) = sum over q->p of hub(q)
// hub(p) = sum over p->q of auth(q)
//
// then L2-normalises both vectors so the scores stay bounded. A nil
// or empty graph yields an empty, safe-to-query result.
func ComputeHITS(g graph.Store) *HITSResult {
if g == nil {
return &HITSResult{Authorities: map[string]float64{}, Hubs: map[string]float64{}}
}
nodes := excludeProxyNodes(g.AllNodes())
n := len(nodes)
if n == 0 {
return &HITSResult{Authorities: map[string]float64{}, Hubs: map[string]float64{}}
}
// Adjacency restricted to the call/reference edge set. outLinks
// drives the hub update; inLinks drives the authority update.
// Each link carries a provenance weight (graph.ProvenanceWeight)
// so the abundant LSP-dispatch / framework-wiring tier does not
// inflate authority over direct, structurally-unambiguous calls.
// With uniform weights (e.g. a graph with no Origin stamped) the
// scaling is constant and, after L2 normalisation, leaves the
// ranking identical to the unweighted computation.
outLinks := make(map[string][]weightedLink)
inLinks := make(map[string][]weightedLink)
// Meta-less kind-scoped scan (see LightEdgeScanner): only e.Kind, endpoints,
// and graph.ProvenanceWeight are read here.
for _, e := range graph.EdgesForKindsLight(g, graph.EdgeCalls, graph.EdgeReferences) {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
continue
}
if edgeTouchesProxy(e) {
continue
}
w := graph.ProvenanceWeight(e)
outLinks[e.From] = append(outLinks[e.From], weightedLink{e.To, w})
inLinks[e.To] = append(inLinks[e.To], weightedLink{e.From, w})
}
auth := make(map[string]float64, n)
hub := make(map[string]float64, n)
for _, nd := range nodes {
auth[nd.ID] = 1.0
hub[nd.ID] = 1.0
}
for iter := 0; iter < hitsIterations; iter++ {
// Authority update: a node's authority is the sum of the hub
// scores of the nodes pointing at it.
nextAuth := make(map[string]float64, n)
for _, nd := range nodes {
var sum float64
for _, src := range inLinks[nd.ID] {
sum += src.w * hub[src.id]
}
nextAuth[nd.ID] = sum
}
// Hub update: a node's hub score is the sum of the (just
// updated) authority scores of the nodes it points at.
nextHub := make(map[string]float64, n)
for _, nd := range nodes {
var sum float64
for _, dst := range outLinks[nd.ID] {
sum += dst.w * nextAuth[dst.id]
}
nextHub[nd.ID] = sum
}
normalizeL2(nextAuth)
normalizeL2(nextHub)
auth, hub = nextAuth, nextHub
}
res := &HITSResult{Authorities: auth, Hubs: hub}
for _, v := range auth {
if v > res.MaxAuth {
res.MaxAuth = v
}
}
for _, v := range hub {
if v > res.MaxHub {
res.MaxHub = v
}
}
return res
}
// normalizeL2 scales a score vector in place to unit L2 norm. A
// zero vector (no edges in the participating set) is left untouched
// so the next iteration starts from a defined state.
func normalizeL2(m map[string]float64) {
var sumSq float64
for _, v := range m {
sumSq += v * v
}
if sumSq == 0 {
return
}
norm := math.Sqrt(sumSq)
for k := range m {
m[k] /= norm
}
}