feat(recommendation): add BuildSessionVector pure function
Pure aggregation per spec §6: Seed flag (true when prior < 3), deduplicated Artists ordered by first appearance, Tags bag-of-counts from tracks.genre (empty genres skipped), RecentTrackIDs preserving input order. JSON round-trip verified.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
type SessionVector struct {
|
||||
Seed bool `json:"seed"`
|
||||
Artists []string `json:"artists"`
|
||||
Tags map[string]int `json:"tags"`
|
||||
RecentTrackIDs []string `json:"recent_track_ids"`
|
||||
}
|
||||
|
||||
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
|
||||
v := SessionVector{
|
||||
Seed: len(priorTracks) < 3,
|
||||
Artists: []string{},
|
||||
Tags: map[string]int{},
|
||||
RecentTrackIDs: []string{},
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, t := range priorTracks {
|
||||
artistID := uuidString(t.ArtistID)
|
||||
if !seen[artistID] {
|
||||
seen[artistID] = true
|
||||
v.Artists = append(v.Artists, artistID)
|
||||
}
|
||||
if t.Genre != nil && *t.Genre != "" {
|
||||
v.Tags[*t.Genre]++
|
||||
}
|
||||
v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func uuidString(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return ""
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func track(artistID pgtype.UUID, genre string) dbq.Track {
|
||||
t := dbq.Track{
|
||||
ArtistID: artistID,
|
||||
}
|
||||
if genre != "" {
|
||||
g := genre
|
||||
t.Genre = &g
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func uuid(s string) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
_ = u.Scan(s)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_Empty_IsSeed(t *testing.T) {
|
||||
v := BuildSessionVector(nil)
|
||||
if !v.Seed {
|
||||
t.Errorf("Seed = false, want true for empty input")
|
||||
}
|
||||
if len(v.Artists) != 0 || len(v.Tags) != 0 || len(v.RecentTrackIDs) != 0 {
|
||||
t.Errorf("non-empty collections: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_OneTrack_IsSeed(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
tr := track(a1, "rock")
|
||||
tr.ID = uuid("22222222-2222-2222-2222-222222222222")
|
||||
v := BuildSessionVector([]dbq.Track{tr})
|
||||
if !v.Seed {
|
||||
t.Errorf("1 track: Seed=false, want true (< 3)")
|
||||
}
|
||||
if len(v.Artists) != 1 {
|
||||
t.Errorf("Artists: %+v", v.Artists)
|
||||
}
|
||||
if v.Tags["rock"] != 1 {
|
||||
t.Errorf("Tags: %+v", v.Tags)
|
||||
}
|
||||
if len(v.RecentTrackIDs) != 1 {
|
||||
t.Errorf("RecentTrackIDs: %+v", v.RecentTrackIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_TwoTracks_IsSeed(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
v := BuildSessionVector([]dbq.Track{track(a1, "rock"), track(a1, "jazz")})
|
||||
if !v.Seed {
|
||||
t.Errorf("2 tracks: Seed=false, want true (< 3)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_ThreeTracks_NotSeed(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
v := BuildSessionVector([]dbq.Track{
|
||||
track(a1, "rock"), track(a1, "rock"), track(a1, "rock"),
|
||||
})
|
||||
if v.Seed {
|
||||
t.Errorf("3 tracks: Seed=true, want false")
|
||||
}
|
||||
// All same artist → 1 entry.
|
||||
if len(v.Artists) != 1 {
|
||||
t.Errorf("Artists len = %d, want 1 (dedup)", len(v.Artists))
|
||||
}
|
||||
// All same genre → count 3.
|
||||
if v.Tags["rock"] != 3 {
|
||||
t.Errorf("Tags['rock'] = %d, want 3", v.Tags["rock"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_DistinctArtistsAndGenres(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
a2 := uuid("22222222-2222-2222-2222-222222222222")
|
||||
a3 := uuid("33333333-3333-3333-3333-333333333333")
|
||||
v := BuildSessionVector([]dbq.Track{
|
||||
track(a1, "rock"),
|
||||
track(a2, "jazz"),
|
||||
track(a3, "rock"),
|
||||
track(a1, "experimental"),
|
||||
})
|
||||
// 4 tracks → not seed.
|
||||
if v.Seed {
|
||||
t.Errorf("Seed=true, want false")
|
||||
}
|
||||
// Distinct artists: 3 (a1 dedup'd).
|
||||
if len(v.Artists) != 3 {
|
||||
t.Errorf("Artists len = %d, want 3", len(v.Artists))
|
||||
}
|
||||
// Tag counts.
|
||||
if v.Tags["rock"] != 2 || v.Tags["jazz"] != 1 || v.Tags["experimental"] != 1 {
|
||||
t.Errorf("Tags = %+v", v.Tags)
|
||||
}
|
||||
if len(v.RecentTrackIDs) != 4 {
|
||||
t.Errorf("RecentTrackIDs len = %d", len(v.RecentTrackIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
v := BuildSessionVector([]dbq.Track{
|
||||
track(a1, ""), // empty genre via the helper (nil pointer)
|
||||
track(a1, "rock"),
|
||||
track(a1, ""),
|
||||
})
|
||||
if v.Tags["rock"] != 1 {
|
||||
t.Errorf("Tags['rock'] = %d, want 1", v.Tags["rock"])
|
||||
}
|
||||
if _, exists := v.Tags[""]; exists {
|
||||
t.Errorf("Tags has empty-string entry: %+v", v.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_JSONRoundTrip(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
v := BuildSessionVector([]dbq.Track{
|
||||
track(a1, "rock"), track(a1, "jazz"), track(a1, "rock"),
|
||||
})
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var got SessionVector
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.Seed != v.Seed {
|
||||
t.Errorf("Seed: %v != %v", got.Seed, v.Seed)
|
||||
}
|
||||
if got.Tags["rock"] != v.Tags["rock"] {
|
||||
t.Errorf("Tags: %+v != %+v", got.Tags, v.Tags)
|
||||
}
|
||||
if len(got.Artists) != len(v.Artists) {
|
||||
t.Errorf("Artists: len %d != %d", len(got.Artists), len(v.Artists))
|
||||
}
|
||||
if len(got.RecentTrackIDs) != len(v.RecentTrackIDs) {
|
||||
t.Errorf("RecentTrackIDs: len %d != %d", len(got.RecentTrackIDs), len(v.RecentTrackIDs))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user