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"`
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 *;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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].
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user