6e0d0e5723
Build a persistent, decaying model of each user's taste, recomputed daily, that later phases consume across every recommendation surface. Phase 1 only BUILDS the object — no behaviour change to what's surfaced yet. Core mechanic — graded engagement (replaces binary was_skipped for learning; was_skipped stays for History): a play's completion ratio maps to a signal in [-1,+1] via two linear ramps (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1). Time-decayed (half-life ~75d) so recent behaviour dominates and the profile tracks drift. Per operator constraints: - No explicit dislike button — negatives come only from passive behaviour (early skips). Nothing recorded to regret or opt out of. - Negatives are track-scoped; artist/tag weight is the decayed SUM of their tracks' engagement, so one skip nets out against many good plays (a DB test asserts a liked artist stays positive despite an early-skipped track). A floor clamp bounds how negative any single entity can get. - migration 0035: taste_profile_artists / taste_profile_tags (signed weight, indexed by (user, weight DESC)). - internal/taste: engagement.go (pure curve + decay) + profile.go (accumulate plays + like bonuses, floor damping, size caps, atomic-replace). - scheduler: rebuildUserDaily recomputes the profile before the playlist build (so phase 2 can read it), best-effort — a taste failure never blocks playlist building. Wired into the daily job + startup catch-up only (not manual/lazy rebuilds). - tests: pure (engagement curve, decay, ranking, floor, genre split) + DB-backed (positive/negative weights, aggregation-protects-artist, like bonus, atomic replace). All green vs real Postgres. Config knobs live in taste.DefaultConfig() for now; wiring them into the server RecommendationConfig is a later follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
2.5 KiB
Go
95 lines
2.5 KiB
Go
package taste
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func strLess(a, b string) bool { return a < b }
|
|
|
|
func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) {
|
|
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
|
|
got := rankWeighted(m, 0.05, 10, 10, strLess)
|
|
if len(got) != 3 {
|
|
t.Fatalf("len = %d, want 3 (tiny dropped by epsilon)", len(got))
|
|
}
|
|
wantOrder := []string{"a", "b", "d"} // 3, 1, -2 descending
|
|
for i, w := range wantOrder {
|
|
if got[i].Key != w {
|
|
t.Errorf("pos %d = %q, want %q", i, got[i].Key, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRankWeighted_CapsTopAndBottom(t *testing.T) {
|
|
m := map[string]float64{"e5": 5, "e4": 4, "e3": 3, "e2": 2, "e1": 1}
|
|
got := rankWeighted(m, 0.0, 1, 1, strLess) // top 1 + bottom 1
|
|
if len(got) != 2 {
|
|
t.Fatalf("len = %d, want 2", len(got))
|
|
}
|
|
if got[0].Key != "e5" || got[1].Key != "e1" {
|
|
t.Errorf("got [%s,%s], want [e5,e1]", got[0].Key, got[1].Key)
|
|
}
|
|
}
|
|
|
|
func TestRankWeighted_DeterministicTieBreak(t *testing.T) {
|
|
m := map[string]float64{"z": 1.0, "a": 1.0, "m": 1.0}
|
|
got := rankWeighted(m, 0.0, 10, 10, strLess)
|
|
// Equal weights → ordered by keyLess (string asc): a, m, z.
|
|
want := []string{"a", "m", "z"}
|
|
for i, w := range want {
|
|
if got[i].Key != w {
|
|
t.Errorf("tie-break pos %d = %q, want %q", i, got[i].Key, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyFloor_ClampsNegatives(t *testing.T) {
|
|
m := map[string]float64{"a": -5, "b": 2, "c": -1}
|
|
applyFloor(m, -3)
|
|
if m["a"] != -3 {
|
|
t.Errorf("a = %.1f, want -3 (floored)", m["a"])
|
|
}
|
|
if m["b"] != 2 {
|
|
t.Errorf("b = %.1f, want 2 (untouched)", m["b"])
|
|
}
|
|
if m["c"] != -1 {
|
|
t.Errorf("c = %.1f, want -1 (above floor)", m["c"])
|
|
}
|
|
}
|
|
|
|
func TestSplitGenres(t *testing.T) {
|
|
mk := func(s string) *string { return &s }
|
|
cases := []struct {
|
|
in *string
|
|
want []string
|
|
}{
|
|
{nil, nil},
|
|
{mk(""), nil},
|
|
{mk("Rock"), []string{"Rock"}},
|
|
{mk(" Rock "), []string{"Rock"}},
|
|
{mk("Rock; Pop, Jazz"), []string{"Rock", "Pop", "Jazz"}},
|
|
}
|
|
for _, c := range cases {
|
|
got := splitGenres(c.in)
|
|
if len(got) != len(c.want) {
|
|
t.Errorf("splitGenres(%v) len = %d, want %d", c.in, len(got), len(c.want))
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != c.want[i] {
|
|
t.Errorf("splitGenres(%v)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUuidLess_Orders(t *testing.T) {
|
|
a := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
b := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
|
|
if !uuidLess(a, b) || uuidLess(b, a) {
|
|
t.Error("uuidLess byte ordering wrong")
|
|
}
|
|
}
|