Skip to content

Commit b725e3e

Browse files
tae2089claude
andcommitted
fix: guarantee per-namespace representation in federated search results
The caller's limit was applied to the globally reranked merged pool, so one namespace with high-scoring hits could push every other namespace out of the response entirely. Rank globally without truncation, then select with a per-namespace quota (limit/namespaces, minimum one) and fill the remaining slots in global rank order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5d2530b commit b725e3e

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

internal/adapters/inbound/mcp/handler_federated_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package mcp
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"os"
67
"path/filepath"
8+
"strings"
79
"testing"
810

911
"github.com/mark3labs/mcp-go/mcp"
@@ -79,6 +81,58 @@ func TestSearch_FederatesAcrossNamespaces(t *testing.T) {
7981
}
8082
}
8183

84+
func TestSearch_FederatedLimitDoesNotStarveNamespaces(t *testing.T) {
85+
deps := setupTestDeps(t)
86+
87+
alphaDir := t.TempDir()
88+
betaDir := t.TempDir()
89+
var alphaSrc strings.Builder
90+
alphaSrc.WriteString("package alpha\n")
91+
for i := 1; i <= 5; i++ {
92+
fmt.Fprintf(&alphaSrc, "\nfunc Payment%d() string {\n\treturn \"payment\"\n}\n", i)
93+
}
94+
if err := os.WriteFile(filepath.Join(alphaDir, "pay.go"), []byte(alphaSrc.String()), 0o644); err != nil {
95+
t.Fatalf("write alpha: %v", err)
96+
}
97+
if err := os.WriteFile(filepath.Join(betaDir, "refund.go"), []byte("package beta\n\nfunc PaymentZz() string {\n\treturn \"payment\"\n}\n"), 0o644); err != nil {
98+
t.Fatalf("write beta: %v", err)
99+
}
100+
deps.Runtime.RepoRoot = alphaDir
101+
if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": alphaDir, "namespace": "alpha"}); res.IsError {
102+
t.Fatalf("parse alpha failed: %+v", res)
103+
}
104+
deps.Runtime.RepoRoot = betaDir
105+
if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": betaDir, "namespace": "beta"}); res.IsError {
106+
t.Fatalf("parse beta failed: %+v", res)
107+
}
108+
109+
result := callTool(t, deps, "search", map[string]any{
110+
"query": "Payment",
111+
"limit": 3,
112+
"namespaces": []string{"alpha", "beta"},
113+
})
114+
var items []struct {
115+
QualifiedName string `json:"qualified_name"`
116+
Namespace string `json:"namespace"`
117+
}
118+
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &items); err != nil {
119+
t.Fatalf("unmarshal: %v", err)
120+
}
121+
if len(items) != 3 {
122+
t.Fatalf("result count = %d, want limit 3", len(items))
123+
}
124+
perNS := map[string]int{}
125+
for _, item := range items {
126+
perNS[item.Namespace]++
127+
}
128+
if perNS["beta"] == 0 {
129+
t.Fatalf("federated limit starved namespace beta: %v", perNS)
130+
}
131+
if perNS["alpha"] == 0 {
132+
t.Fatalf("federated limit starved namespace alpha: %v", perNS)
133+
}
134+
}
135+
82136
func TestSearch_SingleNamespaceResponseUnchanged(t *testing.T) {
83137
deps := setupTestDeps(t)
84138
seedFederatedNamespaces(t, deps)

internal/adapters/inbound/mcp/handler_query.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,8 @@ func (h *handlers) searchFederated(ctx context.Context, query string, limit int,
285285
merged = filtered
286286
}
287287

288-
merged = searchrank.Rerank(query, merged, limit)
288+
merged = searchrank.Rerank(query, merged, 0)
289+
merged = selectWithNamespaceQuota(merged, limit, len(namespaces))
289290
log.Info("federated search completed", "query", query, "namespaces", namespaces, "result_count", len(merged))
290291

291292
items := make([]searchResultItem, len(merged))
@@ -300,6 +301,46 @@ func (h *handlers) searchFederated(ctx context.Context, query string, limit int,
300301
}))
301302
}
302303

304+
// selectWithNamespaceQuota bounds globally-ranked federated results while guaranteeing
305+
// every namespace with hits at least limit/namespaceCount slots (minimum one).
306+
// @intent keep one high-scoring repository from starving the other namespaces out of a federated result.
307+
// @domainRule remaining slots after the per-namespace quota pass are filled in global rank order.
308+
func selectWithNamespaceQuota(ranked []graph.Node, limit, namespaceCount int) []graph.Node {
309+
if limit <= 0 || len(ranked) <= limit {
310+
return ranked
311+
}
312+
quota := max(limit/max(namespaceCount, 1), 1)
313+
chosen := make([]bool, len(ranked))
314+
count := 0
315+
perNamespace := map[string]int{}
316+
for i, n := range ranked {
317+
if count == limit {
318+
break
319+
}
320+
if perNamespace[n.Namespace] < quota {
321+
chosen[i] = true
322+
perNamespace[n.Namespace]++
323+
count++
324+
}
325+
}
326+
for i := range ranked {
327+
if count == limit {
328+
break
329+
}
330+
if !chosen[i] {
331+
chosen[i] = true
332+
count++
333+
}
334+
}
335+
selected := make([]graph.Node, 0, limit)
336+
for i, n := range ranked {
337+
if chosen[i] {
338+
selected = append(selected, n)
339+
}
340+
}
341+
return selected
342+
}
343+
303344
// getAnnotation returns stored annotation metadata for a graph node.
304345
// @intent fetch stored annotation tags and summary data so semantic search results can show business context.
305346
// @param request qualified_name is the fully qualified node name whose annotations should be loaded.

0 commit comments

Comments
 (0)