feat(taste): device-class context conditioning — #1551
Milestone #160 Opt 3b. Adds device class as a third context axis on top of the #1531 time-of-day/weekday affinity: on the radio path, a candidate is boosted when its artist concentrates in the current (daypart × weekday × device) cell. Client-sent (client_id is opaque; no UA stored), so it's captured going forward and applies to radio only (daily mixes are cron-built with no device → stay device-agnostic). Server: - Migration 0048: play_events.device_class text NULL (no CHECK; normalized in Go — one whitelist entry per new client class, not a migration). - events.go: eventRequest.device_class + normalizeDeviceClass (whitelist → mobile/web/…, else "other", empty → NULL); threaded through both RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent. - ListArtistContextPlayCountsForUser gains a current-device param; the cell FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531 time-only behaviour exactly (used by mixes). SessionVector.DeviceClass carries it; the radio handler derives the current device from the user's latest play (GetLatestPlayDeviceClassForUser) — request-free proxy. - No new tuning knob: device narrows the existing ContextAffinityScore (reuses context_time_weight). Clients: - web: play_started sends device_class 'web'. - android: play_started + offline replay send 'mobile' (EventsWire + PlayOfflinePayload + MutationReplayer + PlayEventsReporter). Test: LoadContextAffinity device-narrowing integration test (mobile vs web artist separation; device-agnostic parity). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func insertPlayEventDevice(
|
||||
t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, device string,
|
||||
) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var sessionID pgtype.UUID
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||
VALUES ($1, $2, $2, 'ctx-dev-test') RETURNING id`,
|
||||
userID, startedAt).Scan(&sessionID); err != nil {
|
||||
t.Fatalf("insert play_session: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped, device_class)
|
||||
VALUES ($1, $2, $3, $4, false, $5)`,
|
||||
userID, trackID, sessionID, startedAt, device); err != nil {
|
||||
t.Fatalf("insert play_event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadContextAffinity_DeviceNarrowing seeds one artist played only on
|
||||
// mobile and another only on web (all in the current time cell), and verifies
|
||||
// the current-device parameter narrows the affinity cell (#1551): on 'mobile'
|
||||
// the mobile artist out-scores the web artist and vice-versa, while the
|
||||
// device-agnostic (”) pass treats them equally.
|
||||
func TestLoadContextAffinity_DeviceNarrowing(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
|
||||
u := seedUser(t, pool, "ctx-dev")
|
||||
aMobile := seedArtist(t, pool, "MobileArtist", "")
|
||||
aWeb := seedArtist(t, pool, "WebArtist", "")
|
||||
alM := seedAlbumForArtist(t, pool, aMobile.ID, "AlbM")
|
||||
tM := seedTrackOnAlbum(t, pool, alM.ID, aMobile.ID, "TrkM")
|
||||
alW := seedAlbumForArtist(t, pool, aWeb.ID, "AlbW")
|
||||
tW := seedTrackOnAlbum(t, pool, alW.ID, aWeb.ID, "TrkW")
|
||||
|
||||
now := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
insertPlayEventDevice(t, pool, u.ID, tM.ID, now, "mobile")
|
||||
insertPlayEventDevice(t, pool, u.ID, tW.ID, now, "web")
|
||||
}
|
||||
|
||||
mob, err := LoadContextAffinity(ctx, q, u.ID, "mobile")
|
||||
if err != nil {
|
||||
t.Fatalf("mobile: %v", err)
|
||||
}
|
||||
if mob.Affinity(aMobile.ID) <= mob.Affinity(aWeb.ID) {
|
||||
t.Errorf("on mobile, mobile artist (%.3f) should out-score web artist (%.3f)",
|
||||
mob.Affinity(aMobile.ID), mob.Affinity(aWeb.ID))
|
||||
}
|
||||
|
||||
web, err := LoadContextAffinity(ctx, q, u.ID, "web")
|
||||
if err != nil {
|
||||
t.Fatalf("web: %v", err)
|
||||
}
|
||||
if web.Affinity(aWeb.ID) <= web.Affinity(aMobile.ID) {
|
||||
t.Errorf("on web, web artist (%.3f) should out-score mobile artist (%.3f)",
|
||||
web.Affinity(aWeb.ID), web.Affinity(aMobile.ID))
|
||||
}
|
||||
|
||||
// Device-agnostic ('' ): all plays fall in the same time cell, so the two
|
||||
// artists are treated identically — the device dimension is what separates
|
||||
// them above.
|
||||
agn, err := LoadContextAffinity(ctx, q, u.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("agnostic: %v", err)
|
||||
}
|
||||
if agn.Affinity(aMobile.ID) != agn.Affinity(aWeb.ID) {
|
||||
t.Errorf("device-agnostic affinities should match, got mobile=%.3f web=%.3f",
|
||||
agn.Affinity(aMobile.ID), agn.Affinity(aWeb.ID))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user