// 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" "git.fabledsword.com/bvandeusen/minstrel/internal/mood" ) // 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 // MoodScale weights the mood facet (#1534): each play adds base × this // scale to the canonical mood buckets (melancholic / chill / …) derived // from its enriched folksonomy tags via internal/mood. Coverage is partial // (depends on tag enrichment), so it's a supplement kept ≤ 1; 0 disables // the mood facet entirely. Tunable in the lab (mirrors EraScale). MoodScale 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 MaxMoods int MaxNegMoods 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, MoodScale: 0.5, ArtistFloor: -3.0, TagFloor: -3.0, WeightEpsilon: 0.05, MaxArtists: 500, MaxNegArtists: 150, MaxTags: 200, MaxNegTags: 60, MaxEras: 30, MaxNegEras: 15, MaxMoods: 20, MaxNegMoods: 10, } } // 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, moodW, err := accumulate(ctx, q, userID, cfg) if err != nil { return err } applyFloor(artistW, cfg.ArtistFloor) applyFloor(tagW, cfg.TagFloor) // Era + mood share the tag floor (all text-keyed facets; a decade or mood // that's been heavily skipped shouldn't dominate as an extreme negative). applyFloor(eraW, cfg.TagFloor) applyFloor(moodW, 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) moods := rankWeighted(moodW, cfg.WeightEpsilon, cfg.MaxMoods, cfg.MaxNegMoods, strLess) if err := persist(ctx, pool, userID, artists, tags, eras, moods); err != nil { return err } logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID), "artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods)) return nil } // accumulate sums decayed play engagement and like bonuses into // artist/tag/era/mood 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, map[string]float64, error) { plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{ UserID: userID, Column2: cfg.WindowDays, }) if err != nil { return nil, 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, 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) moodW := 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) foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale) } likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID) if err != nil { return nil, nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err) } likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID) if err != nil { return nil, 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) foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale) } likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID) if err != nil { return nil, nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err) } for _, aid := range likedArtists { artistW[aid] += cfg.ArtistLikeBonus } return artistW, tagW, eraW, moodW, 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 } } // foldMoods adds a track's canonical mood buckets (derived from its enriched // folksonomy tags via internal/mood) to moodW weighted by base × scale. base is // the play's decayed engagement or the tag-like bonus; scale (MoodScale) dials // the facet's strength. scale=0 disables the mood facet; tracks with no // mood-word tags contribute nothing. func foldMoods(moodW map[string]float64, tags []dbq.TrackTag, base, scale float64) { if scale == 0 { return } names := make([]string, len(tags)) for i, t := range tags { names[i] = t.Tag } for _, m := range mood.Of(names) { moodW[m] += 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, moods []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 := qtx.DeleteTasteProfileMoodsForUser(ctx, userID); err != nil { return fmt.Errorf("taste: delete moods: %w", err) } for _, m := range moods { if err := qtx.InsertTasteProfileMood(ctx, dbq.InsertTasteProfileMoodParams{ UserID: userID, Mood: m.Key, Weight: m.Weight, }); err != nil { return fmt.Errorf("taste: insert mood: %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 "" } 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 }