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
2 changes: 1 addition & 1 deletion pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions pkg/match/capmatches_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
25 changes: 19 additions & 6 deletions pkg/match/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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{}
}
Expand Down
81 changes: 81 additions & 0 deletions pkg/match/engine_cache_cap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 4 additions & 4 deletions pkg/match/llm_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down
3 changes: 2 additions & 1 deletion pkg/mcpserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
Loading