Files
minstrel/internal/taste/profile.go
T
bvandeusen 40384cc05e
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s
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>
2026-07-14 09:01:00 -04:00

377 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// profile.go assembles the per-user taste profile from graded play
// engagement (decayed over time) plus explicit-like bonuses, and
// atomic-replaces the taste_profile_* tables. Phase 1 only builds the
// object; candidate sourcing + scoring consume it in phase 2.
package taste
import (
"bytes"
"context"
"fmt"
"log/slog"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
// starting values; wiring these into the server RecommendationConfig is a
// later follow-up (phase 1 uses the defaults).
type Config struct {
Engagement EngagementParams
// HalfLifeDays: a play's engagement halves every this-many days, so the
// profile tracks drift (recent behaviour dominates). WindowDays bounds
// the plays fetched — beyond ~3 half-lives the decayed contribution is
// negligible, so older plays are skipped for cost, not correctness.
HalfLifeDays float64
WindowDays float64
// Explicit-like bonuses. Likes stay the strongest single positive.
ArtistLikeBonus float64
TrackLikeBonus float64
TagLikeBonus float64
// EnrichedTagScale weights folksonomy tags (from the track_tags cache,
// #1490) relative to a track's raw ID3 genre when both fold into the tag
// facet. Each enriched tag contributes base × tag.weight × this scale,
// where base is the play's decayed engagement (or the tag-like bonus for
// a liked track) and tag.weight is the normalized folksonomy strength in
// [0,1]. Below 1 so the richer-but-noisier enriched vocabulary augments
// the ID3 genre signal without swamping it. 0 disables the enriched
// 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
// magnitude so a heavily-skipped entity can't dominate as an extreme
// negative.
ArtistFloor float64
TagFloor float64
// WeightEpsilon: entities with |weight| below this are dropped as noise.
WeightEpsilon float64
// Size caps (guard rails for pathological libraries). Persist the top
// 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.
func DefaultConfig() Config {
return Config{
Engagement: DefaultEngagementParams(),
HalfLifeDays: 75,
WindowDays: 270,
ArtistLikeBonus: 3.0,
TrackLikeBonus: 1.0,
TagLikeBonus: 0.5,
EnrichedTagScale: 0.5,
EraScale: 0.5,
ArtistFloor: -3.0,
TagFloor: -3.0,
WeightEpsilon: 0.05,
MaxArtists: 500,
MaxNegArtists: 150,
MaxTags: 200,
MaxNegTags: 60,
MaxEras: 30,
MaxNegEras: 15,
}
}
// BuildTasteProfile recomputes and atomic-replaces the user's taste profile.
// Reads the decay-windowed play engagement + explicit likes, accumulates
// signed artist/tag weights, applies floor damping + size caps, and persists.
// Self-contained transaction for the replace. Returns an error only on a
// fatal DB failure; the daily caller logs and proceeds to playlist building
// regardless (best-effort).
//
// The decay reference time is the database clock (age is computed in SQL via
// now()), so no Go-side timestamp is threaded here; a phase-3 context model
// that needs a pinned reference time would add one.
func BuildTasteProfile(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
userID pgtype.UUID, cfg Config,
) error {
q := dbq.New(pool)
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, strLess)
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
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), "eras", len(eras))
return nil
}
// 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, map[string]float64, error) {
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
UserID: userID,
Column2: cfg.WindowDays,
})
if err != nil {
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
// "post-punk / shoegaze / melancholic" vocabulary. Window matches the
// play window; a track with no cached tags simply contributes genre only.
playedTagRows, err := q.ListPlayedTrackTagsForUser(ctx, dbq.ListPlayedTrackTagsForUserParams{
UserID: userID,
Column2: int32(cfg.WindowDays),
})
if err != nil {
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
for _, tag := range splitGenres(p.Genre) {
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, nil, fmt.Errorf("taste: load liked tracks: %w", err)
}
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
if err != nil {
return nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
}
likedTags := groupTagsByTrack(likedTagRows)
for _, lt := range likedTracks {
artistW[lt.ArtistID] += cfg.TrackLikeBonus
for _, tag := range splitGenres(lt.Genre) {
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, nil, fmt.Errorf("taste: load liked artists: %w", err)
}
for _, aid := range likedArtists {
artistW[aid] += cfg.ArtistLikeBonus
}
return artistW, tagW, eraW, nil
}
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
// each play/like can fold in its track's enriched tags in one lookup.
func groupTagsByTrack(rows []dbq.TrackTag) map[pgtype.UUID][]dbq.TrackTag {
m := make(map[pgtype.UUID][]dbq.TrackTag)
for _, r := range rows {
m[r.TrackID] = append(m[r.TrackID], r)
}
return m
}
// foldEnrichedTags adds each enriched tag to tagW weighted by
// base × tag.weight × scale — base is the play's decayed engagement or the
// tag-like bonus, tag.weight is the folksonomy strength in [0,1]. scale=0
// disables the enriched contribution (genre-only fallback).
func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
if scale == 0 {
return
}
for _, t := range tags {
tagW[t.Tag] += base * t.Weight * 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, eras []weighted[string],
) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("taste: begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
qtx := dbq.New(tx)
if err := qtx.DeleteTasteProfileArtistsForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete artists: %w", err)
}
for _, a := range artists {
if err := qtx.InsertTasteProfileArtist(ctx, dbq.InsertTasteProfileArtistParams{
UserID: userID, ArtistID: a.Key, Weight: a.Weight,
}); err != nil {
return fmt.Errorf("taste: insert artist: %w", err)
}
}
if err := qtx.DeleteTasteProfileTagsForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete tags: %w", err)
}
for _, t := range tags {
if err := qtx.InsertTasteProfileTag(ctx, dbq.InsertTasteProfileTagParams{
UserID: userID, Tag: t.Key, Weight: t.Weight,
}); err != nil {
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)
}
return nil
}
// weighted is a key with its signed taste weight.
type weighted[K comparable] struct {
Key K
Weight float64
}
// rankWeighted drops near-zero entries, sorts by weight DESC (ties broken by
// keyLess for determinism), and caps to the top topN plus the most-negative
// botN so both ends of the signal survive without unbounded table growth.
func rankWeighted[K comparable](
m map[K]float64, eps float64, topN, botN int, keyLess func(a, b K) bool,
) []weighted[K] {
all := make([]weighted[K], 0, len(m))
for k, w := range m {
if w >= eps || w <= -eps {
all = append(all, weighted[K]{Key: k, Weight: w})
}
}
sort.Slice(all, func(i, j int) bool {
if all[i].Weight != all[j].Weight {
return all[i].Weight > all[j].Weight
}
return keyLess(all[i].Key, all[j].Key)
})
if topN+botN >= len(all) {
return all
}
out := make([]weighted[K], 0, topN+botN)
out = append(out, all[:topN]...)
out = append(out, all[len(all)-botN:]...)
return out
}
// applyFloor clamps every weight in m up to floor (no entity goes more
// negative than the floor).
func applyFloor[K comparable](m map[K]float64, floor float64) {
for k, w := range m {
if w < floor {
m[k] = floor
}
}
}
// uuidLess orders two UUIDs by raw bytes (deterministic tie-break).
func uuidLess(a, b pgtype.UUID) bool {
return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0
}
// uuidString renders a pgtype.UUID canonically for log lines.
func uuidString(u pgtype.UUID) string {
if !u.Valid {
return "<nil>"
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}
// splitGenres splits a track's denormalized genre string on the common
// multi-genre delimiters, trimming whitespace and dropping empties. Mirrors
// the recommendation package's splitter (kept local to avoid a dependency).
func splitGenres(genre *string) []string {
if genre == nil || *genre == "" {
return nil
}
parts := strings.FieldsFunc(*genre, func(r rune) bool {
return r == ';' || r == ','
})
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}