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
531 lines
16 KiB
Go
531 lines
16 KiB
Go
package analysis
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
|
||
"github.com/zzet/gortex/internal/graph"
|
||
)
|
||
|
||
// DetectCommunitiesLeiden runs the Leiden algorithm (Traag, Waltman
|
||
// & van Eck, 2019) on the same weighted graph that DetectCommunities
|
||
// uses for Louvain. Differences from our Louvain implementation:
|
||
//
|
||
// 1. Fast local moves — a queue-based loop that only re-examines a
|
||
// node when one of its neighbours changed community. Converges
|
||
// in fewer iterations than Louvain's full-pass scan.
|
||
//
|
||
// 2. Refinement phase — after local moves stabilise, each
|
||
// community is re-clustered internally by running a sub-pass of
|
||
// local moves restricted to that community. This produces
|
||
// refined sub-communities that are guaranteed to be at least as
|
||
// well-connected as the originals, and crucially lets the next
|
||
// aggregation level move *whole sub-communities* across phase-1
|
||
// boundaries — moves single-node Louvain cannot express.
|
||
//
|
||
// 3. Iteration — aggregation builds a meta-graph from the refined
|
||
// sub-communities, with each meta-node initialised into the
|
||
// phase-1 community its members belonged to. We then re-run
|
||
// phase 1 + refine + aggregate on the meta-graph until no
|
||
// further coarsening helps.
|
||
//
|
||
// Result has the same shape as DetectCommunities so the call site
|
||
// can swap them out without other changes.
|
||
func DetectCommunitiesLeiden(g graph.Store) *CommunityResult {
|
||
result, _ := detectCommunitiesLeidenRaw(g, defaultLeidenOptions())
|
||
return result
|
||
}
|
||
|
||
// LeidenOptions tunes the Leiden community detector.
|
||
type LeidenOptions struct {
|
||
// Resolution (γ) scales the expected-edges (null-model) penalty in
|
||
// the modularity-gain calculation. The standard modularity score is
|
||
//
|
||
// Q = Σ_c ( e_in[c]/m − γ·(Σtot[c]/2m)² )
|
||
//
|
||
// so γ multiplies the null-model term only. γ = 1.0 is plain
|
||
// modularity (the historical default). Higher γ makes the penalty
|
||
// larger, so the optimizer favours smaller, denser communities;
|
||
// lower γ favours fewer, larger ones. Zero or negative values are
|
||
// treated as the 1.0 default.
|
||
Resolution float64
|
||
|
||
// Note: only the modularity objective is supported. A Constant
|
||
// Potts Model (CPM) objective — which replaces the degree-based
|
||
// null model with a per-pair γ penalty driven by community node
|
||
// counts — would need node-weight bookkeeping threaded through the
|
||
// refinement and aggregation phases (which currently track only
|
||
// degree sums and edge weights), so it is intentionally left as a
|
||
// follow-up rather than shipped partially.
|
||
}
|
||
|
||
// defaultLeidenOptions returns the options that reproduce the
|
||
// historical Leiden behaviour exactly: γ = 1.0 (a no-op multiply on
|
||
// the penalty term, so the partition is bit-identical to the
|
||
// pre-resolution implementation).
|
||
func defaultLeidenOptions() LeidenOptions {
|
||
return LeidenOptions{Resolution: 1.0}
|
||
}
|
||
|
||
// resolution returns the effective γ, normalising the zero value and
|
||
// any non-positive input to the 1.0 default.
|
||
func (o LeidenOptions) resolution() float64 {
|
||
if o.Resolution <= 0 {
|
||
return 1.0
|
||
}
|
||
return o.Resolution
|
||
}
|
||
|
||
// DetectCommunitiesLeidenWith runs the Leiden pipeline with explicit
|
||
// options (resolution γ). Passing defaultLeidenOptions() — or an
|
||
// options value whose Resolution is 1.0 — yields a partition that is
|
||
// byte-identical to DetectCommunitiesLeiden.
|
||
func DetectCommunitiesLeidenWith(g graph.Store, opts LeidenOptions) *CommunityResult {
|
||
result, _ := detectCommunitiesLeidenRaw(g, opts)
|
||
return result
|
||
}
|
||
|
||
// detectCommunitiesLeidenRaw runs the full Leiden pipeline and
|
||
// returns both the labelled CommunityResult and the raw partition
|
||
// (original-node-id → stable raw community key, pre-renumbering)
|
||
// alongside the weighted adjacency it was computed on. The raw
|
||
// partition is what the incremental path caches and re-seeds from:
|
||
// the public CommunityResult only carries renumbered "community-N"
|
||
// ids and drops singletons, neither of which can drive a restricted
|
||
// re-optimization. The returned partition is nil when the graph has
|
||
// no clustering-relevant edges (the result is then empty too).
|
||
func detectCommunitiesLeidenRaw(g graph.Store, opts LeidenOptions) (*CommunityResult, *leidenPartition) {
|
||
resolution := opts.resolution()
|
||
lg := buildLeidenGraph(g)
|
||
if lg == nil {
|
||
return &CommunityResult{NodeToComm: make(map[string]string)}, nil
|
||
}
|
||
symbolNodes := lg.symbolNodes
|
||
neighbors := lg.neighbors
|
||
totalWeight := lg.totalWeight
|
||
degree := lg.degree
|
||
|
||
// Per-iteration state. Each iteration shrinks the graph by
|
||
// replacing nodes with refined sub-communities. We keep
|
||
// `origPartition` updated as the iteration descends so the
|
||
// final result is expressed in terms of original node ids.
|
||
currentNodes := sortedKeys(symbolNodes)
|
||
currentNbrs := neighbors
|
||
currentDeg := degree
|
||
currentTotal := totalWeight
|
||
|
||
currentComm := make(map[string]string, len(currentNodes))
|
||
for _, id := range currentNodes {
|
||
currentComm[id] = id
|
||
}
|
||
|
||
// origPartition[origID] = current-iteration node-id the orig belongs to.
|
||
origPartition := make(map[string]string, len(currentNodes))
|
||
for _, id := range currentNodes {
|
||
origPartition[id] = id
|
||
}
|
||
|
||
const maxIters = 12
|
||
for iter := 0; iter < maxIters; iter++ {
|
||
// Phase 1: fast local moves.
|
||
currentComm = leidenFastLocalMoves(currentNodes, currentNbrs, currentDeg, currentTotal, currentComm, resolution)
|
||
|
||
// Phase 2: refinement. Each phase-1 community is internally
|
||
// re-clustered by running local moves on the induced sub-graph.
|
||
refined := leidenRefine(currentComm, currentNbrs, currentDeg, resolution)
|
||
|
||
// If refinement didn't merge anything (every refined comm is
|
||
// a singleton w.r.t. current nodes), no further aggregation
|
||
// can help — we're done.
|
||
if !willAggregate(currentNodes, refined) {
|
||
break
|
||
}
|
||
|
||
// Phase 3: aggregate the graph based on refined sub-communities.
|
||
newNodes, newComm, newNbrs, newDeg, newTotal := leidenAggregate(
|
||
currentNodes, currentComm, refined, currentNbrs,
|
||
)
|
||
|
||
// Propagate origPartition through this aggregation.
|
||
for orig, cur := range origPartition {
|
||
origPartition[orig] = refined[cur]
|
||
}
|
||
|
||
currentNodes, currentComm, currentNbrs, currentDeg, currentTotal = newNodes, newComm, newNbrs, newDeg, newTotal
|
||
}
|
||
|
||
// Map each original node back to its final community via the
|
||
// origPartition trail.
|
||
finalComm := make(map[string]string, len(symbolNodes))
|
||
for orig, cur := range origPartition {
|
||
finalComm[orig] = currentComm[cur]
|
||
}
|
||
|
||
// Renumber, build Community structs, label, disambiguate, group
|
||
// — same downstream pipeline as Louvain so the result is
|
||
// indistinguishable in shape.
|
||
result := buildCommunityResult(g, finalComm, neighbors, totalWeight, degree)
|
||
return result, &leidenPartition{
|
||
comm: finalComm,
|
||
neighbors: neighbors,
|
||
degree: degree,
|
||
totalWeight: totalWeight,
|
||
symbolNodes: symbolNodes,
|
||
}
|
||
}
|
||
|
||
// leidenFastLocalMoves is the queue-based phase-1 routine. Each
|
||
// node starts in `initial` community; moves are taken whenever they
|
||
// improve modularity, and any node whose community changed pushes
|
||
// its neighbours back onto the work queue so they get to react.
|
||
// Returns the final node → community map.
|
||
func leidenFastLocalMoves(
|
||
nodeIDs []string,
|
||
neighbors map[string]map[string]float64,
|
||
degree map[string]float64,
|
||
totalWeight float64,
|
||
initial map[string]string,
|
||
resolution float64,
|
||
) map[string]string {
|
||
comm := make(map[string]string, len(nodeIDs))
|
||
commMembers := make(map[string]map[string]bool)
|
||
sigmaTot := make(map[string]float64)
|
||
for _, id := range nodeIDs {
|
||
cid := initial[id]
|
||
comm[id] = cid
|
||
if commMembers[cid] == nil {
|
||
commMembers[cid] = make(map[string]bool)
|
||
}
|
||
commMembers[cid][id] = true
|
||
sigmaTot[cid] += degree[id]
|
||
}
|
||
|
||
// Queue + in-queue flag avoids duplicates. Start with every
|
||
// node so the first pass evaluates each one.
|
||
queue := make([]string, len(nodeIDs))
|
||
copy(queue, nodeIDs)
|
||
inQueue := make(map[string]bool, len(nodeIDs))
|
||
for _, id := range nodeIDs {
|
||
inQueue[id] = true
|
||
}
|
||
|
||
for len(queue) > 0 {
|
||
id := queue[0]
|
||
queue = queue[1:]
|
||
delete(inQueue, id)
|
||
|
||
currentComm := comm[id]
|
||
commWeights := make(map[string]float64)
|
||
for nbr, w := range neighbors[id] {
|
||
commWeights[comm[nbr]] += w
|
||
}
|
||
ki := degree[id]
|
||
kiIn := commWeights[currentComm]
|
||
// Subtract the self-loop (one direction) since it shouldn't
|
||
// participate in the move calculation — it stays with the node.
|
||
if loop, ok := neighbors[id][id]; ok {
|
||
kiIn -= loop
|
||
}
|
||
// γ (resolution) scales the expected-edges (null-model) penalty
|
||
// only; the actual-edges terms (kiIn, wc) are untouched. With
|
||
// resolution == 1.0 the multiply is the IEEE-754 identity, so
|
||
// the default partition is bit-identical to the unscaled form.
|
||
removeDelta := kiIn - resolution*((sigmaTot[currentComm]-ki)*ki/(2*totalWeight))
|
||
|
||
bestComm := currentComm
|
||
bestGain := 0.0
|
||
for c, wc := range commWeights {
|
||
if c == currentComm {
|
||
continue
|
||
}
|
||
gain := wc - resolution*(sigmaTot[c]*ki/(2*totalWeight)) - removeDelta
|
||
if gain > bestGain {
|
||
bestGain = gain
|
||
bestComm = c
|
||
}
|
||
}
|
||
|
||
if bestComm == currentComm {
|
||
continue
|
||
}
|
||
|
||
// Apply move.
|
||
delete(commMembers[currentComm], id)
|
||
if len(commMembers[currentComm]) == 0 {
|
||
delete(commMembers, currentComm)
|
||
}
|
||
sigmaTot[currentComm] -= ki
|
||
|
||
comm[id] = bestComm
|
||
if commMembers[bestComm] == nil {
|
||
commMembers[bestComm] = make(map[string]bool)
|
||
}
|
||
commMembers[bestComm][id] = true
|
||
sigmaTot[bestComm] += ki
|
||
|
||
// Wake up any neighbour that isn't already queued — their
|
||
// modularity-best community might have changed.
|
||
for nbr := range neighbors[id] {
|
||
if nbr == id {
|
||
continue
|
||
}
|
||
if !inQueue[nbr] {
|
||
queue = append(queue, nbr)
|
||
inQueue[nbr] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
return comm
|
||
}
|
||
|
||
// leidenRefine performs Leiden's refinement step. For each phase-1
|
||
// community, we extract the induced sub-graph and run a fresh
|
||
// local-moves pass on it (starting from singletons) — that finds
|
||
// sub-communities within the original cluster. The returned map is
|
||
// nodeID → refinedSubCommID. Singletons in the input map to
|
||
// themselves.
|
||
//
|
||
// Refinement is constrained to stay within phase-1 communities by
|
||
// construction (we only look at intra-community edges).
|
||
func leidenRefine(
|
||
comm map[string]string,
|
||
neighbors map[string]map[string]float64,
|
||
degree map[string]float64,
|
||
resolution float64,
|
||
) map[string]string {
|
||
byComm := make(map[string][]string)
|
||
for id, cid := range comm {
|
||
byComm[cid] = append(byComm[cid], id)
|
||
}
|
||
|
||
refined := make(map[string]string, len(comm))
|
||
for _, members := range byComm {
|
||
if len(members) == 1 {
|
||
refined[members[0]] = members[0]
|
||
continue
|
||
}
|
||
|
||
memberSet := make(map[string]bool, len(members))
|
||
for _, id := range members {
|
||
memberSet[id] = true
|
||
}
|
||
// Induced sub-graph: edges from `members` to `members`.
|
||
subNbrs := make(map[string]map[string]float64, len(members))
|
||
subDeg := make(map[string]float64, len(members))
|
||
var subTotal float64
|
||
for _, id := range members {
|
||
subNbrs[id] = make(map[string]float64)
|
||
for nbr, w := range neighbors[id] {
|
||
if !memberSet[nbr] {
|
||
continue
|
||
}
|
||
subNbrs[id][nbr] = w
|
||
subDeg[id] += w
|
||
subTotal += w
|
||
}
|
||
}
|
||
subTotal /= 2
|
||
|
||
if subTotal == 0 {
|
||
// No intra-community edges to optimise on — each member
|
||
// becomes its own refined sub-community. Rare; mostly
|
||
// happens with single isolated edges between cohesive
|
||
// blocks.
|
||
for _, id := range members {
|
||
refined[id] = id
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Sort for deterministic visitation.
|
||
sort.Strings(members)
|
||
// Singleton initial partition for the refinement pass.
|
||
init := make(map[string]string, len(members))
|
||
for _, id := range members {
|
||
init[id] = id
|
||
}
|
||
subComm := leidenFastLocalMoves(members, subNbrs, subDeg, subTotal, init, resolution)
|
||
for _, id := range members {
|
||
refined[id] = subComm[id]
|
||
}
|
||
|
||
// Silence unused-variable lint for degree on the off-chance
|
||
// some compiler enforces it; we keep `degree` in the signature
|
||
// for parity with the louvain helpers.
|
||
_ = degree[members[0]]
|
||
}
|
||
return refined
|
||
}
|
||
|
||
// willAggregate reports whether the refined partition has any
|
||
// non-singleton communities — if every refined sub-comm contains
|
||
// exactly one current node, aggregation would produce the same graph
|
||
// and we'd loop forever.
|
||
func willAggregate(nodes []string, refined map[string]string) bool {
|
||
count := make(map[string]int, len(nodes))
|
||
for _, id := range nodes {
|
||
count[refined[id]]++
|
||
if count[refined[id]] >= 2 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// leidenAggregate builds the meta-graph for the next iteration.
|
||
// Each refined sub-community becomes one meta-node. Meta-edges sum
|
||
// the underlying edge weights. Crucially the *meta-community*
|
||
// initialisation comes from the phase-1 partition, not from the
|
||
// refined one — that's what lets the next phase-1 pass discover
|
||
// merges between phase-1 communities by moving whole sub-comms.
|
||
func leidenAggregate(
|
||
nodes []string,
|
||
comm map[string]string,
|
||
refined map[string]string,
|
||
neighbors map[string]map[string]float64,
|
||
) (
|
||
newNodes []string,
|
||
newComm map[string]string,
|
||
newNbrs map[string]map[string]float64,
|
||
newDeg map[string]float64,
|
||
newTotal float64,
|
||
) {
|
||
// Discover all meta-nodes (refined sub-comm ids).
|
||
metaSet := make(map[string]bool)
|
||
memberOf := make(map[string][]string) // meta-node → its current nodes
|
||
for _, id := range nodes {
|
||
r := refined[id]
|
||
metaSet[r] = true
|
||
memberOf[r] = append(memberOf[r], id)
|
||
}
|
||
|
||
newNodes = make([]string, 0, len(metaSet))
|
||
for r := range metaSet {
|
||
newNodes = append(newNodes, r)
|
||
}
|
||
sort.Strings(newNodes)
|
||
|
||
// Initial meta-community = phase-1 community of any member.
|
||
newComm = make(map[string]string, len(newNodes))
|
||
for r, members := range memberOf {
|
||
newComm[r] = comm[members[0]]
|
||
}
|
||
|
||
// Aggregate edge weights.
|
||
newNbrs = make(map[string]map[string]float64, len(newNodes))
|
||
newDeg = make(map[string]float64, len(newNodes))
|
||
for src, srcNbrs := range neighbors {
|
||
srcMeta := refined[src]
|
||
if _, ok := metaSet[srcMeta]; !ok {
|
||
continue
|
||
}
|
||
if newNbrs[srcMeta] == nil {
|
||
newNbrs[srcMeta] = make(map[string]float64)
|
||
}
|
||
for dst, w := range srcNbrs {
|
||
dstMeta := refined[dst]
|
||
newNbrs[srcMeta][dstMeta] += w
|
||
newDeg[srcMeta] += w
|
||
}
|
||
}
|
||
for _, w := range newDeg {
|
||
newTotal += w
|
||
}
|
||
newTotal /= 2
|
||
|
||
return
|
||
}
|
||
|
||
// buildCommunityResult turns a final node → community map into a
|
||
// CommunityResult of the same shape as Louvain returns. Re-uses the
|
||
// label / hub / disambiguation / parent-grouping pipeline so the UI
|
||
// can render Leiden output identically.
|
||
func buildCommunityResult(
|
||
g graph.Store,
|
||
finalComm map[string]string,
|
||
neighbors map[string]map[string]float64,
|
||
totalWeight float64,
|
||
degree map[string]float64,
|
||
) *CommunityResult {
|
||
nodes := g.AllNodes()
|
||
nodeMap := make(map[string]*graph.Node, len(nodes))
|
||
for _, n := range nodes {
|
||
nodeMap[n.ID] = n
|
||
}
|
||
|
||
// Bucket original nodes by their final community.
|
||
byComm := make(map[string][]string)
|
||
for nid, cid := range finalComm {
|
||
byComm[cid] = append(byComm[cid], nid)
|
||
}
|
||
|
||
// Renumber to "community-N" deterministically.
|
||
oldIDs := make([]string, 0, len(byComm))
|
||
for cid := range byComm {
|
||
if len(byComm[cid]) >= 2 {
|
||
oldIDs = append(oldIDs, cid)
|
||
}
|
||
}
|
||
sort.Strings(oldIDs)
|
||
commRemap := make(map[string]string, len(oldIDs))
|
||
for i, cid := range oldIDs {
|
||
commRemap[cid] = fmt.Sprintf("community-%d", i)
|
||
}
|
||
|
||
result := &CommunityResult{NodeToComm: make(map[string]string, len(finalComm))}
|
||
for nid, cid := range finalComm {
|
||
if newID, ok := commRemap[cid]; ok {
|
||
result.NodeToComm[nid] = newID
|
||
}
|
||
}
|
||
|
||
for oldID, members := range byComm {
|
||
newID, ok := commRemap[oldID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
fileSet := make(map[string]bool)
|
||
for _, mid := range members {
|
||
if n, ok := nodeMap[mid]; ok {
|
||
fileSet[n.FilePath] = true
|
||
}
|
||
}
|
||
files := make([]string, 0, len(fileSet))
|
||
for f := range fileSet {
|
||
files = append(files, f)
|
||
}
|
||
sort.Strings(files)
|
||
|
||
c := Community{
|
||
ID: newID,
|
||
Label: inferCommunityLabel(members, nodeMap, files),
|
||
Members: members,
|
||
Files: files,
|
||
Size: len(members),
|
||
Cohesion: computeCohesion(members, neighbors),
|
||
Hub: findHub(members, nodeMap, neighbors),
|
||
}
|
||
result.Communities = append(result.Communities, c)
|
||
}
|
||
|
||
disambiguateLabels(result.Communities)
|
||
assignDirectoryParents(result.Communities)
|
||
sort.Slice(result.Communities, func(i, j int) bool {
|
||
return result.Communities[i].Size > result.Communities[j].Size
|
||
})
|
||
|
||
// Modularity over original graph using final partition.
|
||
result.Modularity = computeModularity(finalComm, neighbors, degree, totalWeight)
|
||
return result
|
||
}
|
||
|
||
func sortedKeys(m map[string]bool) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|