feat(taste): era/decade taste facet — #1530
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s

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:
2026-07-14 09:01:00 -04:00
parent 40056d2e9a
commit 40384cc05e
20 changed files with 376 additions and 65 deletions
@@ -49,6 +49,7 @@ type tasteTuningResp struct {
EngagementNeutral float64 `json:"engagement_neutral"` EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"` EngagementFull float64 `json:"engagement_full"`
EnrichedTagScale float64 `json:"enriched_tag_scale"` EnrichedTagScale float64 `json:"enriched_tag_scale"`
EraScale float64 `json:"era_scale"`
} }
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp { func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
@@ -58,6 +59,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
EngagementNeutral: t.EngagementNeutral, EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull, EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale, EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
} }
} }
+8
View File
@@ -554,6 +554,13 @@ type TasteProfileArtist struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type TasteProfileEra struct {
UserID pgtype.UUID
Era string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileTag struct { type TasteProfileTag struct {
UserID pgtype.UUID UserID pgtype.UUID
Tag string Tag string
@@ -569,6 +576,7 @@ type TasteTuning struct {
EngagementFull float64 EngagementFull float64
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
EnrichedTagScale float64 EnrichedTagScale float64
EraScale float64
} }
type Track struct { type Track struct {
+11 -2
View File
@@ -585,8 +585,10 @@ SELECT
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
pe.skip_count pe.skip_count,
al.release_date AS release_date
FROM tracks t 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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
@@ -620,6 +622,7 @@ type LoadRadioCandidatesRow struct {
LastPlayedAt pgtype.Timestamptz LastPlayedAt pgtype.Timestamptz
PlayCount int64 PlayCount int64
SkipCount int64 SkipCount int64
ReleaseDate pgtype.Date
} }
// Returns all tracks except the seed and any played by the user within // 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.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
&i.SkipCount, &i.SkipCount,
&i.ReleaseDate,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -775,6 +779,7 @@ SELECT
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
pe.skip_count, pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM ( FROM (
SELECT track_id, sim_score FROM lb_similar SELECT track_id, sim_score FROM lb_similar
@@ -785,6 +790,7 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM random_fill UNION ALL SELECT track_id, sim_score FROM random_fill
) u ) u
JOIN tracks t ON t.id = u.track_id 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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at, 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, 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.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at, 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 { type LoadRadioCandidatesV2Params struct {
@@ -818,6 +825,7 @@ type LoadRadioCandidatesV2Row struct {
LastPlayedAt pgtype.Timestamptz LastPlayedAt pgtype.Timestamptz
PlayCount int64 PlayCount int64
SkipCount int64 SkipCount int64
ReleaseDate pgtype.Date
SimilarityScore interface{} SimilarityScore interface{}
} }
@@ -874,6 +882,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.LastPlayedAt, &i.LastPlayedAt,
&i.PlayCount, &i.PlayCount,
&i.SkipCount, &i.SkipCount,
&i.ReleaseDate,
&i.SimilarityScore, &i.SimilarityScore,
); err != nil { ); err != nil {
return nil, err return nil, err
+11 -4
View File
@@ -10,7 +10,7 @@ import (
) )
const getTasteTuning = `-- name: GetTasteTuning :one 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) { 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.EngagementFull,
&i.UpdatedAt, &i.UpdatedAt,
&i.EnrichedTagScale, &i.EnrichedTagScale,
&i.EraScale,
) )
return i, err return i, err
} }
@@ -122,9 +123,10 @@ UPDATE taste_tuning
engagement_neutral = $3, engagement_neutral = $3,
engagement_full = $4, engagement_full = $4,
enriched_tag_scale = $5, enriched_tag_scale = $5,
era_scale = $6,
updated_at = now() updated_at = now()
WHERE singleton = true 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 { type UpdateTasteTuningParams struct {
@@ -133,6 +135,7 @@ type UpdateTasteTuningParams struct {
EngagementNeutral float64 EngagementNeutral float64
EngagementFull float64 EngagementFull float64
EnrichedTagScale float64 EnrichedTagScale float64
EraScale float64
} }
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) { 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.EngagementNeutral,
arg.EngagementFull, arg.EngagementFull,
arg.EnrichedTagScale, arg.EnrichedTagScale,
arg.EraScale,
) )
var i TasteTuning var i TasteTuning
err := row.Scan( err := row.Scan(
@@ -152,6 +156,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
&i.EngagementFull, &i.EngagementFull,
&i.UpdatedAt, &i.UpdatedAt,
&i.EnrichedTagScale, &i.EnrichedTagScale,
&i.EraScale,
) )
return i, err return i, err
} }
@@ -214,8 +219,8 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning ( INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip, singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full, enriched_tag_scale engagement_neutral, engagement_full, enriched_tag_scale, era_scale
) VALUES (true, $1, $2, $3, $4, $5) ) VALUES (true, $1, $2, $3, $4, $5, $6)
ON CONFLICT (singleton) DO NOTHING ON CONFLICT (singleton) DO NOTHING
` `
@@ -225,6 +230,7 @@ type UpsertTasteTuningDefaultsParams struct {
EngagementNeutral float64 EngagementNeutral float64
EngagementFull float64 EngagementFull float64
EnrichedTagScale float64 EnrichedTagScale float64
EraScale float64
} }
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error { 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.EngagementNeutral,
arg.EngagementFull, arg.EngagementFull,
arg.EnrichedTagScale, arg.EnrichedTagScale,
arg.EraScale,
) )
return err return err
} }
+90 -14
View File
@@ -20,6 +20,15 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
return err 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 const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
DELETE FROM taste_profile_tags WHERE user_id = $1 DELETE FROM taste_profile_tags WHERE user_id = $1
` `
@@ -45,6 +54,22 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
return err 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 const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
INSERT INTO taste_profile_tags (user_id, tag, weight) INSERT INTO taste_profile_tags (user_id, tag, weight)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
@@ -87,21 +112,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
} }
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many 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 FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id JOIN tracks t ON t.id = gl.track_id
JOIN albums a ON a.id = t.album_id
WHERE gl.user_id = $1 WHERE gl.user_id = $1
` `
type ListLikedTrackTasteInputsForUserRow struct { type ListLikedTrackTasteInputsForUserRow struct {
TrackID pgtype.UUID TrackID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Genre *string Genre *string
ReleaseDate pgtype.Date
} }
// (track_id, artist_id, genre) for each track the user has explicitly // (track_id, artist_id, genre, release_date) for each track the user has
// liked. Feeds the track-like bonus into the liked track's artist and // explicitly liked. Feeds the track-like bonus into the liked track's artist,
// tags; track_id keys the enriched track_tags lookup (#1490). // 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) { func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID) rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil { if err != nil {
@@ -111,7 +138,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
var items []ListLikedTrackTasteInputsForUserRow var items []ListLikedTrackTasteInputsForUserRow
for rows.Next() { for rows.Next() {
var i ListLikedTrackTasteInputsForUserRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -128,6 +160,7 @@ SELECT
t.id AS track_id, t.id AS track_id,
t.artist_id, t.artist_id,
t.genre, t.genre,
a.release_date,
LEAST(GREATEST( LEAST(GREATEST(
COALESCE(pe.completion_ratio, COALESCE(pe.completion_ratio,
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0), 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 (EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS ( AND NOT EXISTS (
@@ -149,11 +183,12 @@ type ListPlayEngagementInputsForUserParams struct {
} }
type ListPlayEngagementInputsForUserRow struct { type ListPlayEngagementInputsForUserRow struct {
TrackID pgtype.UUID TrackID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Genre *string Genre *string
Completion float64 ReleaseDate pgtype.Date
AgeDays float64 Completion float64
AgeDays float64
} }
// Taste profile (#796 phase 1). The daily build computes signed artist/tag // 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 // One row per play in the decay-relevant window. completion is the play's
// completion ratio (precomputed column when present, else duration_played / // completion ratio (precomputed column when present, else duration_played /
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre // 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) { func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
if err != nil { if err != nil {
@@ -176,6 +212,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
&i.TrackID, &i.TrackID,
&i.ArtistID, &i.ArtistID,
&i.Genre, &i.Genre,
&i.ReleaseDate,
&i.Completion, &i.Completion,
&i.AgeDays, &i.AgeDays,
); err != nil { ); err != nil {
@@ -228,6 +265,45 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
return items, nil 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 const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
SELECT tag, weight SELECT tag, weight
FROM taste_profile_tags 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;
+7 -2
View File
@@ -10,8 +10,10 @@ SELECT
(l.user_id IS NOT NULL)::bool AS is_liked, (l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
pe.skip_count pe.skip_count,
al.release_date AS release_date
FROM tracks t 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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
@@ -145,6 +147,7 @@ SELECT
pe.last_played_at::timestamptz AS last_played_at, pe.last_played_at::timestamptz AS last_played_at,
pe.play_count, pe.play_count,
pe.skip_count, pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM ( FROM (
SELECT track_id, sim_score FROM lb_similar SELECT track_id, sim_score FROM lb_similar
@@ -155,6 +158,7 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM random_fill UNION ALL SELECT track_id, sim_score FROM random_fill
) u ) u
JOIN tracks t ON t.id = u.track_id 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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at, 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, 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.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at, 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 -- name: SuggestArtistsForUser :many
-- M5c: per-user artist suggestions ranked by signal x similarity. The -- M5c: per-user artist suggestions ranked by signal x similarity. The
@@ -30,8 +30,8 @@ RETURNING *;
-- name: UpsertTasteTuningDefaults :exec -- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning ( INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip, singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full, enriched_tag_scale engagement_neutral, engagement_full, enriched_tag_scale, era_scale
) VALUES (true, $1, $2, $3, $4, $5) ) VALUES (true, $1, $2, $3, $4, $5, $6)
ON CONFLICT (singleton) DO NOTHING; ON CONFLICT (singleton) DO NOTHING;
-- name: GetTasteTuning :one -- name: GetTasteTuning :one
@@ -44,6 +44,7 @@ UPDATE taste_tuning
engagement_neutral = $3, engagement_neutral = $3,
engagement_full = $4, engagement_full = $4,
enriched_tag_scale = $5, enriched_tag_scale = $5,
era_scale = $6,
updated_at = now() updated_at = now()
WHERE singleton = true WHERE singleton = true
RETURNING *; RETURNING *;
+24 -5
View File
@@ -6,11 +6,13 @@
-- One row per play in the decay-relevant window. completion is the play's -- One row per play in the decay-relevant window. completion is the play's
-- completion ratio (precomputed column when present, else duration_played / -- completion ratio (precomputed column when present, else duration_played /
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre -- 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 SELECT
t.id AS track_id, t.id AS track_id,
t.artist_id, t.artist_id,
t.genre, t.genre,
a.release_date,
LEAST(GREATEST( LEAST(GREATEST(
COALESCE(pe.completion_ratio, COALESCE(pe.completion_ratio,
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0), 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 (EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS ( AND NOT EXISTS (
@@ -26,12 +29,13 @@ WHERE pe.user_id = $1
); );
-- name: ListLikedTrackTasteInputsForUser :many -- name: ListLikedTrackTasteInputsForUser :many
-- (track_id, artist_id, genre) for each track the user has explicitly -- (track_id, artist_id, genre, release_date) for each track the user has
-- liked. Feeds the track-like bonus into the liked track's artist and -- explicitly liked. Feeds the track-like bonus into the liked track's artist,
-- tags; track_id keys the enriched track_tags lookup (#1490). -- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
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 FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id JOIN tracks t ON t.id = gl.track_id
JOIN albums a ON a.id = t.album_id
WHERE gl.user_id = $1; WHERE gl.user_id = $1;
-- name: ListLikedArtistIDsForUser :many -- name: ListLikedArtistIDsForUser :many
@@ -66,3 +70,18 @@ FROM taste_profile_tags
WHERE user_id = $1 WHERE user_id = $1
ORDER BY weight DESC ORDER BY weight DESC
LIMIT $2; 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;
+2 -2
View File
@@ -57,7 +57,7 @@ func LoadCandidates(
PlayCount: int(r.PlayCount), PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount), SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore, 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), SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore, ContextualMatchScore: ctxScore,
SimilarityScore: simScore, SimilarityScore: simScore,
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre), TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
}, },
}) })
} }
+43 -7
View File
@@ -2,6 +2,7 @@ package recommendation
import ( import (
"context" "context"
"fmt"
"math" "math"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
@@ -10,24 +11,34 @@ import (
) )
// Taste-match tuning. The taste profile (written by internal/taste) holds // 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 // 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 // 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." // 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 ( const (
tasteArtistScale = 4.0 tasteArtistScale = 4.0
tasteTagScale = 3.0 tasteTagScale = 3.0
tasteEraScale = 4.0
tasteArtistShare = 0.7 tasteArtistShare = 0.7
tasteTagShare = 0.3 tasteTagShare = 0.3
tasteEraShare = 0.15
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
) )
// TasteProfile is the read-side view of a user's learned taste: signed // 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 // weights over artists, genre tags, and decade/era buckets. The zero value
// artist/tag) contributes 0, so cold-start users get no taste effect. // (and any unknown artist/tag/era) contributes 0, so cold-start users get no
// taste effect.
type TasteProfile struct { type TasteProfile struct {
artists map[pgtype.UUID]float64 artists map[pgtype.UUID]float64
tags map[string]float64 tags map[string]float64
eras map[string]float64
} }
// LoadTasteProfile reads the user's taste profile from the taste_profile_* // 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 { if err != nil {
return TasteProfile{}, err return TasteProfile{}, err
} }
eras, err := q.ListTasteProfileErasForUser(ctx, dbq.ListTasteProfileErasForUserParams{
UserID: userID, Limit: tasteProfileLimit,
})
if err != nil {
return TasteProfile{}, err
}
p := TasteProfile{ p := TasteProfile{
artists: make(map[pgtype.UUID]float64, len(arts)), artists: make(map[pgtype.UUID]float64, len(arts)),
tags: make(map[string]float64, len(tags)), tags: make(map[string]float64, len(tags)),
eras: make(map[string]float64, len(eras)),
} }
for _, a := range arts { for _, a := range arts {
p.artists[a.ArtistID] = a.Weight 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 { for _, t := range tags {
p.tags[t.Tag] = t.Weight p.tags[t.Tag] = t.Weight
} }
for _, e := range eras {
p.eras[e.Era] = e.Weight
}
return p, nil return p, nil
} }
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of // 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 // the artist's taste weight, the average of its genre tags' weights, and its
// tanh-squashed. Absent artist/tags contribute 0. // decade/era weight, each tanh-squashed. Absent artist/tags/era contribute 0.
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 { func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgtype.Date) float64 {
a := math.Tanh(p.artists[artistID] / tasteArtistScale) a := math.Tanh(p.artists[artistID] / tasteArtistScale)
var tg float64 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) 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]. // clampUnit constrains x to [-1, 1].
+39 -8
View File
@@ -17,6 +17,14 @@ func uuidN(n byte) pgtype.UUID {
func strPtr(s string) *string { return &s } 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) { func TestTasteProfile_Match(t *testing.T) {
loved := uuidN(1) loved := uuidN(1)
disliked := uuidN(2) disliked := uuidN(2)
@@ -26,32 +34,55 @@ func TestTasteProfile_Match(t *testing.T) {
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0}, 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) 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) 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) t.Errorf("unknown artist, no genre = %.3f, want 0", m)
} }
// Artist dominates (0.7 share): loved artist with an unknown tag is still // Artist dominates (0.7 share): loved artist with an unknown tag is still
// clearly positive. // 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) t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
} }
// Output stays within [-1, 1] even with saturated inputs. // Output stays within [-1, 1] even with saturated inputs.
for _, a := range []pgtype.UUID{loved, disliked, unknown} { 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 { if m < -1 || m > 1 {
t.Errorf("Match out of [-1,1]: %.3f", m) 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) { func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
var p TasteProfile // zero value: nil maps 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) 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 { if err != nil {
t.Fatalf("load: %v", err) 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) 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) t.Errorf("absent artist Match = %.3f, want 0", m)
} }
} }
+3
View File
@@ -131,6 +131,8 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
target = &next.EngagementFull target = &next.EngagementFull
case "enriched_tag_scale": case "enriched_tag_scale":
target = &next.EnrichedTagScale target = &next.EnrichedTagScale
case "era_scale":
target = &next.EraScale
default: default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field) 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_neutral", a.EngagementNeutral, b.EngagementNeutral)
add("engagement_full", a.EngagementFull, b.EngagementFull) add("engagement_full", a.EngagementFull, b.EngagementFull)
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale) add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
add("era_scale", a.EraScale, b.EraScale)
return out return out
} }
+9 -2
View File
@@ -38,14 +38,16 @@ const (
) )
// TasteTuning is the tunable subset of taste.Config: the engagement // TasteTuning is the tunable subset of taste.Config: the engagement
// half-life, the completion→engagement curve points, and the enriched-tag // half-life, the completion→engagement curve points, the enriched-tag
// weight (how much folksonomy tags count vs raw ID3 genre, #1520). // 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 { type TasteTuning struct {
HalfLifeDays float64 HalfLifeDays float64
EngagementHardSkip float64 EngagementHardSkip float64
EngagementNeutral float64 EngagementNeutral float64
EngagementFull float64 EngagementFull float64
EnrichedTagScale float64 EnrichedTagScale float64
EraScale float64
} }
// ShippedRadioWeights are the shipped radio-profile defaults (moved // ShippedRadioWeights are the shipped radio-profile defaults (moved
@@ -89,6 +91,7 @@ func ShippedTasteTuning() TasteTuning {
EngagementNeutral: d.Engagement.NeutralCompletion, EngagementNeutral: d.Engagement.NeutralCompletion,
EngagementFull: d.Engagement.FullCompletion, EngagementFull: d.Engagement.FullCompletion,
EnrichedTagScale: d.EnrichedTagScale, EnrichedTagScale: d.EnrichedTagScale,
EraScale: d.EraScale,
} }
} }
@@ -137,6 +140,7 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementNeutral: st.EngagementNeutral, EngagementNeutral: st.EngagementNeutral,
EngagementFull: st.EngagementFull, EngagementFull: st.EngagementFull,
EnrichedTagScale: st.EnrichedTagScale, EnrichedTagScale: st.EnrichedTagScale,
EraScale: st.EraScale,
}); err != nil { }); err != nil {
return fmt.Errorf("seed taste tuning: %w", err) return fmt.Errorf("seed taste tuning: %w", err)
} }
@@ -161,6 +165,7 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementNeutral: tt.EngagementNeutral, EngagementNeutral: tt.EngagementNeutral,
EngagementFull: tt.EngagementFull, EngagementFull: tt.EngagementFull,
EnrichedTagScale: tt.EnrichedTagScale, EnrichedTagScale: tt.EnrichedTagScale,
EraScale: tt.EraScale,
} }
s.mu.Unlock() s.mu.Unlock()
@@ -211,6 +216,7 @@ func (s *Service) TasteConfig() taste.Config {
FullCompletion: t.EngagementFull, FullCompletion: t.EngagementFull,
} }
cfg.EnrichedTagScale = t.EnrichedTagScale cfg.EnrichedTagScale = t.EnrichedTagScale
cfg.EraScale = t.EraScale
return cfg return cfg
} }
@@ -308,6 +314,7 @@ func (s *Service) persistTaste(
EngagementNeutral: t.EngagementNeutral, EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull, EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale, EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
}); err != nil { }); err != nil {
return fmt.Errorf("update taste tuning: %w", err) return fmt.Errorf("update taste tuning: %w", err)
} }
+21
View File
@@ -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) { func TestReset_RestoresShippedAndAudits(t *testing.T) {
pool := newPool(t) pool := newPool(t)
s := newService(t, pool) s := newService(t, pool)
+71 -16
View File
@@ -46,6 +46,14 @@ type Config struct {
// contribution entirely (falls back to genre-only). // contribution entirely (falls back to genre-only).
EnrichedTagScale float64 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 // ArtistFloor / TagFloor clamp how negative a single entity's weight may
// go. Aggregation already protects an artist the user likes (one skip // go. Aggregation already protects an artist the user likes (one skip
// nets out against many good plays); the floor additionally bounds the // nets out against many good plays); the floor additionally bounds the
@@ -58,11 +66,15 @@ type Config struct {
WeightEpsilon float64 WeightEpsilon float64
// Size caps (guard rails for pathological libraries). Persist the top // 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 MaxArtists int
MaxNegArtists int MaxNegArtists int
MaxTags int MaxTags int
MaxNegTags int MaxNegTags int
MaxEras int
MaxNegEras int
} }
// DefaultConfig returns the tuned starting configuration. // DefaultConfig returns the tuned starting configuration.
@@ -75,6 +87,7 @@ func DefaultConfig() Config {
TrackLikeBonus: 1.0, TrackLikeBonus: 1.0,
TagLikeBonus: 0.5, TagLikeBonus: 0.5,
EnrichedTagScale: 0.5, EnrichedTagScale: 0.5,
EraScale: 0.5,
ArtistFloor: -3.0, ArtistFloor: -3.0,
TagFloor: -3.0, TagFloor: -3.0,
WeightEpsilon: 0.05, WeightEpsilon: 0.05,
@@ -82,6 +95,8 @@ func DefaultConfig() Config {
MaxNegArtists: 150, MaxNegArtists: 150,
MaxTags: 200, MaxTags: 200,
MaxNegTags: 60, MaxNegTags: 60,
MaxEras: 30,
MaxNegEras: 15,
} }
} }
@@ -101,36 +116,40 @@ func BuildTasteProfile(
) error { ) error {
q := dbq.New(pool) q := dbq.New(pool)
artistW, tagW, err := accumulate(ctx, q, userID, cfg) artistW, tagW, eraW, err := accumulate(ctx, q, userID, cfg)
if err != nil { if err != nil {
return err return err
} }
applyFloor(artistW, cfg.ArtistFloor) applyFloor(artistW, cfg.ArtistFloor)
applyFloor(tagW, cfg.TagFloor) 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) artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
func(a, b string) bool { return a < b }) 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 return err
} }
logger.Debug("taste: profile rebuilt", logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags)) "artists", len(artists), "tags", len(tags), "eras", len(eras))
return nil 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. // weight maps.
func accumulate( func accumulate(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config, 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{ plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
UserID: userID, UserID: userID,
Column2: cfg.WindowDays, Column2: cfg.WindowDays,
}) })
if err != nil { 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 // Enriched folksonomy tags keyed by track (#1490) — folded into the tag
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached // facet alongside raw ID3 genre so a coarse "Rock" gains the cached
@@ -141,12 +160,13 @@ func accumulate(
Column2: int32(cfg.WindowDays), Column2: int32(cfg.WindowDays),
}) })
if err != nil { 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) playedTags := groupTagsByTrack(playedTagRows)
artistW := make(map[pgtype.UUID]float64) artistW := make(map[pgtype.UUID]float64)
tagW := make(map[string]float64) tagW := make(map[string]float64)
eraW := make(map[string]float64)
for _, p := range plays { for _, p := range plays {
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays) e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
artistW[p.ArtistID] += e artistW[p.ArtistID] += e
@@ -154,15 +174,16 @@ func accumulate(
tagW[tag] += e tagW[tag] += e
} }
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale) foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
} }
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID) likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
if err != nil { 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) likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
if err != nil { 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) likedTags := groupTagsByTrack(likedTagRows)
for _, lt := range likedTracks { for _, lt := range likedTracks {
@@ -171,16 +192,17 @@ func accumulate(
tagW[tag] += cfg.TagLikeBonus tagW[tag] += cfg.TagLikeBonus
} }
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale) foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
} }
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID) likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
if err != nil { 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 { for _, aid := range likedArtists {
artistW[aid] += cfg.ArtistLikeBonus artistW[aid] += cfg.ArtistLikeBonus
} }
return artistW, tagW, nil return artistW, tagW, eraW, nil
} }
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so // 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. // persist atomic-replaces the user's profile rows inside one transaction.
func persist( func persist(
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, 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 { ) error {
tx, err := pool.Begin(ctx) tx, err := pool.Begin(ctx)
if err != nil { if err != nil {
@@ -238,6 +283,16 @@ func persist(
return fmt.Errorf("taste: insert tag: %w", err) 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 { if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("taste: commit: %w", err) return fmt.Errorf("taste: commit: %w", err)
} }
+1
View File
@@ -21,6 +21,7 @@ export type TasteTuning = {
engagement_neutral: number; engagement_neutral: number;
engagement_full: number; engagement_full: number;
enriched_tag_scale: number; enriched_tag_scale: number;
era_scale: number;
}; };
export type TuningScope = 'radio' | 'daily_mix' | 'taste'; export type TuningScope = 'radio' | 'daily_mix' | 'taste';
+2 -1
View File
@@ -37,7 +37,8 @@
{ key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads 1.' }, { key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads 1.' },
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' }, { key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }, { key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' },
{ key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' } { key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' },
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' }
]; ];
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [ const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
@@ -37,6 +37,7 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
engagement_neutral: 0.3, engagement_neutral: 0.3,
engagement_full: 0.9, engagement_full: 0.9,
enriched_tag_scale: 0.5, enriched_tag_scale: 0.5,
era_scale: 0.5,
...over ...over
}); });