-
Notifications
You must be signed in to change notification settings - Fork 0
Localize PersonalizedPageRank traversal #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,9 @@ | ||||||||||||||||||||||||||||||||||||||||||
| package graph | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||||||||||||||
| "math" | ||||||||||||||||||||||||||||||||||||||||||
| "sort" | ||||||||||||||||||||||||||||||||||||||||||
| "sync" | ||||||||||||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -178,8 +180,8 @@ type SubgraphEdge struct { | |||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // SubgraphResult holds the BFS neighborhood with both nodes and edges. | ||||||||||||||||||||||||||||||||||||||||||
| type SubgraphResult struct { | ||||||||||||||||||||||||||||||||||||||||||
| Nodes map[string]int // entityID -> hop distance from seed | ||||||||||||||||||||||||||||||||||||||||||
| Edges []SubgraphEdge // edges connecting nodes within the subgraph | ||||||||||||||||||||||||||||||||||||||||||
| Nodes map[string]int // entityID -> hop distance from seed | ||||||||||||||||||||||||||||||||||||||||||
| Edges []SubgraphEdge // edges connecting nodes within the subgraph | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Subgraph returns the N-hop ego-graph around seeds with full edge data. | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -389,70 +391,94 @@ func (g *Graph) WeightedNeighbors(seeds []string, maxHops int, minWeight float64 | |||||||||||||||||||||||||||||||||||||||||
| // --- Personalized PageRank --- | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // PersonalizedPageRank computes PPR scores seeded from the given entity IDs. | ||||||||||||||||||||||||||||||||||||||||||
| // alpha is the teleport probability (typically 0.15), maxIter limits iterations, | ||||||||||||||||||||||||||||||||||||||||||
| // and epsilon is the convergence threshold. | ||||||||||||||||||||||||||||||||||||||||||
| func (g *Graph) PersonalizedPageRank(seeds []string, alpha float64, maxIter int, epsilon float64) map[string]float64 { | ||||||||||||||||||||||||||||||||||||||||||
| g.mu.RLock() | ||||||||||||||||||||||||||||||||||||||||||
| defer g.mu.RUnlock() | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // The random walk is restricted to the local neighborhood within maxHops of the | ||||||||||||||||||||||||||||||||||||||||||
| // seeds so per-query work scales with the relevant subgraph rather than the | ||||||||||||||||||||||||||||||||||||||||||
| // entire KB graph. alpha is the teleport probability (typically 0.15), maxIter | ||||||||||||||||||||||||||||||||||||||||||
| // limits iterations, and epsilon is the convergence threshold. | ||||||||||||||||||||||||||||||||||||||||||
| func (g *Graph) PersonalizedPageRank( | ||||||||||||||||||||||||||||||||||||||||||
| ctx context.Context, | ||||||||||||||||||||||||||||||||||||||||||
| seeds []string, | ||||||||||||||||||||||||||||||||||||||||||
| maxHops int, | ||||||||||||||||||||||||||||||||||||||||||
| alpha float64, | ||||||||||||||||||||||||||||||||||||||||||
| maxIter int, | ||||||||||||||||||||||||||||||||||||||||||
| epsilon float64, | ||||||||||||||||||||||||||||||||||||||||||
| ) (map[string]float64, error) { | ||||||||||||||||||||||||||||||||||||||||||
| if len(seeds) == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||
| return nil, nil | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Build the personalization vector (uniform over seeds). | ||||||||||||||||||||||||||||||||||||||||||
| seedWeight := 1.0 / float64(len(seeds)) | ||||||||||||||||||||||||||||||||||||||||||
| personal := make(map[string]float64, len(seeds)) | ||||||||||||||||||||||||||||||||||||||||||
| for _, s := range seeds { | ||||||||||||||||||||||||||||||||||||||||||
| personal[s] = seedWeight | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Collect all nodes reachable in the graph (union of forward + reverse keys). | ||||||||||||||||||||||||||||||||||||||||||
| allNodes := make(map[string]struct{}) | ||||||||||||||||||||||||||||||||||||||||||
| for id := range g.forward { | ||||||||||||||||||||||||||||||||||||||||||
| allNodes[id] = struct{}{} | ||||||||||||||||||||||||||||||||||||||||||
| g.mu.RLock() | ||||||||||||||||||||||||||||||||||||||||||
| nodes, neighbors, err := g.localPPRNeighborhoodLocked(ctx, seeds, maxHops) | ||||||||||||||||||||||||||||||||||||||||||
| g.mu.RUnlock() | ||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| for id := range g.reverse { | ||||||||||||||||||||||||||||||||||||||||||
| allNodes[id] = struct{}{} | ||||||||||||||||||||||||||||||||||||||||||
| if len(nodes) == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, nil | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+413
to
421
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap error from The error returned from the helper should be wrapped with context to aid debugging. Proposed fix g.mu.RLock()
nodes, neighbors, err := g.localPPRNeighborhoodLocked(ctx, seeds, maxHops)
g.mu.RUnlock()
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("ppr neighborhood: %w", err)
}As per coding guidelines: 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Degree = total edges (forward + reverse) for undirected view. | ||||||||||||||||||||||||||||||||||||||||||
| degree := make(map[string]int, len(allNodes)) | ||||||||||||||||||||||||||||||||||||||||||
| for id := range allNodes { | ||||||||||||||||||||||||||||||||||||||||||
| degree[id] = len(g.forward[id]) + len(g.reverse[id]) | ||||||||||||||||||||||||||||||||||||||||||
| personal := make(map[string]float64, len(seeds)) | ||||||||||||||||||||||||||||||||||||||||||
| for _, s := range seeds { | ||||||||||||||||||||||||||||||||||||||||||
| if _, ok := neighbors[s]; !ok { | ||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| personal[s]++ | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Initialize ranks. | ||||||||||||||||||||||||||||||||||||||||||
| n := float64(len(allNodes)) | ||||||||||||||||||||||||||||||||||||||||||
| rank := make(map[string]float64, len(allNodes)) | ||||||||||||||||||||||||||||||||||||||||||
| for id := range allNodes { | ||||||||||||||||||||||||||||||||||||||||||
| rank[id] = 1.0 / n | ||||||||||||||||||||||||||||||||||||||||||
| if len(personal) == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, nil | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| seedWeight := 1.0 / float64(len(personal)) | ||||||||||||||||||||||||||||||||||||||||||
| for id := range personal { | ||||||||||||||||||||||||||||||||||||||||||
| personal[id] = seedWeight | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| newRank := make(map[string]float64, len(allNodes)) | ||||||||||||||||||||||||||||||||||||||||||
| rank := make(map[string]float64, len(nodes)) | ||||||||||||||||||||||||||||||||||||||||||
| newRank := make(map[string]float64, len(nodes)) | ||||||||||||||||||||||||||||||||||||||||||
| initialRank := 1.0 / float64(len(nodes)) | ||||||||||||||||||||||||||||||||||||||||||
| for _, id := range nodes { | ||||||||||||||||||||||||||||||||||||||||||
| rank[id] = initialRank | ||||||||||||||||||||||||||||||||||||||||||
| newRank[id] = 0 | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| for iter := 0; iter < maxIter; iter++ { | ||||||||||||||||||||||||||||||||||||||||||
| // Reset newRank for this iteration (reuse allocation). | ||||||||||||||||||||||||||||||||||||||||||
| for id := range newRank { | ||||||||||||||||||||||||||||||||||||||||||
| delete(newRank, id) | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Distribute rank from each node to neighbors. | ||||||||||||||||||||||||||||||||||||||||||
| for id := range allNodes { | ||||||||||||||||||||||||||||||||||||||||||
| if degree[id] == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||
| for _, id := range nodes { | ||||||||||||||||||||||||||||||||||||||||||
| newRank[id] = 0 | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| for i, id := range nodes { | ||||||||||||||||||||||||||||||||||||||||||
| if i%256 == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| share := rank[id] / float64(degree[id]) | ||||||||||||||||||||||||||||||||||||||||||
| for _, e := range g.forward[id] { | ||||||||||||||||||||||||||||||||||||||||||
| newRank[e.TargetID] += share | ||||||||||||||||||||||||||||||||||||||||||
| if len(neighbors[id]) == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| for _, e := range g.reverse[id] { | ||||||||||||||||||||||||||||||||||||||||||
| newRank[e.TargetID] += share | ||||||||||||||||||||||||||||||||||||||||||
| share := rank[id] / float64(len(neighbors[id])) | ||||||||||||||||||||||||||||||||||||||||||
| for j, nextID := range neighbors[id] { | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+461
to
+465
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Localized sinks currently leak probability mass. Restricting the walk to the local subgraph can create sinks, e.g. 🧮 Proposed fix+ danglingMass := 0.0
for i, id := range nodes {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return nil, err
}
}
if len(neighbors[id]) == 0 {
+ danglingMass += rank[id]
continue
}
share := rank[id] / float64(len(neighbors[id]))
@@
- nr := alpha*personal[id] + (1-alpha)*newRank[id]
+ nr := alpha*personal[id] + (1-alpha)*(newRank[id]+danglingMass*personal[id])Also applies to: 482-483 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| if j%256 == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| newRank[nextID] += share | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Apply teleport. | ||||||||||||||||||||||||||||||||||||||||||
| maxDiff := 0.0 | ||||||||||||||||||||||||||||||||||||||||||
| for id := range allNodes { | ||||||||||||||||||||||||||||||||||||||||||
| for i, id := range nodes { | ||||||||||||||||||||||||||||||||||||||||||
| if i%256 == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| nr := alpha*personal[id] + (1-alpha)*newRank[id] | ||||||||||||||||||||||||||||||||||||||||||
| diff := math.Abs(nr - rank[id]) | ||||||||||||||||||||||||||||||||||||||||||
| if diff > maxDiff { | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -466,7 +492,87 @@ func (g *Graph) PersonalizedPageRank(seeds []string, alpha float64, maxIter int, | |||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return rank | ||||||||||||||||||||||||||||||||||||||||||
| return rank, nil | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| func (g *Graph) localPPRNeighborhoodLocked( | ||||||||||||||||||||||||||||||||||||||||||
| ctx context.Context, | ||||||||||||||||||||||||||||||||||||||||||
| seeds []string, | ||||||||||||||||||||||||||||||||||||||||||
| maxHops int, | ||||||||||||||||||||||||||||||||||||||||||
| ) ([]string, map[string][]string, error) { | ||||||||||||||||||||||||||||||||||||||||||
| nodes := make(map[string]struct{}, len(seeds)) | ||||||||||||||||||||||||||||||||||||||||||
| dist := make(map[string]int, len(seeds)) | ||||||||||||||||||||||||||||||||||||||||||
| queue := make([]string, 0, len(seeds)) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| for _, id := range seeds { | ||||||||||||||||||||||||||||||||||||||||||
| if _, seen := dist[id]; seen { | ||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| nodes[id] = struct{}{} | ||||||||||||||||||||||||||||||||||||||||||
| dist[id] = 0 | ||||||||||||||||||||||||||||||||||||||||||
| queue = append(queue, id) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| for head := 0; head < len(queue); head++ { | ||||||||||||||||||||||||||||||||||||||||||
| if head%256 == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| cur := queue[head] | ||||||||||||||||||||||||||||||||||||||||||
| hop := dist[cur] | ||||||||||||||||||||||||||||||||||||||||||
| if hop >= maxHops { | ||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| for _, edges := range [][]Edge{g.forward[cur], g.reverse[cur]} { | ||||||||||||||||||||||||||||||||||||||||||
| for edgeIdx, e := range edges { | ||||||||||||||||||||||||||||||||||||||||||
| if edgeIdx%256 == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| nodes[e.TargetID] = struct{}{} | ||||||||||||||||||||||||||||||||||||||||||
| if _, seen := dist[e.TargetID]; seen { | ||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+527
to
+536
|
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| dist[e.TargetID] = hop + 1 | ||||||||||||||||||||||||||||||||||||||||||
| queue = append(queue, e.TargetID) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| nodeIDs := make([]string, 0, len(nodes)) | ||||||||||||||||||||||||||||||||||||||||||
| neighbors := make(map[string][]string, len(nodes)) | ||||||||||||||||||||||||||||||||||||||||||
| for id := range nodes { | ||||||||||||||||||||||||||||||||||||||||||
| nodeIDs = append(nodeIDs, id) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+544
to
+548
|
||||||||||||||||||||||||||||||||||||||||||
| sort.Strings(nodeIDs) | ||||||||||||||||||||||||||||||||||||||||||
| for i, id := range nodeIDs { | ||||||||||||||||||||||||||||||||||||||||||
| if i%256 == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| localNeighbors := make([]string, 0, len(g.forward[id])+len(g.reverse[id])) | ||||||||||||||||||||||||||||||||||||||||||
| edgeCount := 0 | ||||||||||||||||||||||||||||||||||||||||||
| for _, edges := range [][]Edge{g.forward[id], g.reverse[id]} { | ||||||||||||||||||||||||||||||||||||||||||
| for _, e := range edges { | ||||||||||||||||||||||||||||||||||||||||||
| if edgeCount%256 == 0 { | ||||||||||||||||||||||||||||||||||||||||||
| if err := ctx.Err(); err != nil { | ||||||||||||||||||||||||||||||||||||||||||
| return nil, nil, err | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| edgeCount++ | ||||||||||||||||||||||||||||||||||||||||||
| if _, ok := nodes[e.TargetID]; !ok { | ||||||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| localNeighbors = append(localNeighbors, e.TargetID) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| neighbors[id] = localNeighbors | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return nodeIDs, neighbors, nil | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // --- Helpers --- | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrap context errors with descriptive context.
Per coding guidelines, errors should be wrapped using
fmt.Errorf("context: %w", err). The rawctx.Err()returns at lines 409 and similar locations throughout this function lack context about where the cancellation occurred.Proposed fix
if err := ctx.Err(); err != nil { - return nil, err + return nil, fmt.Errorf("ppr: context check: %w", err) }Apply similar wrapping to other
ctx.Err()returns at lines 446-448, 456-458, 472-474, and inlocalPPRNeighborhoodLockedat lines 512-514.As per coding guidelines:
**/*.go: Always wrap errors with context usingfmt.Errorf("context: %w", err). Never return naked errors.📝 Committable suggestion
🤖 Prompt for AI Agents