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