Files
zzet--gortex/internal/mcp/tools_analyze_pagerank.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

348 lines
11 KiB
Go

// pagerank — graph-EXTRACTION-flavoured centrality analysis.
//
// analyze kind=pagerank ranks symbols by PageRank authority: a
// symbol is "central" when central symbols depend on it, so a
// rarely-called API that's invoked from every domain layer ranks
// higher than a heavily-called test helper. This is qualitatively
// different from the degree-based `hotspots` analyzer — random-walk
// authority weights influence by reach, not by raw fan-in count.
//
// Routing:
//
// - When the backing graph.Store implements graph.PageRanker
// (today only store_sqlite), the analyzer delegates to the
// engine-native parallel implementation (Ligra-based). Saves
// the per-call cost of a fresh Go-side power iteration.
//
// - Otherwise (in-memory store), falls back to
// analysis.ComputePageRank — the same pure-Go implementation
// the search rerank pipeline consumes via the cached
// Server.pageRank field.
package mcp
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/graph"
)
// pageRankRow is the per-symbol shape the analyzer returns.
type pageRankRow struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Kind string `json:"kind,omitempty"`
FilePath string `json:"file_path,omitempty"`
Line int `json:"line,omitempty"`
Rank float64 `json:"rank"`
}
func (s *Server) handleAnalyzePageRank(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
damping := 0.0
if v, ok := args["damping"].(float64); ok && v > 0 && v < 1 {
damping = v
}
maxIter := 0
if v, ok := args["max_iterations"].(float64); ok && v > 0 {
maxIter = int(v)
}
tolerance := 0.0
if v, ok := args["tolerance"].(float64); ok && v > 0 {
tolerance = v
}
nodeKinds := parseKindFilter(stringArg(args, "node_kinds"))
// runPageRank applies the limit internally on BOTH the engine-native
// and in-process paths. When the request narrows scope we must rank the
// full graph, drop out-of-scope hits, and only THEN cap — capping first
// would spend the limit on rows the caller can't see and return fewer
// than `limit` visible symbols. So request unbounded under scope and
// re-apply the limit after the visibility filter below.
prLimit := limit
if s.scopeFiltersActive(ctx) {
prLimit = 0
}
hits := s.runPageRank(graph.PageRankOpts{
NodeKinds: nodeKinds,
DampingFactor: damping,
MaxIterations: maxIter,
Tolerance: tolerance,
Limit: prLimit,
})
// Narrow to the session workspace + optional repo allow-set, then
// re-apply the original limit (runPageRank ran unbounded above under
// scope). pagerank reads s.graph directly, so without this the authority
// ranking would span every workspace. Strict no-op for an unbound
// session with no RepoAllow.
if s.scopeFiltersActive(ctx) {
kept := make([]graph.PageRankHit, 0, len(hits))
for _, h := range hits {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(h.NodeID)) {
kept = append(kept, h)
}
}
hits = kept
if limit > 0 && limit < len(hits) {
hits = hits[:limit]
}
}
// Batch-materialise hit nodes in one backend round-trip instead
// of per-id GetNode. On a disk backend each GetNode is a
// round-trip; on the default limit (20) the per-id path issued 20
// round-trips per pagerank invocation. Single GetNodesByIDs
// collapses that into one bulk query while preserving rank order
// (the local map lookup is keyed by NodeID).
ids := make([]string, 0, len(hits))
for _, h := range hits {
if h.NodeID != "" {
ids = append(ids, h.NodeID)
}
}
nodeByID := s.graph.GetNodesByIDs(ids)
rows := make([]pageRankRow, 0, len(hits))
for _, h := range hits {
row := pageRankRow{ID: h.NodeID, Rank: h.Rank}
if n := nodeByID[h.NodeID]; n != nil {
row.Name = n.Name
row.Kind = string(n.Kind)
row.FilePath = n.FilePath
row.Line = n.StartLine
}
rows = append(rows, row)
}
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeAnalyze("pagerank", rows))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%s %s %s:%d rank=%.6f\n", r.Kind, r.ID, r.FilePath, r.Line, r.Rank)
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{"pagerank": rows, "count": len(rows)})
}
// runPageRank picks the engine-native PageRanker when the
// backing store implements it, otherwise falls back to the
// in-process power iteration.
func (s *Server) runPageRank(opts graph.PageRankOpts) []graph.PageRankHit {
if store := s.backendStore(); store != nil {
if pr, ok := store.(graph.PageRanker); ok {
hits, err := pr.PageRank(opts)
if err == nil {
return hits
}
// Fall through to the in-process path on backend
// error rather than surface a half-completed
// result; engine-native is a hot path optimisation,
// not the source of truth.
}
}
// Fallback: pure-Go power iteration on the in-memory mirror.
// analysis.ComputePageRank doesn't accept the same options
// as the engine-native call yet — it uses fixed damping /
// iteration constants — so opts.DampingFactor / MaxIterations
// / Tolerance are silently ignored on the fallback path. The
// NodeKinds filter is honoured by post-filtering the result.
res := analysis.ComputePageRank(s.graph)
if res == nil || len(res.Scores) == 0 {
return nil
}
allow := makeKindAllow(opts.NodeKinds)
hits := make([]graph.PageRankHit, 0, len(res.Scores))
for id, rank := range res.Scores {
if !allow(s.graph.GetNode(id)) {
continue
}
hits = append(hits, graph.PageRankHit{NodeID: id, Rank: rank})
}
sort.Slice(hits, func(i, j int) bool { return hits[i].Rank > hits[j].Rank })
if opts.Limit > 0 && opts.Limit < len(hits) {
hits = hits[:opts.Limit]
}
return hits
}
// backendStore returns the underlying graph.Store the indexer
// writes to — which is what implements the capability interfaces
// (PageRanker, CommunityDetector, …). Falls back to s.graph when
// no indexer is wired so test fixtures keep working.
func (s *Server) backendStore() graph.Store {
if s.indexer != nil {
return s.indexer.Graph()
}
return s.graph
}
// parseKindFilter parses a comma-separated list of graph node
// kinds (e.g. "function,method,type") into a typed slice. Empty
// input → empty slice (caller treats that as "no filter").
func parseKindFilter(in string) []graph.NodeKind {
in = strings.TrimSpace(in)
if in == "" {
return nil
}
parts := strings.Split(in, ",")
out := make([]graph.NodeKind, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, graph.NodeKind(p))
}
return out
}
// handleAnalyzeLouvain returns the Louvain partitioning of the
// graph. When the backing store implements graph.CommunityDetector
// (today only store_sqlite), the partitioning is delegated to the
// engine-native implementation and threaded through the existing
// label / hub / cohesion / parent post-processing
// (analysis.DetectCommunitiesLouvainBackend) so the response is
// shape-identical to the in-process path. Otherwise the in-process
// DetectCommunitiesLouvain runs.
//
// Distinct from `analyze kind=clusters` which uses the Leiden
// algorithm (the Server's cached communities). Louvain produces
// different — typically more granular — partitions; this kind
// exposes it as a first-class result for clients that want the
// Louvain shape specifically.
func (s *Server) handleAnalyzeLouvain(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
limit := 50
if v, ok := req.GetArguments()["limit"].(float64); ok && v > 0 {
limit = int(v)
}
result := s.runLouvain()
if result == nil {
return s.respondJSONOrTOON(ctx, req, map[string]any{
"communities": []any{},
"modularity": 0.0,
})
}
communities := result.Communities
totalCommunities := len(result.Communities)
// Narrow to the session workspace + optional repo allow-set when the
// request scopes below the global graph: prune each community's members
// to visible nodes, recompute Size/Files, and drop communities left
// empty. louvain reads s.graph directly, so without this the partition
// would span every workspace. Hub is a symbol name (not a node ID) so it
// is left untouched. Filter before the limit cap so the cap and the
// total recompute over the visible set. Strict no-op for an unbound
// session with no RepoAllow.
if s.scopeFiltersActive(ctx) {
kept := make([]analysis.Community, 0, len(communities))
for _, c := range communities {
visMembers := make([]string, 0, len(c.Members))
files := make([]string, 0, len(c.Files))
seenFile := make(map[string]struct{}, len(c.Files))
for _, id := range c.Members {
n := s.graph.GetNode(id)
if !s.analyzeNodeVisible(ctx, n) {
continue
}
visMembers = append(visMembers, id)
if n.FilePath != "" {
if _, dup := seenFile[n.FilePath]; !dup {
seenFile[n.FilePath] = struct{}{}
files = append(files, n.FilePath)
}
}
}
if len(visMembers) == 0 {
continue
}
c.Members = visMembers
c.Size = len(visMembers)
c.Files = files
kept = append(kept, c)
}
communities = kept
totalCommunities = len(kept)
}
if limit > 0 && limit < len(communities) {
communities = communities[:limit]
}
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeAnalyze("louvain", map[string]any{
"communities": communities,
"modularity": result.Modularity,
}))
}
if isCompact(req) {
var b strings.Builder
fmt.Fprintf(&b, "modularity=%.4f communities=%d\n", result.Modularity, totalCommunities)
for _, c := range communities {
fmt.Fprintf(&b, " %s size=%d cohesion=%.3f label=%s hub=%s\n",
c.ID, c.Size, c.Cohesion, c.Label, c.Hub)
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"communities": communities,
"modularity": result.Modularity,
"total": totalCommunities,
})
}
// runLouvain picks the engine-native CommunityDetector when the
// backing store implements it, otherwise falls back to the
// pure-Go in-process Louvain. The output shape is identical
// either way (analysis.DetectCommunitiesLouvainBackend threads
// the engine-native partition through the same post-processing).
func (s *Server) runLouvain() *analysis.CommunityResult {
if store := s.backendStore(); store != nil {
if cd, ok := store.(graph.CommunityDetector); ok {
if r := analysis.DetectCommunitiesLouvainBackend(s.graph, cd); r != nil {
return r
}
// Engine-native error path falls through to the
// in-process implementation rather than surfacing
// a half-completed result.
}
}
return analysis.DetectCommunitiesLouvain(s.graph)
}
// makeKindAllow returns a predicate that reports whether a node's
// kind passes the filter. nil node is always rejected (defensive).
func makeKindAllow(kinds []graph.NodeKind) func(*graph.Node) bool {
if len(kinds) == 0 {
return func(n *graph.Node) bool { return n != nil }
}
set := make(map[graph.NodeKind]struct{}, len(kinds))
for _, k := range kinds {
set[k] = struct{}{}
}
return func(n *graph.Node) bool {
if n == nil {
return false
}
_, ok := set[n.Kind]
return ok
}
}