From 65dd132b3dd49990e72561649d558fdd0eaa0ae9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 14 Jul 2026 09:31:43 -0400 Subject: [PATCH] =?UTF-8?q?feat(taste):=20time-of-day=20/=20weekday=20cont?= =?UTF-8?q?ext=20conditioning=20=E2=80=94=20#1531?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/api/admin_recommendation_tuning.go | 34 +++---- internal/db/dbq/models.go | 21 +++-- internal/db/dbq/recommendation.sql.go | 77 ++++++++++++++++ internal/db/dbq/recommendation_tuning.sql.go | 70 +++++++------- .../0046_context_time_weight.down.sql | 1 + .../0046_context_time_weight.up.sql | 12 +++ internal/db/queries/recommendation.sql | 50 ++++++++++ internal/db/queries/recommendation_tuning.sql | 24 ++--- internal/playlists/system.go | 3 + internal/recommendation/candidates.go | 12 +++ internal/recommendation/context.go | 70 ++++++++++++++ internal/recommendation/context_test.go | 57 ++++++++++++ internal/recommendation/score.go | 25 +++-- internal/recsettings/patch.go | 4 + internal/recsettings/service.go | 91 ++++++++++--------- web/src/lib/api/tuning.ts | 1 + web/src/routes/admin/tuning/+page.svelte | 3 +- web/src/routes/admin/tuning/tuning.test.ts | 1 + 18 files changed, 437 insertions(+), 119 deletions(-) create mode 100644 internal/db/migrations/0046_context_time_weight.down.sql create mode 100644 internal/db/migrations/0046_context_time_weight.up.sql create mode 100644 internal/recommendation/context.go create mode 100644 internal/recommendation/context_test.go diff --git a/internal/api/admin_recommendation_tuning.go b/internal/api/admin_recommendation_tuning.go index 25f7dfb2..c10ab012 100644 --- a/internal/api/admin_recommendation_tuning.go +++ b/internal/api/admin_recommendation_tuning.go @@ -20,26 +20,28 @@ import ( // weightsResp is one weight profile on the wire, keyed by the same // snake_case field names the PATCH body accepts. type weightsResp struct { - BaseWeight float64 `json:"base_weight"` - LikeBoost float64 `json:"like_boost"` - RecencyWeight float64 `json:"recency_weight"` - SkipPenalty float64 `json:"skip_penalty"` - JitterMagnitude float64 `json:"jitter_magnitude"` - ContextWeight float64 `json:"context_weight"` - SimilarityWeight float64 `json:"similarity_weight"` - TasteWeight float64 `json:"taste_weight"` + BaseWeight float64 `json:"base_weight"` + LikeBoost float64 `json:"like_boost"` + RecencyWeight float64 `json:"recency_weight"` + SkipPenalty float64 `json:"skip_penalty"` + JitterMagnitude float64 `json:"jitter_magnitude"` + ContextWeight float64 `json:"context_weight"` + SimilarityWeight float64 `json:"similarity_weight"` + TasteWeight float64 `json:"taste_weight"` + ContextTimeWeight float64 `json:"context_time_weight"` } func weightsRespFrom(w recommendation.ScoringWeights) weightsResp { return weightsResp{ - BaseWeight: w.BaseWeight, - LikeBoost: w.LikeBoost, - RecencyWeight: w.RecencyWeight, - SkipPenalty: w.SkipPenalty, - JitterMagnitude: w.JitterMagnitude, - ContextWeight: w.ContextWeight, - SimilarityWeight: w.SimilarityWeight, - TasteWeight: w.TasteWeight, + BaseWeight: w.BaseWeight, + LikeBoost: w.LikeBoost, + RecencyWeight: w.RecencyWeight, + SkipPenalty: w.SkipPenalty, + JitterMagnitude: w.JitterMagnitude, + ContextWeight: w.ContextWeight, + SimilarityWeight: w.SimilarityWeight, + TasteWeight: w.TasteWeight, + ContextTimeWeight: w.ContextTimeWeight, } } diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index ad5ada62..6fc2258b 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -446,16 +446,17 @@ type RecommendationTuningAudit struct { } type RecommendationWeightProfile struct { - Profile string - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 - ContextWeight float64 - SimilarityWeight float64 - TasteWeight float64 - UpdatedAt pgtype.Timestamptz + Profile string + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 + TasteWeight float64 + UpdatedAt pgtype.Timestamptz + ContextTimeWeight float64 } type RegistrationSetting struct { diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 31cda036..8ea0516d 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -11,6 +11,83 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many +WITH tz AS ( + SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone + FROM users u WHERE u.id = $1 +), +now_cell AS ( + SELECT + CASE + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3 + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0 + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1 + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2 + ELSE 3 + END AS daypart, + (EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend + FROM tz +), +plays AS ( + SELECT t.artist_id, + CASE + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3 + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0 + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1 + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2 + ELSE 3 + END AS daypart, + (EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + CROSS JOIN tz + WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND pe.started_at > now() - interval '365 days' +) +SELECT p.artist_id, + count(*) AS total_plays, + count(*) FILTER ( + WHERE p.daypart = (SELECT daypart FROM now_cell) + AND p.is_weekend = (SELECT is_weekend FROM now_cell) + ) AS cell_plays +FROM plays p +GROUP BY p.artist_id +` + +type ListArtistContextPlayCountsForUserRow struct { + ArtistID pgtype.UUID + TotalPlays int64 + CellPlays int64 +} + +// Per-artist completed-play counts split by whether each play falls in the +// CURRENT daypart × weekday-type cell, in the user's local timezone (#1531). +// Feeds the context-affinity scoring term: an artist whose plays concentrate +// in the current cell (vs the user's overall baseline, computed Go-side from +// these rows) gets boosted right now. Skips excluded; a 365-day window bounds +// cost. Daypart buckets: night [22,5) morning [5,12) afternoon [12,17) +// evening [17,22). Weekend = ISO days 6–7 (Sat/Sun). +func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, id pgtype.UUID) ([]ListArtistContextPlayCountsForUserRow, error) { + rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, id) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListArtistContextPlayCountsForUserRow + for rows.Next() { + var i ListArtistContextPlayCountsForUserRow + if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many WITH user_plays AS ( SELECT t.artist_id, max(pe.started_at) AS last_started diff --git a/internal/db/dbq/recommendation_tuning.sql.go b/internal/db/dbq/recommendation_tuning.sql.go index 2f6437cc..bd1dc787 100644 --- a/internal/db/dbq/recommendation_tuning.sql.go +++ b/internal/db/dbq/recommendation_tuning.sql.go @@ -82,7 +82,7 @@ func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]Recommend } const listWeightProfiles = `-- name: ListWeightProfiles :many -SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile +SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight FROM recommendation_weight_profiles ORDER BY profile ` func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) { @@ -105,6 +105,7 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh &i.SimilarityWeight, &i.TasteWeight, &i.UpdatedAt, + &i.ContextTimeWeight, ); err != nil { return nil, err } @@ -163,29 +164,31 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa const updateWeightProfile = `-- name: UpdateWeightProfile :one UPDATE recommendation_weight_profiles - SET base_weight = $2, - like_boost = $3, - recency_weight = $4, - skip_penalty = $5, - jitter_magnitude = $6, - context_weight = $7, - similarity_weight = $8, - taste_weight = $9, - updated_at = now() + SET base_weight = $2, + like_boost = $3, + recency_weight = $4, + skip_penalty = $5, + jitter_magnitude = $6, + context_weight = $7, + similarity_weight = $8, + taste_weight = $9, + context_time_weight = $10, + updated_at = now() WHERE profile = $1 -RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at +RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight ` type UpdateWeightProfileParams struct { - Profile string - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 - ContextWeight float64 - SimilarityWeight float64 - TasteWeight float64 + Profile string + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 + TasteWeight float64 + ContextTimeWeight float64 } func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) { @@ -199,6 +202,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi arg.ContextWeight, arg.SimilarityWeight, arg.TasteWeight, + arg.ContextTimeWeight, ) var i RecommendationWeightProfile err := row.Scan( @@ -212,6 +216,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi &i.SimilarityWeight, &i.TasteWeight, &i.UpdatedAt, + &i.ContextTimeWeight, ) return i, err } @@ -249,21 +254,23 @@ const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec INSERT INTO recommendation_weight_profiles ( profile, base_weight, like_boost, recency_weight, skip_penalty, - jitter_magnitude, context_weight, similarity_weight, taste_weight -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + jitter_magnitude, context_weight, similarity_weight, taste_weight, + context_time_weight +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (profile) DO NOTHING ` type UpsertWeightProfileDefaultsParams struct { - Profile string - BaseWeight float64 - LikeBoost float64 - RecencyWeight float64 - SkipPenalty float64 - JitterMagnitude float64 - ContextWeight float64 - SimilarityWeight float64 - TasteWeight float64 + Profile string + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 + TasteWeight float64 + ContextTimeWeight float64 } // Recommendation tuning lab queries (#1250). Seeding happens via the @@ -281,6 +288,7 @@ func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWei arg.ContextWeight, arg.SimilarityWeight, arg.TasteWeight, + arg.ContextTimeWeight, ) return err } diff --git a/internal/db/migrations/0046_context_time_weight.down.sql b/internal/db/migrations/0046_context_time_weight.down.sql new file mode 100644 index 00000000..d2be5249 --- /dev/null +++ b/internal/db/migrations/0046_context_time_weight.down.sql @@ -0,0 +1 @@ +ALTER TABLE recommendation_weight_profiles DROP COLUMN IF EXISTS context_time_weight; diff --git a/internal/db/migrations/0046_context_time_weight.up.sql b/internal/db/migrations/0046_context_time_weight.up.sql new file mode 100644 index 00000000..6b7b7388 --- /dev/null +++ b/internal/db/migrations/0046_context_time_weight.up.sql @@ -0,0 +1,12 @@ +-- 0046_context_time_weight.up.sql — time-of-day/weekday context conditioning +-- (#1531, milestone #160 Opt 3). Adds a per-profile scoring weight for the new +-- context-affinity term: how strongly a candidate is boosted when its artist's +-- play history concentrates in the current daypart × weekday-type cell (in the +-- user's local timezone). Mirrors the other ScoringWeights columns. +-- +-- DEFAULT 1.0 backfills both existing profile rows to a modest on-value; the +-- Go ShippedRadioWeights/ShippedDailyMixWeights carry the same 1.0 so fresh +-- installs seed identically. Reconcile only seeds MISSING rows (ON CONFLICT DO +-- NOTHING), so existing rows rely on this DEFAULT until an operator resets. +ALTER TABLE recommendation_weight_profiles + ADD COLUMN context_time_weight double precision NOT NULL DEFAULT 1.0; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index a7cd70bd..954e1d28 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -173,6 +173,56 @@ GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path, l.user_id, pe.last_played_at, pe.play_count, pe.skip_count, al.release_date; +-- name: ListArtistContextPlayCountsForUser :many +-- Per-artist completed-play counts split by whether each play falls in the +-- CURRENT daypart × weekday-type cell, in the user's local timezone (#1531). +-- Feeds the context-affinity scoring term: an artist whose plays concentrate +-- in the current cell (vs the user's overall baseline, computed Go-side from +-- these rows) gets boosted right now. Skips excluded; a 365-day window bounds +-- cost. Daypart buckets: night [22,5) morning [5,12) afternoon [12,17) +-- evening [17,22). Weekend = ISO days 6–7 (Sat/Sun). +WITH tz AS ( + SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone + FROM users u WHERE u.id = $1 +), +now_cell AS ( + SELECT + CASE + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3 + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0 + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1 + WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2 + ELSE 3 + END AS daypart, + (EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend + FROM tz +), +plays AS ( + SELECT t.artist_id, + CASE + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3 + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0 + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1 + WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2 + ELSE 3 + END AS daypart, + (EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + CROSS JOIN tz + WHERE pe.user_id = $1 + AND pe.was_skipped = false + AND pe.started_at > now() - interval '365 days' +) +SELECT p.artist_id, + count(*) AS total_plays, + count(*) FILTER ( + WHERE p.daypart = (SELECT daypart FROM now_cell) + AND p.is_weekend = (SELECT is_weekend FROM now_cell) + ) AS cell_plays +FROM plays p +GROUP BY p.artist_id; + -- name: SuggestArtistsForUser :many -- M5c: per-user artist suggestions ranked by signal x similarity. The -- seeds CTE collects the user's likes (x5) plus recency-decayed plays diff --git a/internal/db/queries/recommendation_tuning.sql b/internal/db/queries/recommendation_tuning.sql index 1d6124db..a627fb5b 100644 --- a/internal/db/queries/recommendation_tuning.sql +++ b/internal/db/queries/recommendation_tuning.sql @@ -6,8 +6,9 @@ -- doesn't exist yet. Never overwrites operator-tuned values. INSERT INTO recommendation_weight_profiles ( profile, base_weight, like_boost, recency_weight, skip_penalty, - jitter_magnitude, context_weight, similarity_weight, taste_weight -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + jitter_magnitude, context_weight, similarity_weight, taste_weight, + context_time_weight +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (profile) DO NOTHING; -- name: ListWeightProfiles :many @@ -15,15 +16,16 @@ SELECT * FROM recommendation_weight_profiles ORDER BY profile; -- name: UpdateWeightProfile :one UPDATE recommendation_weight_profiles - SET base_weight = $2, - like_boost = $3, - recency_weight = $4, - skip_penalty = $5, - jitter_magnitude = $6, - context_weight = $7, - similarity_weight = $8, - taste_weight = $9, - updated_at = now() + SET base_weight = $2, + like_boost = $3, + recency_weight = $4, + skip_penalty = $5, + jitter_magnitude = $6, + context_weight = $7, + similarity_weight = $8, + taste_weight = $9, + context_time_weight = $10, + updated_at = now() WHERE profile = $1 RETURNING *; diff --git a/internal/playlists/system.go b/internal/playlists/system.go index ce822426..fc00e7a7 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -215,6 +215,9 @@ var ( // [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost // while passive avoidance (negative) gently demotes. TasteWeight: 1.5, + // Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts + // uniform with radio pending trend data. + ContextTimeWeight: 1.0, } systemTasteConfig = taste.DefaultConfig() ) diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 2dd3a7e9..9a2a6afe 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -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), }, }) } diff --git a/internal/recommendation/context.go b/internal/recommendation/context.go new file mode 100644 index 00000000..ad36193f --- /dev/null +++ b/internal/recommendation/context.go @@ -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) +} diff --git a/internal/recommendation/context_test.go b/internal/recommendation/context_test.go new file mode 100644 index 00000000..56437b6d --- /dev/null +++ b/internal/recommendation/context_test.go @@ -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) + } +} diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index c3b9a9ef..3876519b 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -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 } diff --git a/internal/recsettings/patch.go b/internal/recsettings/patch.go index ac955f67..43ca51d2 100644 --- a/internal/recsettings/patch.go +++ b/internal/recsettings/patch.go @@ -67,6 +67,10 @@ var weightFields = map[string]weightField{ get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight }, set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v }, }, + "context_time_weight": { + get: func(w recommendation.ScoringWeights) float64 { return w.ContextTimeWeight }, + set: func(w *recommendation.ScoringWeights, v float64) { w.ContextTimeWeight = v }, + }, } // applyWeightPatch validates and applies a partial update, returning diff --git a/internal/recsettings/service.go b/internal/recsettings/service.go index 2e19b597..d8331ee5 100644 --- a/internal/recsettings/service.go +++ b/internal/recsettings/service.go @@ -54,16 +54,19 @@ type TasteTuning struct { // here from config.RecommendationConfig — YAML is bootstrap-only, // rule: config in UI). Radio is seed-directed (the user picked a // direction), so taste is a lighter nudge than in the daily mixes. +// ContextTimeWeight starts uniform (1.0) across both profiles pending +// trend data (#1531); split them once the metrics view justifies it. func ShippedRadioWeights() recommendation.ScoringWeights { return recommendation.ScoringWeights{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - SimilarityWeight: 2.0, - TasteWeight: 1.0, + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 1.0, + JitterMagnitude: 0.1, + ContextWeight: 2.0, + SimilarityWeight: 2.0, + TasteWeight: 1.0, + ContextTimeWeight: 1.0, } } @@ -71,14 +74,15 @@ func ShippedRadioWeights() recommendation.ScoringWeights { // Must stay in sync with the pre-push literal in playlists/system.go. func ShippedDailyMixWeights() recommendation.ScoringWeights { return recommendation.ScoringWeights{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 2.0, - JitterMagnitude: 0.1, - ContextWeight: 0.5, - SimilarityWeight: 1.5, - TasteWeight: 1.5, + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 2.0, + JitterMagnitude: 0.1, + ContextWeight: 0.5, + SimilarityWeight: 1.5, + TasteWeight: 1.5, + ContextTimeWeight: 1.0, } } @@ -348,41 +352,44 @@ func (s *Service) audit( func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams { return dbq.UpsertWeightProfileDefaultsParams{ - Profile: profile, - BaseWeight: w.BaseWeight, - LikeBoost: w.LikeBoost, - RecencyWeight: w.RecencyWeight, - SkipPenalty: w.SkipPenalty, - JitterMagnitude: w.JitterMagnitude, - ContextWeight: w.ContextWeight, - SimilarityWeight: w.SimilarityWeight, - TasteWeight: w.TasteWeight, + Profile: profile, + BaseWeight: w.BaseWeight, + LikeBoost: w.LikeBoost, + RecencyWeight: w.RecencyWeight, + SkipPenalty: w.SkipPenalty, + JitterMagnitude: w.JitterMagnitude, + ContextWeight: w.ContextWeight, + SimilarityWeight: w.SimilarityWeight, + TasteWeight: w.TasteWeight, + ContextTimeWeight: w.ContextTimeWeight, } } func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams { return dbq.UpdateWeightProfileParams{ - Profile: profile, - BaseWeight: w.BaseWeight, - LikeBoost: w.LikeBoost, - RecencyWeight: w.RecencyWeight, - SkipPenalty: w.SkipPenalty, - JitterMagnitude: w.JitterMagnitude, - ContextWeight: w.ContextWeight, - SimilarityWeight: w.SimilarityWeight, - TasteWeight: w.TasteWeight, + Profile: profile, + BaseWeight: w.BaseWeight, + LikeBoost: w.LikeBoost, + RecencyWeight: w.RecencyWeight, + SkipPenalty: w.SkipPenalty, + JitterMagnitude: w.JitterMagnitude, + ContextWeight: w.ContextWeight, + SimilarityWeight: w.SimilarityWeight, + TasteWeight: w.TasteWeight, + ContextTimeWeight: w.ContextTimeWeight, } } func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights { return recommendation.ScoringWeights{ - BaseWeight: r.BaseWeight, - LikeBoost: r.LikeBoost, - RecencyWeight: r.RecencyWeight, - SkipPenalty: r.SkipPenalty, - JitterMagnitude: r.JitterMagnitude, - ContextWeight: r.ContextWeight, - SimilarityWeight: r.SimilarityWeight, - TasteWeight: r.TasteWeight, + BaseWeight: r.BaseWeight, + LikeBoost: r.LikeBoost, + RecencyWeight: r.RecencyWeight, + SkipPenalty: r.SkipPenalty, + JitterMagnitude: r.JitterMagnitude, + ContextWeight: r.ContextWeight, + SimilarityWeight: r.SimilarityWeight, + TasteWeight: r.TasteWeight, + ContextTimeWeight: r.ContextTimeWeight, } } diff --git a/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts index 7c7178f7..d27e1af9 100644 --- a/web/src/lib/api/tuning.ts +++ b/web/src/lib/api/tuning.ts @@ -13,6 +13,7 @@ export type WeightProfile = { context_weight: number; similarity_weight: number; taste_weight: number; + context_time_weight: number; }; export type TasteTuning = { diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte index 0b5d96ec..47acd4cc 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -29,7 +29,8 @@ { key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' }, { key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' }, { key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' }, - { key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' } + { key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }, + { key: 'context_time_weight', label: 'Time-of-day weight', hint: "Artist's time-of-day/weekday affinity for the current context, in [-1, +1]. 0 = ignore when you listen." } ]; const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [ diff --git a/web/src/routes/admin/tuning/tuning.test.ts b/web/src/routes/admin/tuning/tuning.test.ts index 57152ad0..02ae3e9e 100644 --- a/web/src/routes/admin/tuning/tuning.test.ts +++ b/web/src/routes/admin/tuning/tuning.test.ts @@ -28,6 +28,7 @@ const weights = (over: Partial> = {}) => ({ context_weight: 0.5, similarity_weight: 1.5, taste_weight: 1.5, + context_time_weight: 1, ...over });