feat(recommendation): add LoadCandidatesFromSimilarity (5-source candidate pool)
Implements M4c's similarity-driven candidate pool: CandidateSourceLimits, DefaultCandidateSourceLimits, and LoadCandidatesFromSimilarity consuming LoadRadioCandidatesV2 (5-way UNION + max-score dedup). Adds 10 integration tests covering all sources, exclusions, dedup, and edge cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,95 @@ func LoadCandidates(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CandidateSourceLimits controls per-source K values for the M4c
|
||||
// similarity-driven pool. Defaults via DefaultCandidateSourceLimits().
|
||||
type CandidateSourceLimits struct {
|
||||
LBSimilar int
|
||||
SimilarArtist int
|
||||
TagOverlap int
|
||||
LikesOverlap int
|
||||
RandomFill int
|
||||
}
|
||||
|
||||
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||
func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
return CandidateSourceLimits{
|
||||
LBSimilar: 30,
|
||||
SimilarArtist: 30,
|
||||
TagOverlap: 20,
|
||||
LikesOverlap: 20,
|
||||
RandomFill: 30,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
|
||||
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
|
||||
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
|
||||
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
|
||||
//
|
||||
// Caller (radio handler) falls back to LoadCandidates on error.
|
||||
func LoadCandidatesFromSimilarity(
|
||||
ctx context.Context,
|
||||
q *dbq.Queries,
|
||||
userID, seedID pgtype.UUID,
|
||||
recentlyPlayedHours int,
|
||||
currentVector SessionVector,
|
||||
exclude []pgtype.UUID,
|
||||
limits CandidateSourceLimits,
|
||||
) ([]Candidate, error) {
|
||||
if exclude == nil {
|
||||
exclude = []pgtype.UUID{}
|
||||
}
|
||||
rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{
|
||||
UserID: userID,
|
||||
ID: seedID,
|
||||
Column3: int64(recentlyPlayedHours),
|
||||
Column4: exclude,
|
||||
Limit: int32(limits.LBSimilar),
|
||||
Limit_2: int32(limits.SimilarArtist),
|
||||
Limit_3: int32(limits.TagOverlap),
|
||||
Limit_4: int32(limits.LikesOverlap),
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
likes, err := loadContextualLikesByTrack(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
if r.LastPlayedAt.Valid {
|
||||
t := r.LastPlayedAt.Time
|
||||
lpt = &t
|
||||
}
|
||||
// sqlc returns SimilarityScore as interface{} (couldn't infer the
|
||||
// type through max(...) over a UNION). Type-assert; default to 0
|
||||
// on the (impossible-but-defensive) case where it's nil/wrong type.
|
||||
var simScore float64
|
||||
if v, ok := r.SimilarityScore.(float64); ok {
|
||||
simScore = v
|
||||
}
|
||||
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
|
||||
out = append(out, Candidate{
|
||||
Track: r.Track,
|
||||
Inputs: ScoringInputs{
|
||||
IsGeneralLiked: r.IsLiked,
|
||||
LastPlayedAt: lpt,
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
SimilarityScore: simScore,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||
// one query and groups them by track_id. Rows whose session_vector fails
|
||||
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||
|
||||
Reference in New Issue
Block a user