diff --git a/pkg/contextbundle/ppr.go b/pkg/contextbundle/ppr.go new file mode 100644 index 0000000..fecef0d --- /dev/null +++ b/pkg/contextbundle/ppr.go @@ -0,0 +1,134 @@ +package contextbundle + +import ( + "strings" + + "m31labs.dev/canopy/pkg/xref" +) + +// Personalized PageRank context selection (graph-native-harness move +// G-A). Evidence behind the design: Aider's PPR repo map, RepoGraph's +// gains across four SWE-bench frameworks with one graph, and LocAgent's +// 92.7% localization at an 86% cost cut. The pass runs at selection time +// over the already-built xref graph, with personalization mass on the +// focus and changed entities, and folds into the deterministic score +// table as one bounded component — receipts stay explainable, and a +// bundle without focus or changed seeds skips the pass entirely. +const ( + pprDamping = 0.85 + pprIterations = 30 + // pprReverseWeight lets proximity flow callee-to-caller at reduced + // mass: callers of a changed function matter, but less than what + // the changed function itself calls. + pprReverseWeight = 0.5 + // scorePPRMax bounds the signal's contribution to the score table, + // below direct caller/callee (800) and above transitive depth-2 + // (200): graph proximity refines the neighborhood ordering without + // overruling explicit structure. + scorePPRMax = 400 +) + +// pprResolutionWeight scales edge mass by call-resolution confidence: a +// same-file or import-resolved call is a stronger proximity signal than +// a polymorphic candidate set. +func pprResolutionWeight(resolution string) float64 { + switch { + case resolution == "file": + return 1.0 + case resolution == "import": + return 0.9 + case strings.HasPrefix(resolution, "poly_"): + return 0.6 + default: + return 0.8 + } +} + +// pprScores runs fixed-iteration personalized PageRank over the call +// graph and returns a score per definition ID. seeds carry the +// personalization mass; an empty or unmatched seed set returns nil (the +// signal only exists relative to a focus). The iteration count is fixed +// so the pass is deterministic across runs and platforms. +func pprScores(graph *xref.Graph, seeds map[string]bool) map[string]float64 { + if graph == nil || len(seeds) == 0 || len(graph.Definitions) == 0 { + return nil + } + n := len(graph.Definitions) + + personalization := make([]float64, n) + seedCount := 0 + for i, def := range graph.Definitions { + if seeds[def.ID] { + personalization[i] = 1 + seedCount++ + } + } + if seedCount == 0 { + return nil + } + for i := range personalization { + personalization[i] /= float64(seedCount) + } + + type arc struct { + to int + weight float64 + } + out := make([][]arc, n) + outSum := make([]float64, n) + addArc := func(from, to int, weight float64) { + if from < 0 || from >= n || to < 0 || to >= n || weight <= 0 || from == to { + return + } + out[from] = append(out[from], arc{to: to, weight: weight}) + outSum[from] += weight + } + for _, edge := range graph.Edges { + count := edge.Count + if count < 1 { + count = 1 + } + weight := float64(count) * pprResolutionWeight(edge.Resolution) + addArc(edge.CallerIdx, edge.CalleeIdx, weight) + addArc(edge.CalleeIdx, edge.CallerIdx, weight*pprReverseWeight) + } + + rank := make([]float64, n) + copy(rank, personalization) + next := make([]float64, n) + for iteration := 0; iteration < pprIterations; iteration++ { + dangling := 0.0 + for i := range next { + next[i] = (1 - pprDamping) * personalization[i] + } + for i, arcs := range out { + if rank[i] == 0 { + continue + } + if outSum[i] == 0 { + dangling += rank[i] + continue + } + share := pprDamping * rank[i] / outSum[i] + for _, a := range arcs { + next[a.to] += share * a.weight + } + } + // Nodes with no outgoing mass return their rank through the + // personalization vector, keeping total mass conserved. + if dangling > 0 { + for i := range next { + next[i] += pprDamping * dangling * personalization[i] + } + } + rank, next = next, rank + } + + scores := make(map[string]float64, n) + for i, def := range graph.Definitions { + if rank[i] > 0 { + scores[def.ID] = rank[i] + } + } + return scores +} diff --git a/pkg/contextbundle/ppr_test.go b/pkg/contextbundle/ppr_test.go new file mode 100644 index 0000000..8a9706b --- /dev/null +++ b/pkg/contextbundle/ppr_test.go @@ -0,0 +1,123 @@ +package contextbundle + +import ( + "testing" + + "m31labs.dev/canopy/pkg/xref" +) + +// pprTestGraph builds a small call graph by hand: +// +// seed -> near (5 static calls) +// near -> far (1 static call) +// seed -> weak (1 polymorphic call) +// lonely (no edges) +func pprTestGraph() *xref.Graph { + return &xref.Graph{ + Definitions: []xref.Definition{ + {ID: "seed", Name: "Seed", Callable: true}, + {ID: "near", Name: "Near", Callable: true}, + {ID: "far", Name: "Far", Callable: true}, + {ID: "weak", Name: "Weak", Callable: true}, + {ID: "lonely", Name: "Lonely", Callable: true}, + }, + Edges: []xref.Edge{ + {CallerIdx: 0, CalleeIdx: 1, Resolution: "file", Count: 5}, + {CallerIdx: 1, CalleeIdx: 2, Resolution: "file", Count: 1}, + {CallerIdx: 0, CalleeIdx: 3, Resolution: "poly_pkg", Count: 1}, + }, + } +} + +// TestPPRScores_ProximityOrdering locks the ranking shape: the seed +// holds the most mass, its strong static neighbor beats the weak +// polymorphic one, two hops beats disconnected, and a node with no path +// from the seeds scores zero. +func TestPPRScores_ProximityOrdering(t *testing.T) { + t.Parallel() + scores := pprScores(pprTestGraph(), map[string]bool{"seed": true}) + if scores == nil { + t.Fatal("pprScores returned nil for a valid seed") + } + if !(scores["seed"] > scores["near"]) { + t.Fatalf("seed (%v) must outrank near (%v)", scores["seed"], scores["near"]) + } + if !(scores["near"] > scores["weak"]) { + t.Fatalf("strong static neighbor (%v) must outrank weak polymorphic (%v)", scores["near"], scores["weak"]) + } + if !(scores["far"] > 0) { + t.Fatalf("two-hop neighbor must receive mass, got %v", scores["far"]) + } + if scores["lonely"] != 0 { + t.Fatalf("disconnected node scored %v, want 0", scores["lonely"]) + } +} + +// TestPPRScores_Determinism locks the fixed-iteration contract: two runs +// produce identical maps. +func TestPPRScores_Determinism(t *testing.T) { + t.Parallel() + a := pprScores(pprTestGraph(), map[string]bool{"seed": true}) + b := pprScores(pprTestGraph(), map[string]bool{"seed": true}) + if len(a) != len(b) { + t.Fatalf("run sizes differ: %d vs %d", len(a), len(b)) + } + for id, score := range a { + if b[id] != score { + t.Fatalf("score for %s differs across runs: %v vs %v", id, score, b[id]) + } + } +} + +// TestPPRScores_NoSeedsSkips locks the gate: no personalization mass, no +// pass — the signal only exists relative to a focus. +func TestPPRScores_NoSeedsSkips(t *testing.T) { + t.Parallel() + if got := pprScores(pprTestGraph(), nil); got != nil { + t.Fatalf("nil seeds produced scores: %v", got) + } + if got := pprScores(pprTestGraph(), map[string]bool{"unknown": true}); got != nil { + t.Fatalf("unmatched seeds produced scores: %v", got) + } + if got := pprScores(nil, map[string]bool{"seed": true}); got != nil { + t.Fatalf("nil graph produced scores: %v", got) + } +} + +// TestScoreCandidates_PPRProximityPoints locks the score-table fold: a +// non-seed candidate near the changed seed earns bounded +// ppr_graph_proximity points with a reason entry, the strongest +// neighbor earns scorePPRMax, and seed candidates earn none (their +// focus/changed signals already carry them). +func TestScoreCandidates_PPRProximityPoints(t *testing.T) { + t.Parallel() + graph := pprTestGraph() + candidates := []*candidateItem{ + {EntityID: "seed", Name: "Seed", Flags: candidateFlags{Changed: true}}, + {EntityID: "near", Name: "Near"}, + {EntityID: "weak", Name: "Weak"}, + {EntityID: "lonely", Name: "Lonely"}, + } + scoreCandidates(candidates, graph, Request{}) + + points := map[string]int{} + for _, c := range candidates { + for _, reason := range c.Reasons { + if reason.Signal == "ppr_graph_proximity" { + points[c.EntityID] = reason.Points + } + } + } + if points["seed"] != 0 { + t.Fatalf("seed earned proximity points (%d); seeds must not double-count", points["seed"]) + } + if points["near"] != scorePPRMax { + t.Fatalf("strongest neighbor earned %d, want %d", points["near"], scorePPRMax) + } + if points["weak"] <= 0 || points["weak"] >= points["near"] { + t.Fatalf("weak neighbor points = %d, want between 1 and %d", points["weak"], points["near"]) + } + if points["lonely"] != 0 { + t.Fatalf("disconnected candidate earned %d proximity points", points["lonely"]) + } +} diff --git a/pkg/contextbundle/scoring.go b/pkg/contextbundle/scoring.go index e4fdf5f..a4f2b83 100644 --- a/pkg/contextbundle/scoring.go +++ b/pkg/contextbundle/scoring.go @@ -43,6 +43,27 @@ func scoreCandidates(candidates []*candidateItem, graph *xref.Graph, req Request fanInPercentile := fanInPercentileRanks(graph) queryTerms := normalizedTerms(taskQuery(req.Intent)) + // G-A: personalized PageRank proximity to the focus/changed seeds. + // Seed entities already score through their own focus/changed + // signals, so only non-seed candidates receive proximity points, + // normalized against the best non-seed candidate. + pprSeeds := map[string]bool{} + for _, c := range candidates { + if (c.Flags.Focus || c.Flags.Changed) && c.EntityID != "" { + pprSeeds[c.EntityID] = true + } + } + ppr := pprScores(graph, pprSeeds) + maxPPR := 0.0 + for _, c := range candidates { + if c.EntityID == "" || pprSeeds[c.EntityID] { + continue + } + if score := ppr[c.EntityID]; score > maxPPR { + maxPPR = score + } + } + for _, c := range candidates { var reasons []SelectionReason total := 0 @@ -93,6 +114,13 @@ func scoreCandidates(candidates []*candidateItem, graph *xref.Graph, req Request if c.Flags.TransitiveDepth2 { add("transitive_graph_depth_2", scoreTransitiveDepth2) } + if maxPPR > 0 && c.EntityID != "" && !pprSeeds[c.EntityID] { + if score := ppr[c.EntityID]; score > 0 { + if pts := int(math.Round(float64(scorePPRMax) * score / maxPPR)); pts > 0 { + add("ppr_graph_proximity", pts) + } + } + } if c.Flags.Generated && !c.Flags.ExplicitRequired && !c.Flags.ExplicitSelector { add("generated_not_explicitly_requested", scoreGeneratedPenalty) } diff --git a/pkg/contextbundle/service.go b/pkg/contextbundle/service.go index fa2b789..070ecd0 100644 --- a/pkg/contextbundle/service.go +++ b/pkg/contextbundle/service.go @@ -12,7 +12,7 @@ import ( // PolicyVersion identifies the deterministic scoring/packing policy this // service implements (spec 10.8). -const PolicyVersion = "context-selection-v1" +const PolicyVersion = "context-selection-v2" // SchemaVersion identifies the receipt/manifest schema shape. const SchemaVersion = "contextbundle-v1"