From 2b3be8311ab58a20a9a2e9c19a12880ef80694e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 14 Jul 2026 07:51:36 -0400 Subject: [PATCH] feat(tuning): expose EnrichedTagScale in the tuning lab (#1520) Promote the enriched-tag weight (#1490) from a taste.Config default into the DB-backed tuning lab so operators can dial how much folksonomy tags count vs raw ID3 genre (rule #25). - Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills the existing row). - recsettings: TasteTuning gains the field; seeded/read/updated through reconcile + persistTaste; applyTastePatch validates it to [0,1] (generic non-half-life clamp) and diffTaste audits it; TasteConfig maps it into the profile build. - API: tasteTuningResp exposes enriched_tag_scale. - Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre only). Tests: recsettings persist+range, web fixture field. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/admin_recommendation_tuning.go | 2 ++ internal/db/dbq/models.go | 1 + internal/db/dbq/recommendation_tuning.sql.go | 15 +++++++++---- ...4_taste_tuning_enriched_tag_scale.down.sql | 2 ++ ...044_taste_tuning_enriched_tag_scale.up.sql | 6 ++++++ internal/db/queries/recommendation_tuning.sql | 5 +++-- internal/recsettings/patch.go | 3 +++ internal/recsettings/service.go | 9 +++++++- internal/recsettings/service_test.go | 21 +++++++++++++++++++ web/src/lib/api/tuning.ts | 1 + web/src/routes/admin/tuning/+page.svelte | 3 ++- web/src/routes/admin/tuning/tuning.test.ts | 1 + 12 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 internal/db/migrations/0044_taste_tuning_enriched_tag_scale.down.sql create mode 100644 internal/db/migrations/0044_taste_tuning_enriched_tag_scale.up.sql diff --git a/internal/api/admin_recommendation_tuning.go b/internal/api/admin_recommendation_tuning.go index 1a556d9d..ed6c27e5 100644 --- a/internal/api/admin_recommendation_tuning.go +++ b/internal/api/admin_recommendation_tuning.go @@ -48,6 +48,7 @@ type tasteTuningResp struct { EngagementHardSkip float64 `json:"engagement_hard_skip"` EngagementNeutral float64 `json:"engagement_neutral"` EngagementFull float64 `json:"engagement_full"` + EnrichedTagScale float64 `json:"enriched_tag_scale"` } func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp { @@ -56,6 +57,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp { EngagementHardSkip: t.EngagementHardSkip, EngagementNeutral: t.EngagementNeutral, EngagementFull: t.EngagementFull, + EnrichedTagScale: t.EnrichedTagScale, } } diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 966f7e00..94137920 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -568,6 +568,7 @@ type TasteTuning struct { EngagementNeutral float64 EngagementFull float64 UpdatedAt pgtype.Timestamptz + EnrichedTagScale float64 } type Track struct { diff --git a/internal/db/dbq/recommendation_tuning.sql.go b/internal/db/dbq/recommendation_tuning.sql.go index d41c0f19..904946e1 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 FROM taste_tuning WHERE singleton = true +SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale FROM taste_tuning WHERE singleton = true ` func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) { @@ -23,6 +23,7 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) { &i.EngagementNeutral, &i.EngagementFull, &i.UpdatedAt, + &i.EnrichedTagScale, ) return i, err } @@ -120,9 +121,10 @@ UPDATE taste_tuning engagement_hard_skip = $2, engagement_neutral = $3, engagement_full = $4, + enriched_tag_scale = $5, updated_at = now() WHERE singleton = true -RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at +RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale ` type UpdateTasteTuningParams struct { @@ -130,6 +132,7 @@ type UpdateTasteTuningParams struct { EngagementHardSkip float64 EngagementNeutral float64 EngagementFull float64 + EnrichedTagScale float64 } func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) { @@ -138,6 +141,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa arg.EngagementHardSkip, arg.EngagementNeutral, arg.EngagementFull, + arg.EnrichedTagScale, ) var i TasteTuning err := row.Scan( @@ -147,6 +151,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa &i.EngagementNeutral, &i.EngagementFull, &i.UpdatedAt, + &i.EnrichedTagScale, ) return i, err } @@ -209,8 +214,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 -) VALUES (true, $1, $2, $3, $4) + engagement_neutral, engagement_full, enriched_tag_scale +) VALUES (true, $1, $2, $3, $4, $5) ON CONFLICT (singleton) DO NOTHING ` @@ -219,6 +224,7 @@ type UpsertTasteTuningDefaultsParams struct { EngagementHardSkip float64 EngagementNeutral float64 EngagementFull float64 + EnrichedTagScale float64 } func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error { @@ -227,6 +233,7 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste arg.EngagementHardSkip, arg.EngagementNeutral, arg.EngagementFull, + arg.EnrichedTagScale, ) return err } diff --git a/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.down.sql b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.down.sql new file mode 100644 index 00000000..4ad01726 --- /dev/null +++ b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.down.sql @@ -0,0 +1,2 @@ +-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql. +ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale; diff --git a/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.up.sql b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.up.sql new file mode 100644 index 00000000..4b605c32 --- /dev/null +++ b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.up.sql @@ -0,0 +1,6 @@ +-- Expose EnrichedTagScale in the tuning lab (#1520). Adds the enriched-tag +-- weight knob to the taste_tuning singleton so operators can dial how much +-- folksonomy tags (#1490) count vs raw ID3 genre. DEFAULT 0.5 backfills the +-- existing row and matches taste.DefaultConfig().EnrichedTagScale. +ALTER TABLE taste_tuning + ADD COLUMN enriched_tag_scale double precision NOT NULL DEFAULT 0.5; diff --git a/internal/db/queries/recommendation_tuning.sql b/internal/db/queries/recommendation_tuning.sql index 760ea716..8591c3ce 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 -) VALUES (true, $1, $2, $3, $4) + engagement_neutral, engagement_full, enriched_tag_scale +) VALUES (true, $1, $2, $3, $4, $5) ON CONFLICT (singleton) DO NOTHING; -- name: GetTasteTuning :one @@ -43,6 +43,7 @@ UPDATE taste_tuning engagement_hard_skip = $2, engagement_neutral = $3, engagement_full = $4, + enriched_tag_scale = $5, updated_at = now() WHERE singleton = true RETURNING *; diff --git a/internal/recsettings/patch.go b/internal/recsettings/patch.go index e3c78ab6..239afe2f 100644 --- a/internal/recsettings/patch.go +++ b/internal/recsettings/patch.go @@ -129,6 +129,8 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning target = &next.EngagementNeutral case "engagement_full": target = &next.EngagementFull + case "enriched_tag_scale": + target = &next.EnrichedTagScale default: return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field) } @@ -174,5 +176,6 @@ func diffTaste(a, b TasteTuning) []fieldChange { add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip) add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral) add("engagement_full", a.EngagementFull, b.EngagementFull) + add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale) return out } diff --git a/internal/recsettings/service.go b/internal/recsettings/service.go index 4a1a0b0b..fa3c01ea 100644 --- a/internal/recsettings/service.go +++ b/internal/recsettings/service.go @@ -38,12 +38,14 @@ const ( ) // TasteTuning is the tunable subset of taste.Config: the engagement -// half-life and the completion→engagement curve points. +// half-life, the completion→engagement curve points, and the enriched-tag +// weight (how much folksonomy tags count vs raw ID3 genre, #1520). type TasteTuning struct { HalfLifeDays float64 EngagementHardSkip float64 EngagementNeutral float64 EngagementFull float64 + EnrichedTagScale float64 } // ShippedRadioWeights are the shipped radio-profile defaults (moved @@ -86,6 +88,7 @@ func ShippedTasteTuning() TasteTuning { EngagementHardSkip: d.Engagement.HardSkip, EngagementNeutral: d.Engagement.NeutralCompletion, EngagementFull: d.Engagement.FullCompletion, + EnrichedTagScale: d.EnrichedTagScale, } } @@ -133,6 +136,7 @@ func (s *Service) reconcile(ctx context.Context) error { EngagementHardSkip: st.EngagementHardSkip, EngagementNeutral: st.EngagementNeutral, EngagementFull: st.EngagementFull, + EnrichedTagScale: st.EnrichedTagScale, }); err != nil { return fmt.Errorf("seed taste tuning: %w", err) } @@ -156,6 +160,7 @@ func (s *Service) reconcile(ctx context.Context) error { EngagementHardSkip: tt.EngagementHardSkip, EngagementNeutral: tt.EngagementNeutral, EngagementFull: tt.EngagementFull, + EnrichedTagScale: tt.EnrichedTagScale, } s.mu.Unlock() @@ -205,6 +210,7 @@ func (s *Service) TasteConfig() taste.Config { NeutralCompletion: t.EngagementNeutral, FullCompletion: t.EngagementFull, } + cfg.EnrichedTagScale = t.EnrichedTagScale return cfg } @@ -301,6 +307,7 @@ func (s *Service) persistTaste( EngagementHardSkip: t.EngagementHardSkip, EngagementNeutral: t.EngagementNeutral, EngagementFull: t.EngagementFull, + EnrichedTagScale: t.EnrichedTagScale, }); 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 65019ff3..3c7e6816 100644 --- a/internal/recsettings/service_test.go +++ b/internal/recsettings/service_test.go @@ -182,6 +182,27 @@ func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) { } } +func TestUpdateTaste_EnrichedTagScale(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{"enriched_tag_scale": 0.8}); err != nil { + t.Fatalf("UpdateTaste: %v", err) + } + if got := s.Taste().EnrichedTagScale; got != 0.8 { + t.Errorf("Taste().EnrichedTagScale = %v, want 0.8", got) + } + if got := s.TasteConfig().EnrichedTagScale; got != 0.8 { + t.Errorf("TasteConfig().EnrichedTagScale = %v, want 0.8", got) + } + // Out of [0,1] rejects. + if err := s.UpdateTaste(context.Background(), + map[string]float64{"enriched_tag_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/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts index 8ffd0218..0598dd4b 100644 --- a/web/src/lib/api/tuning.ts +++ b/web/src/lib/api/tuning.ts @@ -20,6 +20,7 @@ export type TasteTuning = { engagement_hard_skip: number; engagement_neutral: number; engagement_full: number; + enriched_tag_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 ef7a9d7a..8c7e8ac4 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -36,7 +36,8 @@ { key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." }, { 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: '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.' } ]; 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 d953d164..e8b0d624 100644 --- a/web/src/routes/admin/tuning/tuning.test.ts +++ b/web/src/routes/admin/tuning/tuning.test.ts @@ -36,6 +36,7 @@ const taste = (over: Partial> = {}) => ({ engagement_hard_skip: 0.05, engagement_neutral: 0.3, engagement_full: 0.9, + enriched_tag_scale: 0.5, ...over });