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
+28 -17
View File
@@ -65,7 +65,16 @@ func (w *Writer) RecordPlayStarted(
clientID string,
at time.Time,
) (StartedResult, error) {
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", at)
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", "", at)
}
// strPtrOrNil maps an empty string to a NULL text column (nullable pointer),
// mirroring the clientID/source nil-on-empty pattern used above.
func strPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}
// systemPlaylistSources are the play_events.source values that count
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
func (w *Writer) RecordPlayStartedWithSource(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID, source string,
clientID, source, deviceClass string,
at time.Time,
) (StartedResult, error) {
var out StartedResult
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
DeviceClass: strPtrOrNil(deviceClass),
})
if err != nil {
return err
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
func (w *Writer) RecordOfflinePlay(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID, source string,
clientID, source, deviceClass string,
at time.Time,
durationPlayedMs int32,
) error {
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
DeviceClass: strPtrOrNil(deviceClass),
})
if err != nil {
return err