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

531 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}