Files
zzet--gortex/internal/analysis/incremental_communities.go
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

601 lines
20 KiB
Go

package analysis
import (
"hash/fnv"
"path/filepath"
"sort"
"github.com/zzet/gortex/internal/graph"
)
// Incremental community detection.
//
// A plain `analyze kind=clusters` call recomputes the whole-graph
// partition from scratch every time. When only one or two packages
// changed since the last run that is wasteful: the partition of the
// untouched 95% of the graph is bit-for-bit what it was before.
//
// The incremental path keeps the last partition in a cache keyed by
// a per-package content fingerprint. On the next request it diffs
// the current fingerprints against the cached ones; packages whose
// fingerprint is unchanged keep their cached community assignment,
// and only the changed packages — plus their immediate cross-package
// boundary — are re-partitioned by a restricted local-moves pass
// seeded from the cached partition. The two halves are then merged
// and run through the same labelling pipeline as a full recompute,
// so the wire shape is identical.
//
// Scope: the incremental path covers Leiden only (the default
// algorithm). Louvain and spectral always recompute in full — their
// call sites do not consult the cache. The fallback below also
// triggers a full Leiden recompute whenever there is no usable
// cache or too much of the graph changed.
// changedFractionFullRecompute is the share of packages that must
// change before the incremental path gives up and recomputes the
// whole graph. Past this point the boundary of the changed set is
// large enough that a restricted pass saves little, and a global
// optimum is worth the full cost. A brand-new cache (no overlapping
// packages) trivially exceeds this and falls back.
const changedFractionFullRecompute = 0.5
// leidenPartition is the raw, pre-renumbering output of a Leiden
// run: the data an incremental re-run needs to re-seed from. It is
// deliberately distinct from CommunityResult, which carries only
// renumbered "community-N" ids and omits singletons.
type leidenPartition struct {
// comm maps an original symbol-node id to its stable raw
// community key (an arbitrary member id, not "community-N").
comm map[string]string
// neighbors is the weighted undirected adjacency the partition
// was computed on (symbol nodes only, edgeWeight-weighted).
neighbors map[string]map[string]float64
// degree is the weighted degree per symbol node.
degree map[string]float64
// totalWeight is the sum of edge weights (each undirected edge
// counted once).
totalWeight float64
// symbolNodes is the set of clustering-relevant node ids.
symbolNodes map[string]bool
}
// leidenGraph is the weighted symbol graph both the full and the
// incremental Leiden paths optimize over. Extracting it keeps the
// node/edge filter and weighting identical across the two paths —
// the incremental result is only trustworthy if it is built from
// exactly the same graph the full path would have built.
type leidenGraph struct {
symbolNodes map[string]bool
neighbors map[string]map[string]float64
degree map[string]float64
totalWeight float64
}
// buildLeidenGraph applies the Leiden/Louvain node+edge filter
// (symbol nodes only, edgeWeight-weighted, undirected) and returns
// the resulting weighted graph. Returns nil when the graph has no
// clustering-relevant edges — the caller then yields an empty
// partition.
func buildLeidenGraph(g graph.Store) *leidenGraph {
nodes := g.AllNodes()
// Meta-less scan (see LightEdgeScanner): the Leiden weighting keys only off
// e.Kind (via edgeWeight) and endpoints. No kind argument — edgeWeight scores
// ~14 kinds, so the kind set is pushed down here rather than duplicated at the
// call, and a meta-less all-kinds scan still drops the per-edge blob decode.
edges := graph.EdgesForKindsLight(g)
symbolNodes := make(map[string]bool, len(nodes))
for _, n := range nodes {
if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
symbolNodes[n.ID] = true
}
}
type edgeKey struct{ a, b string }
weights := make(map[edgeKey]float64)
for _, e := range edges {
if !symbolNodes[e.From] || !symbolNodes[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
}
var totalWeight float64
for _, w := range weights {
totalWeight += w
}
totalWeight /= 2
if totalWeight == 0 {
return nil
}
degree := make(map[string]float64)
for id := range symbolNodes {
for _, w := range neighbors[id] {
degree[id] += w
}
}
return &leidenGraph{
symbolNodes: symbolNodes,
neighbors: neighbors,
degree: degree,
totalWeight: totalWeight,
}
}
// LeidenPartitionCache holds the last Leiden partition so a later
// run can recompute only the packages that changed. It is opaque to
// callers: hand a *LeidenPartitionCache (or nil on the first call)
// to DetectCommunitiesLeidenIncremental and store the cache it
// returns for next time. A nil cache is always safe — it simply
// forces a full recompute.
//
// The cache is not safe for concurrent use; callers serialize
// access (the MCP server holds it behind a mutex).
type LeidenPartitionCache struct {
// pkgFingerprint maps a package key to a content hash of that
// package's nodes and incident clustering edges. A package
// whose fingerprint is unchanged between runs keeps its cached
// community assignment verbatim.
pkgFingerprint map[string]uint64
// nodeComm is the cached raw partition: symbol-node id → raw
// community key. Reused wholesale for unchanged packages and as
// the seed for the restricted re-optimization of changed ones.
nodeComm map[string]string
// part is the adjacency + weights the cached partition was
// computed on; needed to evaluate modularity gain during the
// restricted local-moves pass and to relabel the merged result.
part *leidenPartition
// edgeIdentityRevisions snapshots the graph's monotonic
// provenance-revision counter at cache time. A mismatch means
// in-place edge provenance changed under the cache; per-package
// fingerprints already detect topology changes, but a pure
// provenance churn (edge endpoints unchanged) would otherwise
// slip past, so a mismatch alone forces a full recompute.
edgeIdentityRevisions int
}
// PackageFingerprints returns the cached per-package fingerprint map.
// Callers MUST treat the returned value as read-only — it is the live
// map the cache reuses on the next call. Used by the MCP server to
// report total_packages from a cache hit without re-running the
// fingerprint pass.
func (c *LeidenPartitionCache) PackageFingerprints() map[string]uint64 {
if c == nil {
return nil
}
return c.pkgFingerprint
}
// IncrementalCommunityStats reports what the incremental path did on
// a single call — useful for tests and for surfacing on the wire.
type IncrementalCommunityStats struct {
// Incremental is true when the changed-package fast path ran;
// false means a full recompute (no cache, stale cache, or the
// changed fraction exceeded the fallback ratio).
Incremental bool
// FullRecomputeReason names why a full recompute happened. Empty
// when Incremental is true.
FullRecomputeReason string
// ChangedPackages is the count of packages whose fingerprint
// differed from the cache.
ChangedPackages int
// TotalPackages is the package count in the current graph.
TotalPackages int
// RepartitionedNodes is the number of symbol nodes that were
// re-optimized (changed packages plus their boundary).
RepartitionedNodes int
}
// packageKey derives a stable package identity for a symbol node
// from its file path: the directory the file lives in. Nodes in the
// same directory share a key (Go packages are one directory; the
// granularity is right for "which packages changed"). A node with
// no file path is bucketed under "" — a single catch-all package.
func packageKey(filePath string) string {
if filePath == "" {
return ""
}
dir := filepath.Dir(filepath.ToSlash(filePath))
if dir == "." {
return ""
}
return dir
}
// fingerprintPackages computes an order-independent content hash per
// package over the clustering-relevant graph. The hash folds in,
// for every package:
//
// - each member node's id and kind, and
// - each clustering edge with at least one endpoint in the
// package (the edge is mixed into both endpoints' packages so a
// cross-package edge change marks both as changed).
//
// Per-element hashes are XOR-combined, so the result does not
// depend on graph iteration order — two runs over the same graph
// always produce the same fingerprints. Any node added/removed,
// kind change, or edge added/removed/reweighted flips the
// fingerprint of every package it touches and leaves all others
// bit-identical.
func fingerprintPackages(g graph.Store) map[string]uint64 {
nodes := g.AllNodes()
edges := g.AllEdges()
// Symbol-node filter + each node's package, mirroring
// buildLeidenGraph so the fingerprint and the partition agree on
// what counts.
pkgOf := make(map[string]string, len(nodes))
fp := make(map[string]uint64)
for _, n := range nodes {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
pk := packageKey(n.FilePath)
pkgOf[n.ID] = pk
// Mix the node's identity into its package fingerprint.
h := fnv.New64a()
_, _ = h.Write([]byte("n\x00"))
_, _ = h.Write([]byte(n.ID))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(string(n.Kind)))
fp[pk] ^= h.Sum64()
}
for _, e := range edges {
fromPkg, fromOK := pkgOf[e.From]
toPkg, toOK := pkgOf[e.To]
if !fromOK || !toOK {
continue
}
if edgeWeight(e.Kind) == 0 {
continue
}
h := fnv.New64a()
_, _ = h.Write([]byte("e\x00"))
_, _ = h.Write([]byte(e.From))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(e.To))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(string(e.Kind)))
sum := h.Sum64()
// Mix into both endpoints' packages. When from and to share
// a package the two XORs cancel; re-fold a tagged variant so
// an intra-package edge still moves the fingerprint.
if fromPkg == toPkg {
intra := fnv.New64a()
_, _ = intra.Write([]byte("ie\x00"))
_, _ = intra.Write([]byte(e.From))
_, _ = intra.Write([]byte{0})
_, _ = intra.Write([]byte(e.To))
_, _ = intra.Write([]byte{0})
_, _ = intra.Write([]byte(string(e.Kind)))
fp[fromPkg] ^= intra.Sum64()
continue
}
fp[fromPkg] ^= sum
fp[toPkg] ^= sum
}
return fp
}
// diffPackageFingerprints returns the set of package keys whose
// fingerprint differs between old and cur — added packages, removed
// packages, and packages whose content changed. A removed package's
// key is included so its now-orphaned nodes are dropped from the
// reused assignment.
func diffPackageFingerprints(old, cur map[string]uint64) map[string]bool {
changed := make(map[string]bool)
for pk, h := range cur {
if oh, ok := old[pk]; !ok || oh != h {
changed[pk] = true
}
}
for pk := range old {
if _, ok := cur[pk]; !ok {
changed[pk] = true
}
}
return changed
}
// DetectCommunitiesLeidenIncremental detects communities with
// Leiden, recomputing only the packages that changed since the
// cached partition was built. Pass cache == nil on the first call;
// store the returned cache and pass it back next time.
//
// It returns the labelled CommunityResult, a fresh cache to carry
// forward, and stats describing whether the fast path was taken.
// The result is shape-identical to DetectCommunitiesLeiden: for
// unchanged packages the community assignment is exactly what the
// cache held; for changed packages it is a genuine re-partition.
//
// A full recompute happens (and is reflected in the stats) when:
// - cache is nil, or
// - the graph's edge-provenance revision moved under the cache, or
// - the changed-package fraction exceeds changedFractionFullRecompute.
func DetectCommunitiesLeidenIncremental(
g graph.Store,
cache *LeidenPartitionCache,
) (*CommunityResult, *LeidenPartitionCache, IncrementalCommunityStats) {
curFP := fingerprintPackages(g)
stats := IncrementalCommunityStats{TotalPackages: len(curFP)}
edgeRev := g.EdgeIdentityRevisions()
fullRecompute := func(reason string) (*CommunityResult, *LeidenPartitionCache, IncrementalCommunityStats) {
result, part := detectCommunitiesLeidenRaw(g, defaultLeidenOptions())
stats.Incremental = false
stats.FullRecomputeReason = reason
newCache := &LeidenPartitionCache{
pkgFingerprint: curFP,
edgeIdentityRevisions: edgeRev,
}
if part != nil {
newCache.nodeComm = part.comm
newCache.part = part
}
return result, newCache, stats
}
// No cache, or a cache whose partition never materialized
// (previous graph had no clustering edges): nothing to reuse.
if cache == nil || cache.part == nil || len(cache.nodeComm) == 0 {
return fullRecompute("no cached partition")
}
// Edge provenance changed in place under the cache. Topology
// fingerprints would miss a pure provenance churn (same
// endpoints, new origin); recompute to stay correct.
if cache.edgeIdentityRevisions != edgeRev {
return fullRecompute("edge provenance changed")
}
changed := diffPackageFingerprints(cache.pkgFingerprint, curFP)
stats.ChangedPackages = len(changed)
// Too much of the graph moved — a restricted pass would re-touch
// most of it anyway. Recompute globally for a clean optimum.
if len(curFP) == 0 ||
float64(len(changed)) > changedFractionFullRecompute*float64(len(curFP)) {
return fullRecompute("changed fraction exceeded threshold")
}
// Nothing changed: reuse the cached partition verbatim. We still
// rebuild the CommunityResult from the cached raw partition so
// the caller always gets a freshly-labelled result, but no
// re-partitioning happens.
lg := buildLeidenGraph(g)
if lg == nil {
// The graph lost all its clustering edges since the cache
// was built — fall back rather than reuse a stale partition.
return fullRecompute("graph has no clustering edges")
}
result, newPart := incrementalLeiden(g, lg, cache, changed)
stats.Incremental = true
stats.RepartitionedNodes = newPart.repartitioned
newCache := &LeidenPartitionCache{
pkgFingerprint: curFP,
nodeComm: newPart.partition.comm,
part: newPart.partition,
edgeIdentityRevisions: edgeRev,
}
return result, newCache, stats
}
// incrementalResult bundles the relabelled result of an incremental
// run with the raw partition to cache and the size of the
// re-optimized set.
type incrementalResult struct {
partition *leidenPartition
repartitioned int
}
// incrementalLeiden performs the restricted re-partition. It starts
// from the cached node→community assignment, then re-optimizes only
// the nodes that belong to a changed package or sit on its boundary
// (an unchanged-package node with an edge to a changed-package
// node). Boundary nodes are anchors: they feed their cached
// community into the gain calculation but never move themselves, so
// every unchanged package's assignment is preserved bit-for-bit.
func incrementalLeiden(
g graph.Store,
lg *leidenGraph,
cache *LeidenPartitionCache,
changedPkgs map[string]bool,
) (*CommunityResult, incrementalResult) {
// Package of every current symbol node.
pkgOf := make(map[string]string, len(lg.symbolNodes))
for _, n := range g.AllNodes() {
if lg.symbolNodes[n.ID] {
pkgOf[n.ID] = packageKey(n.FilePath)
}
}
// Seed: cached assignment for every node still present; a new
// node (in a changed package, by construction) seeds into its
// own singleton community.
seed := make(map[string]string, len(lg.symbolNodes))
for id := range lg.symbolNodes {
if c, ok := cache.nodeComm[id]; ok {
seed[id] = c
} else {
seed[id] = id
}
}
// movable = nodes in a changed package. boundary = unchanged
// nodes with an edge into a changed package; they participate as
// fixed anchors. The union is the re-optimized frontier.
movable := make(map[string]bool)
for id := range lg.symbolNodes {
if changedPkgs[pkgOf[id]] {
movable[id] = true
}
}
boundary := make(map[string]bool)
for id := range movable {
for nbr := range lg.neighbors[id] {
if nbr == id || movable[nbr] {
continue
}
boundary[nbr] = true
}
}
// Restricted local moves: optimize `movable`, anchored by
// `boundary`. Deterministic — movable nodes are visited in
// sorted order.
movableIDs := make([]string, 0, len(movable))
for id := range movable {
movableIDs = append(movableIDs, id)
}
sort.Strings(movableIDs)
finalComm := make(map[string]string, len(lg.symbolNodes))
for id, c := range seed {
finalComm[id] = c
}
leidenRestrictedLocalMoves(
movableIDs, movable, lg.neighbors, lg.degree, lg.totalWeight, finalComm,
)
// Everything else (unchanged, non-boundary) already carries its
// cached community via the seed copy above, untouched.
result := buildCommunityResult(g, finalComm, lg.neighbors, lg.totalWeight, lg.degree)
return result, incrementalResult{
partition: &leidenPartition{
comm: finalComm,
neighbors: lg.neighbors,
degree: lg.degree,
totalWeight: lg.totalWeight,
symbolNodes: lg.symbolNodes,
},
repartitioned: len(movable) + len(boundary),
}
}
// leidenRestrictedLocalMoves is leidenFastLocalMoves constrained to
// a movable subset. Only nodes in `movable` are ever relocated;
// every other node keeps the community it carries in `comm` and acts
// as a fixed anchor that movable nodes can be pulled toward. It is
// the same modularity-gain rule and queue-driven wake-up as the full
// pass, so a movable node settles into the modularity-best community
// available to it given the frozen anchors.
//
// Determinism is stronger here than in leidenFastLocalMoves: the
// work queue is seeded in the caller's sorted order, candidate
// communities are evaluated in sorted-key order (so an exact gain
// tie always resolves to the same community), and woken neighbours
// are enqueued in sorted-key order. The incremental path asserts
// reproducibility, so it cannot lean on map iteration order.
//
// The community-membership / sigmaTot bookkeeping spans the whole
// graph (anchors included) because a movable node's gain depends on
// the total degree already sitting in each candidate community.
func leidenRestrictedLocalMoves(
movableIDs []string,
movable map[string]bool,
neighbors map[string]map[string]float64,
degree map[string]float64,
totalWeight float64,
comm map[string]string,
) {
if totalWeight == 0 || len(movableIDs) == 0 {
return
}
// sigmaTot over every node so anchor degree counts toward the
// communities movable nodes might join.
sigmaTot := make(map[string]float64)
for id, c := range comm {
sigmaTot[c] += degree[id]
}
queue := make([]string, len(movableIDs))
copy(queue, movableIDs)
inQueue := make(map[string]bool, len(movableIDs))
for _, id := range movableIDs {
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]
if loop, ok := neighbors[id][id]; ok {
kiIn -= loop
}
removeDelta := kiIn - (sigmaTot[currentComm]-ki)*ki/(2*totalWeight)
// Evaluate candidate communities in sorted-key order so a
// gain tie is broken identically on every run.
candidates := make([]string, 0, len(commWeights))
for c := range commWeights {
candidates = append(candidates, c)
}
sort.Strings(candidates)
bestComm := currentComm
bestGain := 0.0
for _, c := range candidates {
if c == currentComm {
continue
}
gain := commWeights[c] - sigmaTot[c]*ki/(2*totalWeight) - removeDelta
if gain > bestGain {
bestGain = gain
bestComm = c
}
}
if bestComm == currentComm {
continue
}
sigmaTot[currentComm] -= ki
comm[id] = bestComm
sigmaTot[bestComm] += ki
// Wake only movable neighbours — anchors never move, so
// re-examining them is wasted work. Sorted order keeps the
// queue evolution deterministic.
woken := make([]string, 0, len(neighbors[id]))
for nbr := range neighbors[id] {
if nbr == id || !movable[nbr] || inQueue[nbr] {
continue
}
woken = append(woken, nbr)
}
sort.Strings(woken)
for _, nbr := range woken {
queue = append(queue, nbr)
inQueue[nbr] = true
}
}
}