Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions pkg/contextbundle/ppr.go
Original file line number Diff line number Diff line change
@@ -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
}
123 changes: 123 additions & 0 deletions pkg/contextbundle/ppr_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
}
28 changes: 28 additions & 0 deletions pkg/contextbundle/scoring.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/contextbundle/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading