diff --git a/pkg/api/api.go b/pkg/api/api.go index 801189b..6456069 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -232,7 +232,7 @@ func (a *API) handleAdminIdeas(w http.ResponseWriter, r *http.Request) { Status: idea.Status, CreatedAt: idea.CreatedAt, UpdatedAt: idea.UpdatedAt, - Matches: summarizeMatches(idea.Matches, idea.CNCFMatches), + Matches: summarizeMatches(match.CapMatches(idea.Matches), idea.CNCFMatches), }) } writeJSON(w, http.StatusOK, out) diff --git a/pkg/match/capmatches_test.go b/pkg/match/capmatches_test.go new file mode 100644 index 0000000..bf05f22 --- /dev/null +++ b/pkg/match/capmatches_test.go @@ -0,0 +1,36 @@ +package match + +import ( + "testing" + + "github.com/hivecommons/dibs/pkg/store" +) + +// TestCapMatchesSortsBeforeCapping: a strong match stored past the cap index +// must survive, and the caller's slice must not be reordered. +func TestCapMatchesSortsBeforeCapping(t *testing.T) { + n := MaxMatches + 3 + in := make([]store.Match, n) + for i := range in { + in[i] = store.Match{RepoID: string(rune('a' + i)), Score: float64(i)} + } + orig := append([]store.Match(nil), in...) + + got := CapMatches(in) + if len(got) != MaxMatches { + t.Fatalf("len = %d, want %d", len(got), MaxMatches) + } + for i := 1; i < len(got); i++ { + if got[i-1].Score < got[i].Score { + t.Fatalf("not sorted desc at %d: %v < %v", i, got[i-1].Score, got[i].Score) + } + } + if got[0].Score != float64(n-1) { + t.Fatalf("top score = %v, want %v", got[0].Score, float64(n-1)) + } + for i := range in { + if in[i] != orig[i] { + t.Fatalf("input mutated at %d", i) + } + } +} diff --git a/pkg/match/engine.go b/pkg/match/engine.go index a19e14d..38a2b0f 100644 --- a/pkg/match/engine.go +++ b/pkg/match/engine.go @@ -19,9 +19,23 @@ import ( "github.com/hivecommons/dibs/pkg/store" ) -// MaxMatches caps how many candidate repos an idea keeps — LLM cost control. +// MaxMatches caps how many candidate repos are RETURNED/surfaced for an idea. +// It does not cap the persisted cache, which holds every scored candidate so +// repos ranked below the cap are not re-scored (LLM cost) or re-notified. const MaxMatches = 10 +// CapMatches returns a score-desc sorted copy of ms limited to MaxMatches +// entries. It sorts first because the persisted order is not guaranteed +// (ScoreForRepo appends without re-sorting). +func CapMatches(ms []store.Match) []store.Match { + out := append([]store.Match(nil), ms...) + sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) + if len(out) > MaxMatches { + out = out[:MaxMatches] + } + return out +} + // MaxCNCFCandidates caps how many BM25-ranked CNCF projects reach the LLM. const MaxCNCFCandidates = 15 @@ -168,7 +182,8 @@ func truncate(s string, n int) string { // MatchesForIdea returns the idea's candidate repos (accepting-only, minus // passed/offered ones), scoring lazily: cached matches whose RepoHash still // matches are reused; everything else is (re)scored, persisted, and — if -// strong and fresh — notified to both sides. Sorted by score desc, capped. +// strong and fresh — notified to both sides. The full set is persisted; the +// returned slice is sorted by score desc and capped at MaxMatches. func (e *Engine) MatchesForIdea(ctx context.Context, idea *store.Idea) ([]store.Match, error) { cached := map[string]store.Match{} for _, m := range idea.Matches { @@ -197,11 +212,8 @@ func (e *Engine) MatchesForIdea(ctx context.Context, idea *store.Idea) ([]store. } } sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) - if len(out) > MaxMatches { - out = out[:MaxMatches] - } if changed { - persisted := out + persisted := append([]store.Match(nil), out...) if _, err := e.Store.Mutate(idea.ID, false, func(i *store.Idea) error { i.Matches = persisted i.MatchesUpdatedAt = time.Now().UTC() @@ -210,6 +222,7 @@ func (e *Engine) MatchesForIdea(ctx context.Context, idea *store.Idea) ([]store. return nil, err } } + out = CapMatches(out) if out == nil { out = []store.Match{} } diff --git a/pkg/match/engine_cache_cap_test.go b/pkg/match/engine_cache_cap_test.go new file mode 100644 index 0000000..fbee040 --- /dev/null +++ b/pkg/match/engine_cache_cap_test.go @@ -0,0 +1,81 @@ +package match + +import ( + "context" + "fmt" + "testing" + + "github.com/hivecommons/dibs/pkg/registry" + "github.com/hivecommons/dibs/pkg/store" +) + +type countingNotifier struct{ n int } + +func (c *countingNotifier) NewMatch(string, string, *store.Idea, *registry.RepoProfile, float64) { + c.n++ +} + +// TestMatchesForIdeaCachesFullSetAndCapsReturned: with MaxMatches+1 repos the +// persisted cache holds all of them, so a second call re-scores and +// re-notifies nothing and does not rewrite the idea. +func TestMatchesForIdeaCachesFullSetAndCapsReturned(t *testing.T) { + st, reg := newFixtures(t) + n := MaxMatches + 1 + repos := make([]registry.RepoProfile, 0, n) + for i := 0; i < n; i++ { + repos = append(repos, registry.RepoProfile{ + RepoID: fmt.Sprintf("org/repo-%02d", i), + Owner: "bob", + Description: "telemetry dashboards grafana", + Topics: []string{"observability"}, + }) + } + repos[0].Description = "kubernetes multicluster scheduling placement policies clusters" + repos[1].Description = "kubernetes multicluster scheduling" + repos[2].Description = "kubernetes scheduling dashboards" + if err := reg.Merge(repos); err != nil { + t.Fatalf("Merge: %v", err) + } + notes := &countingNotifier{} + e := &Engine{Store: st, Registry: reg, Notifier: notes} + idea := createIdea(t, st, "Kubernetes multicluster scheduling", "Schedule workloads across clusters with placement policies.") + + first, err := e.MatchesForIdea(context.Background(), idea) + if err != nil { + t.Fatalf("MatchesForIdea: %v", err) + } + if len(first) != MaxMatches { + t.Fatalf("first call returned %d, want %d", len(first), MaxMatches) + } + saved, err := st.Get(idea.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(saved.Matches) != n { + t.Fatalf("persisted %d matches, want %d", len(saved.Matches), n) + } + notified := notes.n + + second, err := e.MatchesForIdea(context.Background(), saved) + if err != nil { + t.Fatalf("second MatchesForIdea: %v", err) + } + if len(second) != MaxMatches { + t.Fatalf("second call returned %d, want %d", len(second), MaxMatches) + } + for i := range first { + if first[i].RepoID != second[i].RepoID { + t.Fatalf("entry %d changed: %s then %s", i, first[i].RepoID, second[i].RepoID) + } + } + again, err := st.Get(idea.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !again.MatchesUpdatedAt.Equal(saved.MatchesUpdatedAt) { + t.Fatalf("MatchesUpdatedAt bumped on a fully cached call") + } + if notes.n != notified { + t.Fatalf("notifier fired %d more times on second call", notes.n-notified) + } +} diff --git a/pkg/match/llm_contract_test.go b/pkg/match/llm_contract_test.go index 30d42f0..4421c87 100644 --- a/pkg/match/llm_contract_test.go +++ b/pkg/match/llm_contract_test.go @@ -201,10 +201,10 @@ func TestParseRefinedEmptyTitleAfterStrip(t *testing.T) { } } -// TestMatchesForIdeaCappedAtMaxMatches: with more candidate repos than +// TestMatchesForIdeaPersistsAllCapsReturn: with more candidate repos than // MaxMatches the result is truncated to the top-scoring MaxMatches, sorted // by score descending — the LLM cost control the constant documents. -func TestMatchesForIdeaCappedAtMaxMatches(t *testing.T) { +func TestMatchesForIdeaPersistsAllCapsReturn(t *testing.T) { st, reg := newFixtures(t) n := MaxMatches + 5 repos := make([]registry.RepoProfile, 0, n) @@ -246,8 +246,8 @@ func TestMatchesForIdeaCappedAtMaxMatches(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } - if len(kept.Matches) != MaxMatches { - t.Fatalf("persisted %d matches, want the capped %d", len(kept.Matches), MaxMatches) + if len(kept.Matches) != n { + t.Fatalf("persisted %d matches, want the full cache of %d", len(kept.Matches), n) } } diff --git a/pkg/mcpserver/server.go b/pkg/mcpserver/server.go index 72d5aea..350a418 100644 --- a/pkg/mcpserver/server.go +++ b/pkg/mcpserver/server.go @@ -12,6 +12,7 @@ import ( "github.com/hivecommons/dibs/pkg/auth" "github.com/hivecommons/dibs/pkg/deps" + "github.com/hivecommons/dibs/pkg/match" "github.com/hivecommons/dibs/pkg/registry" "github.com/hivecommons/dibs/pkg/store" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -294,7 +295,7 @@ func (t *tools) getIdea(ctx context.Context, req *mcpsdk.CallToolRequest, in get if authErr == nil && id != nil { viewer = id.Username } - matches, offers := idea.Matches, idea.Offers + matches, offers := match.CapMatches(idea.Matches), idea.Offers if idea.Author != viewer { matches, offers = nil, t.offersForViewer(idea.Offers, viewer) }