feat(taste): time-of-day / weekday context conditioning — #1531
Milestone #160 Opt 3 (temporal half). A new additive scoring term that boosts a candidate when its artist's play history concentrates in the CURRENT daypart × weekday-type cell, in the user's local timezone. - Migration 0046: recommendation_weight_profiles.context_time_weight (per-profile scoring weight, DEFAULT 1.0). - Query ListArtistContextPlayCountsForUser: per-artist completed-play counts split by the current cell (daypart night[22,5)/morning[5,12)/ afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via started_at AT TIME ZONE users.timezone; 365-day window, skips excluded. - internal/recommendation/context.go: LoadContextAffinity computes each artist's shrunk cell-share minus the user's baseline share, clamped to [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown artists → 0 (cold-start neutral). - Score() gains context_affinity_score · ContextTimeWeight; both candidate loaders set it per candidate. - Tuning lab: ContextTimeWeight threaded through recsettings + admin API + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both profiles (uniform start, re-bakeable). Device-class axis deferred to #1551 (needs a client_id → device-class mapping that doesn't exist yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,11 @@ func LoadCandidates(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
affinity, err := LoadContextAffinity(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -58,6 +63,7 @@ func LoadCandidates(
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -134,6 +140,11 @@ func LoadCandidatesFromSimilarity(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
affinity, err := LoadContextAffinity(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -159,6 +170,7 @@ func LoadCandidatesFromSimilarity(
|
||||
ContextualMatchScore: ctxScore,
|
||||
SimilarityScore: simScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// contextAffinityShrinkage is the pseudo-count that pulls a low-play artist's
|
||||
// cell-share toward the user's baseline, so an artist with one or two plays
|
||||
// can't swing its affinity to ±1 on noise. At k plays the estimate sits
|
||||
// halfway between the raw cell-share and the baseline.
|
||||
const contextAffinityShrinkage = 5.0
|
||||
|
||||
// ContextAffinity is the read-side map of per-artist time-of-day/weekday
|
||||
// affinity for the CURRENT context (#1531): artist_id → score in [-1, +1].
|
||||
// Absent artists (no play history) score 0, so cold-start candidates stay
|
||||
// neutral. The zero value is a valid all-neutral affinity.
|
||||
type ContextAffinity struct {
|
||||
byArtist map[pgtype.UUID]float64
|
||||
}
|
||||
|
||||
// Affinity returns the artist's current-context affinity, or 0 if unknown.
|
||||
func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
|
||||
return c.byArtist[artistID]
|
||||
}
|
||||
|
||||
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
|
||||
// daypart × weekday cell. For every artist with completed plays in the window
|
||||
// it compares the share of that artist's plays that fall in the current cell
|
||||
// against the user's overall baseline share, shrinking sparse artists toward
|
||||
// the baseline. Returns an empty (all-neutral) affinity when the user has no
|
||||
// plays.
|
||||
func LoadContextAffinity(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID,
|
||||
) (ContextAffinity, error) {
|
||||
rows, err := q.ListArtistContextPlayCountsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return ContextAffinity{}, err
|
||||
}
|
||||
var totalPlays, cellPlays int64
|
||||
for _, r := range rows {
|
||||
totalPlays += r.TotalPlays
|
||||
cellPlays += r.CellPlays
|
||||
}
|
||||
out := ContextAffinity{byArtist: make(map[pgtype.UUID]float64, len(rows))}
|
||||
if totalPlays == 0 {
|
||||
return out, nil
|
||||
}
|
||||
baseline := float64(cellPlays) / float64(totalPlays)
|
||||
for _, r := range rows {
|
||||
out.byArtist[r.ArtistID] = contextAffinity(
|
||||
float64(r.CellPlays), float64(r.TotalPlays), baseline, contextAffinityShrinkage)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// contextAffinity returns an artist's shrunk cell-share minus the user's
|
||||
// baseline share, clamped to [-1, 1]. The shrinkage pseudo-count k pulls
|
||||
// low-play artists toward the baseline (→ 0 affinity) so noise can't dominate;
|
||||
// a heavily-played artist keeps close to its raw over/under-representation.
|
||||
func contextAffinity(cellPlays, totalPlays, baseline, k float64) float64 {
|
||||
if totalPlays == 0 {
|
||||
return 0
|
||||
}
|
||||
shrunk := (cellPlays + baseline*k) / (totalPlays + k)
|
||||
return clampUnit(shrunk - baseline)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContextAffinity(t *testing.T) {
|
||||
const baseline = 0.4 // 40% of the user's plays fall in the current cell
|
||||
const k = contextAffinityShrinkage
|
||||
|
||||
// Heavy history, over-represented in the current cell → positive.
|
||||
if a := contextAffinity(80, 100, baseline, k); a <= 0 {
|
||||
t.Errorf("over-represented artist affinity = %.3f, want positive", a)
|
||||
}
|
||||
// Heavy history, under-represented → negative.
|
||||
if a := contextAffinity(10, 100, baseline, k); a >= 0 {
|
||||
t.Errorf("under-represented artist affinity = %.3f, want negative", a)
|
||||
}
|
||||
// A sparse artist (1/1) shrinks toward the baseline, so its affinity is
|
||||
// smaller than a heavily-played artist with the same raw cell-share.
|
||||
sparse := contextAffinity(1, 1, baseline, k)
|
||||
heavy := contextAffinity(100, 100, baseline, k)
|
||||
if sparse >= heavy {
|
||||
t.Errorf("sparse (%.3f) should shrink below heavy (%.3f)", sparse, heavy)
|
||||
}
|
||||
// No plays → neutral.
|
||||
if a := contextAffinity(0, 0, baseline, k); a != 0 {
|
||||
t.Errorf("no plays affinity = %.3f, want 0", a)
|
||||
}
|
||||
// Result stays within [-1, 1].
|
||||
for _, tc := range [][2]float64{{100, 100}, {0, 100}, {50, 50}} {
|
||||
a := contextAffinity(tc[0], tc[1], baseline, k)
|
||||
if a < -1 || a > 1 {
|
||||
t.Errorf("affinity out of [-1,1]: %.3f", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_ContextTermAddsAndSubtracts(t *testing.T) {
|
||||
now := time.Now()
|
||||
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
|
||||
w := ScoringWeights{ContextTimeWeight: 2.0} // all other weights 0
|
||||
|
||||
pos := Score(ScoringInputs{ContextAffinityScore: 1.0}, w, now, zeroJitter)
|
||||
if !almostEq(pos, 2.0) {
|
||||
t.Errorf("positive context affinity: Score = %.3f, want 2.0", pos)
|
||||
}
|
||||
neg := Score(ScoringInputs{ContextAffinityScore: -1.0}, w, now, zeroJitter)
|
||||
if !almostEq(neg, -2.0) {
|
||||
t.Errorf("negative context affinity: Score = %.3f, want -2.0 (demotes)", neg)
|
||||
}
|
||||
off := Score(ScoringInputs{ContextAffinityScore: 1.0}, ScoringWeights{}, now, zeroJitter)
|
||||
if !almostEq(off, 0.0) {
|
||||
t.Errorf("ContextTimeWeight 0: Score = %.3f, want 0 (no effect)", off)
|
||||
}
|
||||
}
|
||||
@@ -24,19 +24,26 @@ type ScoringInputs struct {
|
||||
// user's taste, negative reflects passive avoidance, 0 when there's no
|
||||
// profile signal (cold start / artist+tags absent from the profile).
|
||||
TasteMatchScore float64
|
||||
// ContextAffinityScore is the candidate artist's time-of-day/weekday
|
||||
// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
|
||||
// artist's plays concentrate in the current daypart × weekday-type cell
|
||||
// more than the user's baseline, negative when under-represented, 0 when
|
||||
// there's no history (cold-start neutral).
|
||||
ContextAffinityScore float64
|
||||
}
|
||||
|
||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||
// config.RecommendationConfig and are propagated here per request.
|
||||
type ScoringWeights struct {
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
// Score computes the weighted-shuffle score per spec §6:
|
||||
@@ -48,6 +55,7 @@ type ScoringWeights struct {
|
||||
// + contextual_match_score * ContextWeight
|
||||
// + similarity_score * SimilarityWeight
|
||||
// + taste_match_score * TasteWeight
|
||||
// + context_affinity_score * ContextTimeWeight
|
||||
// + small_random_jitter
|
||||
//
|
||||
// Higher score = more likely to surface. rng is a function returning a
|
||||
@@ -63,6 +71,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
|
||||
s += in.ContextualMatchScore * w.ContextWeight
|
||||
s += in.SimilarityScore * w.SimilarityWeight
|
||||
s += in.TasteMatchScore * w.TasteWeight
|
||||
s += in.ContextAffinityScore * w.ContextTimeWeight
|
||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||
return s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user