feat(taste): era/decade taste facet — #1530
Milestone #160 Opt 2 (era half). A third taste facet alongside artists + genre tags: signed weights over decade buckets ("1990s") derived from albums.release_date, rebuilt daily and scored into the taste match. - Migration 0045: taste_profile_eras table (mirrors taste_profile_tags) + taste_tuning.era_scale column (DEFAULT 0.5). - Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors EnrichedTagScale), accumulate folds each play/like's decade at base*EraScale, persist atomic-replaces the era rows. - Scorer (internal/recommendation): TasteProfile gains an era term (own tanh scale + additive 0.15 share so it never weakens the existing artist/tag signal when a track is undated); candidate queries return album release_date; decadeOf mirrors the builder helper. - Tuning lab: era_scale threaded through recsettings + admin API + web card (auto-renders the new row) + Go/web tests. Mood facet deferred to #1534 (partial enrichment coverage + needs candidate-side enriched-tag loading). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ type tasteTuningResp struct {
|
||||
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||
EngagementFull float64 `json:"engagement_full"`
|
||||
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||
EraScale float64 `json:"era_scale"`
|
||||
}
|
||||
|
||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
@@ -58,6 +59,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
EngagementNeutral: t.EngagementNeutral,
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -554,6 +554,13 @@ type TasteProfileArtist struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileEra struct {
|
||||
UserID pgtype.UUID
|
||||
Era string
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileTag struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
@@ -569,6 +576,7 @@ type TasteTuning struct {
|
||||
EngagementFull float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
|
||||
@@ -585,8 +585,10 @@ SELECT
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
@@ -620,6 +622,7 @@ type LoadRadioCandidatesRow struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// Returns all tracks except the seed and any played by the user within
|
||||
@@ -660,6 +663,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -775,6 +779,7 @@ SELECT
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date,
|
||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||
FROM (
|
||||
SELECT track_id, sim_score FROM lb_similar
|
||||
@@ -785,6 +790,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(started_at) AS last_played_at,
|
||||
@@ -796,7 +802,8 @@ LEFT JOIN LATERAL (
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||
al.release_date
|
||||
`
|
||||
|
||||
type LoadRadioCandidatesV2Params struct {
|
||||
@@ -818,6 +825,7 @@ type LoadRadioCandidatesV2Row struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
SimilarityScore interface{}
|
||||
}
|
||||
|
||||
@@ -874,6 +882,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
&i.SimilarityScore,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale FROM taste_tuning WHERE singleton = true
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale FROM taste_tuning WHERE singleton = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
@@ -24,6 +24,7 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -122,9 +123,10 @@ UPDATE taste_tuning
|
||||
engagement_neutral = $3,
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale
|
||||
`
|
||||
|
||||
type UpdateTasteTuningParams struct {
|
||||
@@ -133,6 +135,7 @@ type UpdateTasteTuningParams struct {
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||
@@ -142,6 +145,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
arg.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
)
|
||||
var i TasteTuning
|
||||
err := row.Scan(
|
||||
@@ -152,6 +156,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -214,8 +219,8 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full, enriched_tag_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (singleton) DO NOTHING
|
||||
`
|
||||
|
||||
@@ -225,6 +230,7 @@ type UpsertTasteTuningDefaultsParams struct {
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||
@@ -234,6 +240,7 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
|
||||
arg.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileErasForUser = `-- name: DeleteTasteProfileErasForUser :exec
|
||||
DELETE FROM taste_profile_eras WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileErasForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||
`
|
||||
@@ -45,6 +54,22 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileEra = `-- name: InsertTasteProfileEra :exec
|
||||
INSERT INTO taste_profile_eras (user_id, era, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileEraParams struct {
|
||||
UserID pgtype.UUID
|
||||
Era string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProfileEraParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileEra, arg.UserID, arg.Era, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -87,21 +112,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
||||
}
|
||||
|
||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE gl.user_id = $1
|
||||
`
|
||||
|
||||
type ListLikedTrackTasteInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// (track_id, artist_id, genre) for each track the user has explicitly
|
||||
// liked. Feeds the track-like bonus into the liked track's artist and
|
||||
// tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
// (track_id, artist_id, genre, release_date) for each track the user has
|
||||
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||
// tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||
if err != nil {
|
||||
@@ -111,7 +138,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
||||
var items []ListLikedTrackTasteInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListLikedTrackTasteInputsForUserRow
|
||||
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.ReleaseDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -128,6 +160,7 @@ SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
a.release_date,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
@@ -135,6 +168,7 @@ SELECT
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
@@ -149,11 +183,12 @@ type ListPlayEngagementInputsForUserParams struct {
|
||||
}
|
||||
|
||||
type ListPlayEngagementInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
}
|
||||
|
||||
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
||||
@@ -162,7 +197,8 @@ type ListPlayEngagementInputsForUserRow struct {
|
||||
// One row per play in the decay-relevant window. completion is the play's
|
||||
// completion ratio (precomputed column when present, else duration_played /
|
||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
// is split into tags in Go. Quarantined tracks are excluded.
|
||||
// is split into tags in Go; release_date derives the decade for the era
|
||||
// facet (#1530). Quarantined tracks are excluded.
|
||||
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
@@ -176,6 +212,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.ReleaseDate,
|
||||
&i.Completion,
|
||||
&i.AgeDays,
|
||||
); err != nil {
|
||||
@@ -228,6 +265,45 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileErasForUser = `-- name: ListTasteProfileErasForUser :many
|
||||
SELECT era, weight
|
||||
FROM taste_profile_eras
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileErasForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileErasForUserRow struct {
|
||||
Era string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
|
||||
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileErasForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileErasForUserRow
|
||||
if err := rows.Scan(&i.Era, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS era_scale;
|
||||
DROP TABLE IF EXISTS taste_profile_eras;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 0045_taste_profile_eras.up.sql — era/decade taste facet (#1530,
|
||||
-- milestone #160 Opt 2). A third taste facet alongside artists + tags:
|
||||
-- signed weights over decade buckets ("1990s") derived from
|
||||
-- albums.release_date, rebuilt daily by internal/taste next to the
|
||||
-- artist/tag facets. Weight is a signed float — positive = drawn to
|
||||
-- that era, negative = passively avoided; magnitude reflects decayed
|
||||
-- engagement. Consumed by the recommendation scorer's taste match.
|
||||
-- Mirrors taste_profile_tags: CASCADE on user delete, indexed by
|
||||
-- (user_id, weight DESC) so "top eras" reads stay cheap.
|
||||
CREATE TABLE taste_profile_eras (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
era text NOT NULL,
|
||||
weight double precision NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, era)
|
||||
);
|
||||
|
||||
CREATE INDEX taste_profile_eras_user_weight_idx
|
||||
ON taste_profile_eras (user_id, weight DESC);
|
||||
|
||||
-- Era-facet build knob, tunable in the lab (mirrors enriched_tag_scale,
|
||||
-- #1520): scales how strongly a decade-play imprints on the profile.
|
||||
-- DEFAULT 0.5 backfills the existing singleton and matches
|
||||
-- taste.DefaultConfig().EraScale; 0 disables the era facet entirely.
|
||||
ALTER TABLE taste_tuning
|
||||
ADD COLUMN era_scale double precision NOT NULL DEFAULT 0.5;
|
||||
@@ -10,8 +10,10 @@ SELECT
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
@@ -145,6 +147,7 @@ SELECT
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date,
|
||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||
FROM (
|
||||
SELECT track_id, sim_score FROM lb_similar
|
||||
@@ -155,6 +158,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(started_at) AS last_played_at,
|
||||
@@ -166,7 +170,8 @@ LEFT JOIN LATERAL (
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||
al.release_date;
|
||||
|
||||
-- name: SuggestArtistsForUser :many
|
||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
|
||||
@@ -30,8 +30,8 @@ RETURNING *;
|
||||
-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full, enriched_tag_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (singleton) DO NOTHING;
|
||||
|
||||
-- name: GetTasteTuning :one
|
||||
@@ -44,6 +44,7 @@ UPDATE taste_tuning
|
||||
engagement_neutral = $3,
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
RETURNING *;
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
-- One row per play in the decay-relevant window. completion is the play's
|
||||
-- completion ratio (precomputed column when present, else duration_played /
|
||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
||||
-- is split into tags in Go; release_date derives the decade for the era
|
||||
-- facet (#1530). Quarantined tracks are excluded.
|
||||
SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
a.release_date,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
@@ -18,6 +20,7 @@ SELECT
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
@@ -26,12 +29,13 @@ WHERE pe.user_id = $1
|
||||
);
|
||||
|
||||
-- name: ListLikedTrackTasteInputsForUser :many
|
||||
-- (track_id, artist_id, genre) for each track the user has explicitly
|
||||
-- liked. Feeds the track-like bonus into the liked track's artist and
|
||||
-- tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
-- (track_id, artist_id, genre, release_date) for each track the user has
|
||||
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||
-- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE gl.user_id = $1;
|
||||
|
||||
-- name: ListLikedArtistIDsForUser :many
|
||||
@@ -66,3 +70,18 @@ FROM taste_profile_tags
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: DeleteTasteProfileErasForUser :exec
|
||||
DELETE FROM taste_profile_eras WHERE user_id = $1;
|
||||
|
||||
-- name: InsertTasteProfileEra :exec
|
||||
INSERT INTO taste_profile_eras (user_id, era, weight)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: ListTasteProfileErasForUser :many
|
||||
-- Top-weighted taste eras (#1530); consumed by the scorer's era term.
|
||||
SELECT era, weight
|
||||
FROM taste_profile_eras
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
@@ -57,7 +57,7 @@ func LoadCandidates(
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func LoadCandidatesFromSimilarity(
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
SimilarityScore: simScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -10,24 +11,34 @@ import (
|
||||
)
|
||||
|
||||
// Taste-match tuning. The taste profile (written by internal/taste) holds
|
||||
// signed, unbounded artist/tag weights; these scales squash them into a
|
||||
// signed, unbounded artist/tag/era weights; these scales squash them into a
|
||||
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
|
||||
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||
//
|
||||
// The era term (#1530) is added on TOP of artist+tag (shares don't sum to 1)
|
||||
// so it's a pure decade nudge that never weakens the existing artist/tag
|
||||
// signal when a track is undated; clampUnit bounds the combined result. Its
|
||||
// share is small — a decade is a coarse signal — and re-bakeable if the lab
|
||||
// shows it should carry more.
|
||||
const (
|
||||
tasteArtistScale = 4.0
|
||||
tasteTagScale = 3.0
|
||||
tasteEraScale = 4.0
|
||||
tasteArtistShare = 0.7
|
||||
tasteTagShare = 0.3
|
||||
tasteEraShare = 0.15
|
||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||
)
|
||||
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
||||
// weights over artists and genre tags. The zero value (and any unknown
|
||||
// artist/tag) contributes 0, so cold-start users get no taste effect.
|
||||
// weights over artists, genre tags, and decade/era buckets. The zero value
|
||||
// (and any unknown artist/tag/era) contributes 0, so cold-start users get no
|
||||
// taste effect.
|
||||
type TasteProfile struct {
|
||||
artists map[pgtype.UUID]float64
|
||||
tags map[string]float64
|
||||
eras map[string]float64
|
||||
}
|
||||
|
||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
||||
@@ -46,9 +57,16 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
eras, err := q.ListTasteProfileErasForUser(ctx, dbq.ListTasteProfileErasForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
p := TasteProfile{
|
||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||
tags: make(map[string]float64, len(tags)),
|
||||
eras: make(map[string]float64, len(eras)),
|
||||
}
|
||||
for _, a := range arts {
|
||||
p.artists[a.ArtistID] = a.Weight
|
||||
@@ -56,13 +74,16 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
for _, t := range tags {
|
||||
p.tags[t.Tag] = t.Weight
|
||||
}
|
||||
for _, e := range eras {
|
||||
p.eras[e.Era] = e.Weight
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
||||
// the artist's taste weight and the average of its genre tags' weights, each
|
||||
// tanh-squashed. Absent artist/tags contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||
// the artist's taste weight, the average of its genre tags' weights, and its
|
||||
// decade/era weight, each tanh-squashed. Absent artist/tags/era contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgtype.Date) float64 {
|
||||
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||
|
||||
var tg float64
|
||||
@@ -76,7 +97,22 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
|
||||
}
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
|
||||
|
||||
var er float64
|
||||
if decade := decadeOf(releaseDate); decade != "" {
|
||||
er = math.Tanh(p.eras[decade] / tasteEraScale)
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er)
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket ("1990s"), or "" for
|
||||
// an absent date. Mirrors the taste builder's helper (kept local to avoid a
|
||||
// cross-package dependency) so a candidate's era is derived exactly as learned.
|
||||
func decadeOf(d pgtype.Date) string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
|
||||
}
|
||||
|
||||
// clampUnit constrains x to [-1, 1].
|
||||
|
||||
@@ -17,6 +17,14 @@ func uuidN(n byte) pgtype.UUID {
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// noDate is an absent release date — the era term contributes 0.
|
||||
func noDate() pgtype.Date { return pgtype.Date{} }
|
||||
|
||||
// dateInYear builds a valid release date in the given year (era = its decade).
|
||||
func dateInYear(year int) pgtype.Date {
|
||||
return pgtype.Date{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
||||
}
|
||||
|
||||
func TestTasteProfile_Match(t *testing.T) {
|
||||
loved := uuidN(1)
|
||||
disliked := uuidN(2)
|
||||
@@ -26,32 +34,55 @@ func TestTasteProfile_Match(t *testing.T) {
|
||||
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
||||
}
|
||||
|
||||
if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 {
|
||||
if m := p.Match(loved, strPtr("Jazz"), noDate()); m <= 0.5 {
|
||||
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 {
|
||||
if m := p.Match(disliked, strPtr("Noise"), noDate()); m >= -0.5 {
|
||||
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
|
||||
}
|
||||
if m := p.Match(unknown, nil); m != 0 {
|
||||
if m := p.Match(unknown, nil, noDate()); m != 0 {
|
||||
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||
}
|
||||
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||
// clearly positive.
|
||||
if m := p.Match(loved, strPtr("Unheard")); m <= 0 {
|
||||
if m := p.Match(loved, strPtr("Unheard"), noDate()); m <= 0 {
|
||||
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||
}
|
||||
// Output stays within [-1, 1] even with saturated inputs.
|
||||
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
||||
m := p.Match(a, strPtr("Jazz"))
|
||||
m := p.Match(a, strPtr("Jazz"), dateInYear(1994))
|
||||
if m < -1 || m > 1 {
|
||||
t.Errorf("Match out of [-1,1]: %.3f", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTasteProfile_EraTerm verifies the decade facet nudges the match: with
|
||||
// artist + genre held neutral, a loved era lifts the score and a disliked era
|
||||
// lowers it, while an undated track is unaffected.
|
||||
func TestTasteProfile_EraTerm(t *testing.T) {
|
||||
art := uuidN(1)
|
||||
p := TasteProfile{
|
||||
artists: map[pgtype.UUID]float64{},
|
||||
tags: map[string]float64{},
|
||||
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
|
||||
}
|
||||
loved := p.Match(art, nil, dateInYear(1994))
|
||||
if loved <= 0 {
|
||||
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
|
||||
}
|
||||
disliked := p.Match(art, nil, dateInYear(1987))
|
||||
if disliked >= 0 {
|
||||
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate()); m != 0 {
|
||||
t.Errorf("undated track = %.3f, want 0 (no era contribution)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||
var p TasteProfile // zero value: nil maps
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 {
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994)); m != 0 {
|
||||
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
|
||||
}
|
||||
}
|
||||
@@ -100,10 +131,10 @@ func TestLoadTasteProfile_RoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 {
|
||||
if m := p.Match(art.ID, strPtr("Jazz"), noDate()); m <= 0.5 {
|
||||
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(uuidN(9), nil); m != 0 {
|
||||
if m := p.Match(uuidN(9), nil, noDate()); m != 0 {
|
||||
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
||||
target = &next.EngagementFull
|
||||
case "enriched_tag_scale":
|
||||
target = &next.EnrichedTagScale
|
||||
case "era_scale":
|
||||
target = &next.EraScale
|
||||
default:
|
||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||
}
|
||||
@@ -177,5 +179,6 @@ func diffTaste(a, b TasteTuning) []fieldChange {
|
||||
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
|
||||
add("engagement_full", a.EngagementFull, b.EngagementFull)
|
||||
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
|
||||
add("era_scale", a.EraScale, b.EraScale)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -38,14 +38,16 @@ const (
|
||||
)
|
||||
|
||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||
// half-life, the completion→engagement curve points, and the enriched-tag
|
||||
// weight (how much folksonomy tags count vs raw ID3 genre, #1520).
|
||||
// half-life, the completion→engagement curve points, the enriched-tag
|
||||
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
|
||||
// era-facet weight (how strongly a decade-play imprints, #1530).
|
||||
type TasteTuning struct {
|
||||
HalfLifeDays float64
|
||||
EngagementHardSkip float64
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
}
|
||||
|
||||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||
@@ -89,6 +91,7 @@ func ShippedTasteTuning() TasteTuning {
|
||||
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||||
EngagementFull: d.Engagement.FullCompletion,
|
||||
EnrichedTagScale: d.EnrichedTagScale,
|
||||
EraScale: d.EraScale,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +140,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
EngagementNeutral: st.EngagementNeutral,
|
||||
EngagementFull: st.EngagementFull,
|
||||
EnrichedTagScale: st.EnrichedTagScale,
|
||||
EraScale: st.EraScale,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("seed taste tuning: %w", err)
|
||||
}
|
||||
@@ -161,6 +165,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
EngagementNeutral: tt.EngagementNeutral,
|
||||
EngagementFull: tt.EngagementFull,
|
||||
EnrichedTagScale: tt.EnrichedTagScale,
|
||||
EraScale: tt.EraScale,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -211,6 +216,7 @@ func (s *Service) TasteConfig() taste.Config {
|
||||
FullCompletion: t.EngagementFull,
|
||||
}
|
||||
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||||
cfg.EraScale = t.EraScale
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -308,6 +314,7 @@ func (s *Service) persistTaste(
|
||||
EngagementNeutral: t.EngagementNeutral,
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update taste tuning: %w", err)
|
||||
}
|
||||
|
||||
@@ -203,6 +203,27 @@ func TestUpdateTaste_EnrichedTagScale(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaste_EraScale(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
// In-range update persists into cache + the assembled taste config.
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"era_scale": 0.8}); err != nil {
|
||||
t.Fatalf("UpdateTaste: %v", err)
|
||||
}
|
||||
if got := s.Taste().EraScale; got != 0.8 {
|
||||
t.Errorf("Taste().EraScale = %v, want 0.8", got)
|
||||
}
|
||||
if got := s.TasteConfig().EraScale; got != 0.8 {
|
||||
t.Errorf("TasteConfig().EraScale = %v, want 0.8", got)
|
||||
}
|
||||
// Out of [0,1] rejects.
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"era_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
|
||||
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
|
||||
+71
-16
@@ -46,6 +46,14 @@ type Config struct {
|
||||
// contribution entirely (falls back to genre-only).
|
||||
EnrichedTagScale float64
|
||||
|
||||
// EraScale weights the decade/era facet (#1530): each play adds
|
||||
// base × this scale to its album's decade bucket ("1990s"), where base
|
||||
// is the decayed engagement (or the tag-like bonus for a liked track).
|
||||
// Era is a coarse signal, so it's kept ≤ 1 to imprint at most as strongly
|
||||
// as the raw engagement; 0 disables the era facet entirely. Tunable in the
|
||||
// lab (mirrors EnrichedTagScale, #1520).
|
||||
EraScale float64
|
||||
|
||||
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||
// go. Aggregation already protects an artist the user likes (one skip
|
||||
// nets out against many good plays); the floor additionally bounds the
|
||||
@@ -58,11 +66,15 @@ type Config struct {
|
||||
WeightEpsilon float64
|
||||
|
||||
// Size caps (guard rails for pathological libraries). Persist the top
|
||||
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags.
|
||||
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags
|
||||
// and eras. Real libraries span ~a dozen decades, so the era caps rarely
|
||||
// bind — they exist for symmetry with the other facets.
|
||||
MaxArtists int
|
||||
MaxNegArtists int
|
||||
MaxTags int
|
||||
MaxNegTags int
|
||||
MaxEras int
|
||||
MaxNegEras int
|
||||
}
|
||||
|
||||
// DefaultConfig returns the tuned starting configuration.
|
||||
@@ -75,6 +87,7 @@ func DefaultConfig() Config {
|
||||
TrackLikeBonus: 1.0,
|
||||
TagLikeBonus: 0.5,
|
||||
EnrichedTagScale: 0.5,
|
||||
EraScale: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
@@ -82,6 +95,8 @@ func DefaultConfig() Config {
|
||||
MaxNegArtists: 150,
|
||||
MaxTags: 200,
|
||||
MaxNegTags: 60,
|
||||
MaxEras: 30,
|
||||
MaxNegEras: 15,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,36 +116,40 @@ func BuildTasteProfile(
|
||||
) error {
|
||||
q := dbq.New(pool)
|
||||
|
||||
artistW, tagW, err := accumulate(ctx, q, userID, cfg)
|
||||
artistW, tagW, eraW, err := accumulate(ctx, q, userID, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyFloor(artistW, cfg.ArtistFloor)
|
||||
applyFloor(tagW, cfg.TagFloor)
|
||||
// Era shares the tag floor (both are text-keyed facets; a decade that's
|
||||
// been heavily skipped shouldn't dominate as an extreme negative either).
|
||||
applyFloor(eraW, cfg.TagFloor)
|
||||
|
||||
strLess := func(a, b string) bool { return a < b }
|
||||
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
|
||||
func(a, b string) bool { return a < b })
|
||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
|
||||
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
|
||||
|
||||
if err := persist(ctx, pool, userID, artists, tags); err != nil {
|
||||
if err := persist(ctx, pool, userID, artists, tags, eras); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Debug("taste: profile rebuilt",
|
||||
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags))
|
||||
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
|
||||
"artists", len(artists), "tags", len(tags), "eras", len(eras))
|
||||
return nil
|
||||
}
|
||||
|
||||
// accumulate sums decayed play engagement and like bonuses into artist/tag
|
||||
// accumulate sums decayed play engagement and like bonuses into artist/tag/era
|
||||
// weight maps.
|
||||
func accumulate(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
|
||||
) (map[pgtype.UUID]float64, map[string]float64, error) {
|
||||
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, error) {
|
||||
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: cfg.WindowDays,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
}
|
||||
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
|
||||
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
|
||||
@@ -141,12 +160,13 @@ func accumulate(
|
||||
Column2: int32(cfg.WindowDays),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
}
|
||||
playedTags := groupTagsByTrack(playedTagRows)
|
||||
|
||||
artistW := make(map[pgtype.UUID]float64)
|
||||
tagW := make(map[string]float64)
|
||||
eraW := make(map[string]float64)
|
||||
for _, p := range plays {
|
||||
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
||||
artistW[p.ArtistID] += e
|
||||
@@ -154,15 +174,16 @@ func accumulate(
|
||||
tagW[tag] += e
|
||||
}
|
||||
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
|
||||
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
|
||||
}
|
||||
|
||||
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
}
|
||||
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
}
|
||||
likedTags := groupTagsByTrack(likedTagRows)
|
||||
for _, lt := range likedTracks {
|
||||
@@ -171,16 +192,17 @@ func accumulate(
|
||||
tagW[tag] += cfg.TagLikeBonus
|
||||
}
|
||||
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
|
||||
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
|
||||
}
|
||||
|
||||
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
}
|
||||
for _, aid := range likedArtists {
|
||||
artistW[aid] += cfg.ArtistLikeBonus
|
||||
}
|
||||
return artistW, tagW, nil
|
||||
return artistW, tagW, eraW, nil
|
||||
}
|
||||
|
||||
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
|
||||
@@ -206,10 +228,33 @@ func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale
|
||||
}
|
||||
}
|
||||
|
||||
// foldEra adds a track's decade bucket to eraW weighted by base × scale — base
|
||||
// is the play's decayed engagement or the tag-like bonus, scale (EraScale)
|
||||
// dials the facet's strength. scale=0 disables the era facet; undated tracks
|
||||
// (decade "") contribute nothing so they don't pollute the profile.
|
||||
func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
|
||||
if scale == 0 {
|
||||
return
|
||||
}
|
||||
if decade := decadeOf(d); decade != "" {
|
||||
eraW[decade] += base * scale
|
||||
}
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket like "1990s", or ""
|
||||
// for an absent/invalid date. Mirrored by the recommendation scorer so a
|
||||
// candidate's era is derived the same way it was learned.
|
||||
func decadeOf(d pgtype.Date) string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
|
||||
}
|
||||
|
||||
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||
func persist(
|
||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
artists []weighted[pgtype.UUID], tags []weighted[string],
|
||||
artists []weighted[pgtype.UUID], tags, eras []weighted[string],
|
||||
) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -238,6 +283,16 @@ func persist(
|
||||
return fmt.Errorf("taste: insert tag: %w", err)
|
||||
}
|
||||
}
|
||||
if err := qtx.DeleteTasteProfileErasForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("taste: delete eras: %w", err)
|
||||
}
|
||||
for _, e := range eras {
|
||||
if err := qtx.InsertTasteProfileEra(ctx, dbq.InsertTasteProfileEraParams{
|
||||
UserID: userID, Era: e.Key, Weight: e.Weight,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("taste: insert era: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("taste: commit: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user