40384cc05e
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>
141 lines
4.6 KiB
Go
141 lines
4.6 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
func uuidN(n byte) pgtype.UUID {
|
|
return pgtype.UUID{Bytes: [16]byte{15: n}, Valid: true}
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
// noDate is an absent release date — the era term contributes 0.
|
|
func noDate() pgtype.Date { return pgtype.Date{} }
|
|
|
|
// dateInYear builds a valid release date in the given year (era = its decade).
|
|
func dateInYear(year int) pgtype.Date {
|
|
return pgtype.Date{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
|
}
|
|
|
|
func TestTasteProfile_Match(t *testing.T) {
|
|
loved := uuidN(1)
|
|
disliked := uuidN(2)
|
|
unknown := uuidN(3)
|
|
p := TasteProfile{
|
|
artists: map[pgtype.UUID]float64{loved: 8.0, disliked: -8.0},
|
|
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
|
}
|
|
|
|
if m := p.Match(loved, strPtr("Jazz"), noDate()); m <= 0.5 {
|
|
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
|
|
}
|
|
if m := p.Match(disliked, strPtr("Noise"), noDate()); m >= -0.5 {
|
|
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
|
|
}
|
|
if m := p.Match(unknown, nil, noDate()); m != 0 {
|
|
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
|
}
|
|
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
|
// clearly positive.
|
|
if m := p.Match(loved, strPtr("Unheard"), noDate()); m <= 0 {
|
|
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
|
}
|
|
// Output stays within [-1, 1] even with saturated inputs.
|
|
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
|
m := p.Match(a, strPtr("Jazz"), dateInYear(1994))
|
|
if m < -1 || m > 1 {
|
|
t.Errorf("Match out of [-1,1]: %.3f", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTasteProfile_EraTerm verifies the decade facet nudges the match: with
|
|
// artist + genre held neutral, a loved era lifts the score and a disliked era
|
|
// lowers it, while an undated track is unaffected.
|
|
func TestTasteProfile_EraTerm(t *testing.T) {
|
|
art := uuidN(1)
|
|
p := TasteProfile{
|
|
artists: map[pgtype.UUID]float64{},
|
|
tags: map[string]float64{},
|
|
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
|
|
}
|
|
loved := p.Match(art, nil, dateInYear(1994))
|
|
if loved <= 0 {
|
|
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
|
|
}
|
|
disliked := p.Match(art, nil, dateInYear(1987))
|
|
if disliked >= 0 {
|
|
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
|
|
}
|
|
if m := p.Match(art, nil, noDate()); m != 0 {
|
|
t.Errorf("undated track = %.3f, want 0 (no era contribution)", m)
|
|
}
|
|
}
|
|
|
|
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
|
var p TasteProfile // zero value: nil maps
|
|
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994)); m != 0 {
|
|
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
|
|
}
|
|
}
|
|
|
|
func TestScore_TasteTermAddsAndSubtracts(t *testing.T) {
|
|
now := time.Now()
|
|
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
|
|
w := ScoringWeights{TasteWeight: 2.0} // all other weights 0
|
|
|
|
pos := Score(ScoringInputs{TasteMatchScore: 1.0}, w, now, zeroJitter)
|
|
if !almostEq(pos, 2.0) {
|
|
t.Errorf("positive taste: Score = %.3f, want 2.0", pos)
|
|
}
|
|
neg := Score(ScoringInputs{TasteMatchScore: -1.0}, w, now, zeroJitter)
|
|
if !almostEq(neg, -2.0) {
|
|
t.Errorf("negative taste: Score = %.3f, want -2.0 (demotes)", neg)
|
|
}
|
|
off := Score(ScoringInputs{TasteMatchScore: 1.0}, ScoringWeights{}, now, zeroJitter)
|
|
if !almostEq(off, 0.0) {
|
|
t.Errorf("TasteWeight 0: Score = %.3f, want 0 (no effect)", off)
|
|
}
|
|
}
|
|
|
|
func almostEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
|
|
|
|
// TestLoadTasteProfile_RoundTrip seeds taste_profile rows and verifies the
|
|
// reader hydrates them into a profile that scores a matching track positively.
|
|
func TestLoadTasteProfile_RoundTrip(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
u := seedUser(t, pool, "taste-rt")
|
|
art := seedArtist(t, pool, "Loved Artist", "")
|
|
|
|
if _, err := pool.Exec(ctx,
|
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`,
|
|
u.ID, art.ID, 8.0); err != nil {
|
|
t.Fatalf("seed taste artist: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx,
|
|
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)`,
|
|
u.ID, "Jazz", 6.0); err != nil {
|
|
t.Fatalf("seed taste tag: %v", err)
|
|
}
|
|
|
|
p, err := LoadTasteProfile(ctx, dbq.New(pool), u.ID)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if m := p.Match(art.ID, strPtr("Jazz"), noDate()); m <= 0.5 {
|
|
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
|
|
}
|
|
if m := p.Match(uuidN(9), nil, noDate()); m != 0 {
|
|
t.Errorf("absent artist Match = %.3f, want 0", m)
|
|
}
|
|
}
|