// 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 // 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. MaxArtists int MaxNegArtists int MaxTags int MaxNegTags 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, ArtistFloor: -3.0, TagFloor: -3.0, WeightEpsilon: 0.05, MaxArtists: 500, MaxNegArtists: 150, MaxTags: 200, MaxNegTags: 60, } } // 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, err := accumulate(ctx, q, userID, cfg) if err != nil { return err } applyFloor(artistW, cfg.ArtistFloor) applyFloor(tagW, cfg.TagFloor) 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 }) if err := persist(ctx, pool, userID, artists, tags); err != nil { return err } logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID), "artists", len(artists), "tags", len(tags)) return nil } // accumulate sums decayed play engagement and like bonuses into artist/tag // weight maps. func accumulate( ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config, ) (map[pgtype.UUID]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) } artistW := make(map[pgtype.UUID]float64) tagW := 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 } } likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID) if err != nil { return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err) } for _, lt := range likedTracks { artistW[lt.ArtistID] += cfg.TrackLikeBonus for _, tag := range splitGenres(lt.Genre) { tagW[tag] += cfg.TagLikeBonus } } likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID) if err != nil { return nil, nil, fmt.Errorf("taste: load liked artists: %w", err) } for _, aid := range likedArtists { artistW[aid] += cfg.ArtistLikeBonus } return artistW, tagW, nil } // 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], ) 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 := 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 }