13b3fca949
golangci-lint v2 (CI-only; local is v1) flagged two unused-parameter issues: - BuildTasteProfile's `now` was genuinely dead — decay/windowing are computed DB-side via now(), so no Go-side timestamp is threaded. Removed it (a phase-3 context model that needs a pinned reference time would re-add it); updated the scheduler call site. - the degenerate-params engagement test ignored t; reworked it to assert the result stays in [-1,1], which also strengthens the test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
268 lines
8.2 KiB
Go
268 lines
8.2 KiB
Go
// 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 "<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
|
|
}
|