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) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@ type tasteTuningResp struct {
|
|||||||
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
@@ -56,6 +57,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|||||||
EngagementHardSkip: t.EngagementHardSkip,
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
EngagementNeutral: t.EngagementNeutral,
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
EngagementFull: t.EngagementFull,
|
EngagementFull: t.EngagementFull,
|
||||||
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -568,6 +568,7 @@ type TasteTuning struct {
|
|||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
EnrichedTagScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
|
|||||||
@@ -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 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) {
|
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.EngagementNeutral,
|
||||||
&i.EngagementFull,
|
&i.EngagementFull,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrichedTagScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -120,9 +121,10 @@ UPDATE taste_tuning
|
|||||||
engagement_hard_skip = $2,
|
engagement_hard_skip = $2,
|
||||||
engagement_neutral = $3,
|
engagement_neutral = $3,
|
||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
|
enriched_tag_scale = $5,
|
||||||
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
|
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateTasteTuningParams struct {
|
type UpdateTasteTuningParams struct {
|
||||||
@@ -130,6 +132,7 @@ type UpdateTasteTuningParams struct {
|
|||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
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.EngagementHardSkip,
|
||||||
arg.EngagementNeutral,
|
arg.EngagementNeutral,
|
||||||
arg.EngagementFull,
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
)
|
)
|
||||||
var i TasteTuning
|
var i TasteTuning
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -147,6 +151,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
|||||||
&i.EngagementNeutral,
|
&i.EngagementNeutral,
|
||||||
&i.EngagementFull,
|
&i.EngagementFull,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrichedTagScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -209,8 +214,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
|
engagement_neutral, engagement_full, enriched_tag_scale
|
||||||
) VALUES (true, $1, $2, $3, $4)
|
) VALUES (true, $1, $2, $3, $4, $5)
|
||||||
ON CONFLICT (singleton) DO NOTHING
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -219,6 +224,7 @@ type UpsertTasteTuningDefaultsParams struct {
|
|||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
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.EngagementHardSkip,
|
||||||
arg.EngagementNeutral,
|
arg.EngagementNeutral,
|
||||||
arg.EngagementFull,
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql.
|
||||||
|
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale;
|
||||||
@@ -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;
|
||||||
@@ -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
|
engagement_neutral, engagement_full, enriched_tag_scale
|
||||||
) VALUES (true, $1, $2, $3, $4)
|
) VALUES (true, $1, $2, $3, $4, $5)
|
||||||
ON CONFLICT (singleton) DO NOTHING;
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
-- name: GetTasteTuning :one
|
-- name: GetTasteTuning :one
|
||||||
@@ -43,6 +43,7 @@ UPDATE taste_tuning
|
|||||||
engagement_hard_skip = $2,
|
engagement_hard_skip = $2,
|
||||||
engagement_neutral = $3,
|
engagement_neutral = $3,
|
||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
|
enriched_tag_scale = $5,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE singleton = true
|
WHERE singleton = true
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
|||||||
target = &next.EngagementNeutral
|
target = &next.EngagementNeutral
|
||||||
case "engagement_full":
|
case "engagement_full":
|
||||||
target = &next.EngagementFull
|
target = &next.EngagementFull
|
||||||
|
case "enriched_tag_scale":
|
||||||
|
target = &next.EnrichedTagScale
|
||||||
default:
|
default:
|
||||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
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_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
|
||||||
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)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,14 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
// 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 {
|
type TasteTuning struct {
|
||||||
HalfLifeDays float64
|
HalfLifeDays float64
|
||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||||
@@ -86,6 +88,7 @@ func ShippedTasteTuning() TasteTuning {
|
|||||||
EngagementHardSkip: d.Engagement.HardSkip,
|
EngagementHardSkip: d.Engagement.HardSkip,
|
||||||
EngagementNeutral: d.Engagement.NeutralCompletion,
|
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||||||
EngagementFull: d.Engagement.FullCompletion,
|
EngagementFull: d.Engagement.FullCompletion,
|
||||||
|
EnrichedTagScale: d.EnrichedTagScale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +136,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
EngagementHardSkip: st.EngagementHardSkip,
|
EngagementHardSkip: st.EngagementHardSkip,
|
||||||
EngagementNeutral: st.EngagementNeutral,
|
EngagementNeutral: st.EngagementNeutral,
|
||||||
EngagementFull: st.EngagementFull,
|
EngagementFull: st.EngagementFull,
|
||||||
|
EnrichedTagScale: st.EnrichedTagScale,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("seed taste tuning: %w", err)
|
return fmt.Errorf("seed taste tuning: %w", err)
|
||||||
}
|
}
|
||||||
@@ -156,6 +160,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
EngagementHardSkip: tt.EngagementHardSkip,
|
EngagementHardSkip: tt.EngagementHardSkip,
|
||||||
EngagementNeutral: tt.EngagementNeutral,
|
EngagementNeutral: tt.EngagementNeutral,
|
||||||
EngagementFull: tt.EngagementFull,
|
EngagementFull: tt.EngagementFull,
|
||||||
|
EnrichedTagScale: tt.EnrichedTagScale,
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -205,6 +210,7 @@ func (s *Service) TasteConfig() taste.Config {
|
|||||||
NeutralCompletion: t.EngagementNeutral,
|
NeutralCompletion: t.EngagementNeutral,
|
||||||
FullCompletion: t.EngagementFull,
|
FullCompletion: t.EngagementFull,
|
||||||
}
|
}
|
||||||
|
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +307,7 @@ func (s *Service) persistTaste(
|
|||||||
EngagementHardSkip: t.EngagementHardSkip,
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
EngagementNeutral: t.EngagementNeutral,
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
EngagementFull: t.EngagementFull,
|
EngagementFull: t.EngagementFull,
|
||||||
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("update taste tuning: %w", err)
|
return fmt.Errorf("update taste tuning: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
s := newService(t, pool)
|
s := newService(t, pool)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type TasteTuning = {
|
|||||||
engagement_hard_skip: number;
|
engagement_hard_skip: number;
|
||||||
engagement_neutral: number;
|
engagement_neutral: number;
|
||||||
engagement_full: number;
|
engagement_full: number;
|
||||||
|
enriched_tag_scale: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
{ key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." },
|
{ 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_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.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
|
|||||||
engagement_hard_skip: 0.05,
|
engagement_hard_skip: 0.05,
|
||||||
engagement_neutral: 0.3,
|
engagement_neutral: 0.3,
|
||||||
engagement_full: 0.9,
|
engagement_full: 0.9,
|
||||||
|
enriched_tag_scale: 0.5,
|
||||||
...over
|
...over
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user