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
359 lines
9.2 KiB
Go
359 lines
9.2 KiB
Go
package analysis
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
|
||
"github.com/zzet/gortex/internal/graph"
|
||
)
|
||
|
||
// Spectral clustering tuning.
|
||
const (
|
||
// spectralMinSplitSize — connected node sets at or below this are
|
||
// emitted as a cluster rather than bisected further.
|
||
spectralMinSplitSize = 16
|
||
// spectralPowerIters — shifted power-iteration steps used to
|
||
// approximate the Fiedler vector. The ranking (sign pattern)
|
||
// stabilises well before this bound on real call graphs.
|
||
spectralPowerIters = 150
|
||
// spectralMinCluster — clusters smaller than this are dropped, in
|
||
// step with the Louvain/Leiden detectors' singleton handling.
|
||
spectralMinCluster = 2
|
||
)
|
||
|
||
// SpectralClusters partitions the call / reference graph by recursive
|
||
// spectral bisection: each cut splits a connected node set along the
|
||
// sign of its Fiedler vector — the eigenvector of the graph
|
||
// Laplacian's second-smallest eigenvalue — the classic spectral
|
||
// partitioning step. It is offered as an alternative to the
|
||
// modularity-driven Louvain / Leiden detectors; spectral cuts pair
|
||
// better with embedding-similarity edges, where modularity's
|
||
// resolution limit blurs cluster boundaries.
|
||
//
|
||
// The result has the same shape as DetectCommunities so analyze
|
||
// kind=clusters can swap algorithms transparently.
|
||
func SpectralClusters(g graph.Store) *CommunityResult {
|
||
nodes := g.AllNodes()
|
||
edges := g.AllEdges()
|
||
|
||
symbolNodes := make(map[string]bool)
|
||
for _, n := range nodes {
|
||
if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
|
||
symbolNodes[n.ID] = true
|
||
}
|
||
}
|
||
|
||
// Undirected weighted adjacency — same construction the Louvain
|
||
// detector uses, so the two algorithms cluster the same graph.
|
||
type edgeKey struct{ a, b string }
|
||
weights := make(map[edgeKey]float64)
|
||
for _, e := range edges {
|
||
if !symbolNodes[e.From] || !symbolNodes[e.To] || e.From == e.To {
|
||
continue
|
||
}
|
||
w := edgeWeight(e.Kind)
|
||
if w == 0 {
|
||
continue
|
||
}
|
||
weights[edgeKey{e.From, e.To}] += w
|
||
weights[edgeKey{e.To, e.From}] += w
|
||
}
|
||
neighbors := make(map[string]map[string]float64)
|
||
for k, w := range weights {
|
||
if neighbors[k.a] == nil {
|
||
neighbors[k.a] = make(map[string]float64)
|
||
}
|
||
neighbors[k.a][k.b] = w
|
||
}
|
||
if len(neighbors) == 0 {
|
||
return &CommunityResult{NodeToComm: make(map[string]string)}
|
||
}
|
||
|
||
nodeMap := make(map[string]*graph.Node, len(nodes))
|
||
for _, n := range nodes {
|
||
nodeMap[n.ID] = n
|
||
}
|
||
|
||
// Recursively bisect every connected component.
|
||
all := make([]string, 0, len(neighbors))
|
||
for id := range neighbors {
|
||
all = append(all, id)
|
||
}
|
||
sort.Strings(all)
|
||
clusters := spectralBisect(all, neighbors)
|
||
|
||
// Order clusters deterministically by their smallest member.
|
||
sort.Slice(clusters, func(i, j int) bool {
|
||
return minMember(clusters[i]) < minMember(clusters[j])
|
||
})
|
||
|
||
result := &CommunityResult{NodeToComm: make(map[string]string)}
|
||
idx := 0
|
||
for _, members := range clusters {
|
||
if len(members) < spectralMinCluster {
|
||
continue
|
||
}
|
||
sort.Strings(members)
|
||
id := fmt.Sprintf("community-%d", idx)
|
||
idx++
|
||
|
||
fileSet := make(map[string]bool)
|
||
for _, mid := range members {
|
||
if n := nodeMap[mid]; n != nil {
|
||
fileSet[n.FilePath] = true
|
||
}
|
||
}
|
||
files := make([]string, 0, len(fileSet))
|
||
for f := range fileSet {
|
||
files = append(files, f)
|
||
}
|
||
sort.Strings(files)
|
||
|
||
for _, mid := range members {
|
||
result.NodeToComm[mid] = id
|
||
}
|
||
result.Communities = append(result.Communities, Community{
|
||
ID: id,
|
||
Label: inferCommunityLabel(members, nodeMap, files),
|
||
Members: members,
|
||
Files: files,
|
||
Size: len(members),
|
||
Cohesion: computeCohesion(members, neighbors),
|
||
Hub: findHub(members, nodeMap, neighbors),
|
||
})
|
||
}
|
||
|
||
disambiguateLabels(result.Communities)
|
||
assignDirectoryParents(result.Communities)
|
||
sort.Slice(result.Communities, func(i, j int) bool {
|
||
if result.Communities[i].Size != result.Communities[j].Size {
|
||
return result.Communities[i].Size > result.Communities[j].Size
|
||
}
|
||
return result.Communities[i].ID < result.Communities[j].ID
|
||
})
|
||
result.Modularity = graphModularity(neighbors, result.NodeToComm)
|
||
return result
|
||
}
|
||
|
||
// spectralBisect recursively partitions a node set. A set that splits
|
||
// into multiple connected components is divided along them first;
|
||
// a single connected component larger than the floor is cut by its
|
||
// Fiedler vector; everything else is emitted as a cluster.
|
||
func spectralBisect(members []string, neighbors map[string]map[string]float64) [][]string {
|
||
comps := connectedComponentsWithin(members, neighbors)
|
||
if len(comps) > 1 {
|
||
var out [][]string
|
||
for _, c := range comps {
|
||
out = append(out, spectralBisect(c, neighbors)...)
|
||
}
|
||
return out
|
||
}
|
||
if len(members) <= spectralMinSplitSize {
|
||
return [][]string{members}
|
||
}
|
||
left, right := fiedlerSplit(members, neighbors)
|
||
if len(left) == 0 || len(right) == 0 {
|
||
// The Fiedler vector did not separate the set — emit as-is
|
||
// rather than recursing forever.
|
||
return [][]string{members}
|
||
}
|
||
out := spectralBisect(left, neighbors)
|
||
return append(out, spectralBisect(right, neighbors)...)
|
||
}
|
||
|
||
// connectedComponentsWithin returns the connected components of the
|
||
// subgraph induced by members (edges to nodes outside the set are
|
||
// ignored).
|
||
func connectedComponentsWithin(members []string, neighbors map[string]map[string]float64) [][]string {
|
||
inSet := make(map[string]bool, len(members))
|
||
for _, m := range members {
|
||
inSet[m] = true
|
||
}
|
||
visited := make(map[string]bool, len(members))
|
||
var comps [][]string
|
||
for _, start := range members {
|
||
if visited[start] {
|
||
continue
|
||
}
|
||
var comp []string
|
||
queue := []string{start}
|
||
visited[start] = true
|
||
for len(queue) > 0 {
|
||
cur := queue[0]
|
||
queue = queue[1:]
|
||
comp = append(comp, cur)
|
||
for nb := range neighbors[cur] {
|
||
if inSet[nb] && !visited[nb] {
|
||
visited[nb] = true
|
||
queue = append(queue, nb)
|
||
}
|
||
}
|
||
}
|
||
comps = append(comps, comp)
|
||
}
|
||
return comps
|
||
}
|
||
|
||
// fiedlerSplit approximates the Fiedler vector of the subgraph induced
|
||
// by members via shifted power iteration on (c·I − L), deflating the
|
||
// constant eigenvector each step, then splits the set by the vector's
|
||
// sign. The members slice must be a single connected component.
|
||
func fiedlerSplit(members []string, neighbors map[string]map[string]float64) (left, right []string) {
|
||
n := len(members)
|
||
index := make(map[string]int, n)
|
||
for i, id := range members {
|
||
index[id] = i
|
||
}
|
||
|
||
// Local degree and the Laplacian shift c = maxDegree·2 + 1, which
|
||
// keeps c·I − L positive so the dominant eigenvector of the
|
||
// shifted matrix is the Fiedler vector of L.
|
||
degree := make([]float64, n)
|
||
var maxDeg float64
|
||
for i, id := range members {
|
||
for nb, w := range neighbors[id] {
|
||
if _, ok := index[nb]; ok {
|
||
degree[i] += w
|
||
}
|
||
}
|
||
if degree[i] > maxDeg {
|
||
maxDeg = degree[i]
|
||
}
|
||
}
|
||
shift := maxDeg*2 + 1
|
||
|
||
// Deterministic, non-constant seed vector.
|
||
v := make([]float64, n)
|
||
for i := range v {
|
||
v[i] = math.Sin(float64(i + 1))
|
||
}
|
||
deflateAndNormalize(v)
|
||
|
||
next := make([]float64, n)
|
||
for iter := 0; iter < spectralPowerIters; iter++ {
|
||
for i, id := range members {
|
||
// (L v)[i] = degree[i]·v[i] − Σ_j A_ij v[j]
|
||
lv := degree[i] * v[i]
|
||
for nb, w := range neighbors[id] {
|
||
if j, ok := index[nb]; ok {
|
||
lv -= w * v[j]
|
||
}
|
||
}
|
||
// w = (c·I − L) v
|
||
next[i] = shift*v[i] - lv
|
||
}
|
||
copy(v, next)
|
||
deflateAndNormalize(v)
|
||
}
|
||
|
||
// Sign split. A degenerate all-one-sign vector falls back to a
|
||
// median split so the recursion still makes progress.
|
||
threshold := 0.0
|
||
if allSameSign(v) {
|
||
threshold = median(v)
|
||
}
|
||
for i, id := range members {
|
||
if v[i] >= threshold {
|
||
left = append(left, id)
|
||
} else {
|
||
right = append(right, id)
|
||
}
|
||
}
|
||
return left, right
|
||
}
|
||
|
||
// deflateAndNormalize projects v onto the subspace orthogonal to the
|
||
// all-ones vector (removing L's trivial zero-eigenvalue component),
|
||
// then scales it to unit length.
|
||
func deflateAndNormalize(v []float64) {
|
||
if len(v) == 0 {
|
||
return
|
||
}
|
||
var mean float64
|
||
for _, x := range v {
|
||
mean += x
|
||
}
|
||
mean /= float64(len(v))
|
||
var norm float64
|
||
for i := range v {
|
||
v[i] -= mean
|
||
norm += v[i] * v[i]
|
||
}
|
||
norm = math.Sqrt(norm)
|
||
if norm < 1e-12 {
|
||
// Collapsed to the constant vector — reseed.
|
||
for i := range v {
|
||
v[i] = math.Sin(float64(i)*2 + 1)
|
||
}
|
||
deflateAndNormalize(v)
|
||
return
|
||
}
|
||
for i := range v {
|
||
v[i] /= norm
|
||
}
|
||
}
|
||
|
||
func allSameSign(v []float64) bool {
|
||
pos, neg := false, false
|
||
for _, x := range v {
|
||
if x >= 0 {
|
||
pos = true
|
||
} else {
|
||
neg = true
|
||
}
|
||
}
|
||
return !pos || !neg
|
||
}
|
||
|
||
func median(v []float64) float64 {
|
||
if len(v) == 0 {
|
||
return 0
|
||
}
|
||
cp := append([]float64(nil), v...)
|
||
sort.Float64s(cp)
|
||
return cp[len(cp)/2]
|
||
}
|
||
|
||
func minMember(ids []string) string {
|
||
if len(ids) == 0 {
|
||
return ""
|
||
}
|
||
m := ids[0]
|
||
for _, id := range ids[1:] {
|
||
if id < m {
|
||
m = id
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// graphModularity scores a partition's modularity on the undirected
|
||
// weighted adjacency — Q = (1/2m) Σ_ij [A_ij − k_i k_j/2m] δ(c_i,c_j).
|
||
func graphModularity(neighbors map[string]map[string]float64, nodeToComm map[string]string) float64 {
|
||
degree := make(map[string]float64, len(neighbors))
|
||
var m2 float64
|
||
for id, nbrs := range neighbors {
|
||
for _, w := range nbrs {
|
||
degree[id] += w
|
||
m2 += w
|
||
}
|
||
}
|
||
if m2 == 0 {
|
||
return 0
|
||
}
|
||
var q float64
|
||
for id, nbrs := range neighbors {
|
||
ci, ok := nodeToComm[id]
|
||
if !ok {
|
||
continue
|
||
}
|
||
for j, w := range nbrs {
|
||
if nodeToComm[j] == ci {
|
||
q += w - degree[id]*degree[j]/m2
|
||
}
|
||
}
|
||
}
|
||
return q / m2
|
||
}
|