diff --git a/internal/anime/recommendations/constants.go b/internal/anime/recommendations/constants.go index 54d92e2..89bb9a9 100644 --- a/internal/anime/recommendations/constants.go +++ b/internal/anime/recommendations/constants.go @@ -6,6 +6,7 @@ const ( maxSeeds = 8 maxRecommendations = 10 candidateFetchLimit = 60 + candidateFetchBuffer = 6 TopPickLimit = 18 TopPicksLimit = 60 profileSearchLimit = 8 diff --git a/internal/anime/recommendations/engine.go b/internal/anime/recommendations/engine.go index dce1217..372726e 100644 --- a/internal/anime/recommendations/engine.go +++ b/internal/anime/recommendations/engine.go @@ -64,7 +64,7 @@ func (e engine) getTopPicksForYou(ctx context.Context, userID string, resultLimi return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil } - candidates, err := e.scoreRankedCandidates(ctx, now, profile, ranked) + candidates, err := e.scoreRankedCandidates(ctx, now, profile, ranked, resultLimit) if err != nil { return domain.CatalogSectionData{}, err } @@ -191,8 +191,9 @@ func (e engine) scoreRankedCandidates( now time.Time, profile userTasteProfile, ranked []rankedCandidate, + resultLimit int, ) ([]recommendationCandidate, error) { - limit := min(len(ranked), candidateFetchLimit) + limit := min(len(ranked), candidateScoreLimit(resultLimit)) candidates := make([]recommendationCandidate, 0, limit) var candidatesMu sync.Mutex var g errgroup.Group @@ -244,3 +245,11 @@ func (e engine) scoreRankedCandidates( return candidates, nil } + +func candidateScoreLimit(resultLimit int) int { + if resultLimit <= 0 { + return 0 + } + + return min(candidateFetchLimit, resultLimit+candidateFetchBuffer) +} diff --git a/internal/anime/recommendations/recommendations_test.go b/internal/anime/recommendations/recommendations_test.go index 777eac3..d7a5052 100644 --- a/internal/anime/recommendations/recommendations_test.go +++ b/internal/anime/recommendations/recommendations_test.go @@ -174,6 +174,18 @@ func TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) { } } +func TestCandidateScoreLimitTracksRequestedResultSize(t *testing.T) { + if got := candidateScoreLimit(TopPickLimit); got != TopPickLimit+candidateFetchBuffer { + t.Fatalf("expected top-pick scoring to fetch a small oversample, got %d", got) + } + if got := candidateScoreLimit(TopPicksLimit); got != candidateFetchLimit { + t.Fatalf("expected full top-picks scoring to keep existing cap, got %d", got) + } + if got := candidateScoreLimit(0); got != 0 { + t.Fatalf("expected zero result limit to skip scoring, got %d", got) + } +} + func testRecommendationAnime(id int, genreID int) jikan.Anime { return jikan.Anime{ MalID: id,