Files
minstrel/internal/recommendation/context.go
T
bvandeusen 5749f48b4a
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
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>
2026-07-14 12:47:59 -04:00

76 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package recommendation
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// contextAffinityShrinkage is the pseudo-count that pulls a low-play artist's
// cell-share toward the user's baseline, so an artist with one or two plays
// can't swing its affinity to ±1 on noise. At k plays the estimate sits
// halfway between the raw cell-share and the baseline.
const contextAffinityShrinkage = 5.0
// ContextAffinity is the read-side map of per-artist time-of-day/weekday
// affinity for the CURRENT context (#1531): artist_id → score in [-1, +1].
// Absent artists (no play history) score 0, so cold-start candidates stay
// neutral. The zero value is a valid all-neutral affinity.
type ContextAffinity struct {
byArtist map[pgtype.UUID]float64
}
// Affinity returns the artist's current-context affinity, or 0 if unknown.
func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
return c.byArtist[artistID]
}
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
// 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, deviceClass string,
) (ContextAffinity, error) {
rows, err := q.ListArtistContextPlayCountsForUser(ctx, dbq.ListArtistContextPlayCountsForUserParams{
ID: userID,
Column2: deviceClass,
})
if err != nil {
return ContextAffinity{}, err
}
var totalPlays, cellPlays int64
for _, r := range rows {
totalPlays += r.TotalPlays
cellPlays += r.CellPlays
}
out := ContextAffinity{byArtist: make(map[pgtype.UUID]float64, len(rows))}
if totalPlays == 0 {
return out, nil
}
baseline := float64(cellPlays) / float64(totalPlays)
for _, r := range rows {
out.byArtist[r.ArtistID] = contextAffinity(
float64(r.CellPlays), float64(r.TotalPlays), baseline, contextAffinityShrinkage)
}
return out, nil
}
// contextAffinity returns an artist's shrunk cell-share minus the user's
// baseline share, clamped to [-1, 1]. The shrinkage pseudo-count k pulls
// low-play artists toward the baseline (→ 0 affinity) so noise can't dominate;
// a heavily-played artist keeps close to its raw over/under-representation.
func contextAffinity(cellPlays, totalPlays, baseline, k float64) float64 {
if totalPlays == 0 {
return 0
}
shrunk := (cellPlays + baseline*k) / (totalPlays + k)
return clampUnit(shrunk - baseline)
}