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:
+71
-16
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user