diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go index ae6c7901..6d325378 100644 --- a/internal/playlists/scheduler.go +++ b/internal/playlists/scheduler.go @@ -227,7 +227,7 @@ func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiv // build can read a fresh profile (phase-2 consumption); both steps are // best-effort and a failure in one is logged without blocking the other. func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) { - if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, now, taste.DefaultConfig()); err != nil { + if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil { s.logger.Warn("scheduler: taste profile rebuild failed", "user_id", uuidStringPL(userID), "err", err) } diff --git a/internal/taste/engagement_test.go b/internal/taste/engagement_test.go index 3c1fd1f8..38c1c48e 100644 --- a/internal/taste/engagement_test.go +++ b/internal/taste/engagement_test.go @@ -44,11 +44,15 @@ func TestEngagement_MonotonicNonDecreasing(t *testing.T) { } } -func TestEngagement_DegenerateParamsNoPanic(t *testing.T) { - // Non-increasing thresholds must collapse ramps, not divide by zero. +func TestEngagement_DegenerateParamsStayInRange(t *testing.T) { + // Non-increasing thresholds must collapse ramps (no divide-by-zero) and + // still produce a value in [-1, 1]. p := EngagementParams{HardSkip: 0.5, NeutralCompletion: 0.5, FullCompletion: 0.5} for _, c := range []float64{0, 0.25, 0.5, 0.75, 1} { - _ = Engagement(c, p) + got := Engagement(c, p) + if got < -1 || got > 1 { + t.Errorf("Engagement(%.2f) = %.3f out of [-1,1]", c, got) + } } } diff --git a/internal/taste/profile.go b/internal/taste/profile.go index 195e93fc..c9696bdf 100644 --- a/internal/taste/profile.go +++ b/internal/taste/profile.go @@ -11,7 +11,6 @@ import ( "log/slog" "sort" "strings" - "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -81,9 +80,13 @@ func DefaultConfig() Config { // Self-contained transaction for the replace. Returns an error only on a // fatal DB failure; the daily caller logs and proceeds to playlist building // regardless (best-effort). +// +// The decay reference time is the database clock (age is computed in SQL via +// now()), so no Go-side timestamp is threaded here; a phase-3 context model +// that needs a pinned reference time would add one. func BuildTasteProfile( ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, - userID pgtype.UUID, now time.Time, cfg Config, + userID pgtype.UUID, cfg Config, ) error { q := dbq.New(pool) diff --git a/internal/taste/profile_db_test.go b/internal/taste/profile_db_test.go index 2d9968a8..8f301154 100644 --- a/internal/taste/profile_db_test.go +++ b/internal/taste/profile_db_test.go @@ -155,7 +155,7 @@ func TestBuildTasteProfile_PositiveVsNegative(t *testing.T) { seedPlay(t, pool, u.ID, skippedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 0.02) } - if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, now, DefaultConfig()); err != nil { + if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build: %v", err) } @@ -190,7 +190,7 @@ func TestBuildTasteProfile_AggregationProtectsArtist(t *testing.T) { skipTrack := seedTrack(t, pool, fav.ID, "Indie") seedPlay(t, pool, u.ID, skipTrack.ID, now.Add(-2*time.Hour), 0.02) - if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, now, DefaultConfig()); err != nil { + if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build: %v", err) } w, ok := artistWeight(t, pool, u.ID, fav.ID) @@ -203,7 +203,6 @@ func TestBuildTasteProfile_AggregationProtectsArtist(t *testing.T) { // plays still earns a positive weight from the like bonus. func TestBuildTasteProfile_ArtistLikeBonus(t *testing.T) { pool := newPool(t) - now := time.Now().UTC() u := seedUser(t, pool, "tp3") liked := seedArtist(t, pool, "LikedOnly") if _, err := pool.Exec(context.Background(), @@ -212,7 +211,7 @@ func TestBuildTasteProfile_ArtistLikeBonus(t *testing.T) { t.Fatalf("seed artist like: %v", err) } - if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, now, DefaultConfig()); err != nil { + if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build: %v", err) } w, ok := artistWeight(t, pool, u.ID, liked.ID) @@ -240,7 +239,7 @@ func TestBuildTasteProfile_AtomicReplace(t *testing.T) { return n } for i := 0; i < 2; i++ { - if err := BuildTasteProfile(ctx, pool, discardLogger(), u.ID, now, DefaultConfig()); err != nil { + if err := BuildTasteProfile(ctx, pool, discardLogger(), u.ID, DefaultConfig()); err != nil { t.Fatalf("build %d: %v", i, err) } }