From cff99e1c2f535dbe2f44b34709c1d104d80d8bd9 Mon Sep 17 00:00:00 2001 From: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:23:08 -0400 Subject: [PATCH 1/2] fix(match): persist the full match cache and cap only the returned slice Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- pkg/api/api.go | 2 +- pkg/match/engine.go | 22 +++++--- pkg/match/engine_cache_cap_test.go | 81 ++++++++++++++++++++++++++++++ pkg/match/llm_contract_test.go | 4 +- pkg/mcpserver/server.go | 3 +- 5 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 pkg/match/engine_cache_cap_test.go 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/engine.go b/pkg/match/engine.go index a19e14d..1e9d472 100644 --- a/pkg/match/engine.go +++ b/pkg/match/engine.go @@ -19,9 +19,20 @@ 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 at most MaxMatches leading entries of an already +// score-sorted slice, for surfacing the persisted cache to callers. +func CapMatches(ms []store.Match) []store.Match { + if len(ms) > MaxMatches { + return ms[:MaxMatches] + } + return ms +} + // MaxCNCFCandidates caps how many BM25-ranked CNCF projects reach the LLM. const MaxCNCFCandidates = 15 @@ -168,7 +179,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 +209,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 +219,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..113fe35 100644 --- a/pkg/match/llm_contract_test.go +++ b/pkg/match/llm_contract_test.go @@ -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) } From 23c2ebf6def6c3be0f37dc78f8dbd7357e0c2eab Mon Sep 17 00:00:00 2001 From: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:31:20 -0400 Subject: [PATCH 2/2] fix(match): sort before capping in CapMatches so unsorted cached matches keep top scores Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- pkg/match/capmatches_test.go | 36 ++++++++++++++++++++++++++++++++++ pkg/match/engine.go | 13 +++++++----- pkg/match/llm_contract_test.go | 4 ++-- 3 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 pkg/match/capmatches_test.go 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 1e9d472..38a2b0f 100644 --- a/pkg/match/engine.go +++ b/pkg/match/engine.go @@ -24,13 +24,16 @@ import ( // repos ranked below the cap are not re-scored (LLM cost) or re-notified. const MaxMatches = 10 -// CapMatches returns at most MaxMatches leading entries of an already -// score-sorted slice, for surfacing the persisted cache to callers. +// 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 { - if len(ms) > MaxMatches { - return ms[:MaxMatches] + 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 ms + return out } // MaxCNCFCandidates caps how many BM25-ranked CNCF projects reach the LLM. diff --git a/pkg/match/llm_contract_test.go b/pkg/match/llm_contract_test.go index 113fe35..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)