feat(taste): device-class context conditioning — #1551
test-go / test (push) Successful in 45s
test-web / test (push) Successful in 52s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 5m5s

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:
2026-07-14 12:47:59 -04:00
parent f0c08e7326
commit 5749f48b4a
20 changed files with 306 additions and 64 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ func LoadCandidates(
return nil, err
}
affinity, err := LoadContextAffinity(ctx, q, userID)
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
if err != nil {
return nil, err
}
@@ -150,7 +150,7 @@ func LoadCandidatesFromSimilarity(
return nil, err
}
affinity, err := LoadContextAffinity(ctx, q, userID)
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
if err != nil {
return nil, err
}
+9 -4
View File
@@ -28,15 +28,20 @@ func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
}
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
// daypart × weekday cell. For every artist with completed plays in the window
// it compares the share of that artist's plays that fall in the current cell
// context cell — daypart × weekday, narrowed by deviceClass when non-empty
// (#1551; radio passes the current device, the daily mixes pass "" for a
// device-agnostic cell). For every artist with completed plays in the window it
// compares the share of that artist's plays that fall in the current cell
// against the user's overall baseline share, shrinking sparse artists toward
// the baseline. Returns an empty (all-neutral) affinity when the user has no
// plays.
func LoadContextAffinity(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID,
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, deviceClass string,
) (ContextAffinity, error) {
rows, err := q.ListArtistContextPlayCountsForUser(ctx, userID)
rows, err := q.ListArtistContextPlayCountsForUser(ctx, dbq.ListArtistContextPlayCountsForUserParams{
ID: userID,
Column2: deviceClass,
})
if err != nil {
return ContextAffinity{}, err
}
@@ -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))
}
}
+5
View File
@@ -13,6 +13,11 @@ type SessionVector struct {
Artists []string `json:"artists"`
Tags map[string]int `json:"tags"`
RecentTrackIDs []string `json:"recent_track_ids"`
// DeviceClass is the current request's device (#1551), set by the radio
// handler from the user's latest play; drives the device dimension of the
// context-affinity term. Empty (omitted) for the daily mixes and for the
// snapshot stored on play_events, so it never narrows those.
DeviceClass string `json:"device_class,omitempty"`
}
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {