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:
+11
-10
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recommendation_weight_profiles DROP COLUMN IF EXISTS context_time_weight;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user