Compare commits

...

11 Commits

Author SHA1 Message Date
bvandeusen 29e1e7c64c test(web/tuning): marker summary appears in tooltips too — use getAllByText
test-web / test (push) Successful in 32s
The knob-turn summary renders both as the sparkline tick tooltips (one
per series) and in the list under the chart; the single-element query
tripped on the duplicates in CI run 1906.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:35:51 -04:00
bvandeusen 9ad4343c76 feat(tuning): weekly trend view — per-surface series + knob-turn markers
test-go / test (push) Successful in 33s
test-web / test (push) Failing after 38s
test-go / integration (push) Successful in 4m44s
The verify half of the tune→verify loop (#1251), on the same admin
Tuning page as the knobs:

- RecommendationWeeklyTrends: weekly per-source outcomes aggregated
  across all users (the knobs are global, so judging a turn needs
  global outcomes — rows carry rates only, no track/user identity),
  with a taste-hit count per bucket: plays whose track's artist has a
  positive weight in the player's current taste profile. That's the
  "cheap recompute" reading — retroactive over the whole window, at
  the cost of profile drift.
- GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52):
  per-family weekly series (skip rate, sample-weighted completion,
  taste-hit rate) plus the tuning-audit markers inside the window.
- Web: sparkline table under the tuning cards — skip rate per week on
  a shared axis with dashed ticks at knob turns, latest-week columns,
  window taste-hit rate, low-volume rows dimmed as anecdote, and a
  plain-text list of the window's tuning changes.

Also fixes the revive unused-parameter lint on the tuning GET handler
that failed CI run 1903 on the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:29:33 -04:00
bvandeusen 0d0a8f46b1 feat(tuning): scoring weights → DB-backed admin tuning lab
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:22:03 -04:00
bvandeusen 9e02878b61 feat(playlists): For You composition v2 — multi-seed blend + weighted fresh tail
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m37s
Two approved composition changes (#1269), mechanism only — the
taste/fresh share stays data-decided (#1252) and pick_kind
attribution is unchanged.

Multi-seed blending: each day's build now seeds from up to 3 of the
user's top-5 tracks (pickDailySeeds, the generalized daily shuffle)
instead of one rotating anchor, so the mix spans neighborhoods within
a day and stops feeling bipolar as the rotation swings between
dissimilar seeds. Per-seed pools merge first-seen-deduped; the head
is filled best-first under 50/30/20 per-seed quotas (60/40 for two
seeds) so one neighborhood can't monopolize it, with thin-seed quota
spilling best-first.

Score-weighted fresh tail: the tail sample (rank 2*headN onward) was
uniform — the 380th-best candidate as likely as the 101st. It now
uses deterministic Efraimidis-Spirakis keys with weight halving every
50 ranks, so freshness keeps its "you'll probably enjoy this" half
while still rotating daily.

The retired single-seed picker's one other caller, You-might-like,
moves to pickDailySeeds(n=1) — a single neighborhood per day is right
for a short shelf, and the behavior note is inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:02:35 -04:00
bvandeusen 48f288e2e5 feat(mixes): tiered rebuilds for New for you + First listens (rule #131)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m39s
Both mixes move from a single hard eligibility rule to the tiered
ladder, with their tier stamped onto playlist_tracks.pick_kind via the
#1270 provenance pipeline.

New for you (#1267) — consume on play, degrade by stepping back:
- "Consumed" = any track attempted >=30s; played albums leave the mix
  at the next build instead of crowding it until the calendar window
  expires.
- Tier 1: unconsumed albums added <30d by direct-affinity artists.
  Tier 2: unconsumed affinity albums from the wider 30-90d window —
  added while you weren't looking. Tier 3: any unconsumed album added
  <90d, newest first.

First listens (#1268) — track-level "attempted" threshold:
- A 2-second accidental brush no longer disqualifies a whole album;
  "attempted" is duration_played_ms >= 30000 per track.
- Tier 1: albums with zero attempted tracks. Tier 2: barely-attempted
  albums (<=25% of tracks reached 30s), minus the attempted tracks
  themselves. The artist-affinity ordering signal also moves to the
  >=30s definition so skip-only contact doesn't read as trust.

Producer plumbing: fetch adapters map the tier column onto pick kinds,
finishMix propagates PickKind into the persisted candidates, and
rotateForDay now rotates within contiguous same-pick-kind blocks so
daily rotation can't hoist tier-3 filler above tier-1's exact fits
(untiered pools are one block — original behavior).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 08:54:20 -04:00
bvandeusen 2be07ef271 fix(mixes): close three intent gaps found in the system-playlists audit
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m36s
Three discovery-mix defects from the intent audit (Scribe note #1254),
all sharing the same root pattern — skips treated as non-events:

- Deep Cuts (#1257): eligibility counted only unskipped plays, so a
  track skipped twice with zero completed listens read as "barely
  heard" and kept being re-offered. Tracks with >=2 skips no longer
  qualify; a single accidental skip doesn't banish.

- Rediscover (#1258): a skip on a rediscover-sourced play — the user
  explicitly declining the resurfacing invitation — changed nothing,
  so declined tracks re-qualified the next day forever. Such tracks
  now sit out 90 days.

- On This Day (#1256): day-of-year distance used plain ABS, so
  Dec 28 vs Jan 3 read as 359 days apart and the window silently
  gutted itself for ~3 weeks around every New Year. Now circular
  (LEAST(d, 365-d)), anchored on the build-date parameter instead of
  now() so it's testable and consistent with the mix's daily
  determinism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 08:47:01 -04:00
bvandeusen a670840114 fix(playlists): Songs-like mixes no longer vanish after a quiet week
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m30s
PickSeedArtists had a hard 7-day window with no fallback: a week
without listening emptied the seed pool, produceSeedMixes returned
zero playlists, and the daily atomic-replace build deleted every
existing "Songs like X" mix until the user played something again
(#1255).

The query now falls back through widening engagement windows — 7d →
30d → all-time → liked artists — the same tiered shape that fixed the
identical vanish for For You's seeds (PickTopPlayedTracksForUser).
Like-boost scoring is preserved in every tier.

All returned rows share the winning tier, and produceSeedMixes maps it
onto the rule-#131 pick-kind ladder (7d = tier1 exact, 30d = tier2,
all-time/liked = tier3) and stamps the built tracks — the #1270
provenance pipeline then attributes plays and skips to seed freshness,
so the metrics card can say whether stale-seeded mixes actually
perform worse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 08:38:54 -04:00
bvandeusen 5faa57634b feat(metrics): provenance as standard — pick_kind for all system mixes
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 38s
test-go / integration (push) Successful in 4m36s
The #1249 mechanism (stamp WHY a track is in the snapshot at build
time, freeze it onto the play at ingestion, break it down in metrics)
generalizes from a For You one-off to the standard for every system
mix (#1270):

- Migration 0039 widens both pick_kind CHECKs (drop + re-add in the
  same change) to taste/fresh + Discover's dormant/cross_user/random
  + tier1-3 for the rule-#131 eligibility ladders.
- GetForYouPickKindForTrack becomes GetSystemPickKindForTrack
  (user, variant, track); ingestion stamps any systemPlaylistSources
  play from its own variant's live snapshot, live + offline paths.
- Discover stamps its candidate bucket on discoverTrack before the
  interleave, making the 40/30/30 allocation measurable; dedup keeps
  the taking bucket's stamp.
- Metrics replace the for_you special-case with one pick-kind
  vocabulary — any family with attributed plays gets a breakdown,
  future stamping mixes need no metrics change.
- Web: breakdown sub-rows are now toggled per surface (collapsed by
  default) so eight stamping mixes don't swamp the card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 23:26:52 -04:00
bvandeusen fb4431207d feat(recommendation): For You exploration attribution — taste vs fresh picks
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s
Milestone #127 step 2 (#1249). For You deliberately blends two
populations — a head of top-scored taste picks and a tail sampled from
deeper ranking (the freshness injection) — but the metrics judged it as
one blob, so its skip rate couldn't distinguish "the taste engine is
missing" from "the freshness tax is too high". That number decides the
exploration share before we tune it.

- Migration 0038: nullable pick_kind ('taste'|'fresh') on both
  playlist_tracks (stamped at snapshot build) and play_events (frozen at
  play-ingestion — the snapshot rebuilds daily, so attribution cannot be
  reconstructed at read time).
- Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool
  fallback is all taste (top-N-by-score IS the taste mechanism). Other
  variants persist NULL.
- Ingestion: for_you plays (live + offline replay) look the track up in
  the user's current snapshot; not found → unattributed, never guessed.
- Metrics: For You's row gains a breakdown (taste / fresh / earlier
  unattributed plays), parent row stays the sum; web card renders the
  sub-rows indented with the same baseline deltas + low-data dimming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 20:39:41 -04:00
bvandeusen 60533073ad feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m30s
The recommendation metrics table was observable but not actionable: raw
source strings (album:<uuid> one-offs) drowned the stable surfaces, and
manual plays were excluded so skip rates had no control group.

- SQL: include NULL-source rows (the baseline) and carry completion_n
  so family merges can weight avg_completion correctly.
- Handler buckets raw sources into stable families (radio:<uuid> →
  Radio, album:/artist: → direct plays, etc.) grouped by surface
  intent: go-to / discovery / direct — each band judged against its
  job, since discovery mixes are expected to skip hotter. Families
  under 20 plays are flagged low-confidence, not hidden.
- Settings card renders the baseline row and per-surface deltas in
  percentage points vs baseline (worse-than-baseline deltas in danger
  color), intent hint copy per group, low-data rows dimmed.
- Pure-unit test for the bucketing/merge; DB test updated to the new
  contract (baseline included, radio:<uuid> collapse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 18:00:40 -04:00
bvandeusen 4b150a277e fix(recommendation): Rediscover no longer ships a one-song playlist (#1246)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m35s
Confirmed against prod: exactly one track (17 plays, cold since May 21)
met the c>=5 + 30d-cold bar, and three process defects turned that into
a 1-track playlist instead of the locked placeholder.

- ListRediscoverTracks: collapse the two-tier UNION into one blended
  pool. The old shallow-tier gate (WHERE NOT EXISTS deep) was
  all-or-nothing — one 6-month row suppressed the entire 30-day tier —
  and deep was a strict subset of shallow anyway. Eligibility drops to
  >=3 non-skip plays (on a weeks-old history the >=5-play tracks are
  precisely the ones still in rotation); ordering prefers >=6mo cold,
  then >=5 plays, then raw count.
- Minimum viable mix floor for all five discovery mixes: below
  minLen (15; 5 for the album-coherent NewForYou/FirstListens) the
  variant is withheld so Home renders the 'listen more to unlock'
  placeholder instead of a mix that reads as built-wrong.
- /api/events: clamp client-supplied 'at' to [user.created_at,
  now+5m]. Unbounded client clocks could write arbitrarily old plays
  and poison the 6-month ordering (prod data verified clean — no
  scrub needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-02 17:29:41 -04:00
59 changed files with 5508 additions and 504 deletions
+12
View File
@@ -25,6 +25,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging" "git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/server" "git.fabledsword.com/bvandeusen/minstrel/internal/server"
@@ -146,6 +147,16 @@ func run() error {
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings) coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
coverEnricher.DataDir = cfg.Storage.DataDir coverEnricher.DataDir = cfg.Storage.DataDir
// Recommendation tuning lab (#1250): seeds shipped defaults on first
// boot and pushes the daily_mix weights + taste config into package
// playlists — must precede the scheduler so the first builds score
// with the operator's tuned values, not the pre-push literals.
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
if err != nil {
logger.Error("recommendation settings service init failed", "err", err)
os.Exit(1)
}
// One unified scan chain: library walk → MBID backfill → cover enrich. // One unified scan chain: library walk → MBID backfill → cover enrich.
// Boot-time scan and manual-trigger scans share this path; results land // Boot-time scan and manual-trigger scans share this path; results land
// in scan_runs for the admin overview. // in scan_runs for the admin overview.
@@ -294,6 +305,7 @@ func run() error {
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg) }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
srv.Bus = bus srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler srv.PlaylistScheduler = playlistScheduler
srv.RecSettings = recSettings
srv.StreamSecret = cfg.StreamSecret srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{ httpServer := &http.Server{
Addr: cfg.Server.Address, Addr: cfg.Server.Address,
+202
View File
@@ -0,0 +1,202 @@
// Admin recommendation-trends endpoint (#1251): weekly per-surface
// outcome series with tuning-audit markers — the verify half of the
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
// all users because the knobs are global; rows carry rates only.
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
trendsDefaultWeeks = 12
trendsMaxWeeks = 52
// trendsAuditFetchCap bounds the audit fetch; markers older than
// the window are dropped in Go. Far above any real knob-turn count
// inside a year.
trendsAuditFetchCap = 500
)
// trendPoint is one week of one surface family's outcomes.
type trendPoint struct {
WeekStart string `json:"week_start"` // ISO date (Monday)
Plays int64 `json:"plays"`
Skips int64 `json:"skips"`
SkipRate float64 `json:"skip_rate"`
AvgCompletion float64 `json:"avg_completion"`
// TasteHitRate is the share of plays whose track's artist carries a
// positive weight in the player's current taste profile — a drifted
// but retroactive read on whether the surface feeds taste-fitting
// tracks.
TasteHitRate float64 `json:"taste_hit_rate"`
// completionN carries the completion sample count through same-week
// merges so avg_completion stays sample-weighted; not serialized.
completionN int64
}
// trendSeries is one surface family's weekly series.
type trendSeries struct {
Key string `json:"key"`
Label string `json:"label"`
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
Points []trendPoint `json:"points"`
}
// trendMarker is one tuning-audit event annotated on the timeline.
type trendMarker struct {
ChangedAt string `json:"changed_at"`
Scope string `json:"scope"`
Action string `json:"action"`
Changes json.RawMessage `json:"changes"`
}
type trendsResp struct {
Weeks int `json:"weeks"`
Series []trendSeries `json:"series"`
Markers []trendMarker `json:"markers"`
}
// handleGetRecommendationTrends implements
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
weeks := trendsDefaultWeeks
if v := r.URL.Query().Get("weeks"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
return
}
if n > trendsMaxWeeks {
n = trendsMaxWeeks
}
weeks = n
}
q := dbq.New(h.pool)
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
if err != nil {
h.logger.Error("api: recommendation trends", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
if err != nil {
h.logger.Error("api: recommendation trends audit", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
}
// buildTrendsResponse folds weekly rows into per-family series and
// windows the audit markers. Split from the handler for pure-unit
// testability.
func buildTrendsResponse(
weeks int, now time.Time,
rows []dbq.RecommendationWeeklyTrendsRow,
audits []dbq.RecommendationTuningAudit,
) trendsResp {
type accum struct {
fam recFamily
plays int64
points []trendPoint
}
families := map[string]*accum{}
for _, row := range rows {
fam := recFamily{key: "manual", label: "Manual library plays"}
if row.Source != nil && *row.Source != "" {
fam = bucketRecSource(*row.Source)
}
acc, ok := families[fam.key]
if !ok {
acc = &accum{fam: fam}
families[fam.key] = acc
}
acc.plays += row.Plays
p := trendPoint{
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
Plays: row.Plays,
Skips: row.Skips,
AvgCompletion: row.AvgCompletion,
completionN: row.CompletionN,
}
if row.Plays > 0 {
p.SkipRate = float64(row.Skips) / float64(row.Plays)
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
}
// Same family can arrive as several raw sources (radio:<uuid>);
// merge same-week points play-weighted.
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
} else {
acc.points = append(acc.points, p)
}
}
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
for _, acc := range families {
resp.Series = append(resp.Series, trendSeries{
Key: acc.fam.key,
Label: acc.fam.label,
Intent: acc.fam.intent,
Plays: acc.plays,
Points: acc.points,
})
}
sort.Slice(resp.Series, func(i, j int) bool {
if resp.Series[i].Plays != resp.Series[j].Plays {
return resp.Series[i].Plays > resp.Series[j].Plays
}
return resp.Series[i].Key < resp.Series[j].Key
})
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
for _, a := range audits {
if a.ChangedAt.Time.Before(cutoff) {
continue
}
resp.Markers = append(resp.Markers, trendMarker{
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
Scope: a.Scope,
Action: a.Action,
Changes: json.RawMessage(a.Changes),
})
}
// ListTuningAudit returns newest-first; the timeline reads better
// oldest-first.
sort.Slice(resp.Markers, func(i, j int) bool {
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
})
return resp
}
// mergeTrendPoints combines two same-week points of one family:
// counts add, skip/taste rates re-derive from the merged counts, and
// avg_completion is weighted by each side's completion sample count.
func mergeTrendPoints(a, b trendPoint) trendPoint {
out := trendPoint{
WeekStart: a.WeekStart,
Plays: a.Plays + b.Plays,
Skips: a.Skips + b.Skips,
completionN: a.completionN + b.completionN,
}
if out.Plays > 0 {
out.SkipRate = float64(out.Skips) / float64(out.Plays)
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
float64(out.Plays)
}
if out.completionN > 0 {
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
float64(out.completionN)
}
return out
}
@@ -0,0 +1,152 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func date(s string) pgtype.Date {
t, _ := time.Parse("2006-01-02", s)
return pgtype.Date{Time: t, Valid: true}
}
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
src := func(s string) *string { return &s }
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
rows := []dbq.RecommendationWeeklyTrendsRow{
// Two radio session sources in the same week collapse into one
// family point; completion weighting by sample count.
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
// NULL source = manual baseline family.
{WeekStart: date("2026-06-29"), Source: nil,
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
}
audits := []dbq.RecommendationTuningAudit{
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
// Older than the window → dropped.
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
}
resp := buildTrendsResponse(12, now, rows, audits)
if len(resp.Series) != 2 {
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
}
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
if radio.Key != "radio" || radio.Plays != 6 {
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
}
if len(radio.Points) != 2 {
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
}
wk1 := radio.Points[0]
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
}
if wk1.SkipRate != 0.5 {
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
}
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
}
// Taste hits: 3 of 4 plays.
if wk1.TasteHitRate != 0.75 {
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
}
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
}
if len(resp.Markers) != 1 {
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
}
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
}
}
func newTrendsRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
return r
}
func TestRecommendationTrends_EndToEnd(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "trends", "pw", true)
artist := seedArtist(t, pool, "TrendArtist")
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
session := seedPlaySession(t, pool, user.ID, time.Now())
// Positive taste weight for the artist → plays count as taste hits.
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
user.ID, artist.ID); err != nil {
t.Fatalf("seed taste profile: %v", err)
}
radio := "radio"
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
// A knob turn to mark the timeline.
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
map[string]float64{"taste_weight": 2}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
var resp trendsResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Weeks != trendsDefaultWeeks {
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
}
var radioSeries *trendSeries
for i := range resp.Series {
if resp.Series[i].Key == "radio" {
radioSeries = &resp.Series[i]
}
}
if radioSeries == nil || len(radioSeries.Points) != 1 {
t.Fatalf("radio series = %+v, want one point", radioSeries)
}
if radioSeries.Points[0].TasteHitRate != 1.0 {
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
radioSeries.Points[0].TasteHitRate)
}
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
}
}
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
rec := httptest.NewRecorder()
newTrendsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
+152
View File
@@ -0,0 +1,152 @@
// Admin recommendation-tuning endpoints (#1250): the defaults-
// discovery lab. GET returns current values + shipped defaults for
// every scope; PATCH applies a partial update to one scope; reset
// restores a scope to shipped defaults. Every change writes an audit
// row (consumed by the metrics trend view, #1251).
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
}
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
return weightsResp{
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
}
}
type tasteTuningResp struct {
HalfLifeDays float64 `json:"half_life_days"`
EngagementHardSkip float64 `json:"engagement_hard_skip"`
EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
return tasteTuningResp{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
}
}
// tuningSnapshot is both the GET response and the post-mutation echo:
// current values alongside shipped defaults so the card can mark
// which knobs deviate.
type tuningSnapshot struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
Shipped struct {
Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"`
} `json:"shipped"`
}
func (h *handlers) tuningSnapshot() tuningSnapshot {
var out tuningSnapshot
out.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
}
out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
}
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
return out
}
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// patchTuningReq carries the partial update: field name → new value,
// using the same snake_case names the GET response emits.
type patchTuningReq struct {
Values map[string]float64 `json:"values"`
}
// handlePatchRecommendationTuning implements
// PATCH /api/admin/recommendation-tuning/{scope}.
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
var body patchTuningReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
return
}
if len(body.Values) == 0 {
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
return
}
var err error
if scope == recsettings.ScopeTaste {
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
} else {
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
}
if err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// handleResetRecommendationTuning implements
// POST /api/admin/recommendation-tuning/{scope}/reset.
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
scope := chi.URLParam(r, "scope")
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
writeTuningErr(w, h, scope, err)
return
}
writeJSON(w, http.StatusOK, h.tuningSnapshot())
}
// writeTuningErr maps recsettings validation errors to 400s and
// everything else to a logged 500.
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
switch {
case errors.Is(err, recsettings.ErrUnknownScope):
writeErr(w, &apierror.Error{
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
})
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
default:
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
writeErr(w, apierror.InternalMsg("tuning update failed", err))
}
}
@@ -0,0 +1,110 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
)
func newTuningRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
return r
}
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
t.Helper()
var snap tuningSnapshot
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
t.Fatalf("decode: %v", err)
}
return snap
}
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
rec := httptest.NewRecorder()
newTuningRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
}
if snap.Taste.HalfLifeDays != 75 {
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
}
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
t.Error("untouched values must equal shipped defaults")
}
}
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
}
snap := decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 2.5 {
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
}
// The change is live for the radio scoring path.
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
}
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200", rec.Code)
}
snap = decodeTuning(t, rec)
if snap.Profiles["radio"].TasteWeight != 1.0 {
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
}
}
func TestRecommendationTuning_PatchErrors(t *testing.T) {
h, _ := testHandlers(t)
r := newTuningRouter(h)
cases := []struct {
name, path, body string
want int
}{
{"unknown scope", "/api/admin/recommendation-tuning/banana",
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
{"unknown field", "/api/admin/recommendation-tuning/radio",
`{"values":{"vibes":1}}`, http.StatusBadRequest},
{"out of range", "/api/admin/recommendation-tuning/taste",
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
{"empty values", "/api/admin/recommendation-tuning/radio",
`{"values":{}}`, http.StatusBadRequest},
{"bad json", "/api/admin/recommendation-tuning/radio",
`{`, http.StatusBadRequest},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
}
}
}
+12 -1
View File
@@ -22,16 +22,18 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
) )
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside // Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer // RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store. // is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) { func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
rng := rand.New(rand.NewSource(rand.Int63())) rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{ h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg, pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64, rng: rng.Float64,
lidarrCfg: lidarrCfg, lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs, lidarrRequests: lidarrReqs,
@@ -201,6 +203,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/smtp-config", h.handleGetSMTPConfig) admin.Get("/smtp-config", h.handleGetSMTPConfig)
admin.Put("/smtp-config", h.handleUpdateSMTPConfig) admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig) admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
// Recommendation tuning lab (#1250): scoring-weight
// profiles + taste-build knobs, DB-backed, live effect.
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
// Weekly outcome trends + knob-turn markers (#1251).
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
}) })
authed.Get("/playlists", h.handleListPlaylists) authed.Get("/playlists", h.handleListPlaylists)
@@ -223,6 +233,7 @@ type handlers struct {
logger *slog.Logger logger *slog.Logger
events *playevents.Writer events *playevents.Writer
recCfg config.RecommendationConfig recCfg config.RecommendationConfig
recSettings *recsettings.Service
rng func() float64 rng func() float64
lidarrCfg *lidarrconfig.Service lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service lidarrRequests *lidarrrequests.Service
+6 -4
View File
@@ -29,6 +29,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
) )
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dbtest.ResetDB(t, pool) dbtest.ResetDB(t, pool)
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
recCfg := config.RecommendationConfig{ recCfg := config.RecommendationConfig{
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
SkipPenalty: 1.0, JitterMagnitude: 0.1,
ContextWeight: 2.0, SimilarityWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
} }
recSettings, err := recsettings.New(context.Background(), pool, logger)
if err != nil {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool) lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil) lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil) lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
dataDir := t.TempDir() dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir) tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir) playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}} h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
return h, pool return h, pool
} }
+21 -1
View File
@@ -31,6 +31,26 @@ type playStartedResponse struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
} }
// eventFutureSkew is the tolerated client-clock drift into the future
// before a timestamp is treated as bogus and replaced with now.
const eventFutureSkew = 5 * time.Minute
// clampEventTime bounds a client-supplied event timestamp to sanity:
// no earlier than the account's creation (offline replays can
// legitimately be days old, but no play can predate the user) and no
// later than now + a small skew allowance. Unbounded client clocks
// previously let a skewed device write arbitrarily old plays, which
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
if at.Before(userCreatedAt) {
return userCreatedAt
}
if at.After(now.Add(eventFutureSkew)) {
return now
}
return at
}
type okResponse struct { type okResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`
} }
@@ -52,7 +72,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp")) writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
return return
} }
at = parsed at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
} }
clientID := "" clientID := ""
if req.ClientID != nil { if req.ClientID != nil {
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000) 30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
paths := []string{ paths := []string{
"/api/artists", "/api/artists",
+255 -26
View File
@@ -2,7 +2,9 @@ package api
import ( import (
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -11,28 +13,142 @@ import (
const ( const (
recMetricsDefaultDays = 30 recMetricsDefaultDays = 30
recMetricsMaxDays = 365 recMetricsMaxDays = 365
// recMetricsLowVolume marks a family as low-confidence rather than
// hiding it: with fewer plays than this a skip rate is anecdote, not
// signal, but silently dropping the row would misread as "surface
// unused". The web renders low-confidence rows dimmed.
recMetricsLowVolume = 20
) )
// recommendationMetric is one recommendation surface's outcomes. // Surface intents (milestone #127): each family is judged against its
type recommendationMetric struct { // job, not one global bar — discovery mixes are EXPECTED to run higher
Source string `json:"source"` // 'for_you' | 'discover' | mixes // skip rates than the go-to surfaces.
Plays int64 `json:"plays"` // plays launched from this surface const (
intentGoTo = "go_to"
intentDiscovery = "discovery"
intentDirect = "direct"
)
// surfaceMetric is one bucketed surface family's outcomes.
type surfaceMetric struct {
Key string `json:"key"` // stable family key ("for_you", "radio", …)
Label string `json:"label"` // display label
Plays int64 `json:"plays"` // plays launched from this family
Skips int64 `json:"skips"` // of those, marked skipped Skips int64 `json:"skips"` // of those, marked skipped
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1] SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1] AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
// Breakdown splits the family into the pick-kind populations its
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
// that predate attribution. Present only when the family has at
// least one attributed play; the parent row remains the sum of its
// breakdown.
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
}
// surfaceGroup is one intent band of surface families.
type surfaceGroup struct {
Intent string `json:"intent"` // go_to | discovery | direct
Label string `json:"label"`
Surfaces []surfaceMetric `json:"surfaces"`
} }
type recommendationMetricsResp struct { type recommendationMetricsResp struct {
WindowDays int `json:"window_days"` WindowDays int `json:"window_days"`
Sources []recommendationMetric `json:"sources"` // Baseline is the control group: plays the user picked manually
// (source IS NULL). Surfaces are judged as deltas against it; nil
// when the window holds no manual plays.
Baseline *surfaceMetric `json:"baseline"`
Groups []surfaceGroup `json:"groups"`
}
// recFamily is the bucketing target for a raw play_events.source value.
type recFamily struct {
key string
label string
intent string
}
// bucketRecSource maps a raw client-stamped source string to its stable
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
// their family so the table stays readable at any library size.
func bucketRecSource(src string) recFamily {
switch {
case src == "for_you":
return recFamily{"for_you", "For You", intentGoTo}
case src == "songs_like_artist":
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
case src == "radio" || strings.HasPrefix(src, "radio:"):
return recFamily{"radio", "Radio", intentGoTo}
case src == "discover":
return recFamily{"discover", "Discover", intentDiscovery}
case src == "deep_cuts":
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
case src == "rediscover":
return recFamily{"rediscover", "Rediscover", intentDiscovery}
case src == "new_for_you":
return recFamily{"new_for_you", "New for you", intentDiscovery}
case src == "on_this_day":
return recFamily{"on_this_day", "On this day", intentDiscovery}
case src == "first_listens":
return recFamily{"first_listens", "First listens", intentDiscovery}
case strings.HasPrefix(src, "album:"):
return recFamily{"direct_album", "Album plays", intentDirect}
case strings.HasPrefix(src, "artist:"):
return recFamily{"direct_artist", "Artist plays", intentDirect}
case strings.HasPrefix(src, "offline:"):
return recFamily{"offline", "Offline pools", intentDirect}
case strings.HasPrefix(src, "home:"):
return recFamily{"home", "Home sections", intentDirect}
case src == "history":
return recFamily{"history", "History", intentDirect}
default:
return recFamily{"other", "Other", intentDirect}
}
}
// familyAccum merges raw source rows into one family, carrying the
// completion sample count so the merged average stays play-weighted.
type familyAccum struct {
fam recFamily
plays int64
skips int64
completionN int64
// completionSum is avg*count re-expanded, so merging N raw rows
// reduces to a single weighted division at the end.
completionSum float64
}
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
a.plays += row.Plays
a.skips += row.Skips
a.completionN += row.CompletionN
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
}
func (a *familyAccum) metric() surfaceMetric {
m := surfaceMetric{
Key: a.fam.key,
Label: a.fam.label,
Plays: a.plays,
Skips: a.skips,
LowConfidence: a.plays < recMetricsLowVolume,
}
if a.plays > 0 {
m.SkipRate = float64(a.skips) / float64(a.plays)
}
if a.completionN > 0 {
m.AvgCompletion = a.completionSum / float64(a.completionN)
}
return m
} }
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics. // handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the // Bucketed per-surface-family outcomes for the caller over the last `days`
// caller over the last `days` (default 30, capped at 365), so the operator can // (default 30, capped at 365), grouped by surface intent and anchored by the
// see which recommendation surfaces are landing and tune the taste weights. // manual-plays baseline so the numbers are judgeable, not just observable.
// Only plays tagged with a system-playlist source count; library/radio plays
// (no source) are excluded.
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) { func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r) caller, ok := requireUser(w, r)
if !ok { if !ok {
@@ -51,28 +167,141 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
return return
} }
out := recommendationMetricsResp{ writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
WindowDays: days, }
Sources: make([]recommendationMetric, 0, len(rows)),
// pickKindLabels is the display vocabulary for play_events.pick_kind
// values (mirrors the CHECK in migration 0039). Every system mix that
// stamps provenance gets its breakdown from this one map — adding a
// stamping mix needs no metrics change.
var pickKindLabels = map[string]string{
"taste": "Taste picks",
"fresh": "Fresh picks",
"dormant": "Dormant artists",
"cross_user": "Liked by others",
"random": "Random unheard",
"tier1": "Tier 1 (exact)",
"tier2": "Tier 2 (relaxed)",
"tier3": "Tier 3 (stretched)",
}
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
// pick_kind — plays recorded before the mix stamped provenance, or
// whose track had rotated out of the snapshot at ingestion) renders
// last, kept visible so the parent row's sums stay transparent instead
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
// vocabulary, so iterating the list is exhaustive.
var pickKindOrder = []string{
"taste", "fresh", "dormant", "cross_user", "random",
"tier1", "tier2", "tier3", "",
}
// pickKindFamily derives the sub-family for one (family, pick_kind)
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
func pickKindFamily(parent recFamily, kind string) recFamily {
if kind == "" {
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
} }
label, ok := pickKindLabels[kind]
if !ok {
label = kind
}
return recFamily{parent.key + "_" + kind, label, parent.intent}
}
// pickKindBreakdown folds a family's per-pick-kind accums into its
// Breakdown rows. Attached only when at least one attributed play
// exists — an all-unattributed breakdown would just repeat the parent
// row, and families that never stamp (radio, direct plays) stay flat.
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
attributed := int64(0)
for kind, acc := range picks {
if kind != "" {
attributed += acc.plays
}
}
if attributed == 0 {
return nil
}
out := make([]surfaceMetric, 0, len(picks))
for _, kind := range pickKindOrder {
if acc, ok := picks[kind]; ok && acc.plays > 0 {
out = append(out, acc.metric())
}
}
return out
}
// bucketMetricsResponse folds the raw per-source rows into the grouped,
// baseline-anchored response shape. Split from the handler for pure-unit
// testability.
func bucketMetricsResponse(
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
) recommendationMetricsResp {
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
families := map[string]*familyAccum{}
picks := map[string]map[string]*familyAccum{}
for _, row := range rows { for _, row := range rows {
source := "" if row.Source == nil || *row.Source == "" {
if row.Source != nil { baseline.add(row)
source = *row.Source continue
} }
var skipRate float64 fam := bucketRecSource(*row.Source)
if row.Plays > 0 { acc, exists := families[fam.key]
skipRate = float64(row.Skips) / float64(row.Plays) if !exists {
acc = &familyAccum{fam: fam}
families[fam.key] = acc
} }
out.Sources = append(out.Sources, recommendationMetric{ acc.add(row)
Source: source, // Accumulate the pick-kind population unconditionally; families
Plays: row.Plays, // that never stamp end up all-unattributed and get no breakdown.
Skips: row.Skips, kind := ""
SkipRate: skipRate, if row.PickKind != nil {
AvgCompletion: row.AvgCompletion, kind = *row.PickKind
}
byKind, ok := picks[fam.key]
if !ok {
byKind = map[string]*familyAccum{}
picks[fam.key] = byKind
}
pick, ok := byKind[kind]
if !ok {
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
byKind[kind] = pick
}
pick.add(row)
}
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
if baseline.plays > 0 {
m := baseline.metric()
resp.Baseline = &m
}
for _, g := range []struct{ intent, label string }{
{intentGoTo, "Go-to surfaces"},
{intentDiscovery, "Discovery mixes"},
{intentDirect, "Direct plays"},
} {
group := surfaceGroup{Intent: g.intent, Label: g.label}
for _, acc := range families {
if acc.fam.intent == g.intent {
m := acc.metric()
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
group.Surfaces = append(group.Surfaces, m)
}
}
if len(group.Surfaces) == 0 {
continue
}
sort.Slice(group.Surfaces, func(i, j int) bool {
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
}
return group.Surfaces[i].Key < group.Surfaces[j].Key
}) })
resp.Groups = append(resp.Groups, group)
} }
writeJSON(w, http.StatusOK, out) return resp
} }
// parseMetricsDays reads the `days` query param (default 30, capped at 365). // parseMetricsDays reads the `days` query param (default 30, capped at 365).
+197 -24
View File
@@ -11,6 +11,8 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
func newMetricsRouter(h *handlers) chi.Router { func newMetricsRouter(h *handlers) chi.Router {
@@ -20,17 +22,19 @@ func newMetricsRouter(h *handlers) chi.Router {
} }
// seedSourcedPlay inserts a play_event with an explicit source + completion + // seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (library/radio play). // skip flag. A nil source inserts NULL (manual library play → baseline).
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
// except attributed for_you plays.
func seedSourcedPlay( func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID, t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
source *string, completion float64, skipped bool, source, pickKind *string, completion float64, skipped bool,
) { ) {
t.Helper() t.Helper()
if _, err := h.pool.Exec(context.Background(), if _, err := h.pool.Exec(context.Background(),
`INSERT INTO play_events `INSERT INTO play_events
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped) (user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6)`, VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
userID, trackID, sessionID, source, completion, skipped); err != nil { userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
t.Fatalf("seed sourced play: %v", err) t.Fatalf("seed sourced play: %v", err)
} }
} }
@@ -45,7 +49,168 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
} }
} }
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) { // findSurface returns the named family from any group, or nil.
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
for _, g := range resp.Groups {
for i := range g.Surfaces {
if g.Surfaces[i].Key == key {
return &g.Surfaces[i]
}
}
}
return nil
}
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
src := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
// Two radio sessions collapse into one "radio" family; weighted
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
// NULL source = manual plays → baseline, not a group row.
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
if resp.Baseline == nil {
t.Fatal("baseline missing")
}
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
}
if resp.Baseline.LowConfidence {
t.Error("baseline with 25 plays should not be low-confidence")
}
radio := findSurface(resp, "radio")
if radio == nil {
t.Fatal("radio family missing")
}
if radio.Plays != 2 || radio.Skips != 1 {
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
}
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
}
if !radio.LowConfidence {
t.Error("radio with 2 plays should be low-confidence")
}
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
t.Errorf("direct_album = %+v, want plays=1", s)
}
if s := findSurface(resp, ""); s != nil {
t.Error("NULL source must not appear as a surface family")
}
// No attributed (taste/fresh) plays in this fixture → no breakdown;
// an all-unattributed breakdown would just repeat the parent row.
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
}
// Group ordering is intent-banded: go_to before discovery before direct.
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
if len(resp.Groups) != len(wantOrder) {
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
}
for i, g := range resp.Groups {
if g.Intent != wantOrder[i] {
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
}
}
}
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
CompletionN: 30, AvgCompletion: 0.9},
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
CompletionN: 10, AvgCompletion: 0.5},
// NULL pick_kind = plays that predate attribution.
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
fy := findSurface(resp, "for_you")
if fy == nil {
t.Fatal("for_you family missing")
}
// The parent row stays the sum of its breakdown.
if fy.Plays != 45 || fy.Skips != 8 {
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
}
if len(fy.Breakdown) != 3 {
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
}
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
for i, k := range wantKeys {
if fy.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
}
}
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
}
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
}
// Breakdown rows never appear as their own surface families.
if s := findSurface(resp, "for_you_taste"); s != nil {
t.Error("for_you_taste must not be a top-level surface")
}
}
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
// Provenance is standard (#1270): Discover's bucket stamps surface as
// a breakdown exactly like For You's taste/fresh — this is what makes
// the 40/30/30 allocation judgeable instead of a guess.
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
CompletionN: 8, AvgCompletion: 0.8},
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
CompletionN: 6, AvgCompletion: 0.6},
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
CompletionN: 4, AvgCompletion: 0.4},
// NULL pick_kind = plays that predate bucket stamping.
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
d := findSurface(resp, "discover")
if d == nil {
t.Fatal("discover family missing")
}
if d.Plays != 20 || d.Skips != 8 {
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
}
wantKeys := []string{
"discover_dormant", "discover_cross_user", "discover_random",
"discover_unattributed",
}
if len(d.Breakdown) != len(wantKeys) {
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
}
for i, k := range wantKeys {
if d.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
}
}
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
}
}
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set") t.Skip("MINSTREL_TEST_DATABASE_URL not set")
} }
@@ -57,15 +222,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
session := seedPlaySession(t, pool, user.ID, time.Now()) session := seedPlaySession(t, pool, user.ID, time.Now())
forYou := "for_you" forYou := "for_you"
discover := "discover" radioA := "radio:11111111-1111-1111-1111-111111111111"
taste, fresh := "taste", "fresh"
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667. // for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false) // Two attributed as taste picks, the skipped one as a fresh pick (#1249).
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false) seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true) seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
// discover: 1 play. seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false) // A radio session play collapses into the "radio" family.
// library play (NULL source) — must be excluded. seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false) // Manual play (NULL source) — the baseline row.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil) req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
req = withUser(req, user) req = withUser(req, user)
@@ -82,15 +249,11 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
if resp.WindowDays != recMetricsDefaultDays { if resp.WindowDays != recMetricsDefaultDays {
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays) t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
} }
bySource := map[string]recommendationMetric{} if resp.Baseline == nil || resp.Baseline.Plays != 1 {
for _, m := range resp.Sources { t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
bySource[m.Source] = m
} }
if _, present := bySource[""]; present { fy := findSurface(resp, "for_you")
t.Error("NULL-source (library) plays should be excluded") if fy == nil {
}
fy, ok := bySource["for_you"]
if !ok {
t.Fatal("for_you metrics missing") t.Fatal("for_you metrics missing")
} }
if fy.Plays != 3 || fy.Skips != 1 { if fy.Plays != 3 || fy.Skips != 1 {
@@ -102,7 +265,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 { if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion) t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
} }
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 { // Pick-kind attribution surfaces as the For You breakdown (#1249).
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d) if len(fy.Breakdown) != 2 {
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
}
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
}
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
}
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
} }
} }
+4 -10
View File
@@ -15,6 +15,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
) )
// RadioResponse is the body of GET /api/radio. // RadioResponse is the body of GET /api/radio.
@@ -100,16 +101,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
} }
} }
weights := recommendation.ScoringWeights{ // Scoring weights come from the DB-backed tuning lab (#1250) —
BaseWeight: h.recCfg.BaseWeight, // read per request so an admin change takes effect live.
LikeBoost: h.recCfg.LikeBoost, weights := h.recSettings.Weights(recsettings.ScopeRadio)
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
SimilarityWeight: h.recCfg.SimilarityWeight,
TasteWeight: h.recCfg.TasteWeight,
}
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
out := make([]TrackRef, 0, len(picks)+1) out := make([]TrackRef, 0, len(picks)+1)
+4 -20
View File
@@ -88,17 +88,11 @@ type EventsConfig struct {
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"` SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
} }
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6). // RecommendationConfig holds the radio path's operational knobs. The
// All weights are operator-tunable; defaults match the spec recommendations. // scoring WEIGHTS moved to DB-backed admin settings (#1250,
// internal/recsettings) — YAML is bootstrap-only; anything an operator
// tunes lives in the UI with live effect.
type RecommendationConfig struct { type RecommendationConfig struct {
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"`
SimilarityWeight float64 `yaml:"similarity_weight"`
TasteWeight float64 `yaml:"taste_weight"`
RecentlyPlayedHours int `yaml:"recently_played_hours"` RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"` RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"` RadioSizeMax int `yaml:"radio_size_max"`
@@ -120,16 +114,6 @@ func Default() Config {
SkipMaxDurationPlayedMs: 30000, SkipMaxDurationPlayedMs: 30000,
}, },
Recommendation: RecommendationConfig{ Recommendation: RecommendationConfig{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
// Radio is seed-directed (the user picked a direction), so taste
// is a lighter nudge here than in the daily mixes (1.5).
TasteWeight: 1.0,
RecentlyPlayedHours: 1, RecentlyPlayedHours: 1,
RadioSize: 50, RadioSize: 50,
RadioSizeMax: 200, RadioSizeMax: 200,
+47 -6
View File
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
} }
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
WHERE user_id = $1 AND ended_at IS NULL WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT 1 LIMIT 1
@@ -79,12 +79,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err return i, err
} }
const getPlayEventByID = `-- name: GetPlayEventByID :one const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1 SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
` `
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) { func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
@@ -104,15 +105,48 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err return i, err
} }
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
SELECT pt.pick_kind
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1
AND p.system_variant = $2
AND pt.track_id = $3
LIMIT 1
`
type GetSystemPickKindForTrackParams struct {
UserID pgtype.UUID
SystemVariant *string
TrackID pgtype.UUID
}
// Looks a track up in the user's CURRENT snapshot of the given system
// variant and returns its pick_kind. Used at play-ingestion time to
// freeze provenance onto the play_event — snapshots rebuild daily, so
// attribution can't be reconstructed at read time (#1249/#1270). No
// row = track not in today's snapshot (caller stores NULL); a row with
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
// is non-singleton (up to 3 mixes/user); a track in two of them takes
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
// same eligibility ladder.
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
var pick_kind *string
err := row.Scan(&pick_kind)
return pick_kind, err
}
const insertPlayEvent = `-- name: InsertPlayEvent :one const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events ( INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6) ) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
` `
type InsertPlayEventParams struct { type InsertPlayEventParams struct {
@@ -122,8 +156,12 @@ type InsertPlayEventParams struct {
StartedAt pgtype.Timestamptz StartedAt pgtype.Timestamptz
ClientID *string ClientID *string
Source *string Source *string
PickKind *string
} }
// pick_kind is non-NULL only for system-playlist plays whose track was
// found (with a stamped kind) in the user's live snapshot for that
// variant at ingestion time (#1249, generalized in #1270).
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) { func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent, row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID, arg.UserID,
@@ -132,6 +170,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.StartedAt, arg.StartedAt,
arg.ClientID, arg.ClientID,
arg.Source, arg.Source,
arg.PickKind,
) )
var i PlayEvent var i PlayEvent
err := row.Scan( err := row.Scan(
@@ -148,6 +187,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err return i, err
} }
@@ -291,7 +331,7 @@ SET ended_at = $2,
completion_ratio = $4, completion_ratio = $4,
was_skipped = $5 was_skipped = $5
WHERE id = $1 WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
` `
type UpdatePlayEventEndedParams struct { type UpdatePlayEventEndedParams struct {
@@ -328,6 +368,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.SessionVectorAtPlay, &i.SessionVectorAtPlay,
&i.ScrobbledAt, &i.ScrobbledAt,
&i.Source, &i.Source,
&i.PickKind,
) )
return i, err return i, err
} }
+32
View File
@@ -383,6 +383,7 @@ type PlayEvent struct {
SessionVectorAtPlay []byte SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz ScrobbledAt pgtype.Timestamptz
Source *string Source *string
PickKind *string
} }
type PlaySession struct { type PlaySession struct {
@@ -433,6 +434,28 @@ type PlaylistTrack struct {
AlbumTitle string AlbumTitle string
DurationSec int32 DurationSec int32
AddedAt pgtype.Timestamptz AddedAt pgtype.Timestamptz
PickKind *string
}
type RecommendationTuningAudit struct {
ID int64
ChangedAt pgtype.Timestamptz
Scope string
Action string
Changes []byte
}
type RecommendationWeightProfile struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
} }
type RegistrationSetting struct { type RegistrationSetting struct {
@@ -523,6 +546,15 @@ type TasteProfileTag struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type TasteTuning struct {
Singleton bool
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
UpdatedAt pgtype.Timestamptz
}
type Track struct { type Track struct {
ID pgtype.UUID ID pgtype.UUID
Title string Title string
+13 -7
View File
@@ -12,7 +12,7 @@ import (
) )
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec) INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT SELECT
$1::uuid, $1::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0), COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
@@ -20,24 +20,27 @@ SELECT
t.title, t.title,
artists.name, artists.name,
albums.title, albums.title,
(t.duration_ms / 1000)::integer (t.duration_ms / 1000)::integer,
$2::text
FROM tracks t FROM tracks t
JOIN albums ON albums.id = t.album_id JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id JOIN artists ON artists.id = t.artist_id
WHERE t.id = $2::uuid WHERE t.id = $3::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
` `
type AppendPlaylistTrackParams struct { type AppendPlaylistTrackParams struct {
PlaylistID pgtype.UUID PlaylistID pgtype.UUID
PickKind *string
TrackID pgtype.UUID TrackID pgtype.UUID
} }
// Inserts at the next available position. Snapshot fields are copied // Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms // from the tracks/albums/artists join at insert time. tracks.duration_ms
// is converted to seconds for the snapshot. // is converted to seconds for the snapshot. pick_kind is NULL for manual
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) { func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID) row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
var i PlaylistTrack var i PlaylistTrack
err := row.Scan( err := row.Scan(
&i.PlaylistID, &i.PlaylistID,
@@ -48,6 +51,7 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
&i.AlbumTitle, &i.AlbumTitle,
&i.DurationSec, &i.DurationSec,
&i.AddedAt, &i.AddedAt,
&i.PickKind,
) )
return i, err return i, err
} }
@@ -257,7 +261,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
} }
const listPlaylistTracks = `-- name: ListPlaylistTracks :many const listPlaylistTracks = `-- name: ListPlaylistTracks :many
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
t.id AS live_track_id, t.id AS live_track_id,
albums.id AS album_id, albums.id AS album_id,
artists.id AS artist_id artists.id AS artist_id
@@ -278,6 +282,7 @@ type ListPlaylistTracksRow struct {
AlbumTitle string AlbumTitle string
DurationSec int32 DurationSec int32
AddedAt pgtype.Timestamptz AddedAt pgtype.Timestamptz
PickKind *string
LiveTrackID pgtype.UUID LiveTrackID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
@@ -305,6 +310,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
&i.AlbumTitle, &i.AlbumTitle,
&i.DurationSec, &i.DurationSec,
&i.AddedAt, &i.AddedAt,
&i.PickKind,
&i.LiveTrackID, &i.LiveTrackID,
&i.AlbumID, &i.AlbumID,
&i.ArtistID, &i.ArtistID,
+91 -12
View File
@@ -12,19 +12,17 @@ import (
) )
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
SELECT SELECT
pe.source, pe.source,
pe.pick_kind,
count(*)::bigint AS plays, count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips, count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
COALESCE( count(pe.completion_ratio)::bigint AS completion_n,
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL), COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
0)::float8 AS avg_completion
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source GROUP BY pe.source, pe.pick_kind
ORDER BY plays DESC ORDER BY plays DESC
` `
@@ -35,18 +33,17 @@ type RecommendationSourceMetricsForUserParams struct {
type RecommendationSourceMetricsForUserRow struct { type RecommendationSourceMetricsForUserRow struct {
Source *string Source *string
PickKind *string
Plays int64 Plays int64
Skips int64 Skips int64
CompletionN int64
AvgCompletion float64 AvgCompletion float64
} }
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
// NULL for library / radio / user-playlist plays, which are excluded here.
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the // $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
// mean completion ratio over plays that recorded one (0 when none did). // mean completion ratio over the completion_n plays that recorded one.
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
// it is NULL for every other source, so those still group to one row.
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) { func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
if err != nil { if err != nil {
@@ -58,8 +55,10 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
var i RecommendationSourceMetricsForUserRow var i RecommendationSourceMetricsForUserRow
if err := rows.Scan( if err := rows.Scan(
&i.Source, &i.Source,
&i.PickKind,
&i.Plays, &i.Plays,
&i.Skips, &i.Skips,
&i.CompletionN,
&i.AvgCompletion, &i.AvgCompletion,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -71,3 +70,83 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
} }
return items, nil return items, nil
} }
const recommendationWeeklyTrends = `-- name: RecommendationWeeklyTrends :many
SELECT
date_trunc('week', pe.started_at)::date AS week_start,
pe.source,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
LEFT JOIN taste_profile_artists tpa
ON tpa.user_id = pe.user_id
AND tpa.artist_id = t.artist_id
AND tpa.weight > 0
WHERE pe.started_at > now() - ($1::int * INTERVAL '1 week')
GROUP BY 1, 2
ORDER BY 1, 2
`
type RecommendationWeeklyTrendsRow struct {
WeekStart pgtype.Date
Source *string
Plays int64
Skips int64
CompletionN int64
AvgCompletion float64
TasteHits int64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a recommendation surface; NULL means the user picked the track manually —
// those rows are INCLUDED here as the baseline control group the surfaces are
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
// actionable). Raw source strings are bucketed into stable surface families in
// the Go handler; completion_n is carried so family merges can weight
// avg_completion correctly.
// Weekly per-source outcome series for the tuning lab's trend view
// (#1251). Aggregated across ALL users: the tuning knobs are global,
// so judging a knob turn needs global outcomes — rows carry rates
// only, no track or user identity. NULL-source (manual) rows are
// included as the baseline family.
//
// taste_hits counts plays whose track's artist has a positive weight
// in that user's CURRENT taste profile — the "cheap recompute" option:
// retroactive over the whole window, at the cost of drift (the profile
// is today's, the play may be weeks old). Good enough to read whether
// a surface is feeding taste-fitting tracks.
// $1 window in weeks.
func (q *Queries) RecommendationWeeklyTrends(ctx context.Context, weeks int32) ([]RecommendationWeeklyTrendsRow, error) {
rows, err := q.db.Query(ctx, recommendationWeeklyTrends, weeks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeeklyTrendsRow
for rows.Next() {
var i RecommendationWeeklyTrendsRow
if err := rows.Scan(
&i.WeekStart,
&i.Source,
&i.Plays,
&i.Skips,
&i.CompletionN,
&i.AvgCompletion,
&i.TasteHits,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,272 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: recommendation_tuning.sql
package dbq
import (
"context"
)
const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
`
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
row := q.db.QueryRow(ctx, getTasteTuning)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
)
return i, err
}
const insertTuningAudit = `-- name: InsertTuningAudit :exec
INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3)
`
type InsertTuningAuditParams struct {
Scope string
Action string
Changes []byte
}
// changes is a jsonb array of {field, old, new} objects.
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
return err
}
const listTuningAudit = `-- name: ListTuningAudit :many
SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit
ORDER BY changed_at DESC, id DESC
LIMIT $1
`
// Newest first; consumed by the metrics trend view (#1251) to annotate
// knob turns on the timeline.
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
rows, err := q.db.Query(ctx, listTuningAudit, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationTuningAudit
for rows.Next() {
var i RecommendationTuningAudit
if err := rows.Scan(
&i.ID,
&i.ChangedAt,
&i.Scope,
&i.Action,
&i.Changes,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWeightProfiles = `-- name: ListWeightProfiles :many
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
`
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
rows, err := q.db.Query(ctx, listWeightProfiles)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecommendationWeightProfile
for rows.Next() {
var i RecommendationWeightProfile
if err := rows.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateTasteTuning = `-- name: UpdateTasteTuning :one
UPDATE taste_tuning
SET half_life_days = $1,
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
`
type UpdateTasteTuningParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
row := q.db.QueryRow(ctx, updateTasteTuning,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
)
var i TasteTuning
err := row.Scan(
&i.Singleton,
&i.HalfLifeDays,
&i.EngagementHardSkip,
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
)
return i, err
}
const updateWeightProfile = `-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
WHERE profile = $1
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
`
type UpdateWeightProfileParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
}
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
row := q.db.QueryRow(ctx, updateWeightProfile,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
)
var i RecommendationWeightProfile
err := row.Scan(
&i.Profile,
&i.BaseWeight,
&i.LikeBoost,
&i.RecencyWeight,
&i.SkipPenalty,
&i.JitterMagnitude,
&i.ContextWeight,
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
)
return i, err
}
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
ON CONFLICT (singleton) DO NOTHING
`
type UpsertTasteTuningDefaultsParams struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
arg.HalfLifeDays,
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
)
return err
}
const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (profile) DO NOTHING
`
type UpsertWeightProfileDefaultsParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
}
// Recommendation tuning lab queries (#1250). Seeding happens via the
// recsettings boot reconcile; shipped defaults live in Go only.
// Boot reconcile: insert the shipped defaults for a profile if the row
// doesn't exist yet. Never overwrites operator-tuned values.
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
arg.Profile,
arg.BaseWeight,
arg.LikeBoost,
arg.RecencyWeight,
arg.SkipPenalty,
arg.JitterMagnitude,
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
)
return err
}
+164 -64
View File
@@ -27,12 +27,20 @@ play_counts AS (
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
),
skip_counts AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = true
GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN affinity_artists aa ON aa.artist_id = t.artist_id JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.id LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.track_id = t.id
WHERE COALESCE(pc.c, 0) <= 2 WHERE COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
@@ -60,6 +68,11 @@ type ListDeepCutsTracksRow struct {
// gives per-play variety on top. // gives per-play variety on top.
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user // #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
// has liked OR played heavily (>=5 non-skip plays across the artist). // has liked OR played heavily (>=5 non-skip plays across the artist).
// Tracks the user has skipped twice or more don't qualify (#1257):
// eligibility used to count only unskipped plays, so the most actively
// rejected tracks read as "barely heard" and kept being re-offered.
// Threshold 2 so a single accidental skip doesn't banish a track;
// passive-signal only (no dislike UI, rule #101).
// $1 user_id, $2 date string. // $1 user_id, $2 date string.
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) { func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
@@ -82,31 +95,49 @@ func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracks
} }
const listFirstListensTracks = `-- name: ListFirstListensTracks :many const listFirstListensTracks = `-- name: ListFirstListensTracks :many
WITH heard_albums AS ( WITH attempted AS (
SELECT DISTINCT t.album_id SELECT DISTINCT pe.track_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
), ),
played_artists AS ( album_attempts AS (
SELECT t.album_id,
COUNT(a.track_id) AS attempted_count,
COUNT(*) AS track_count
FROM tracks t
LEFT JOIN attempted a ON a.track_id = t.id
GROUP BY t.album_id
),
attempted_artists AS (
SELECT DISTINCT t.artist_id SELECT DISTINCT t.artist_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id FROM attempted a JOIN tracks t ON t.id = a.track_id
WHERE pe.user_id = $1 ),
albums_tiered AS (
SELECT aa.album_id,
CASE
WHEN aa.attempted_count = 0 THEN 1
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
END AS tier
FROM album_attempts aa
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
FROM tracks t FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id) JOIN albums_tiered alt ON alt.album_id = al.id
WHERE alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY ORDER BY
alt.tier,
(CASE (CASE
WHEN EXISTS ( WHEN EXISTS (
SELECT 1 FROM general_likes_artists gla SELECT 1 FROM general_likes_artists gla
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
) THEN 0 ) THEN 0
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1 WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
ELSE 2 ELSE 2
END), END),
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
@@ -117,12 +148,25 @@ type ListFirstListensTracksRow struct {
ID pgtype.UUID ID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Tier int32
} }
// #423 First Listens: albums the user has never played any track of. // #423 First Listens: songs the user has never heard or even
// Tiered: liked-artist albums first, then played-artist albums, then // attempted. "Attempted" is track-level with a >=30s listen threshold
// the rest — album-coherent within each tier. Not diversity-capped // (#1268, operator decision 2026-07-03) — the old version disqualified
// (whole-album discovery). $1 user_id. // a whole album on ANY play_event, so a 2-second accidental brush
// banished it permanently.
//
// tier 1 the exact desire: albums with ZERO attempted tracks
// tier 2 step back: barely-attempted albums (<=25% of tracks
// reached 30s) — brushed, never explored. The already-
// attempted tracks themselves are excluded; they're not
// first listens.
//
// Within each tier, affinity-ordered as before: liked artist, then
// attempted-played artist (>=30s — skip-only contact isn't trust),
// then the rest; album-coherent. The tier lands on
// playlist_tracks.pick_kind via the producer. $1 user_id.
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) { func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
rows, err := q.db.Query(ctx, listFirstListensTracks, userID) rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
if err != nil { if err != nil {
@@ -132,7 +176,12 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
var items []ListFirstListensTracksRow var items []ListFirstListensTracksRow
for rows.Next() { for rows.Next() {
var i ListFirstListensTracksRow var i ListFirstListensTracksRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil { if err := rows.Scan(
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -144,39 +193,76 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
} }
const listNewForYouTracks = `-- name: ListNewForYouTracks :many const listNewForYouTracks = `-- name: ListNewForYouTracks :many
WITH affinity_artists AS ( WITH attempted AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
),
consumed_albums AS (
SELECT DISTINCT t.album_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
affinity_artists AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
UNION UNION
SELECT t.artist_id SELECT t.artist_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id FROM attempted a JOIN tracks t ON t.id = a.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id GROUP BY t.artist_id
HAVING COUNT(*) >= 3 HAVING COUNT(*) >= 3
),
albums_tiered AS (
SELECT al.id AS album_id, al.created_at,
CASE
WHEN al.created_at >= now() - interval '30 days'
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
ELSE 3
END AS tier
FROM albums al
WHERE al.created_at >= now() - interval '90 days'
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
FROM tracks t FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums_tiered alt ON alt.album_id = t.album_id
JOIN affinity_artists aa ON aa.artist_id = al.artist_id WHERE NOT EXISTS (
WHERE al.created_at >= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST ORDER BY alt.tier, alt.created_at DESC, t.album_id,
LIMIT 200 t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 300
` `
type ListNewForYouTracksRow struct { type ListNewForYouTracksRow struct {
ID pgtype.UUID ID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
Tier int32
} }
// #421 New for you: tracks from albums added in the last 30 days // #421 New for you, tiered per project rule #131 (#1267). The old
// whose artist the user has liked OR played (>=3 non-skip). Album- // single hard rule (added <30d AND direct affinity) had two failure
// coherent (newest album first, then disc/track) — the producer does // modes with one root: no notion of consumption — the album you
// NOT diversity-cap these; they're meant as whole-album discovery. // devoured in week one crowded the mix for three more weeks, and
// $1 user_id. // after a quiet month nothing qualified at all.
//
// "Consumed" = any track attempted for >=30s (the First Listens
// threshold, #1268): once you've meaningfully engaged, the album is
// no longer news. Applies to every tier — played albums leave the
// mix the next build.
//
// tier 1 the exact desire: unconsumed albums added <30d by
// direct-affinity artists (liked, or >=3 attempted plays)
// tier 2 step back a little: unconsumed affinity albums from the
// wider 30-90d window — added while you weren't looking
// tier 3 step back more: any unconsumed album added <90d
// regardless of affinity, newest first
//
// Album-coherent within tiers (newest album first, then disc/track);
// the producer fills top-down and rotates within tier blocks. The
// tier lands on playlist_tracks.pick_kind so metrics can price what
// each step-back trades away. $1 user_id.
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) { func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
rows, err := q.db.Query(ctx, listNewForYouTracks, userID) rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
if err != nil { if err != nil {
@@ -186,7 +272,12 @@ func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) (
var items []ListNewForYouTracksRow var items []ListNewForYouTracksRow
for rows.Next() { for rows.Next() {
var i ListNewForYouTracksRow var i ListNewForYouTracksRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil { if err := rows.Scan(
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.Tier,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -203,7 +294,10 @@ WITH windowed AS (
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '30 days' AND started_at < now() - interval '30 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10 AND LEAST(
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
) <= 10
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
@@ -235,6 +329,13 @@ type ListOnThisDayTracksRow struct {
// and window ±7→±10 so it surfaces on a months-old library instead // and window ±7→±10 so it surfaces on a months-old library instead
// of needing a full year of history; still skips cleanly (no rows → // of needing a full year of history; still skips cleanly (no rows →
// no playlist) when there's no qualifying history yet. // no playlist) when there's no qualifying history yet.
// Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
// Jan 3 read as 359 days apart, silently gutting the window for ~3
// weeks around every New Year — precisely when holiday nostalgia is
// the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
// ±1 is absorbed by the ±10 window. The build date ($2, already here
// for the md5 rotation) anchors "today" instead of now() so the
// window is testable and consistent with the mix's daily determinism.
// $1 user_id, $2 date string. // $1 user_id, $2 date string.
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) { func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
@@ -262,37 +363,32 @@ WITH stats AS (
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id GROUP BY pe.track_id
), )
deep AS ( SELECT t.id, t.album_id, t.artist_id
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t FROM tracks t
JOIN stats s ON s.track_id = t.id JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5 WHERE s.c >= 3
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days' AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
) -- Declined-resurfacing cooldown (#1258): a skip on a
SELECT id, album_id, artist_id -- rediscover-sourced play is the user explicitly saying "I've moved
FROM ( -- on" to the exact invitation this mix extends — such tracks sit
SELECT id, album_id, artist_id, c, tier FROM deep -- out ~90 days instead of re-qualifying the next day. Passive
UNION ALL -- signal only (rule #101).
SELECT id, album_id, artist_id, c, tier FROM shallow AND NOT EXISTS (
WHERE NOT EXISTS (SELECT 1 FROM deep) SELECT 1 FROM play_events rp
) u WHERE rp.user_id = $1
ORDER BY tier, c DESC, id AND rp.track_id = t.id
AND rp.source = 'rediscover'
AND rp.was_skipped = true
AND rp.started_at > now() - interval '90 days'
)
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
(s.c >= 5) DESC,
s.c DESC, t.id
LIMIT 200 LIMIT 200
` `
@@ -302,13 +398,17 @@ type ListRediscoverTracksRow struct {
ArtistID pgtype.UUID ArtistID pgtype.UUID
} }
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but // #420 Rediscover: tracks the user played a lot but has drifted away
// has drifted away from. Tiered so a young library still gets a mix: // from. One blended pool (issue #1246: the old two-tier UNION was
// // all-or-nothing — a single ">=6 months cold" row suppressed the whole
// tier 0 not played in the last 6 months (true rediscovery) // ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
// tier 1 (only if tier 0 empty) not played in the last 30 days // tier was also a strict subset of the 30d tier). Eligibility is >=3
// // non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
// Ordered by historical affection. $1 user_id. // so a weeks-old library still fields a pool — on young histories the
// >=5-play tracks are precisely the ones still in rotation. Ordering
// prefers true rediscoveries (>=6 months cold), then strong affection
// (>=5 plays), then raw play count; the producer's minimum floor
// decides whether the pool is big enough to ship at all. $1 user_id.
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) { func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
rows, err := q.db.Query(ctx, listRediscoverTracks, userID) rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
if err != nil { if err != nil {
+75 -19
View File
@@ -290,9 +290,13 @@ func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlayli
} }
const pickSeedArtists = `-- name: PickSeedArtists :many const pickSeedArtists = `-- name: PickSeedArtists :many
WITH plays AS ( WITH liked AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
),
recent7 AS (
SELECT t.artist_id, SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
0 AS tier
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 WHERE pe.user_id = $1
@@ -300,27 +304,79 @@ WITH plays AS (
AND t.artist_id IS NOT NULL AND t.artist_id IS NOT NULL
GROUP BY t.artist_id GROUP BY t.artist_id
), ),
liked AS ( recent30 AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
alltime AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
2 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
likedonly AS (
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
FROM liked l
),
chosen AS (
SELECT artist_id, play_count, tier FROM recent7
UNION ALL
SELECT artist_id, play_count, tier FROM recent30
WHERE NOT EXISTS (SELECT 1 FROM recent7)
UNION ALL
SELECT artist_id, play_count, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
UNION ALL
SELECT artist_id, play_count, tier FROM likedonly
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
AND NOT EXISTS (SELECT 1 FROM alltime)
) )
SELECT p.artist_id, SELECT c.artist_id,
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score (c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
FROM plays p c.tier::int AS tier
LEFT JOIN liked l ON l.artist_id = p.artist_id FROM chosen c
ORDER BY score DESC, p.artist_id LEFT JOIN liked l ON l.artist_id = c.artist_id
ORDER BY score DESC, c.artist_id
LIMIT 5 LIMIT 5
` `
type PickSeedArtistsRow struct { type PickSeedArtistsRow struct {
ArtistID pgtype.UUID ArtistID pgtype.UUID
Score int64 Score int64
Tier int32
} }
// Top-5 most-engaged distinct artist candidates in the user's last 7 // Top-5 most-engaged distinct artist candidates, tiered so the
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these // "Songs like X" mixes never silently vanish (#1255): the old hard
// daily-deterministically and takes the first 3 so the set of // 7-day window emptied the seed pool after a quiet week, and the
// "Songs like X" mixes rotates day-to-day. // daily atomic-replace build then deleted every existing mix until
// Score = unskipped-play count + 5 if user has liked the artist. // the user played something again. Same fallback shape as
// PickTopPlayedTracksForUser (For You's seeds):
//
// tier 0 engagement in the last 7 days
// tier 1 (only if tier 0 empty) last 30 days
// tier 2 (only if tiers 0+1 empty) all-time
// tier 3 (only if 0-2 empty) liked artists with no play history
//
// All returned rows share one tier; produceSeedMixes maps it onto the
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
// can compare mixes seeded from fresh vs stale engagement.
// Score = unskipped-play count + 5 if user has liked the artist. The
// Go-side picker (pickSeedArtistsForDay) shuffles the 5
// daily-deterministically and takes 3 so the mix set rotates.
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) { func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID) rows, err := q.db.Query(ctx, pickSeedArtists, userID)
if err != nil { if err != nil {
@@ -330,7 +386,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi
var items []PickSeedArtistsRow var items []PickSeedArtistsRow
for rows.Next() { for rows.Next() {
var i PickSeedArtistsRow var i PickSeedArtistsRow
if err := rows.Scan(&i.ArtistID, &i.Score); err != nil { if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -457,10 +513,10 @@ SELECT id
// tier 2 (only if tiers 0+1 empty) liked tracks // tier 2 (only if tiers 0+1 empty) liked tracks
// //
// Returns up to 5 ids; tie-break by track_id for determinism. The // Returns up to 5 ids; tie-break by track_id for determinism. The
// Go-side picker (pickForYouSeedForDay) rotates one per day via // Go-side picker (pickDailySeeds) draws the day's seeds from these.
// userIDHash. Widened from a hard 7-day window, which made For-You // Widened from a hard 7-day window, which made For-You disappear
// disappear after a week of not listening and never recover on a // after a week of not listening and never recover on a self-hosted
// self-hosted library with sparse history. // library with sparse history.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID) rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
if err != nil { if err != nil {
@@ -0,0 +1,2 @@
ALTER TABLE play_events DROP COLUMN pick_kind;
ALTER TABLE playlist_tracks DROP COLUMN pick_kind;
@@ -0,0 +1,34 @@
-- For You exploration attribution (milestone #127 step 2, Scribe #1249).
--
-- For You is deliberately composed of two populations: a head of
-- top-scored similarity matches ("taste" picks) and a tail sampled from
-- deeper in the candidate ranking ("fresh" picks — the freshness
-- injection). Until now the metrics could only judge For You as one
-- blob, so a high skip rate couldn't be read as "the taste engine is
-- missing" vs "the freshness tax is too high".
--
-- Two denormalized columns, both nullable:
--
-- 1. playlist_tracks.pick_kind — stamped by the system-playlist builder
-- at snapshot build time. NULL for manual playlists and for system
-- variants that don't split (Discover, Songs like X, discovery
-- mixes). System snapshots are atomically replaced daily, so the
-- column never goes stale within a snapshot.
--
-- 2. play_events.pick_kind — stamped at play-ingestion time by looking
-- the track up in the user's CURRENT For You snapshot. Attribution
-- must be frozen at play time: For You rebuilds daily, so a 30-day
-- metrics window spans ~30 snapshots and a join-at-read against the
-- live snapshot would misattribute nearly everything. Plays that
-- predate this feature (or whose track already rotated out, e.g. a
-- late offline replay) stay NULL → reported as unattributed.
ALTER TABLE playlist_tracks
ADD COLUMN pick_kind text
CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
ALTER TABLE play_events
ADD COLUMN pick_kind text
CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
@@ -0,0 +1,19 @@
-- Rows stamped with the widened vocabulary must be cleared before the
-- narrow CHECK can be re-added; NULL reads as "unattributed", which is
-- the honest downgrade.
UPDATE play_events SET pick_kind = NULL
WHERE pick_kind NOT IN ('taste', 'fresh');
UPDATE playlist_tracks SET pick_kind = NULL
WHERE pick_kind NOT IN ('taste', 'fresh');
ALTER TABLE play_events
DROP CONSTRAINT play_events_pick_kind_check;
ALTER TABLE play_events
ADD CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
ALTER TABLE playlist_tracks
DROP CONSTRAINT playlist_tracks_pick_kind_check;
ALTER TABLE playlist_tracks
ADD CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN ('taste', 'fresh'));
@@ -0,0 +1,41 @@
-- Provenance as standard (milestone #127, Scribe #1270).
--
-- #1249 introduced pick_kind as a For You one-off ('taste'/'fresh').
-- The mechanism — stamp WHY a track is in the snapshot at build time,
-- freeze it onto the play at ingestion, break it down in metrics — is
-- now the standard for every system mix, so the CHECK vocabulary
-- widens to cover:
--
-- taste / fresh For You head vs freshness-injection tail
-- dormant / cross_user / random
-- Discover's three candidate buckets — makes
-- the 40/30/30 allocation measurable instead
-- of a guess
-- tier1 / tier2 / tier3 the tiered-eligibility ladder (project rule
-- #131): tier 1 pins the mix's exact desire,
-- each higher tier steps back a little. Tier
-- provenance is how we measure what the
-- step-back trades away.
--
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD
-- with the expanded list, in the same migration.
ALTER TABLE playlist_tracks
DROP CONSTRAINT playlist_tracks_pick_kind_check;
ALTER TABLE playlist_tracks
ADD CONSTRAINT playlist_tracks_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random',
'tier1', 'tier2', 'tier3'
));
ALTER TABLE play_events
DROP CONSTRAINT play_events_pick_kind_check;
ALTER TABLE play_events
ADD CONSTRAINT play_events_pick_kind_check
CHECK (pick_kind IN (
'taste', 'fresh',
'dormant', 'cross_user', 'random',
'tier1', 'tier2', 'tier3'
));
@@ -0,0 +1,3 @@
DROP TABLE recommendation_tuning_audit;
DROP TABLE taste_tuning;
DROP TABLE recommendation_weight_profiles;
@@ -0,0 +1,57 @@
-- Recommendation tuning lab (milestone #127, Scribe #1250).
--
-- The scoring weights move out of YAML (radio profile) and out of the
-- systemMixWeights hard-code (daily_mix profile) into DB-backed
-- settings with live effect — the defaults-discovery lab (decision
-- #1247): the operator turns knobs here to FIND good values, which
-- then get baked into shipped defaults; end users and other operators
-- should never need this card.
--
-- Rows are seeded by the recsettings service's boot reconcile (the
-- coverart SettingsService pattern), not by this migration, so shipped
-- defaults live in exactly one place (Go).
CREATE TABLE recommendation_weight_profiles (
profile text PRIMARY KEY
CONSTRAINT recommendation_weight_profiles_profile_check
CHECK (profile IN ('radio', 'daily_mix')),
base_weight double precision NOT NULL,
like_boost double precision NOT NULL,
recency_weight double precision NOT NULL,
skip_penalty double precision NOT NULL,
jitter_magnitude double precision NOT NULL,
context_weight double precision NOT NULL,
similarity_weight double precision NOT NULL,
taste_weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Taste-profile build knobs: engagement half-life + the completion→
-- engagement curve points (taste.Config's tunable subset).
CREATE TABLE taste_tuning (
singleton boolean PRIMARY KEY DEFAULT true
CONSTRAINT taste_tuning_singleton_check CHECK (singleton),
half_life_days double precision NOT NULL,
engagement_hard_skip double precision NOT NULL,
engagement_neutral double precision NOT NULL,
engagement_full double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Every knob turn writes one audit row; the metrics trend view
-- (#1251) annotates these on its timeline so cause→effect is visible
-- after a change. changes is a jsonb array of {field, old, new}.
CREATE TABLE recommendation_tuning_audit (
id bigserial PRIMARY KEY,
changed_at timestamptz NOT NULL DEFAULT now(),
scope text NOT NULL
CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste')),
action text NOT NULL
CONSTRAINT recommendation_tuning_audit_action_check
CHECK (action IN ('update', 'reset')),
changes jsonb NOT NULL
);
CREATE INDEX recommendation_tuning_audit_changed_at_idx
ON recommendation_tuning_audit (changed_at DESC);
+23 -2
View File
@@ -24,11 +24,32 @@ ORDER BY started_at DESC
LIMIT 1; LIMIT 1;
-- name: InsertPlayEvent :one -- name: InsertPlayEvent :one
-- pick_kind is non-NULL only for system-playlist plays whose track was
-- found (with a stamped kind) in the user's live snapshot for that
-- variant at ingestion time (#1249, generalized in #1270).
INSERT INTO play_events ( INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6) ) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
RETURNING *; RETURNING *;
-- name: GetSystemPickKindForTrack :one
-- Looks a track up in the user's CURRENT snapshot of the given system
-- variant and returns its pick_kind. Used at play-ingestion time to
-- freeze provenance onto the play_event — snapshots rebuild daily, so
-- attribution can't be reconstructed at read time (#1249/#1270). No
-- row = track not in today's snapshot (caller stores NULL); a row with
-- NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
-- is non-singleton (up to 3 mixes/user); a track in two of them takes
-- whichever LIMIT 1 hits — acceptable, tier stamps there describe the
-- same eligibility ladder.
SELECT pt.pick_kind
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1
AND p.system_variant = $2
AND pt.track_id = $3
LIMIT 1;
-- name: UpdatePlayEventEnded :one -- name: UpdatePlayEventEnded :one
-- Closes a play_event by id with the given ended_at, duration, and skip flag. -- Closes a play_event by id with the given ended_at, duration, and skip flag.
-- completion_ratio is computed from duration_played_ms and the track duration -- completion_ratio is computed from duration_played_ms and the track duration
+5 -3
View File
@@ -68,8 +68,9 @@ ORDER BY pt.position;
-- name: AppendPlaylistTrack :one -- name: AppendPlaylistTrack :one
-- Inserts at the next available position. Snapshot fields are copied -- Inserts at the next available position. Snapshot fields are copied
-- from the tracks/albums/artists join at insert time. tracks.duration_ms -- from the tracks/albums/artists join at insert time. tracks.duration_ms
-- is converted to seconds for the snapshot. -- is converted to seconds for the snapshot. pick_kind is NULL for manual
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec) -- playlists; the For You builder stamps 'taste'/'fresh' (#1249).
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT SELECT
sqlc.arg(playlist_id)::uuid, sqlc.arg(playlist_id)::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0), COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
@@ -77,7 +78,8 @@ SELECT
t.title, t.title,
artists.name, artists.name,
albums.title, albums.title,
(t.duration_ms / 1000)::integer (t.duration_ms / 1000)::integer,
sqlc.narg(pick_kind)::text
FROM tracks t FROM tracks t
JOIN albums ON albums.id = t.album_id JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id JOIN artists ON artists.id = t.artist_id
+46 -10
View File
@@ -1,22 +1,58 @@
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the -- Recommendation observability (#796 phase 4). Per-source play outcomes so the
-- operator can see whether each recommendation surface is landing and tune the -- operator can see whether each recommendation surface is landing and tune the
-- taste weights. Source is stamped on play_events when a play is launched from -- taste weights. Source is stamped on play_events when a play is launched from
-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes); -- a recommendation surface; NULL means the user picked the track manually —
-- NULL for library / radio / user-playlist plays, which are excluded here. -- those rows are INCLUDED here as the baseline control group the surfaces are
-- judged against (milestone #127: delta-vs-baseline is what makes the numbers
-- actionable). Raw source strings are bucketed into stable surface families in
-- the Go handler; completion_n is carried so family merges can weight
-- avg_completion correctly.
-- name: RecommendationSourceMetricsForUser :many -- name: RecommendationWeeklyTrends :many
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the -- Weekly per-source outcome series for the tuning lab's trend view
-- mean completion ratio over plays that recorded one (0 when none did). -- (#1251). Aggregated across ALL users: the tuning knobs are global,
-- so judging a knob turn needs global outcomes — rows carry rates
-- only, no track or user identity. NULL-source (manual) rows are
-- included as the baseline family.
--
-- taste_hits counts plays whose track's artist has a positive weight
-- in that user's CURRENT taste profile — the "cheap recompute" option:
-- retroactive over the whole window, at the cost of drift (the profile
-- is today's, the play may be weeks old). Good enough to read whether
-- a surface is feeding taste-fitting tracks.
-- $1 window in weeks.
SELECT SELECT
date_trunc('week', pe.started_at)::date AS week_start,
pe.source, pe.source,
count(*)::bigint AS plays, count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips, count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
COALESCE( count(pe.completion_ratio)::bigint AS completion_n,
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL), COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
0)::float8 AS avg_completion count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
LEFT JOIN taste_profile_artists tpa
ON tpa.user_id = pe.user_id
AND tpa.artist_id = t.artist_id
AND tpa.weight > 0
WHERE pe.started_at > now() - (sqlc.arg(weeks)::int * INTERVAL '1 week')
GROUP BY 1, 2
ORDER BY 1, 2;
-- name: RecommendationSourceMetricsForUser :many
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
-- mean completion ratio over the completion_n plays that recorded one.
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
-- it is NULL for every other source, so those still group to one row.
SELECT
pe.source,
pe.pick_kind,
count(*)::bigint AS plays,
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
count(pe.completion_ratio)::bigint AS completion_n,
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
AND pe.source IS NOT NULL
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
GROUP BY pe.source GROUP BY pe.source, pe.pick_kind
ORDER BY plays DESC; ORDER BY plays DESC;
@@ -0,0 +1,61 @@
-- Recommendation tuning lab queries (#1250). Seeding happens via the
-- recsettings boot reconcile; shipped defaults live in Go only.
-- name: UpsertWeightProfileDefaults :exec
-- Boot reconcile: insert the shipped defaults for a profile if the row
-- doesn't exist yet. Never overwrites operator-tuned values.
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (profile) DO NOTHING;
-- name: ListWeightProfiles :many
SELECT * FROM recommendation_weight_profiles ORDER BY profile;
-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
WHERE profile = $1
RETURNING *;
-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
ON CONFLICT (singleton) DO NOTHING;
-- name: GetTasteTuning :one
SELECT * FROM taste_tuning WHERE singleton = true;
-- name: UpdateTasteTuning :one
UPDATE taste_tuning
SET half_life_days = $1,
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
updated_at = now()
WHERE singleton = true
RETURNING *;
-- name: InsertTuningAudit :exec
-- changes is a jsonb array of {field, old, new} objects.
INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3);
-- name: ListTuningAudit :many
-- Newest first; consumed by the metrics trend view (#1251) to annotate
-- knob turns on the timeline.
SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit
ORDER BY changed_at DESC, id DESC
LIMIT $1;
+150 -60
View File
@@ -8,6 +8,11 @@
-- name: ListDeepCutsTracks :many -- name: ListDeepCutsTracks :many
-- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user -- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
-- has liked OR played heavily (>=5 non-skip plays across the artist). -- has liked OR played heavily (>=5 non-skip plays across the artist).
-- Tracks the user has skipped twice or more don't qualify (#1257):
-- eligibility used to count only unskipped plays, so the most actively
-- rejected tracks read as "barely heard" and kept being re-offered.
-- Threshold 2 so a single accidental skip doesn't banish a track;
-- passive-signal only (no dislike UI, rule #101).
-- $1 user_id, $2 date string. -- $1 user_id, $2 date string.
WITH affinity_artists AS ( WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 SELECT artist_id FROM general_likes_artists WHERE user_id = $1
@@ -23,12 +28,20 @@ play_counts AS (
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id GROUP BY track_id
),
skip_counts AS (
SELECT track_id, COUNT(*) AS c
FROM play_events
WHERE user_id = $1 AND was_skipped = true
GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN affinity_artists aa ON aa.artist_id = t.artist_id JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.id LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.track_id = t.id
WHERE COALESCE(pc.c, 0) <= 2 WHERE COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
@@ -37,75 +50,112 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200; LIMIT 200;
-- name: ListRediscoverTracks :many -- name: ListRediscoverTracks :many
-- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but -- #420 Rediscover: tracks the user played a lot but has drifted away
-- has drifted away from. Tiered so a young library still gets a mix: -- from. One blended pool (issue #1246: the old two-tier UNION was
-- tier 0 not played in the last 6 months (true rediscovery) -- all-or-nothing — a single ">=6 months cold" row suppressed the whole
-- tier 1 (only if tier 0 empty) not played in the last 30 days -- ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
-- Ordered by historical affection. $1 user_id. -- tier was also a strict subset of the 30d tier). Eligibility is >=3
-- non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
-- so a weeks-old library still fields a pool — on young histories the
-- >=5-play tracks are precisely the ones still in rotation. Ordering
-- prefers true rediscoveries (>=6 months cold), then strong affection
-- (>=5 plays), then raw play count; the producer's minimum floor
-- decides whether the pool is big enough to ship at all. $1 user_id.
WITH stats AS ( WITH stats AS (
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
FROM play_events pe FROM play_events pe
WHERE pe.user_id = $1 AND pe.was_skipped = false WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY pe.track_id GROUP BY pe.track_id
), )
deep AS ( SELECT t.id, t.album_id, t.artist_id
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
FROM tracks t FROM tracks t
JOIN stats s ON s.track_id = t.id JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5 WHERE s.c >= 3
AND s.last_at <= now() - interval '6 months'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
),
shallow AS (
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
FROM tracks t
JOIN stats s ON s.track_id = t.id
WHERE s.c >= 5
AND s.last_at <= now() - interval '30 days' AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
) -- Declined-resurfacing cooldown (#1258): a skip on a
SELECT id, album_id, artist_id -- rediscover-sourced play is the user explicitly saying "I've moved
FROM ( -- on" to the exact invitation this mix extends — such tracks sit
SELECT id, album_id, artist_id, c, tier FROM deep -- out ~90 days instead of re-qualifying the next day. Passive
UNION ALL -- signal only (rule #101).
SELECT id, album_id, artist_id, c, tier FROM shallow AND NOT EXISTS (
WHERE NOT EXISTS (SELECT 1 FROM deep) SELECT 1 FROM play_events rp
) u WHERE rp.user_id = $1
ORDER BY tier, c DESC, id AND rp.track_id = t.id
AND rp.source = 'rediscover'
AND rp.was_skipped = true
AND rp.started_at > now() - interval '90 days'
)
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
(s.c >= 5) DESC,
s.c DESC, t.id
LIMIT 200; LIMIT 200;
-- name: ListNewForYouTracks :many -- name: ListNewForYouTracks :many
-- #421 New for you: tracks from albums added in the last 30 days -- #421 New for you, tiered per project rule #131 (#1267). The old
-- whose artist the user has liked OR played (>=3 non-skip). Album- -- single hard rule (added <30d AND direct affinity) had two failure
-- coherent (newest album first, then disc/track) — the producer does -- modes with one root: no notion of consumption — the album you
-- NOT diversity-cap these; they're meant as whole-album discovery. -- devoured in week one crowded the mix for three more weeks, and
-- $1 user_id. -- after a quiet month nothing qualified at all.
WITH affinity_artists AS ( --
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 -- "Consumed" = any track attempted for >=30s (the First Listens
-- threshold, #1268): once you've meaningfully engaged, the album is
-- no longer news. Applies to every tier — played albums leave the
-- mix the next build.
--
-- tier 1 the exact desire: unconsumed albums added <30d by
-- direct-affinity artists (liked, or >=3 attempted plays)
-- tier 2 step back a little: unconsumed affinity albums from the
-- wider 30-90d window — added while you weren't looking
-- tier 3 step back more: any unconsumed album added <90d
-- regardless of affinity, newest first
--
-- Album-coherent within tiers (newest album first, then disc/track);
-- the producer fills top-down and rotates within tier blocks. The
-- tier lands on playlist_tracks.pick_kind so metrics can price what
-- each step-back trades away. $1 user_id.
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
),
consumed_albums AS (
SELECT DISTINCT t.album_id
FROM attempted a JOIN tracks t ON t.id = a.track_id
),
affinity_artists AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
UNION UNION
SELECT t.artist_id SELECT t.artist_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id FROM attempted a JOIN tracks t ON t.id = a.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id GROUP BY t.artist_id
HAVING COUNT(*) >= 3 HAVING COUNT(*) >= 3
),
albums_tiered AS (
SELECT al.id AS album_id, al.created_at,
CASE
WHEN al.created_at >= now() - interval '30 days'
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
ELSE 3
END AS tier
FROM albums al
WHERE al.created_at >= now() - interval '90 days'
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
FROM tracks t FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums_tiered alt ON alt.album_id = t.album_id
JOIN affinity_artists aa ON aa.artist_id = al.artist_id WHERE NOT EXISTS (
WHERE al.created_at >= now() - interval '30 days'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST ORDER BY alt.tier, alt.created_at DESC, t.album_id,
LIMIT 200; t.disc_number NULLS FIRST, t.track_number NULLS FIRST
LIMIT 300;
-- name: ListOnThisDayTracks :many -- name: ListOnThisDayTracks :many
-- #422 On This Day: tracks the user played around this calendar date -- #422 On This Day: tracks the user played around this calendar date
@@ -115,13 +165,23 @@ SELECT t.id, t.album_id, t.artist_id
-- and window ±7→±10 so it surfaces on a months-old library instead -- and window ±7→±10 so it surfaces on a months-old library instead
-- of needing a full year of history; still skips cleanly (no rows → -- of needing a full year of history; still skips cleanly (no rows →
-- no playlist) when there's no qualifying history yet. -- no playlist) when there's no qualifying history yet.
-- Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
-- Jan 3 read as 359 days apart, silently gutting the window for ~3
-- weeks around every New Year — precisely when holiday nostalgia is
-- the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
-- ±1 is absorbed by the ±10 window. The build date ($2, already here
-- for the md5 rotation) anchors "today" instead of now() so the
-- window is testable and consistent with the mix's daily determinism.
-- $1 user_id, $2 date string. -- $1 user_id, $2 date string.
WITH windowed AS ( WITH windowed AS (
SELECT track_id, COUNT(*) AS c SELECT track_id, COUNT(*) AS c
FROM play_events FROM play_events
WHERE user_id = $1 AND was_skipped = false WHERE user_id = $1 AND was_skipped = false
AND started_at < now() - interval '30 days' AND started_at < now() - interval '30 days'
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10 AND LEAST(
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
) <= 10
GROUP BY track_id GROUP BY track_id
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
@@ -135,35 +195,65 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200; LIMIT 200;
-- name: ListFirstListensTracks :many -- name: ListFirstListensTracks :many
-- #423 First Listens: albums the user has never played any track of. -- #423 First Listens: songs the user has never heard or even
-- Tiered: liked-artist albums first, then played-artist albums, then -- attempted. "Attempted" is track-level with a >=30s listen threshold
-- the rest — album-coherent within each tier. Not diversity-capped -- (#1268, operator decision 2026-07-03) — the old version disqualified
-- (whole-album discovery). $1 user_id. -- a whole album on ANY play_event, so a 2-second accidental brush
WITH heard_albums AS ( -- banished it permanently.
SELECT DISTINCT t.album_id --
FROM play_events pe JOIN tracks t ON t.id = pe.track_id -- tier 1 the exact desire: albums with ZERO attempted tracks
WHERE pe.user_id = $1 -- tier 2 step back: barely-attempted albums (<=25% of tracks
-- reached 30s) — brushed, never explored. The already-
-- attempted tracks themselves are excluded; they're not
-- first listens.
--
-- Within each tier, affinity-ordered as before: liked artist, then
-- attempted-played artist (>=30s — skip-only contact isn't trust),
-- then the rest; album-coherent. The tier lands on
-- playlist_tracks.pick_kind via the producer. $1 user_id.
WITH attempted AS (
SELECT DISTINCT pe.track_id
FROM play_events pe
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
), ),
played_artists AS ( album_attempts AS (
SELECT t.album_id,
COUNT(a.track_id) AS attempted_count,
COUNT(*) AS track_count
FROM tracks t
LEFT JOIN attempted a ON a.track_id = t.id
GROUP BY t.album_id
),
attempted_artists AS (
SELECT DISTINCT t.artist_id SELECT DISTINCT t.artist_id
FROM play_events pe JOIN tracks t ON t.id = pe.track_id FROM attempted a JOIN tracks t ON t.id = a.track_id
WHERE pe.user_id = $1 ),
albums_tiered AS (
SELECT aa.album_id,
CASE
WHEN aa.attempted_count = 0 THEN 1
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
END AS tier
FROM album_attempts aa
) )
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
FROM tracks t FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id) JOIN albums_tiered alt ON alt.album_id = al.id
WHERE alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
) )
ORDER BY ORDER BY
alt.tier,
(CASE (CASE
WHEN EXISTS ( WHEN EXISTS (
SELECT 1 FROM general_likes_artists gla SELECT 1 FROM general_likes_artists gla
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
) THEN 0 ) THEN 0
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1 WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
ELSE 2 ELSE 2
END), END),
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
+71 -18
View File
@@ -47,14 +47,29 @@ UPDATE system_playlist_runs
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true; UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
-- name: PickSeedArtists :many -- name: PickSeedArtists :many
-- Top-5 most-engaged distinct artist candidates in the user's last 7 -- Top-5 most-engaged distinct artist candidates, tiered so the
-- days. The Go-side picker (pickSeedArtistsForDay) shuffles these -- "Songs like X" mixes never silently vanish (#1255): the old hard
-- daily-deterministically and takes the first 3 so the set of -- 7-day window emptied the seed pool after a quiet week, and the
-- "Songs like X" mixes rotates day-to-day. -- daily atomic-replace build then deleted every existing mix until
-- Score = unskipped-play count + 5 if user has liked the artist. -- the user played something again. Same fallback shape as
WITH plays AS ( -- PickTopPlayedTracksForUser (For You's seeds):
-- tier 0 engagement in the last 7 days
-- tier 1 (only if tier 0 empty) last 30 days
-- tier 2 (only if tiers 0+1 empty) all-time
-- tier 3 (only if 0-2 empty) liked artists with no play history
-- All returned rows share one tier; produceSeedMixes maps it onto the
-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics
-- can compare mixes seeded from fresh vs stale engagement.
-- Score = unskipped-play count + 5 if user has liked the artist. The
-- Go-side picker (pickSeedArtistsForDay) shuffles the 5
-- daily-deterministically and takes 3 so the mix set rotates.
WITH liked AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
),
recent7 AS (
SELECT t.artist_id, SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
0 AS tier
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 WHERE pe.user_id = $1
@@ -62,14 +77,52 @@ WITH plays AS (
AND t.artist_id IS NOT NULL AND t.artist_id IS NOT NULL
GROUP BY t.artist_id GROUP BY t.artist_id
), ),
liked AS ( recent30 AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
1 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND pe.started_at > now() - INTERVAL '30 days'
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
alltime AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
2 AS tier
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
AND t.artist_id IS NOT NULL
GROUP BY t.artist_id
),
likedonly AS (
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
FROM liked l
),
chosen AS (
SELECT artist_id, play_count, tier FROM recent7
UNION ALL
SELECT artist_id, play_count, tier FROM recent30
WHERE NOT EXISTS (SELECT 1 FROM recent7)
UNION ALL
SELECT artist_id, play_count, tier FROM alltime
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
UNION ALL
SELECT artist_id, play_count, tier FROM likedonly
WHERE NOT EXISTS (SELECT 1 FROM recent7)
AND NOT EXISTS (SELECT 1 FROM recent30)
AND NOT EXISTS (SELECT 1 FROM alltime)
) )
SELECT p.artist_id, SELECT c.artist_id,
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score (c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
FROM plays p c.tier::int AS tier
LEFT JOIN liked l ON l.artist_id = p.artist_id FROM chosen c
ORDER BY score DESC, p.artist_id LEFT JOIN liked l ON l.artist_id = c.artist_id
ORDER BY score DESC, c.artist_id
LIMIT 5; LIMIT 5;
-- name: PickTopPlayedTracksForUser :many -- name: PickTopPlayedTracksForUser :many
@@ -78,10 +131,10 @@ SELECT p.artist_id,
-- tier 1 (only if tier 0 empty) all-time top non-skip plays -- tier 1 (only if tier 0 empty) all-time top non-skip plays
-- tier 2 (only if tiers 0+1 empty) liked tracks -- tier 2 (only if tiers 0+1 empty) liked tracks
-- Returns up to 5 ids; tie-break by track_id for determinism. The -- Returns up to 5 ids; tie-break by track_id for determinism. The
-- Go-side picker (pickForYouSeedForDay) rotates one per day via -- Go-side picker (pickDailySeeds) draws the day's seeds from these.
-- userIDHash. Widened from a hard 7-day window, which made For-You -- Widened from a hard 7-day window, which made For-You disappear
-- disappear after a week of not listening and never recover on a -- after a week of not listening and never recover on a self-hosted
-- self-hosted library with sparse history. -- library with sparse history.
WITH recent AS ( WITH recent AS (
SELECT t.id, COUNT(*) AS c, 0 AS tier SELECT t.id, COUNT(*) AS c, 0 AS tier
FROM play_events pe FROM play_events pe
+5
View File
@@ -62,6 +62,11 @@ var dataTables = []string{
// (never recreates the singleton, seeded once by 0018); ResetDB // (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead. // resets its counter via UPDATE below instead.
"cover_art_provider_settings", "cover_art_provider_settings",
// recsettings.New reconciles shipped defaults on every construction
// (#1250), so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
"taste_tuning",
"recommendation_tuning_audit",
"tracks", "tracks",
"albums", "albums",
"artists", "artists",
+46
View File
@@ -97,6 +97,36 @@ var systemPlaylistSources = map[string]bool{
"songs_like_artist": true, "songs_like_artist": true,
} }
// lookupSystemPickKind resolves a system-playlist play's pick_kind
// (taste/fresh, Discover's bucket, or a rule-#131 tier) from the user's
// live snapshot of that variant at ingestion time. Attribution must be
// frozen now — snapshots rebuild daily, so it can't be reconstructed at
// metrics-read time (#1249, generalized in #1270). Source strings
// double as system_variant values (see systemPlaylistSources). Returns
// nil (unattributed) when the track isn't in the current snapshot —
// plays predating the feature, or an offline replay arriving after the
// track rotated out — and when the variant doesn't stamp yet. Real DB
// errors propagate and fail the caller's transaction.
func lookupSystemPickKind(
ctx context.Context,
q *dbq.Queries,
userID, trackID pgtype.UUID,
variant string,
) (*string, error) {
kind, err := q.GetSystemPickKindForTrack(ctx, dbq.GetSystemPickKindForTrackParams{
UserID: userID,
SystemVariant: &variant,
TrackID: trackID,
})
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return kind, nil
}
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source` // RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
// tag identifying which surface the play came from. When source is a // tag identifying which surface the play came from. When source is a
// known system-playlist kind the track is appended to that user's // known system-playlist kind the track is appended to that user's
@@ -127,6 +157,13 @@ func (w *Writer) RecordPlayStartedWithSource(
if source != "" { if source != "" {
sourcePtr = &source sourcePtr = &source
} }
var pickKind *string
if systemPlaylistSources[source] {
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
if err != nil {
return err
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{ ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID, UserID: userID,
TrackID: trackID, TrackID: trackID,
@@ -134,6 +171,7 @@ func (w *Writer) RecordPlayStartedWithSource(
StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr, ClientID: clientIDPtr,
Source: sourcePtr, Source: sourcePtr,
PickKind: pickKind,
}) })
if err != nil { if err != nil {
return err return err
@@ -395,6 +433,13 @@ func (w *Writer) RecordOfflinePlay(
if source != "" { if source != "" {
sourcePtr = &source sourcePtr = &source
} }
var pickKind *string
if systemPlaylistSources[source] {
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
if err != nil {
return err
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{ ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID, UserID: userID,
TrackID: trackID, TrackID: trackID,
@@ -402,6 +447,7 @@ func (w *Writer) RecordOfflinePlay(
StartedAt: pgtype.Timestamptz{Time: at, Valid: true}, StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr, ClientID: clientIDPtr,
Source: sourcePtr, Source: sourcePtr,
PickKind: pickKind,
}) })
if err != nil { if err != nil {
return err return err
+120
View File
@@ -488,3 +488,123 @@ func TestRecordPlayStarted_NoRotationWithoutSource(t *testing.T) {
t.Errorf("expected no rotation row for a source-less play") t.Errorf("expected no rotation row for a source-less play")
} }
} }
// seedSystemSnapshot creates a system playlist of the given variant
// containing the fixture track with the given pick_kind, mimicking
// what the builder persists (#1249, generalized #1270).
func seedSystemSnapshot(t *testing.T, f fixture, variant, pickKind string) {
t.Helper()
pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{
UserID: f.user, Name: variant, SystemVariant: &variant,
})
if err != nil {
t.Fatalf("CreateSystemPlaylist: %v", err)
}
if _, err := f.q.AppendPlaylistTrack(context.Background(), dbq.AppendPlaylistTrackParams{
PlaylistID: pl.ID, TrackID: f.track, PickKind: &pickKind,
}); err != nil {
t.Fatalf("AppendPlaylistTrack: %v", err)
}
}
func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "fresh")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind == nil || *got.PickKind != "fresh" {
t.Errorf("pick_kind = %v, want fresh (frozen from live snapshot)", got.PickKind)
}
}
func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T) {
// A for_you play whose track isn't in the current snapshot (rotated
// out, or no snapshot exists) stays unattributed rather than guessing.
f := newFixture(t, 200_000)
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind != nil {
t.Errorf("pick_kind = %q, want NULL (track not in snapshot)", *got.PickKind)
}
}
func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
// Attribution is variant-scoped: even with a For You snapshot
// containing the track, a play from a different surface must not
// pick up For You's stamp — it reads its OWN variant's snapshot,
// which here doesn't exist.
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "taste")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind != nil {
t.Errorf("pick_kind = %q, want NULL for a cross-variant play", *got.PickKind)
}
}
func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
// Discover stamps its candidate bucket (#1270); a discover play whose
// track is in the live snapshot freezes that bucket onto the play.
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "discover", "dormant")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.PickKind == nil || *got.PickKind != "dormant" {
t.Errorf("pick_kind = %v, want dormant (frozen from live snapshot)", got.PickKind)
}
}
func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
// The offline replay path threads source the same way; a replayed
// for_you play whose track is still in the snapshot gets attributed.
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "taste")
at := time.Now().UTC().Add(-time.Hour)
if err := f.w.RecordOfflinePlay(
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
); err != nil {
t.Fatalf("RecordOfflinePlay: %v", err)
}
var pickKind *string
if err := f.pool.QueryRow(context.Background(),
`SELECT pick_kind FROM play_events WHERE user_id = $1 AND track_id = $2`,
f.user, f.track,
).Scan(&pickKind); err != nil {
t.Fatalf("select: %v", err)
}
if pickKind == nil || *pickKind != "taste" {
t.Errorf("pick_kind = %v, want taste", pickKind)
}
}
+18 -5
View File
@@ -20,11 +20,15 @@ const (
// discoverTrack is the common shape used by the bucket allocator. The // discoverTrack is the common shape used by the bucket allocator. The
// three sqlc-generated row types collapse into this internal struct so // three sqlc-generated row types collapse into this internal struct so
// downstream functions don't need to be generic over them. // downstream functions don't need to be generic over them. PickKind is
// the originating bucket (dormant/cross_user/random, #1270), stamped in
// the row adapters so it survives the interleave — bucket identity is
// what makes the 40/30/30 allocation measurable in the metrics.
type discoverTrack struct { type discoverTrack struct {
ID pgtype.UUID ID pgtype.UUID
AlbumID pgtype.UUID AlbumID pgtype.UUID
ArtistID pgtype.UUID ArtistID pgtype.UUID
PickKind string
} }
// buildDiscoverCandidates assembles the Discover playlist track list. // buildDiscoverCandidates assembles the Discover playlist track list.
@@ -91,7 +95,7 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
// already gave us the ranking. // already gave us the ranking.
ranked := make([]rankedCandidate, 0, len(out)) ranked := make([]rankedCandidate, 0, len(out))
for _, t := range out { for _, t := range out {
ranked = append(ranked, rankedCandidate{TrackID: t.ID}) ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
} }
return ranked, nil return ranked, nil
} }
@@ -99,7 +103,10 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack { func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindDormant,
}
} }
return out return out
} }
@@ -107,7 +114,10 @@ func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []dis
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack { func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindCrossUser,
}
} }
return out return out
} }
@@ -115,7 +125,10 @@ func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack { func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindRandom,
}
} }
return out return out
} }
+24
View File
@@ -161,3 +161,27 @@ func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
} }
} }
} }
func TestInterleaveBuckets_PreservesBucketPickKind(t *testing.T) {
// Bucket provenance (#1270) is stamped on discoverTrack before the
// interleave and must survive it — including dedup, where a track in
// two buckets keeps the stamp of the bucket it was taken from.
dormant := []discoverTrack{
{ID: uuidN(1), PickKind: pickKindDormant},
{ID: uuidN(2), PickKind: pickKindDormant},
}
random := []discoverTrack{
{ID: uuidN(1), PickKind: pickKindRandom}, // shared with dormant
{ID: uuidN(3), PickKind: pickKindRandom},
}
got := interleaveBuckets(dormant, nil, random)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
want := map[byte]string{1: pickKindDormant, 2: pickKindDormant, 3: pickKindRandom}
for _, tr := range got {
if w := want[tr.ID.Bytes[15]]; tr.PickKind != w {
t.Errorf("track %d pick_kind = %q, want %q", tr.ID.Bytes[15], tr.PickKind, w)
}
}
}
+195 -8
View File
@@ -99,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) {
makeCand(2, 11, 101, 0.9), makeCand(2, 11, 101, 0.9),
makeCand(3, 12, 102, 0.8), makeCand(3, 12, 102, 0.8),
} }
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2) got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
if len(got) != 3 { if len(got) != 3 {
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got)) t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
} }
@@ -112,7 +112,7 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
for i := 0; i < 7; i++ { for i := 0; i < 7; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i))) in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
} }
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2) got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
if len(got) != 7 { if len(got) != 7 {
t.Errorf("len = %d, want 7 (pool == total)", len(got)) t.Errorf("len = %d, want 7 (pool == total)", len(got))
} }
@@ -126,7 +126,7 @@ func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
} }
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5) got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
if len(got) != 25 { if len(got) != 25 {
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got)) t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
} }
@@ -139,8 +139,8 @@ func TestPickHeadAndTail_Determinism(t *testing.T) {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
} }
now := time.Now() now := time.Now()
got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
if len(got1) != len(got2) { if len(got1) != len(got2) {
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2)) t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
} }
@@ -161,8 +161,8 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
} }
now := time.Now() now := time.Now()
day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5) day2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-08", now, 20, 5)
if len(day1) != 25 || len(day2) != 25 { if len(day1) != 25 || len(day2) != 25 {
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2)) t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
} }
@@ -205,7 +205,7 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
sim := float64(50 - i) sim := float64(50 - i)
in = append(in, makeCand(i+1, i+1, i+1, sim)) in = append(in, makeCand(i+1, i+1, i+1, sim))
} }
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3) got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 3)
if len(got) != 8 { if len(got) != 8 {
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got)) t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
} }
@@ -243,3 +243,190 @@ func TestPickTopN_DiversityCap(t *testing.T) {
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got)) t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
} }
} }
func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
// taste picks, the 5 tail entries the freshness injection (#1249).
in := make([]recommendation.Candidate, 0, 100)
for i := 0; i < 100; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
if len(got) != 25 {
t.Fatalf("len = %d, want 25", len(got))
}
for i := 0; i < 20; i++ {
if got[i].PickKind != pickKindTaste {
t.Errorf("head[%d].PickKind = %q, want %q", i, got[i].PickKind, pickKindTaste)
}
}
for i := 20; i < 25; i++ {
if got[i].PickKind != pickKindFresh {
t.Errorf("tail[%d].PickKind = %q, want %q", i-20, got[i].PickKind, pickKindFresh)
}
}
}
func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
// The small-pool fallback is pure top-N-by-score — that IS the taste
// mechanism, so nothing on this path is an exploration pick.
in := []recommendation.Candidate{
makeCand(1, 10, 100, 1.0),
makeCand(2, 11, 101, 0.9),
makeCand(3, 12, 102, 0.8),
}
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
for i, rc := range got {
if rc.PickKind != pickKindTaste {
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
i, rc.PickKind, pickKindTaste)
}
}
}
func TestPickTopN_NoPickKind(t *testing.T) {
// Songs-like-X and the discovery mixes don't split; their rows must
// persist pick_kind NULL (empty string here).
in := []recommendation.Candidate{makeCand(1, 10, 100, 1.0)}
got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
if len(got) != 1 || got[0].PickKind != "" {
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
}
}
func TestHeadQuotas(t *testing.T) {
cases := []struct {
seeds, headN int
want []int
}{
{1, 50, []int{50}},
{2, 50, []int{30, 20}},
{3, 50, []int{25, 15, 10}},
{3, 7, []int{4, 2, 1}}, // rounding remainder → primary seed
}
for _, c := range cases {
got := headQuotas(c.seeds, c.headN)
if len(got) != len(c.want) {
t.Fatalf("headQuotas(%d, %d) len = %d, want %d", c.seeds, c.headN, len(got), len(c.want))
}
sum := 0
for i := range got {
sum += got[i]
if got[i] != c.want[i] {
t.Errorf("headQuotas(%d, %d)[%d] = %d, want %d", c.seeds, c.headN, i, got[i], c.want[i])
}
}
if sum != c.headN {
t.Errorf("headQuotas(%d, %d) sums to %d, want %d", c.seeds, c.headN, sum, c.headN)
}
}
}
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
// Seed 0's neighborhood out-scores everything (#1269): without
// quotas the head would be all seed-0. With headN=10 and quotas
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
// 2 get their guaranteed slots from deeper ranks.
in := make([]recommendation.Candidate, 0, 30)
seedOf := map[pgtype.UUID]int{}
for i := 0; i < 30; i++ {
c := makeCand(i+1, i+1, i+1, float64(30-i))
si := 0
if i >= 20 && i < 25 {
si = 1
} else if i >= 25 {
si = 2
}
seedOf[c.Track.ID] = si
in = append(in, c)
}
// Input is already score-descending and cap-free.
head := pickQuotaHead(in, seedOf, 3, 10)
if len(head) != 10 {
t.Fatalf("head len = %d, want 10", len(head))
}
want := []byte{1, 2, 3, 4, 5, 21, 22, 23, 26, 27}
for i, w := range want {
if head[i].Track.ID.Bytes[15] != w {
t.Errorf("head[%d] = track %d, want %d", i, head[i].Track.ID.Bytes[15], w)
}
}
}
func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
// Seed 1 has a single candidate; its unfilled quota goes to the
// best remaining candidates regardless of seed.
in := make([]recommendation.Candidate, 0, 12)
seedOf := map[pgtype.UUID]int{}
for i := 0; i < 12; i++ {
c := makeCand(i+1, i+1, i+1, float64(12-i))
si := 0
if i == 11 {
si = 1 // the worst-ranked candidate is seed 1's only one
}
seedOf[c.Track.ID] = si
in = append(in, c)
}
head := pickQuotaHead(in, seedOf, 2, 10)
if len(head) != 10 {
t.Fatalf("head len = %d, want 10", len(head))
}
// Quotas [6,4]: seed 0 fills 6 (tracks 1-6), seed 1 fills 1
// (track 12), spill tops up with tracks 7-9.
got := map[byte]bool{}
for _, c := range head {
got[c.Track.ID.Bytes[15]] = true
}
for _, w := range []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 12} {
if !got[w] {
t.Errorf("head missing track %d (spill should fill best-first)", w)
}
}
}
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
// The fresh tail's sample is rank-weighted (#1269): across many
// days, the mean selected rank must sit well below the uniform
// draw's ~99.5 for a 200-deep pool.
pool := make([]recommendation.Candidate, 0, 200)
for i := 0; i < 200; i++ {
c := makeCand(1, i+1, i+1, 1.0)
// Distinct track IDs beyond one byte: bytes wrap at 256.
c.Track.ID.Bytes[14] = byte(i / 200)
c.Track.ID.Bytes[15] = byte(i % 200)
pool = append(pool, c)
}
rankOf := map[pgtype.UUID]int{}
for i, c := range pool {
rankOf[c.Track.ID] = i
}
totalRank, picks := 0, 0
for day := 1; day <= 28; day++ {
got := pickWeightedTail(pool, "2026-06-"+twoDigits(day), 10)
if len(got) != 10 {
t.Fatalf("day %d: len = %d, want 10", day, len(got))
}
for _, c := range got {
totalRank += rankOf[c.Track.ID]
picks++
}
}
mean := float64(totalRank) / float64(picks)
if mean > 80 {
t.Errorf("mean selected rank = %.1f; want well below uniform ~99.5", mean)
}
}
func TestPickWeightedTail_DeterministicWithinDay(t *testing.T) {
pool := make([]recommendation.Candidate, 0, 60)
for i := 0; i < 60; i++ {
pool = append(pool, makeCand(i+1, i+1, i+1, 1.0))
}
a := pickWeightedTail(pool, "2026-06-05", 8)
b := pickWeightedTail(pool, "2026-06-05", 8)
for i := range a {
if a[i].Track.ID != b[i].Track.ID {
t.Fatal("weighted tail must be deterministic within a day")
}
}
}
+1 -1
View File
@@ -236,7 +236,7 @@ func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiv
// build can read a fresh profile (phase-2 consumption); both steps are // build can read a fresh profile (phase-2 consumption); both steps are
// best-effort and a failure in one is logged without blocking the other. // best-effort and a failure in one is logged without blocking the other.
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) { func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil { if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, currentTasteConfig()); err != nil {
s.logger.Warn("scheduler: taste profile rebuild failed", s.logger.Warn("scheduler: taste profile rebuild failed",
"user_id", uuidStringPL(userID), "err", err) "user_id", uuidStringPL(userID), "err", err)
} }
+49 -24
View File
@@ -46,6 +46,26 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) {
} }
} }
func TestPickKindForSeedTier(t *testing.T) {
// The seed query's fallback tiers map onto the rule-#131 ladder:
// fresh 7-day engagement is the exact desire, everything past the
// 30-day step-back collapses into the far tier (#1255).
cases := []struct {
tier int32
want string
}{
{0, pickKindTier1},
{1, pickKindTier2},
{2, pickKindTier3},
{3, pickKindTier3},
}
for _, c := range cases {
if got := pickKindForSeedTier(c.tier); got != c.want {
t.Errorf("pickKindForSeedTier(%d) = %q, want %q", c.tier, got, c.want)
}
}
}
// userIDHash is the per-user, per-day hash that drives the daily- // userIDHash is the per-user, per-day hash that drives the daily-
// determinism RNGs. Same family as tieBreakHash, just keyed on user // determinism RNGs. Same family as tieBreakHash, just keyed on user
// ID instead of track ID. // ID instead of track ID.
@@ -76,60 +96,65 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
} }
} }
// pickForYouSeedForDay rotates the chosen For-You seed across the // pickDailySeeds shuffles the candidate pool daily-deterministically
// user's top-played candidates using userIDHash. Verifies the picker // and takes up to n — For-You uses n=forYouSeedCount for its
// is deterministic within a day, varies across days, and degrades // multi-seed blend (#1269), Songs-like uses n=3 via the
// gracefully when fewer than 5 candidates exist. // pickSeedArtistsForDay wrapper. Verifies determinism within a day,
// variation across days, and graceful degradation on small pools.
func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) { func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
seeds := []pgtype.UUID{ pool := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true}, {Bytes: [16]byte{50}, Valid: true},
} }
a := pickForYouSeedForDay(seeds, u, "2026-05-04") a := pickDailySeeds(pool, u, "2026-05-04", 3)
b := pickForYouSeedForDay(seeds, u, "2026-05-04") b := pickDailySeeds(pool, u, "2026-05-04", 3)
if a != b { if len(a) != 3 || len(b) != 3 {
t.Fatalf("same day should pick same seed; got %v then %v", a, b) t.Fatalf("lens = %d, %d; want 3, 3", len(a), len(b))
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("same day should pick same seeds; got %v then %v", a, b)
}
} }
} }
func TestPickForYouSeedForDay_VariesAcrossDays(t *testing.T) { func TestPickDailySeeds_VariesAcrossDays(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
seeds := []pgtype.UUID{ pool := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true}, {Bytes: [16]byte{10}, Valid: true},
{Bytes: [16]byte{20}, Valid: true}, {Bytes: [16]byte{20}, Valid: true},
{Bytes: [16]byte{30}, Valid: true}, {Bytes: [16]byte{30}, Valid: true},
{Bytes: [16]byte{40}, Valid: true}, {Bytes: [16]byte{40}, Valid: true},
{Bytes: [16]byte{50}, Valid: true}, {Bytes: [16]byte{50}, Valid: true},
} }
picks := map[[16]byte]bool{} firstPicks := map[[16]byte]bool{}
for i := 1; i <= 30; i++ { for i := 1; i <= 30; i++ {
date := "2026-05-" + twoDigits(i) date := "2026-05-" + twoDigits(i)
picks[pickForYouSeedForDay(seeds, u, date).Bytes] = true firstPicks[pickDailySeeds(pool, u, date, 3)[0].Bytes] = true
} }
if len(picks) < 2 { if len(firstPicks) < 2 {
t.Errorf("expected >=2 distinct seeds across 30 days; got %d", len(picks)) t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks))
} }
} }
func TestPickForYouSeedForDay_SingleCandidate(t *testing.T) { func TestPickDailySeeds_SmallPool(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true} only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true}
got := pickForYouSeedForDay([]pgtype.UUID{only}, u, "2026-05-04") got := pickDailySeeds([]pgtype.UUID{only}, u, "2026-05-04", 3)
if got != only { if len(got) != 1 || got[0] != only {
t.Errorf("single-seed pool should return that seed; got %v", got) t.Errorf("single-entry pool should return just that entry; got %v", got)
} }
} }
func TestPickForYouSeedForDay_EmptyPool(t *testing.T) { func TestPickDailySeeds_EmptyPool(t *testing.T) {
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
got := pickForYouSeedForDay(nil, u, "2026-05-04") if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 {
if got.Valid { t.Errorf("empty pool should return nothing; got %v", got)
t.Errorf("empty pool should return zero UUID; got %v", got)
} }
} }
+315 -63
View File
@@ -12,8 +12,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"math/rand" "math/rand"
"sort" "sort"
"sync"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -22,6 +24,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
) )
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape. // seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
@@ -83,32 +86,15 @@ func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
return binary.BigEndian.Uint64(sum[:8]) return binary.BigEndian.Uint64(sum[:8])
} }
// pickForYouSeedForDay picks one of the user's top-played candidate // pickDailySeeds takes a candidate pool of UUIDs and returns up to n
// tracks as today's For-You seed. With 5 candidates and SHA-256-based // of them, daily-deterministically shuffled. Each day gets a different
// rotation, each candidate gets picked roughly 1 day in 5; the chosen // ordering / selection, but within-day stability is preserved (same
// seed drives the similarity candidate pool, so the resulting mix is // inputs always produce the same output).
// substantially different across days for a user with diverse top-N
// plays.
//
// Degrades gracefully: 1 candidate → returns it; 0 → returns zero UUID
// (caller treats as "no seed available" and skips For-You).
func pickForYouSeedForDay(seeds []pgtype.UUID, userID pgtype.UUID, dateStr string) pgtype.UUID {
if len(seeds) == 0 {
return pgtype.UUID{}
}
idx := int(userIDHash(userID, dateStr) % uint64(len(seeds)))
return seeds[idx]
}
// pickSeedArtistsForDay takes a candidate pool of up to N artists and
// returns up to 3 of them, daily-deterministically shuffled. Each day
// gets a different ordering / selection, but within-day stability is
// preserved (same inputs always produce the same output).
// //
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash. // Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
// Degrades gracefully: <3 candidates returns whatever is available in // Degrades gracefully: <n candidates returns whatever is available in
// shuffled order. // shuffled order.
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID { func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n int) []pgtype.UUID {
if len(pool) == 0 { if len(pool) == 0 {
return nil return nil
} }
@@ -118,29 +104,94 @@ func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr strin
rng.Shuffle(len(shuffled), func(i, j int) { rng.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i] shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
}) })
n := 3
if len(shuffled) < n { if len(shuffled) < n {
n = len(shuffled) n = len(shuffled)
} }
return shuffled[:n] return shuffled[:n]
} }
// pickSeedArtistsForDay picks up to 3 seed artists for the Songs-like
// mixes; For-You uses pickDailySeeds directly with forYouSeedCount.
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
return pickDailySeeds(pool, userID, dateStr, 3)
}
// rankedCandidate is a (track_id, score) pair used during in-memory // rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from // sorting before insert into playlist_tracks. T5 fills these from
// recommendation.Candidate scores. // recommendation.Candidate scores. PickKind is the track's provenance
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
type rankedCandidate struct { type rankedCandidate struct {
TrackID pgtype.UUID TrackID pgtype.UUID
Score float64 Score float64
PickKind string
}
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
// attribute skips to the population that sourced the track — For You's
// "taste engine missing" vs "freshness tax", Discover's three buckets
// (#1270). Values mirror the CHECK in migration 0039; the tier1-3
// values there land with the tiered-mix rebuilds.
const (
pickKindTaste = "taste"
pickKindFresh = "fresh"
pickKindDormant = "dormant"
pickKindCrossUser = "cross_user"
pickKindRandom = "random"
pickKindTier1 = "tier1"
pickKindTier2 = "tier2"
pickKindTier3 = "tier3"
)
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
// mix's exact desire (tier1), the 30-day window steps back a little
// (tier2), and all-time / liked-only seeds are the far fallback
// (tier3). Stamped mix-wide — seed staleness is a property of the
// whole build, and it's what the metrics breakdown attributes skips to.
func pickKindForSeedTier(tier int32) string {
switch tier {
case 0:
return pickKindTier1
case 1:
return pickKindTier2
default:
return pickKindTier3
}
}
// pickKindForMixTier maps a tiered mix query's 1-based tier column
// (numbered to match rule #131's ladder directly) onto the pick-kind
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
// count fallback steps of the seed pool rather than eligibility rungs.
func pickKindForMixTier(tier int32) string {
switch tier {
case 1:
return pickKindTier1
case 2:
return pickKindTier2
default:
return pickKindTier3
}
} }
const systemMixLength = 25 const systemMixLength = 25
// systemMixWeights are the fixed scoring weights used by the cron worker. // systemMixWeights are the scoring weights used by the daily builds.
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded // JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
// RNG (see scoreAndSortCandidates) — same (user, day) produces same // RNG (see scoreAndSortCandidates) — same (user, day) produces same
// scores within a day, but near-tied candidates reshuffle across days // scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen. // so the playlist doesn't feel frozen.
var systemMixWeights = recommendation.ScoringWeights{ //
// DB-tunable since #1250: the recsettings service pushes the current
// daily_mix profile via SetSystemMixWeights at boot and on every admin
// change (coverart Configure() pattern — no signature threading, live
// effect without restart). The literal here is only the pre-push
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
// which must stay in sync with it.
var (
systemTuningMu sync.RWMutex
systemMixWeights = recommendation.ScoringWeights{
BaseWeight: 1.0, BaseWeight: 1.0,
LikeBoost: 2.0, LikeBoost: 2.0,
RecencyWeight: 1.0, RecencyWeight: 1.0,
@@ -153,6 +204,37 @@ var systemMixWeights = recommendation.ScoringWeights{
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost // [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes. // while passive avoidance (negative) gently demotes.
TasteWeight: 1.5, TasteWeight: 1.5,
}
systemTasteConfig = taste.DefaultConfig()
)
// SetSystemMixWeights installs the current daily_mix scoring weights.
// Called by the recsettings service at boot and on admin updates.
func SetSystemMixWeights(w recommendation.ScoringWeights) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
systemMixWeights = w
}
// SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as
// SetSystemMixWeights.
func SetTasteConfig(c taste.Config) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
systemTasteConfig = c
}
func currentSystemMixWeights() recommendation.ScoringWeights {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return systemMixWeights
}
func currentTasteConfig() taste.Config {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return systemTasteConfig
} }
// forYouHeadN is the number of top-scored tracks that anchor the For-You // forYouHeadN is the number of top-scored tracks that anchor the For-You
@@ -175,6 +257,122 @@ const (
forYouTailN = 50 forYouTailN = 50
) )
// forYouSeedCount is how many of the user's top-5 tracks seed each
// day's build (#1269). A single rotating seed made the mix bipolar
// day-to-day — swinging whole neighborhoods as the rotation moved
// between dissimilar anchors; blending 3 spans neighborhoods within
// one day and smooths the swing across days.
const forYouSeedCount = 3
// headQuotas fixes each seed's share of the For-You head so one
// neighborhood can't monopolize it even when its candidates out-score
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
// it for one. Rounding remainder goes to the primary seed; a thin
// seed's unfilled quota spills best-first in pickQuotaHead.
func headQuotas(numSeeds, headN int) []int {
if numSeeds <= 1 {
return []int{headN}
}
fractions := []float64{0.5, 0.3, 0.2}
if numSeeds == 2 {
fractions = []float64{0.6, 0.4}
}
if numSeeds > len(fractions) {
// Not reachable at forYouSeedCount = 3; even split keeps any
// future seed-count change from silently starving seeds.
fractions = make([]float64, numSeeds)
for i := range fractions {
fractions[i] = 1.0 / float64(numSeeds)
}
}
quotas := make([]int, numSeeds)
assigned := 0
for i := 0; i < numSeeds; i++ {
quotas[i] = int(float64(headN) * fractions[i])
assigned += quotas[i]
}
quotas[0] += headN - assigned
return quotas
}
// pickQuotaHead walks the score-sorted capped pool best-first, taking
// candidates whose originating seed still has head quota; slots a thin
// seed can't fill spill best-first regardless of seed in a second
// pass. A nil seedOf (single-seed path, tests) attributes everything
// to seed 0, which reduces to plain top-headN.
func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds, headN int) []recommendation.Candidate {
quotas := headQuotas(numSeeds, headN)
taken := make([]int, len(quotas))
inHead := make(map[pgtype.UUID]bool, headN)
head := make([]recommendation.Candidate, 0, headN)
for _, c := range capped {
if len(head) >= headN {
break
}
si := seedOf[c.Track.ID]
if si < 0 || si >= len(quotas) {
si = 0
}
if taken[si] >= quotas[si] {
continue
}
taken[si]++
head = append(head, c)
inHead[c.Track.ID] = true
}
for _, c := range capped {
if len(head) >= headN {
break
}
if inHead[c.Track.ID] {
continue
}
head = append(head, c)
inHead[c.Track.ID] = true
}
return head
}
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
// candidate's sampling weight halves every 50 ranks, so the 101st-best
// candidate is far likelier than the 401st-best instead of equal —
// freshness keeps its "you'll probably enjoy this" half.
const forYouTailHalfLifeRanks = 50.0
// tailSampleUniverse quantizes tieBreakHash into a uniform (0,1] draw
// for the weighted sample; 1e6 buckets is plenty of resolution for
// pools of a few hundred.
const tailSampleUniverse uint64 = 1_000_000
// pickWeightedTail samples up to tailN candidates from the rank-
// ordered tail pool with exponentially rank-decaying weights, using
// deterministic Efraimidis-Spirakis keys (u^(1/w), u derived from
// tieBreakHash): same (track, day) → same key, so the sample is stable
// within a day and rotates across days. Replaces the uniform daily
// draw where rank 380 had the same chance as rank 101.
func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN int) []recommendation.Candidate {
if len(tailPool) <= tailN {
return tailPool
}
type keyed struct {
c recommendation.Candidate
key float64
}
keys := make([]keyed, len(tailPool))
for i, c := range tailPool {
u := (float64(tieBreakHash(c.Track.ID, dateStr)%tailSampleUniverse) + 1) /
float64(tailSampleUniverse+1)
w := math.Exp2(-float64(i) / forYouTailHalfLifeRanks)
keys[i] = keyed{c: c, key: math.Pow(u, 1/w)}
}
sort.SliceStable(keys, func(i, j int) bool { return keys[i].key > keys[j].key })
out := make([]recommendation.Candidate, tailN)
for i := 0; i < tailN; i++ {
out[i] = keys[i].c
}
return out
}
// scoreAndSortCandidates scores every candidate with recommendation.Score // scoreAndSortCandidates scores every candidate with recommendation.Score
// and returns a new slice sorted by score DESC (ties broken by // and returns a new slice sorted by score DESC (ties broken by
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is // tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
@@ -199,9 +397,10 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
sort.SliceStable(ordered, func(i, j int) bool { sort.SliceStable(ordered, func(i, j int) bool {
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID) return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
}) })
weights := currentSystemMixWeights()
pairs := make([]scored, len(ordered)) pairs := make([]scored, len(ordered))
for i, c := range ordered { for i, c := range ordered {
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)} pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
} }
sort.SliceStable(pairs, func(i, j int) bool { sort.SliceStable(pairs, func(i, j int) bool {
if pairs[i].score != pairs[j].score { if pairs[i].score != pairs[j].score {
@@ -336,10 +535,11 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
} }
} }
// produceForYou: today's seed from the user's top-5 played tracks // produceForYou: blend candidate pools from up to forYouSeedCount of
// (rotates daily via userIDHash), similarity candidate pool, head+ // the user's top-5 played tracks (rotating daily via pickDailySeeds),
// tail composition. The base seed query failing is fatal; a // then head+tail composition with per-seed head quotas (#1269). The
// candidate-load failure is logged and yields no For-You. // base seed query failing is fatal; a per-seed candidate-load failure
// is logged and that seed just contributes nothing.
func produceForYou( func produceForYou(
ctx context.Context, q *dbq.Queries, logger *slog.Logger, ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time, userID pgtype.UUID, dateStr string, now time.Time,
@@ -348,24 +548,40 @@ func produceForYou(
if err != nil { if err != nil {
return nil, fmt.Errorf("pick for-you seed candidates: %w", err) return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
} }
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr) seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount)
if !forYouSeed.Valid { if len(seeds) == 0 {
return nil, nil return nil, nil
} }
zeroVec := recommendation.SessionVector{Seed: true} zeroVec := recommendation.SessionVector{Seed: true}
// Merge per-seed pools; first-seen wins on dedup, and seedOf
// remembers which seed sourced each track for the head quotas.
var merged []recommendation.Candidate
seedOf := map[pgtype.UUID]int{}
for i, seed := range seeds {
cands, cerr := recommendation.LoadCandidatesFromSimilarity( cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, forYouSeed, ctx, q, userID, seed,
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood 1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
zeroVec, zeroVec,
[]pgtype.UUID{forYouSeed}, seeds,
systemForYouSourceLimits(), systemForYouSourceLimits(),
) )
if cerr != nil { if cerr != nil {
logger.Warn("system playlist: for-you candidates load failed; skipping", logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
"user_id", uuidStringPL(userID), "err", cerr) "user_id", uuidStringPL(userID), "seed", uuidStringPL(seed), "err", cerr)
continue
}
for _, c := range cands {
if _, seen := seedOf[c.Track.ID]; seen {
continue
}
seedOf[c.Track.ID] = i
merged = append(merged, c)
}
}
if len(merged) == 0 {
return nil, nil return nil, nil
} }
tracks := pickHeadAndTail(cands, userID, dateStr, now, forYouHeadN, forYouTailN) tracks := pickHeadAndTail(merged, seedOf, len(seeds), userID, dateStr, now, forYouHeadN, forYouTailN)
if len(tracks) == 0 { if len(tracks) == 0 {
return nil, nil return nil, nil
} }
@@ -373,8 +589,11 @@ func produceForYou(
} }
// produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed // produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
// artists rotate daily-deterministically. The base seed-artist // artists rotate daily-deterministically; the seed query falls back
// query failing is fatal; per-artist failures are logged + skipped. // through widening engagement windows (#1255) and every returned row
// shares the winning tier, stamped onto the built tracks as their
// pick_kind. The base seed-artist query failing is fatal; per-artist
// failures are logged + skipped.
func produceSeedMixes( func produceSeedMixes(
ctx context.Context, q *dbq.Queries, logger *slog.Logger, ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time, userID pgtype.UUID, dateStr string, now time.Time,
@@ -383,6 +602,10 @@ func produceSeedMixes(
if err != nil { if err != nil {
return nil, fmt.Errorf("pick seed artists: %w", err) return nil, fmt.Errorf("pick seed artists: %w", err)
} }
seedTierKind := ""
if len(seedRows) > 0 {
seedTierKind = pickKindForSeedTier(seedRows[0].Tier)
}
seedRowsLocal := make([]seedArtistRow, 0, len(seedRows)) seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
for _, r := range seedRows { for _, r := range seedRows {
seedRowsLocal = append(seedRowsLocal, seedArtistRow{ seedRowsLocal = append(seedRowsLocal, seedArtistRow{
@@ -427,6 +650,9 @@ func produceSeedMixes(
if len(tracks) == 0 { if len(tracks) == 0 {
continue continue
} }
for i := range tracks {
tracks[i].PickKind = seedTierKind
}
out = append(out, builtPlaylist{ out = append(out, builtPlaylist{
Name: fmt.Sprintf("Songs like %s", artistRow.Name), Name: fmt.Sprintf("Songs like %s", artistRow.Name),
Variant: "songs_like_artist", Variant: "songs_like_artist",
@@ -601,20 +827,22 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
capped = capped[:n] capped = capped[:n]
} }
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
out := make([]rankedCandidate, len(capped)) out := make([]rankedCandidate, len(capped))
for i, c := range capped { for i, c := range capped {
out[i] = rankedCandidate{ out[i] = rankedCandidate{
TrackID: c.Track.ID, TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64), Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
} }
} }
return out return out
} }
// pickHeadAndTail picks headN from the score-sorted head plus tailN from // pickHeadAndTail picks headN taste anchors from the score-sorted pool
// positions 2*headN onward (the tail), with the tail sampled // (under per-seed quotas when seedOf/numSeeds describe a multi-seed
// daily-deterministically via tieBreakHash. Caps applied before the // blend, #1269) plus tailN freshness picks sampled rank-weighted from
// head/tail split. Used by For-You only. // positions 2*headN onward. Caps applied before the head/tail split.
// Used by For-You only.
// //
// The "tail" — candidates ranked beyond 2*headN — is still similarity- // The "tail" — candidates ranked beyond 2*headN — is still similarity-
// related (every candidate passed the similarity filter) but isn't among // related (every candidate passed the similarity filter) but isn't among
@@ -624,14 +852,20 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
// Falls back to standard pickTopN behavior when the candidate pool is too // Falls back to standard pickTopN behavior when the candidate pool is too
// small to support a meaningful head/tail split (capped pool <= // small to support a meaningful head/tail split (capped pool <=
// headN+tailN, or no candidates at or beyond position 2*headN). // headN+tailN, or no candidates at or beyond position 2*headN).
func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate { func pickHeadAndTail(
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
) []rankedCandidate {
sorted := scoreAndSortCandidates(cands, userID, dateStr, now) sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted) capped := capCandidatesByAlbumAndArtist(sorted)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
total := headN + tailN total := headN + tailN
if len(capped) <= total { if len(capped) <= total {
// Pool too small for a head/tail split — return up to total entries. // Pool too small for a head/tail split — return up to total entries.
// All marked taste: top-N-by-score IS the taste mechanism; no
// exploration sampling happens on this path.
if len(capped) < total { if len(capped) < total {
total = len(capped) total = len(capped)
} }
@@ -639,43 +873,55 @@ func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateS
for i := 0; i < total; i++ { for i := 0; i < total; i++ {
out[i] = rankedCandidate{ out[i] = rankedCandidate{
TrackID: capped[i].Track.ID, TrackID: capped[i].Track.ID,
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64), Score: recommendation.Score(capped[i].Inputs, weights, now, rng.Float64),
PickKind: pickKindTaste,
} }
} }
return out return out
} }
head := capped[:headN] head := pickQuotaHead(capped, seedOf, numSeeds, headN)
inHead := make(map[pgtype.UUID]bool, len(head))
for _, c := range head {
inHead[c.Track.ID] = true
}
tailStart := 2 * headN tailStart := 2 * headN
if tailStart >= len(capped) { if tailStart >= len(capped) {
tailStart = headN tailStart = headN
} }
// Defensive copy so that sorting the tail pool does not mutate capped. // The tail pool keeps its rank order (position drives the sampling
tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...) // weight); head members are excluded — a quota walk can reach past
// tailStart when a seed's candidates rank deep.
// Sort tail pool by tieBreakHash (daily-deterministic), take tailN. tailPool := make([]recommendation.Candidate, 0, len(capped)-tailStart)
// Sample is stable across requests within a day but varies across days. for _, c := range capped[tailStart:] {
sort.SliceStable(tailPool, func(i, j int) bool { if inHead[c.Track.ID] {
return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr) continue
})
tail := tailPool
if len(tail) > tailN {
tail = tail[:tailN]
} }
tailPool = append(tailPool, c)
}
tail := pickWeightedTail(tailPool, dateStr, tailN)
// Combine: head order preserved (score-sorted), tail in tieBreakHash // Combine: head first (score-sorted under quotas), then the fresh
// order. "First similar, then surprise" reads naturally in playback. // sample. "First similar, then surprise" reads naturally in
// playback. Head entries are the taste picks; tail entries are the
// freshness injection (#1249) — the split the metrics page
// attributes skips to.
combined := make([]recommendation.Candidate, 0, len(head)+len(tail)) combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
combined = append(combined, head...) combined = append(combined, head...)
combined = append(combined, tail...) combined = append(combined, tail...)
out := make([]rankedCandidate, len(combined)) out := make([]rankedCandidate, len(combined))
for i, c := range combined { for i, c := range combined {
kind := pickKindTaste
if i >= len(head) {
kind = pickKindFresh
}
out[i] = rankedCandidate{ out[i] = rankedCandidate{
TrackID: c.Track.ID, TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64), Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
PickKind: kind,
} }
} }
return out return out
@@ -709,9 +955,15 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
} }
for _, t := range tracks { for _, t := range tracks {
var pickKind *string
if t.PickKind != "" {
k := t.PickKind
pickKind = &k
}
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{ if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
PlaylistID: p.ID, PlaylistID: p.ID,
TrackID: t.TrackID, TrackID: t.TrackID,
PickKind: pickKind,
}); err != nil { }); err != nil {
// Track may have been deleted between candidate-load and insert; // Track may have been deleted between candidate-load and insert;
// skip silently rather than failing the whole build. // skip silently rather than failing the whole build.
+64 -13
View File
@@ -40,6 +40,17 @@ import (
// Discover so shuffle-on-play has a varied pool within a day. // Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100 const discoveryMixLen = 100
// Minimum viable mix sizes (issue #1246): below the floor the variant
// is withheld entirely so Home renders its "listen more to unlock"
// placeholder — a playlist with a couple of songs reads as a build
// bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get
// a lower floor because one legitimate new album (~5+ tracks) is a
// useful mix on its own; the scattered mixes need more to feel real.
const (
discoveryMixMinLen = 15
discoveryMixMinLenAlbum = 5
)
// discoveryMixSpec describes one discovery mix. The unified producer // discoveryMixSpec describes one discovery mix. The unified producer
// reads the spec and runs a single code path for all variants. // reads the spec and runs a single code path for all variants.
type discoveryMixSpec struct { type discoveryMixSpec struct {
@@ -55,6 +66,11 @@ type discoveryMixSpec struct {
// or when day-over-day stability is the intended UX (NewForYou). // or when day-over-day stability is the intended UX (NewForYou).
dailyRotate bool dailyRotate bool
// minLen is the minimum viable mix size: a finished pool below it
// is withheld (no playlist row) so the client renders the locked
// placeholder instead of a mix that looks built-wrong (#1246).
minLen int
// fetch returns the raw ranked rows. dateStr is supplied for // fetch returns the raw ranked rows. dateStr is supplied for
// queries that accept it (passed as the second positional arg // queries that accept it (passed as the second positional arg
// historically); queries that don't accept it ignore the param. // historically); queries that don't accept it ignore the param.
@@ -79,25 +95,49 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
if spec.dailyRotate { if spec.dailyRotate {
pool = rotateForDay(pool, userID, dateStr) pool = rotateForDay(pool, userID, dateStr)
} }
return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil tracks := finishMix(pool, spec.diversify)
if len(tracks) < spec.minLen {
logger.Info("system playlist: "+spec.variant+" below minimum viable size; withholding",
"user_id", uuidStringPL(userID), "pool", len(tracks), "min", spec.minLen)
return nil, nil
}
return emit(spec.name, spec.variant, tracks), nil
} }
} }
// rotateForDay rotates pool left by a daily-deterministic offset so // rotateForDay rotates the pool left by a daily-deterministic offset
// each day's downstream truncate-to-N surfaces a different slice of // so each day's downstream truncate-to-N surfaces a different slice
// the pool while contiguous-block ordering inside the slice is // while contiguous-block ordering inside the slice is preserved.
// preserved. Empty / single-element pools pass through unchanged. //
// Rotation happens WITHIN each contiguous same-pick-kind block
// (#1267): tiered mixes arrive tier-ordered, and a whole-pool
// rotation would hoist tier-3 filler above tier-1's exact fits.
// Untiered pools are a single block, which reduces to the original
// whole-pool rotation (same seed, same first draw). Empty /
// single-element pools pass through unchanged.
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack { func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
n := len(pool) n := len(pool)
if n <= 1 { if n <= 1 {
return pool return pool
} }
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
offset := rng.Intn(n) out := make([]discoverTrack, 0, n)
rotated := make([]discoverTrack, 0, n) for start := 0; start < n; {
rotated = append(rotated, pool[offset:]...) end := start + 1
rotated = append(rotated, pool[:offset]...) for end < n && pool[end].PickKind == pool[start].PickKind {
return rotated end++
}
block := pool[start:end]
if len(block) > 1 {
offset := rng.Intn(len(block))
out = append(out, block[offset:]...)
out = append(out, block[:offset]...)
} else {
out = append(out, block...)
}
start = end
}
return out
} }
// discoveryMixSpecs is the concrete spec list used by the registry in // discoveryMixSpecs is the concrete spec list used by the registry in
@@ -109,6 +149,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "Deep Cuts", variant: "deep_cuts", name: "Deep Cuts", variant: "deep_cuts",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2) diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
minLen: discoveryMixMinLen,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{ rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
UserID: uid, Column2: ds, UserID: uid, Column2: ds,
@@ -126,6 +167,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "Rediscover", variant: "rediscover", name: "Rediscover", variant: "rediscover",
diversify: true, dailyRotate: true, // SQL has no date arg diversify: true, dailyRotate: true, // SQL has no date arg
minLen: discoveryMixMinLen,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListRediscoverTracks(ctx, uid) rows, err := q.ListRediscoverTracks(ctx, uid)
if err != nil { if err != nil {
@@ -141,6 +183,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "New for you", variant: "new_for_you", name: "New for you", variant: "new_for_you",
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
minLen: discoveryMixMinLenAlbum,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListNewForYouTracks(ctx, uid) rows, err := q.ListNewForYouTracks(ctx, uid)
if err != nil { if err != nil {
@@ -148,7 +191,10 @@ var discoveryMixSpecs = []discoveryMixSpec{
} }
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindForMixTier(r.Tier),
}
} }
return out, nil return out, nil
}, },
@@ -156,6 +202,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "On this day", variant: "on_this_day", name: "On this day", variant: "on_this_day",
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2) diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
minLen: discoveryMixMinLen,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{ rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
UserID: uid, Column2: ds, UserID: uid, Column2: ds,
@@ -173,6 +220,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
{ {
name: "First listens", variant: "first_listens", name: "First listens", variant: "first_listens",
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
minLen: discoveryMixMinLenAlbum,
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) { fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
rows, err := q.ListFirstListensTracks(ctx, uid) rows, err := q.ListFirstListensTracks(ctx, uid)
if err != nil { if err != nil {
@@ -180,7 +228,10 @@ var discoveryMixSpecs = []discoveryMixSpec{
} }
out := make([]discoverTrack, len(rows)) out := make([]discoverTrack, len(rows))
for i, r := range rows { for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindForMixTier(r.Tier),
}
} }
return out, nil return out, nil
}, },
@@ -217,7 +268,7 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
} }
tracks := make([]rankedCandidate, len(pool)) tracks := make([]rankedCandidate, len(pool))
for i, t := range pool { for i, t := range pool {
tracks[i] = rankedCandidate{TrackID: t.ID} tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind}
} }
return tracks return tracks
} }
+342
View File
@@ -0,0 +1,342 @@
package playlists_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// seedTrackForArtist creates another track under an existing artist +
// album pair. seedTrack's mbid-less upsert never dedupes artists, and
// Deep Cuts' affinity rule needs several tracks sharing one artist_id.
func seedTrackForArtist(t *testing.T, pool *pgxpool.Pool, title string, albumID, artistID pgtype.UUID) dbq.Track {
t.Helper()
track, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: albumID, ArtistID: artistID,
DurationMs: 1000,
FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("%s.mp3", title)),
FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track for artist: %v", err)
}
return track
}
// seedSourcedPlayEvent is seedPlayEvent plus a source tag — needed by
// the Rediscover cooldown, which keys off rediscover-sourced skips.
func seedSourcedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool, source string) {
t.Helper()
_, err := pool.Exec(context.Background(), `
WITH s AS (
INSERT INTO play_sessions (user_id, started_at, last_event_at)
VALUES ($1, $3, $3)
RETURNING id
)
INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped, source)
SELECT $1, $2, s.id, $3, $4, $5 FROM s
`, userID, trackID, startedAt, wasSkipped, source)
if err != nil {
t.Fatalf("seed sourced play_event: %v", err)
}
}
// idSet collapses candidate rows into a lookup by track id.
func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool {
out := make(map[pgtype.UUID]bool, len(rows))
for _, r := range rows {
out[id(r)] = true
}
return out
}
// seedPlayWithDuration inserts a completed play with an explicit
// duration_played_ms — the "attempted >= 30s" queries key off it.
func seedPlayWithDuration(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, durationMs int32) {
t.Helper()
_, err := pool.Exec(context.Background(), `
WITH s AS (
INSERT INTO play_sessions (user_id, started_at, last_event_at)
VALUES ($1, $3, $3)
RETURNING id
)
INSERT INTO play_events (user_id, track_id, session_id, started_at, duration_played_ms)
SELECT $1, $2, s.id, $3, $4 FROM s
`, userID, trackID, startedAt, durationMs)
if err != nil {
t.Fatalf("seed play with duration: %v", err)
}
}
// seedAlbumForArtist creates a fresh album under an existing artist —
// the tiered album queries need several albums sharing one artist_id.
func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, title string, artistID pgtype.UUID) pgtype.UUID {
t.Helper()
al, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: title, SortTitle: title, ArtistID: artistID,
})
if err != nil {
t.Fatalf("seed album for artist: %v", err)
}
return al.ID
}
func backdateAlbum(t *testing.T, pool *pgxpool.Pool, albumID pgtype.UUID, to time.Time) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`UPDATE albums SET created_at = $2 WHERE id = $1`, albumID, to); err != nil {
t.Fatalf("backdate album: %v", err)
}
}
func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`,
userID, artistID); err != nil {
t.Fatalf("like artist: %v", err)
}
}
func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
// #1257: eligibility used to count only unskipped plays, so a track
// skipped repeatedly (and never finished) read as "barely heard".
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "dc1")
now := time.Now().UTC()
// Affinity artist: 5 non-skip plays on the heavy track.
heavy := seedTrack(t, pool, "dc1-heavy", "dc1-artist")
for p := 0; p < 5; p++ {
seedPlayEvent(t, pool, u.ID, heavy.ID, now.Add(-time.Duration(p+1)*time.Hour), false)
}
// Actively rejected: two skips, zero completed plays.
rejected := seedTrackForArtist(t, pool, "dc1-rejected", heavy.AlbumID, heavy.ArtistID)
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-2*time.Hour), true)
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-3*time.Hour), true)
// Genuinely unexplored.
unheard := seedTrackForArtist(t, pool, "dc1-unheard", heavy.AlbumID, heavy.ArtistID)
// One accidental skip stays below the threshold.
oneSkip := seedTrackForArtist(t, pool, "dc1-oneskip", heavy.AlbumID, heavy.ArtistID)
seedPlayEvent(t, pool, u.ID, oneSkip.ID, now.Add(-4*time.Hour), true)
rows, err := q.ListDeepCutsTracks(context.Background(), dbq.ListDeepCutsTracksParams{
UserID: u.ID, Column2: "2026-07-03",
})
if err != nil {
t.Fatalf("ListDeepCutsTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListDeepCutsTracksRow) pgtype.UUID { return r.ID })
if got[rejected.ID] {
t.Error("twice-skipped track qualified as a deep cut; skips must disqualify")
}
if !got[unheard.ID] {
t.Error("unexplored track missing from deep cuts")
}
if !got[oneSkip.ID] {
t.Error("single accidental skip must not banish a track")
}
if got[heavy.ID] {
t.Error("heavily-played track must not be a deep cut")
}
}
func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
// #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359
// days apart. The build date parameter anchors "today" so the wrap
// is testable at any run date.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "otd1")
// Played Dec 28, 2025 — 6 circular days from the Jan 3 build date.
winter := seedTrack(t, pool, "otd1-winter", "otd1-artist")
seedPlayEvent(t, pool, u.ID, winter.ID,
time.Date(2025, 12, 28, 12, 0, 0, 0, time.UTC), false)
// Played mid-March — far outside the ±10-day window.
spring := seedTrack(t, pool, "otd1-spring", "otd1-artist")
seedPlayEvent(t, pool, u.ID, spring.ID,
time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), false)
rows, err := q.ListOnThisDayTracks(context.Background(), dbq.ListOnThisDayTracksParams{
UserID: u.ID, Column2: "2026-01-03",
})
if err != nil {
t.Fatalf("ListOnThisDayTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListOnThisDayTracksRow) pgtype.UUID { return r.ID })
if !got[winter.ID] {
t.Error("play 6 calendar days across the year boundary must qualify (circular DOY)")
}
if got[spring.ID] {
t.Error("play ~70 days away must not qualify")
}
}
func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
// #1267: tiered eligibility with consume-on-play. Tier 1 = fresh
// affinity albums; tier 2 = affinity albums from the wider 90-day
// window; tier 3 = fresh additions without affinity; any attempted
// (>=30s) track consumes the album out of the mix entirely.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "nfy1")
now := time.Now().UTC()
// Affinity artist via a like; anchor track establishes the artist.
anchor := seedTrack(t, pool, "nfy1-anchor", "nfy1-artistX")
likeArtist(t, pool, u.ID, anchor.ArtistID)
backdateAlbum(t, pool, anchor.AlbumID, now.Add(-200*24*time.Hour)) // out of every window
// Fresh affinity album → tier 1.
freshAlbum := seedAlbumForArtist(t, pool, "nfy1-fresh", anchor.ArtistID)
freshTrack := seedTrackForArtist(t, pool, "nfy1-fresh-1", freshAlbum, anchor.ArtistID)
// Affinity album added 60 days ago → tier 2.
olderAlbum := seedAlbumForArtist(t, pool, "nfy1-older", anchor.ArtistID)
olderTrack := seedTrackForArtist(t, pool, "nfy1-older-1", olderAlbum, anchor.ArtistID)
backdateAlbum(t, pool, olderAlbum, now.Add(-60*24*time.Hour))
// Fresh album by an unrelated artist → tier 3.
otherTrack := seedTrack(t, pool, "nfy1-other-1", "nfy1-artistY")
// Fresh affinity album with one attempted track → consumed, absent.
consumedAlbum := seedAlbumForArtist(t, pool, "nfy1-consumed", anchor.ArtistID)
consumedTrack := seedTrackForArtist(t, pool, "nfy1-consumed-1", consumedAlbum, anchor.ArtistID)
seedPlayWithDuration(t, pool, u.ID, consumedTrack.ID, now.Add(-time.Hour), 35_000)
// Affinity album added 120 days ago → outside every window, absent.
ancientAlbum := seedAlbumForArtist(t, pool, "nfy1-ancient", anchor.ArtistID)
ancientTrack := seedTrackForArtist(t, pool, "nfy1-ancient-1", ancientAlbum, anchor.ArtistID)
backdateAlbum(t, pool, ancientAlbum, now.Add(-120*24*time.Hour))
rows, err := q.ListNewForYouTracks(context.Background(), u.ID)
if err != nil {
t.Fatalf("ListNewForYouTracks: %v", err)
}
tiers := map[pgtype.UUID]int32{}
for _, r := range rows {
tiers[r.ID] = r.Tier
}
if got := tiers[freshTrack.ID]; got != 1 {
t.Errorf("fresh affinity album tier = %d, want 1", got)
}
if got := tiers[olderTrack.ID]; got != 2 {
t.Errorf("60-day affinity album tier = %d, want 2", got)
}
if got := tiers[otherTrack.ID]; got != 3 {
t.Errorf("fresh non-affinity album tier = %d, want 3", got)
}
if _, in := tiers[consumedTrack.ID]; in {
t.Error("album with an attempted track must be consumed out of the mix")
}
if _, in := tiers[ancientTrack.ID]; in {
t.Error("album added 120 days ago must be outside every window")
}
}
func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
// #1268: "attempted" is track-level at >=30s. A 2-second brush no
// longer disqualifies an album; barely-attempted albums step back
// to tier 2 with the attempted tracks themselves excluded.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "fl1")
now := time.Now().UTC()
// Never touched → tier 1.
untouched := seedTrack(t, pool, "fl1-untouched-1", "fl1-artistA")
// One 2-second accidental brush → still tier 1 (the headline bug).
brushed := seedTrack(t, pool, "fl1-brushed-1", "fl1-artistB")
seedPlayWithDuration(t, pool, u.ID, brushed.ID, now.Add(-time.Hour), 2_000)
// 4-track album with one attempted (25%) → tier 2, attempted track
// excluded, siblings present.
barely1 := seedTrack(t, pool, "fl1-barely-1", "fl1-artistC")
barely2 := seedTrackForArtist(t, pool, "fl1-barely-2", barely1.AlbumID, barely1.ArtistID)
barely3 := seedTrackForArtist(t, pool, "fl1-barely-3", barely1.AlbumID, barely1.ArtistID)
barely4 := seedTrackForArtist(t, pool, "fl1-barely-4", barely1.AlbumID, barely1.ArtistID)
seedPlayWithDuration(t, pool, u.ID, barely1.ID, now.Add(-time.Hour), 40_000)
// 2-track album with one attempted (50%) → past the brush
// threshold, absent entirely.
explored1 := seedTrack(t, pool, "fl1-explored-1", "fl1-artistD")
explored2 := seedTrackForArtist(t, pool, "fl1-explored-2", explored1.AlbumID, explored1.ArtistID)
seedPlayWithDuration(t, pool, u.ID, explored1.ID, now.Add(-time.Hour), 40_000)
rows, err := q.ListFirstListensTracks(context.Background(), u.ID)
if err != nil {
t.Fatalf("ListFirstListensTracks: %v", err)
}
tiers := map[pgtype.UUID]int32{}
for _, r := range rows {
tiers[r.ID] = r.Tier
}
if got := tiers[untouched.ID]; got != 1 {
t.Errorf("untouched album tier = %d, want 1", got)
}
if got := tiers[brushed.ID]; got != 1 {
t.Errorf("2-second brush must not disqualify: tier = %d, want 1", got)
}
if _, in := tiers[barely1.ID]; in {
t.Error("the attempted track itself is not a first listen")
}
for _, tr := range []dbq.Track{barely2, barely3, barely4} {
if got := tiers[tr.ID]; got != 2 {
t.Errorf("sibling of attempted track tier = %d, want 2", got)
}
}
if _, in := tiers[explored1.ID]; in {
t.Error("half-attempted album must be excluded (attempted track)")
}
if _, in := tiers[explored2.ID]; in {
t.Error("half-attempted album must be excluded (sibling)")
}
}
func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) {
// #1258: a skip on a rediscover-sourced play is a declined
// invitation — the track sits out ~90 days instead of re-qualifying
// the next day. Skips from other surfaces don't cool down, and the
// cooldown expires.
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "rd1")
now := time.Now().UTC()
eligible := func(name string, playsAge time.Duration) dbq.Track {
tk := seedTrack(t, pool, name, name+"-artist")
for p := 0; p < 3; p++ {
seedPlayEvent(t, pool, u.ID, tk.ID,
now.Add(-playsAge-time.Duration(p)*time.Hour), false)
}
return tk
}
// Declined 5 days ago from Rediscover itself → cooling down.
declined := eligible("rd1-declined", 60*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, declined.ID, now.Add(-5*24*time.Hour), true, "rediscover")
// Skipped 5 days ago, but from a different surface → still eligible.
otherSkip := eligible("rd1-otherskip", 60*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, otherSkip.ID, now.Add(-5*24*time.Hour), true, "radio")
// Declined 100 days ago → cooldown expired, eligible again.
lapsed := eligible("rd1-lapsed", 120*24*time.Hour)
seedSourcedPlayEvent(t, pool, u.ID, lapsed.ID, now.Add(-100*24*time.Hour), true, "rediscover")
rows, err := q.ListRediscoverTracks(context.Background(), u.ID)
if err != nil {
t.Fatalf("ListRediscoverTracks: %v", err)
}
got := idSet(rows, func(r dbq.ListRediscoverTracksRow) pgtype.UUID { return r.ID })
if got[declined.ID] {
t.Error("track declined from rediscover 5 days ago must be cooling down")
}
if !got[otherSkip.ID] {
t.Error("skip from a non-rediscover surface must not cool the track down")
}
if !got[lapsed.ID] {
t.Error("cooldown must expire after ~90 days")
}
}
+104
View File
@@ -0,0 +1,104 @@
package playlists
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func mixTrack(b byte, kind string) discoverTrack {
return discoverTrack{ID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, PickKind: kind}
}
func idsOf(pool []discoverTrack) []byte {
out := make([]byte, len(pool))
for i, t := range pool {
out[i] = t.ID.Bytes[0]
}
return out
}
func TestRotateForDay_RotatesWithinTierBlocks(t *testing.T) {
// Tiered pools arrive tier-ordered; rotation must vary the daily
// slice WITHIN each tier without hoisting tier-3 filler above
// tier-1's exact fits (#1267).
pool := []discoverTrack{
mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1),
mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2),
mixTrack(6, pickKindTier3),
}
u := pgtype.UUID{Bytes: [16]byte{42}, Valid: true}
got := rotateForDay(pool, u, "2026-07-03")
if len(got) != len(pool) {
t.Fatalf("len = %d, want %d", len(got), len(pool))
}
wantKinds := []string{
pickKindTier1, pickKindTier1, pickKindTier1,
pickKindTier2, pickKindTier2, pickKindTier3,
}
for i, k := range wantKinds {
if got[i].PickKind != k {
t.Fatalf("pos %d kind = %q, want %q (tier order broken: %v)",
i, got[i].PickKind, k, idsOf(got))
}
}
// Each block is a rotation of its input: contiguity check for the
// 3-element tier-1 block (successor relation preserved cyclically).
wantOrder := map[byte]byte{1: 2, 2: 3, 3: 1}
for i := 0; i < 2; i++ {
if got[i+1].ID.Bytes[0] != wantOrder[got[i].ID.Bytes[0]] {
t.Errorf("tier1 block is not a rotation: %v", idsOf(got[:3]))
break
}
}
// Determinism within a day.
again := rotateForDay(pool, u, "2026-07-03")
for i := range got {
if got[i].ID != again[i].ID {
t.Fatal("rotation must be deterministic for the same (user, day)")
}
}
}
func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) {
// Untiered pools (all PickKind "") are one block — the original
// whole-pool rotation semantics.
pool := []discoverTrack{mixTrack(1, ""), mixTrack(2, ""), mixTrack(3, ""), mixTrack(4, "")}
got := rotateForDay(pool, pgtype.UUID{Bytes: [16]byte{7}, Valid: true}, "2026-07-03")
if len(got) != 4 {
t.Fatalf("len = %d, want 4", len(got))
}
wantNext := map[byte]byte{1: 2, 2: 3, 3: 4, 4: 1}
for i := 0; i < 3; i++ {
cur, next := got[i].ID.Bytes[0], got[i+1].ID.Bytes[0]
if wantNext[cur] != next {
t.Errorf("not a rotation of the input: %v", idsOf(got))
break
}
}
}
func TestFinishMix_PropagatesPickKind(t *testing.T) {
// Tier stamps ride discoverTrack through diversify/truncate into
// the rankedCandidates that insertSystemPlaylist persists (#1267).
pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)}
got := finishMix(pool, false)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].PickKind != pickKindTier1 || got[1].PickKind != pickKindTier2 {
t.Errorf("pick kinds = %q, %q — want tier1, tier2", got[0].PickKind, got[1].PickKind)
}
}
func TestPickKindForMixTier(t *testing.T) {
cases := []struct {
tier int32
want string
}{{1, pickKindTier1}, {2, pickKindTier2}, {3, pickKindTier3}, {9, pickKindTier3}}
for _, c := range cases {
if got := pickKindForMixTier(c.tier); got != c.want {
t.Errorf("pickKindForMixTier(%d) = %q, want %q", c.tier, got, c.want)
}
}
}
+53
View File
@@ -135,6 +135,59 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
} }
} }
func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) {
// #1255: the seed-artist query's old hard 7-day window emptied the
// pool after a quiet week, and the atomic-replace build then deleted
// every "Songs like X" mix. With the tiered fallback, plays that are
// ~20 days old (outside 7d, inside 30d) must still seed the mixes —
// and the built tracks carry the tier2 stamp so metrics can compare
// stale-seeded mixes against fresh ones.
pool := newPool(t)
logger := discardLogger()
u := seedUser(t, pool, "stale1")
old := time.Now().UTC().Add(-20 * 24 * time.Hour)
for a := 0; a < 4; a++ {
for k := 0; k < 3; k++ {
tk := seedTrack(t, pool,
"stale1-track-"+string(rune('A'+a))+string(rune('0'+k)),
"stale1-artist-"+string(rune('A'+a)))
for p := 0; p < 3; p++ {
seedPlayEvent(t, pool, u.ID, tk.ID,
old.Add(-time.Duration(a*10+k+p)*time.Hour), false)
}
}
}
if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
t.Fatalf("build: %v", err)
}
rows, err := pool.Query(context.Background(), `
SELECT DISTINCT COALESCE(pt.pick_kind, '<null>')
FROM playlist_tracks pt
JOIN playlists p ON p.id = pt.playlist_id
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
`, u.ID)
if err != nil {
t.Fatalf("query pick_kinds: %v", err)
}
defer rows.Close()
var kinds []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
t.Fatalf("scan: %v", err)
}
kinds = append(kinds, k)
}
if len(kinds) == 0 {
t.Fatal("expected songs_like_artist tracks from the 30-day fallback tier; got none (the vanish bug)")
}
if len(kinds) != 1 || kinds[0] != "tier2" {
t.Errorf("pick_kinds = %v, want exactly [tier2] (30-day fallback seeds)", kinds)
}
}
func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) { func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) {
pool := newPool(t) pool := newPool(t)
logger := discardLogger() logger := discardLogger()
+8 -3
View File
@@ -83,10 +83,14 @@ func buildYouMightLike(
"user_id", uuidStringPL(userID), "err", err) "user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false} return youMightLikeResult{built: false}
} }
seed := pickForYouSeedForDay(seeds, userID, dateStr) // One rotating seed is right here (unlike For-You's multi-seed
if !seed.Valid { // blend, #1269): the row is a short shelf, not a mix, and a single
// neighborhood per day keeps it coherent.
daily := pickDailySeeds(seeds, userID, dateStr, 1)
if len(daily) == 0 {
return youMightLikeResult{built: true} return youMightLikeResult{built: true}
} }
seed := daily[0]
zeroVec := recommendation.SessionVector{Seed: true} zeroVec := recommendation.SessionVector{Seed: true}
// You-might-like surfaces in-library artists the user does NOT actively // You-might-like surfaces in-library artists the user does NOT actively
@@ -123,11 +127,12 @@ func rollUpCandidates(
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
) (albumIDs, artistIDs []pgtype.UUID) { ) (albumIDs, artistIDs []pgtype.UUID) {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr)))) rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
albumScores := map[pgtype.UUID][]float64{} albumScores := map[pgtype.UUID][]float64{}
artistScores := map[pgtype.UUID][]float64{} artistScores := map[pgtype.UUID][]float64{}
albumArtist := map[pgtype.UUID]pgtype.UUID{} albumArtist := map[pgtype.UUID]pgtype.UUID{}
for _, c := range cands { for _, c := range cands {
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64) s := recommendation.Score(c.Inputs, weights, now, rng.Float64)
if c.Track.AlbumID.Valid { if c.Track.AlbumID.Valid {
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s) albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
albumArtist[c.Track.AlbumID] = c.Track.ArtistID albumArtist[c.Track.AlbumID] = c.Track.ArtistID
+178
View File
@@ -0,0 +1,178 @@
// patch.go — field-name mapping + validation for the tuning patches.
// Wire field names are the snake_case column names; the admin API and
// web card use them verbatim.
package recsettings
import (
"errors"
"fmt"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
var (
ErrUnknownScope = errors.New("unknown tuning scope")
ErrUnknownField = errors.New("unknown tuning field")
ErrOutOfRange = errors.New("tuning value out of range")
)
// weightBound caps every scoring weight's magnitude. The scoring
// terms are all in [-1, 1] before weighting, so ±10 is far past any
// useful setting — the bound exists to catch typos (e.g. 100 for
// 1.00), not to constrain exploration.
const weightBound = 10.0
// weightField describes one patchable ScoringWeights field.
type weightField struct {
get func(recommendation.ScoringWeights) float64
set func(*recommendation.ScoringWeights, float64)
// nonNegative marks fields where a negative value is meaningless
// (a negative jitter magnitude or skip penalty inverts intent in a
// way the score formula already expresses through its sign).
nonNegative bool
}
var weightFields = map[string]weightField{
"base_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.BaseWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.BaseWeight = v },
},
"like_boost": {
get: func(w recommendation.ScoringWeights) float64 { return w.LikeBoost },
set: func(w *recommendation.ScoringWeights, v float64) { w.LikeBoost = v },
},
"recency_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.RecencyWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.RecencyWeight = v },
},
"skip_penalty": {
get: func(w recommendation.ScoringWeights) float64 { return w.SkipPenalty },
set: func(w *recommendation.ScoringWeights, v float64) { w.SkipPenalty = v },
nonNegative: true,
},
"jitter_magnitude": {
get: func(w recommendation.ScoringWeights) float64 { return w.JitterMagnitude },
set: func(w *recommendation.ScoringWeights, v float64) { w.JitterMagnitude = v },
nonNegative: true,
},
"context_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.ContextWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextWeight = v },
},
"similarity_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.SimilarityWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.SimilarityWeight = v },
},
"taste_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
},
}
// applyWeightPatch validates and applies a partial update, returning
// the new weights and the list of actual changes (values equal to the
// current setting are dropped, so a re-submitted form is a no-op).
func applyWeightPatch(
current recommendation.ScoringWeights, patch map[string]float64,
) (recommendation.ScoringWeights, []fieldChange, error) {
next := current
var changes []fieldChange
for field, v := range patch {
f, ok := weightFields[field]
if !ok {
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
if v < -weightBound || v > weightBound {
return current, nil, fmt.Errorf("%w: %s = %v (|v| must be <= %v)",
ErrOutOfRange, field, v, weightBound)
}
if f.nonNegative && v < 0 {
return current, nil, fmt.Errorf("%w: %s = %v (must be >= 0)",
ErrOutOfRange, field, v)
}
old := f.get(next)
if old == v {
continue
}
f.set(&next, v)
changes = append(changes, fieldChange{Field: field, Old: old, New: v})
}
return next, changes, nil
}
// Taste tuning bounds. The half-life window is generous — from "taste
// is last week" to "taste is a decade" — and the curve points must
// stay ordered inside [0, 1] or the engagement ramps degenerate.
const (
tasteHalfLifeMin = 1.0
tasteHalfLifeMax = 3650.0
)
// applyTastePatch validates and applies a partial taste update. The
// curve-ordering invariant (hard_skip < neutral < full) is checked on
// the PATCHED result, so a patch may move several points at once.
func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning, []fieldChange, error) {
next := current
var changes []fieldChange
for field, v := range patch {
var target *float64
switch field {
case "half_life_days":
if v < tasteHalfLifeMin || v > tasteHalfLifeMax {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
ErrOutOfRange, field, v, tasteHalfLifeMin, tasteHalfLifeMax)
}
target = &next.HalfLifeDays
case "engagement_hard_skip":
target = &next.EngagementHardSkip
case "engagement_neutral":
target = &next.EngagementNeutral
case "engagement_full":
target = &next.EngagementFull
default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
if field != "half_life_days" && (v < 0 || v > 1) {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, 1])",
ErrOutOfRange, field, v)
}
if *target == v {
continue
}
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
*target = v
}
if !(next.EngagementHardSkip < next.EngagementNeutral &&
next.EngagementNeutral < next.EngagementFull) {
return current, nil, fmt.Errorf(
"%w: engagement curve must satisfy hard_skip < neutral < full (got %v < %v < %v)",
ErrOutOfRange, next.EngagementHardSkip, next.EngagementNeutral, next.EngagementFull)
}
return next, changes, nil
}
// diffWeights returns per-field changes from a to b (empty when equal).
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
var out []fieldChange
for field, f := range weightFields {
if f.get(a) != f.get(b) {
out = append(out, fieldChange{Field: field, Old: f.get(a), New: f.get(b)})
}
}
return out
}
// diffTaste returns per-field changes from a to b (empty when equal).
func diffTaste(a, b TasteTuning) []fieldChange {
var out []fieldChange
add := func(field string, oldV, newV float64) {
if oldV != newV {
out = append(out, fieldChange{Field: field, Old: oldV, New: newV})
}
}
add("half_life_days", a.HalfLifeDays, b.HalfLifeDays)
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
add("engagement_full", a.EngagementFull, b.EngagementFull)
return out
}
+374
View File
@@ -0,0 +1,374 @@
// Package recsettings is the DB-backed home of the recommendation
// tuning knobs (#1250): the two scoring-weight profiles (radio /
// daily_mix) and the taste-profile build settings (engagement
// half-life + completion curve). It follows the coverart
// SettingsService pattern — boot reconcile seeds shipped defaults for
// missing rows, values are cached under a RWMutex, and every change
// takes effect live (the daily_mix profile + taste config are pushed
// into package playlists; radio reads the cache per request).
//
// Framing (decision #1247): this is the defaults-discovery lab. The
// operator turns knobs to FIND good values; found-good values get
// baked back into the Shipped* functions below as new shipped
// defaults. End users and other operators should never need the card.
package recsettings
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)
// Scope names — the three tunable groups. radio + daily_mix are weight
// profiles; taste is the profile-build settings singleton.
const (
ScopeRadio = "radio"
ScopeDailyMix = "daily_mix"
ScopeTaste = "taste"
)
// TasteTuning is the tunable subset of taste.Config: the engagement
// half-life and the completion→engagement curve points.
type TasteTuning struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
}
// ShippedRadioWeights are the shipped radio-profile defaults (moved
// here from config.RecommendationConfig — YAML is bootstrap-only,
// rule: config in UI). Radio is seed-directed (the user picked a
// direction), so taste is a lighter nudge than in the daily mixes.
func ShippedRadioWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
TasteWeight: 1.0,
}
}
// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
// Must stay in sync with the pre-push literal in playlists/system.go.
func ShippedDailyMixWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
TasteWeight: 1.5,
}
}
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
func ShippedTasteTuning() TasteTuning {
d := taste.DefaultConfig()
return TasteTuning{
HalfLifeDays: d.HalfLifeDays,
EngagementHardSkip: d.Engagement.HardSkip,
EngagementNeutral: d.Engagement.NeutralCompletion,
EngagementFull: d.Engagement.FullCompletion,
}
}
// Service caches the tuning values and owns their DB persistence +
// audit trail. Construct with New at boot.
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
mu sync.RWMutex
profiles map[string]recommendation.ScoringWeights
taste TasteTuning
}
// New boots the service: seeds shipped defaults for missing rows,
// loads the current values, and pushes the daily_mix weights + taste
// config into package playlists so the daily builds pick them up.
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
s := &Service{
pool: pool,
logger: logger,
profiles: map[string]recommendation.ScoringWeights{},
}
if err := s.reconcile(ctx); err != nil {
return nil, fmt.Errorf("recsettings boot: %w", err)
}
return s, nil
}
// reconcile seeds missing rows with shipped defaults, reads everything
// back into the cache, and pushes the playlist-side values.
func (s *Service) reconcile(ctx context.Context) error {
q := dbq.New(s.pool)
for profile, w := range map[string]recommendation.ScoringWeights{
ScopeRadio: ShippedRadioWeights(),
ScopeDailyMix: ShippedDailyMixWeights(),
} {
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
return fmt.Errorf("seed profile %q: %w", profile, err)
}
}
st := ShippedTasteTuning()
if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
HalfLifeDays: st.HalfLifeDays,
EngagementHardSkip: st.EngagementHardSkip,
EngagementNeutral: st.EngagementNeutral,
EngagementFull: st.EngagementFull,
}); err != nil {
return fmt.Errorf("seed taste tuning: %w", err)
}
rows, err := q.ListWeightProfiles(ctx)
if err != nil {
return fmt.Errorf("list weight profiles: %w", err)
}
tt, err := q.GetTasteTuning(ctx)
if err != nil {
return fmt.Errorf("get taste tuning: %w", err)
}
s.mu.Lock()
s.profiles = map[string]recommendation.ScoringWeights{}
for _, r := range rows {
s.profiles[r.Profile] = weightsFromRow(r)
}
s.taste = TasteTuning{
HalfLifeDays: tt.HalfLifeDays,
EngagementHardSkip: tt.EngagementHardSkip,
EngagementNeutral: tt.EngagementNeutral,
EngagementFull: tt.EngagementFull,
}
s.mu.Unlock()
s.push()
return nil
}
// push installs the daily-build values into package playlists (the
// coverart Configure() pattern). Radio needs no push — its handler
// reads Weights(ScopeRadio) per request.
func (s *Service) push() {
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
playlists.SetTasteConfig(s.TasteConfig())
}
// Weights returns the cached weights for a profile scope. Unknown
// scopes return the shipped radio defaults (defensive; callers pass
// the Scope* constants).
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
s.mu.RLock()
defer s.mu.RUnlock()
if w, ok := s.profiles[profile]; ok {
return w
}
return ShippedRadioWeights()
}
// Taste returns the cached taste-tuning values.
func (s *Service) Taste() TasteTuning {
s.mu.RLock()
defer s.mu.RUnlock()
return s.taste
}
// TasteConfig assembles the full taste.Config the profile builder
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
// plus the tuned half-life and curve. WindowDays scales with the
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
// window is a query-cost bound, not an independent knob.
func (s *Service) TasteConfig() taste.Config {
t := s.Taste()
cfg := taste.DefaultConfig()
cfg.HalfLifeDays = t.HalfLifeDays
cfg.WindowDays = t.HalfLifeDays * 3.6
cfg.Engagement = taste.EngagementParams{
HardSkip: t.EngagementHardSkip,
NeutralCompletion: t.EngagementNeutral,
FullCompletion: t.EngagementFull,
}
return cfg
}
// fieldChange is one entry of an audit row's changes array.
type fieldChange struct {
Field string `json:"field"`
Old float64 `json:"old"`
New float64 `json:"new"`
}
// UpdateProfile applies a partial update to one weight profile.
// Unknown fields and out-of-range values reject the whole patch. A
// no-op patch (all values equal to current) writes no audit row.
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
if profile != ScopeRadio && profile != ScopeDailyMix {
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
}
current := s.Weights(profile)
next, changes, err := applyWeightPatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistProfile(ctx, profile, next, "update", changes)
}
// UpdateTaste applies a partial update to the taste tuning singleton.
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
current := s.Taste()
next, changes, err := applyTastePatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistTaste(ctx, next, "update", changes)
}
// Reset restores a scope to its shipped defaults, with one audit row
// carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error {
switch scope {
case ScopeRadio, ScopeDailyMix:
shipped := ShippedRadioWeights()
if scope == ScopeDailyMix {
shipped = ShippedDailyMixWeights()
}
changes := diffWeights(s.Weights(scope), shipped)
if len(changes) == 0 {
return nil
}
return s.persistProfile(ctx, scope, shipped, "reset", changes)
case ScopeTaste:
shipped := ShippedTasteTuning()
changes := diffTaste(s.Taste(), shipped)
if len(changes) == 0 {
return nil
}
return s.persistTaste(ctx, shipped, "reset", changes)
default:
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
}
}
// persistProfile writes the profile row + audit entry, refreshes the
// cache, and pushes daily-build values.
func (s *Service) persistProfile(
ctx context.Context, profile string, w recommendation.ScoringWeights,
action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
return fmt.Errorf("update profile %q: %w", profile, err)
}
if err := s.audit(ctx, q, profile, action, changes); err != nil {
return err
}
s.mu.Lock()
s.profiles[profile] = w
s.mu.Unlock()
s.push()
return nil
}
func (s *Service) persistTaste(
ctx context.Context, t TasteTuning, action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
}); err != nil {
return fmt.Errorf("update taste tuning: %w", err)
}
if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
return err
}
s.mu.Lock()
s.taste = t
s.mu.Unlock()
s.push()
return nil
}
// audit writes one recommendation_tuning_audit row. Changes are
// sorted by field so rows are deterministic and diff-friendly.
func (s *Service) audit(
ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
) error {
sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
payload, err := json.Marshal(changes)
if err != nil {
return fmt.Errorf("marshal audit changes: %w", err)
}
if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
Scope: scope, Action: action, Changes: payload,
}); err != nil {
return fmt.Errorf("insert audit row: %w", err)
}
return nil
}
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
return dbq.UpsertWeightProfileDefaultsParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
}
}
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
return dbq.UpdateWeightProfileParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
}
}
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: r.BaseWeight,
LikeBoost: r.LikeBoost,
RecencyWeight: r.RecencyWeight,
SkipPenalty: r.SkipPenalty,
JitterMagnitude: r.JitterMagnitude,
ContextWeight: r.ContextWeight,
SimilarityWeight: r.SimilarityWeight,
TasteWeight: r.TasteWeight,
}
}
+222
View File
@@ -0,0 +1,222 @@
package recsettings
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func newService(t *testing.T, pool *pgxpool.Pool) *Service {
t.Helper()
s, err := New(context.Background(), pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("recsettings.New: %v", err)
}
return s
}
func auditRows(t *testing.T, pool *pgxpool.Pool) []struct {
Scope, Action string
Changes []fieldChange
} {
t.Helper()
rows, err := pool.Query(context.Background(),
`SELECT scope, action, changes FROM recommendation_tuning_audit ORDER BY id`)
if err != nil {
t.Fatalf("query audit: %v", err)
}
defer rows.Close()
var out []struct {
Scope, Action string
Changes []fieldChange
}
for rows.Next() {
var scope, action string
var raw []byte
if err := rows.Scan(&scope, &action, &raw); err != nil {
t.Fatalf("scan audit: %v", err)
}
var changes []fieldChange
if err := json.Unmarshal(raw, &changes); err != nil {
t.Fatalf("unmarshal audit changes: %v", err)
}
out = append(out, struct {
Scope, Action string
Changes []fieldChange
}{scope, action, changes})
}
return out
}
func TestNew_SeedsShippedDefaults(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if got := s.Weights(ScopeRadio); got != ShippedRadioWeights() {
t.Errorf("radio weights = %+v, want shipped defaults", got)
}
if got := s.Weights(ScopeDailyMix); got != ShippedDailyMixWeights() {
t.Errorf("daily_mix weights = %+v, want shipped defaults", got)
}
if got := s.Taste(); got != ShippedTasteTuning() {
t.Errorf("taste tuning = %+v, want shipped defaults", got)
}
// Seeding must not write audit rows — nothing changed.
if rows := auditRows(t, pool); len(rows) != 0 {
t.Errorf("boot reconcile wrote %d audit rows, want 0", len(rows))
}
}
func TestUpdateProfile_PersistsAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateProfile(context.Background(), ScopeRadio,
map[string]float64{"taste_weight": 2.5, "skip_penalty": 3.0}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
got := s.Weights(ScopeRadio)
if got.TasteWeight != 2.5 || got.SkipPenalty != 3.0 {
t.Errorf("weights = %+v, want taste 2.5 / skip 3.0", got)
}
// The other profile is untouched.
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
t.Error("daily_mix must be unaffected by a radio update")
}
// Values survive a fresh boot (persisted, not just cached), and the
// reconcile's ON CONFLICT DO NOTHING doesn't clobber tuned rows.
s2 := newService(t, pool)
if got := s2.Weights(ScopeRadio); got.TasteWeight != 2.5 {
t.Errorf("rebooted taste_weight = %v, want 2.5", got.TasteWeight)
}
rows := auditRows(t, pool)
if len(rows) != 1 {
t.Fatalf("audit rows = %d, want 1", len(rows))
}
if rows[0].Scope != ScopeRadio || rows[0].Action != "update" || len(rows[0].Changes) != 2 {
t.Errorf("audit row = %+v, want radio/update with 2 changes", rows[0])
}
// Changes are field-sorted: skip_penalty before taste_weight.
if rows[0].Changes[0].Field != "skip_penalty" || rows[0].Changes[0].New != 3.0 {
t.Errorf("changes[0] = %+v, want skip_penalty → 3.0", rows[0].Changes[0])
}
}
func TestUpdateProfile_Validation(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
cases := []struct {
name string
scope string
patch map[string]float64
want error
}{
{"unknown scope", "banana", map[string]float64{"taste_weight": 1}, ErrUnknownScope},
{"unknown field", ScopeRadio, map[string]float64{"vibes": 1}, ErrUnknownField},
{"over bound", ScopeRadio, map[string]float64{"taste_weight": 11}, ErrOutOfRange},
{"negative jitter", ScopeRadio, map[string]float64{"jitter_magnitude": -0.1}, ErrOutOfRange},
}
for _, c := range cases {
if err := s.UpdateProfile(context.Background(), c.scope, c.patch); !errors.Is(err, c.want) {
t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
}
}
if s.Weights(ScopeRadio) != ShippedRadioWeights() {
t.Error("rejected patches must not partially apply")
}
if rows := auditRows(t, pool); len(rows) != 0 {
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
}
}
func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// Moving neutral above full must reject on the PATCHED result.
err := s.UpdateTaste(context.Background(), map[string]float64{"engagement_neutral": 0.95})
if !errors.Is(err, ErrOutOfRange) {
t.Fatalf("err = %v, want ErrOutOfRange (curve ordering)", err)
}
// A coherent multi-point move is fine.
if err := s.UpdateTaste(context.Background(), map[string]float64{
"engagement_neutral": 0.40, "engagement_full": 0.95, "half_life_days": 30,
}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
cfg := s.TasteConfig()
if cfg.HalfLifeDays != 30 || cfg.Engagement.NeutralCompletion != 0.40 {
t.Errorf("taste config = %+v, want tuned values", cfg)
}
// WindowDays scales with the half-life at the shipped ratio.
if cfg.WindowDays != 30*3.6 {
t.Errorf("WindowDays = %v, want %v", cfg.WindowDays, 30*3.6)
}
}
func TestReset_RestoresShippedAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// Reset with nothing changed is a no-op (no audit row).
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
t.Fatalf("no-op reset: %v", err)
}
if rows := auditRows(t, pool); len(rows) != 0 {
t.Fatalf("no-op reset wrote audit rows")
}
if err := s.UpdateProfile(context.Background(), ScopeDailyMix,
map[string]float64{"similarity_weight": 4}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
t.Fatalf("Reset: %v", err)
}
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
t.Error("reset must restore shipped defaults")
}
rows := auditRows(t, pool)
if len(rows) != 2 || rows[1].Action != "reset" {
t.Fatalf("audit rows = %+v, want update then reset", rows)
}
}
func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
shipped := ShippedRadioWeights()
if err := s.UpdateProfile(context.Background(), ScopeRadio,
map[string]float64{"taste_weight": shipped.TasteWeight}); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
if rows := auditRows(t, pool); len(rows) != 0 {
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
}
}
+20 -1
View File
@@ -27,6 +27,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/web" "git.fabledsword.com/bvandeusen/minstrel/web"
@@ -88,6 +89,13 @@ type Server struct {
// PUT /api/me/timezone and POST /api/auth/register can call // PUT /api/me/timezone and POST /api/auth/register can call
// Refresh synchronously. // Refresh synchronously.
PlaylistScheduler *playlists.Scheduler PlaylistScheduler *playlists.Scheduler
// RecSettings is the DB-backed recommendation tuning lab (#1250):
// scoring-weight profiles + taste-build knobs. Constructed in
// cmd/minstrel/main.go (it pushes daily-mix weights into package
// playlists at boot); the API layer reads radio weights per request
// and serves the admin tuning endpoints from it. Router() constructs
// a fallback when nil (tests).
RecSettings *recsettings.Service
// StreamSecret is the HMAC key used by /api/cast/stream-token to // StreamSecret is the HMAC key used by /api/cast/stream-token to
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream // mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
// to verify them. Sourced from config.Config.StreamSecret. Tests that // to verify them. Sourced from config.Config.StreamSecret. Tests that
@@ -143,7 +151,18 @@ func (s *Server) Router() http.Handler {
if bus == nil { if bus == nil {
bus = eventbus.New() bus = eventbus.New()
} }
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) recSettings := s.RecSettings
if recSettings == nil {
// Test contexts construct Server directly without main.go's
// boot wiring; reconcile here so radio + the admin tuning
// endpoints work against the same pool.
var err error
recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger)
if err != nil {
s.Logger.Error("server: recsettings boot failed", "err", err)
}
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
// /api/admin/scan is the only admin route owned by the server package // /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware // (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second // route — using r.Route("/api/admin", ...) here would create a second
+22 -20
View File
@@ -1,18 +1,36 @@
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { api } from './client'; import { api } from './client';
// Mirrors internal/api/me_recommendation_metrics.go. // Mirrors internal/api/me_recommendation_metrics.go: raw play sources are
export type RecommendationMetric = { // bucketed server-side into stable surface families, grouped by intent, and
source: string; // anchored by the manual-plays baseline (milestone #127).
export type SurfaceMetric = {
key: string;
label: string;
plays: number; plays: number;
skips: number; skips: number;
skip_rate: number; skip_rate: number;
avg_completion: number; avg_completion: number;
low_confidence: boolean;
// Present when the surface's builder stamps pick-kind provenance and
// the window holds attributed plays (#1249, generalized #1270): For
// You's taste/fresh split, Discover's candidate buckets, the tiered
// mixes' tier1-3 — plus pre-attribution plays.
breakdown?: SurfaceMetric[];
};
export type SurfaceIntent = 'go_to' | 'discovery' | 'direct';
export type SurfaceGroup = {
intent: SurfaceIntent;
label: string;
surfaces: SurfaceMetric[];
}; };
export type RecommendationMetrics = { export type RecommendationMetrics = {
window_days: number; window_days: number;
sources: RecommendationMetric[]; baseline: SurfaceMetric | null;
groups: SurfaceGroup[];
}; };
export function getRecommendationMetrics(): Promise<RecommendationMetrics> { export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
@@ -28,19 +46,3 @@ export function createRecommendationMetricsQuery() {
staleTime: 60_000 staleTime: 60_000
}); });
} }
// Friendly labels for the system-playlist source keys (play_events.source).
const SOURCE_LABELS: Record<string, string> = {
for_you: 'For You',
discover: 'Discover',
deep_cuts: 'Deep cuts',
rediscover: 'Rediscover',
new_for_you: 'New for you',
on_this_day: 'On this day',
first_listens: 'First listens',
songs_like_artist: 'Songs like…'
};
export function sourceLabel(source: string): string {
return SOURCE_LABELS[source] ?? source;
}
+86
View File
@@ -0,0 +1,86 @@
import { api } from './client';
// Mirrors internal/api/admin_recommendation_tuning.go (#1250): the
// recommendation tuning lab. Field names are the server's snake_case
// keys, used verbatim in PATCH bodies.
export type WeightProfile = {
base_weight: number;
like_boost: number;
recency_weight: number;
skip_penalty: number;
jitter_magnitude: number;
context_weight: number;
similarity_weight: number;
taste_weight: number;
};
export type TasteTuning = {
half_life_days: number;
engagement_hard_skip: number;
engagement_neutral: number;
engagement_full: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
export type TuningSnapshot = {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
shipped: {
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
};
};
export function getTuning(): Promise<TuningSnapshot> {
return api.get<TuningSnapshot>('/api/admin/recommendation-tuning');
}
export function patchTuning(
scope: TuningScope,
values: Record<string, number>
): Promise<TuningSnapshot> {
return api.patch<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}`, { values });
}
export function resetTuning(scope: TuningScope): Promise<TuningSnapshot> {
return api.post<TuningSnapshot>(`/api/admin/recommendation-tuning/${scope}/reset`, {});
}
// Trends (#1251): weekly per-surface outcome series with knob-turn
// markers. Mirrors internal/api/admin_recommendation_trends.go.
export type TrendPoint = {
week_start: string;
plays: number;
skips: number;
skip_rate: number;
avg_completion: number;
taste_hit_rate: number;
};
export type TrendSeries = {
key: string;
label: string;
intent: string;
plays: number;
points: TrendPoint[];
};
export type TrendMarker = {
changed_at: string;
scope: string;
action: string;
changes: { field: string; old: number; new: number }[];
};
export type TrendsResponse = {
weeks: number;
series: TrendSeries[];
markers: TrendMarker[];
};
export function getTrends(weeks = 12): Promise<TrendsResponse> {
return api.get<TrendsResponse>(`/api/admin/recommendation-trends?weeks=${weeks}`);
}
+1
View File
@@ -10,6 +10,7 @@
{ href: '/admin/quarantine', label: 'Quarantine' }, { href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/playback-errors', label: 'Playback errors' }, { href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' }, { href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' },
{ href: '/admin/users', label: 'Users' } { href: '/admin/users', label: 'Users' }
]; ];
+2 -1
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
); );
}); });
test('renders all seven tabs in order', () => { test('renders all eight tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin'); state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs); render(AdminTabs);
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');
@@ -63,6 +63,7 @@ describe('AdminTabs', () => {
'Quarantine', 'Quarantine',
'Playback errors', 'Playback errors',
'Diagnostics', 'Diagnostics',
'Tuning',
'Users' 'Users'
]); ]);
}); });
+434
View File
@@ -0,0 +1,434 @@
<script lang="ts">
import { pageTitle } from '$lib/branding';
import {
getTuning,
patchTuning,
resetTuning,
getTrends,
type TuningScope,
type TuningSnapshot,
type WeightProfile,
type TasteTuning,
type TrendsResponse,
type TrendSeries,
type TrendMarker
} from '$lib/api/tuning';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// The defaults-discovery lab (#1250): DB-backed scoring-weight
// profiles + taste-build knobs with live effect. This card exists to
// FIND good values — found-good values get baked into shipped
// defaults, so end users and other operators never need it.
const weightFields: { key: keyof WeightProfile; label: string; hint: string }[] = [
{ key: 'base_weight', label: 'Base weight', hint: 'Floor score every candidate starts from.' },
{ key: 'like_boost', label: 'Like boost', hint: 'Added when the track is liked.' },
{ key: 'recency_weight', label: 'Recency weight', hint: 'Rewards tracks not played recently (030d ramp).' },
{ key: 'skip_penalty', label: 'Skip penalty', hint: 'Subtracts skips/plays ratio.' },
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' },
{ key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' },
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' },
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }
];
const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [
{ key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." },
{ key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads 1.' },
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }
];
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
];
let snapshot = $state<TuningSnapshot | null>(null);
let loadFailed = $state(false);
// Editable copies, string-typed for the inputs; parsed on save.
let form = $state<Record<string, Record<string, string>>>({});
let saving = $state<TuningScope | null>(null);
function fillForm(snap: TuningSnapshot) {
const f: Record<string, Record<string, string>> = { radio: {}, daily_mix: {}, taste: {} };
for (const p of ['radio', 'daily_mix'] as const) {
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
}
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
form = f;
}
$effect(() => {
getTuning()
.then((snap) => {
fillForm(snap);
snapshot = snap;
})
.catch(() => {
loadFailed = true;
});
});
// A knob deviates when its CURRENT SAVED value differs from shipped;
// the dot marks where this install has drifted from defaults.
function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean {
if (!snapshot) return false;
if (scope === 'taste') {
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning];
}
return (
snapshot.profiles[scope][key as keyof WeightProfile] !==
snapshot.shipped.profiles[scope][key as keyof WeightProfile]
);
}
function currentValue(scope: TuningScope, key: string): number {
if (!snapshot) return 0;
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning];
return snapshot.profiles[scope][key as keyof WeightProfile];
}
async function save(scope: TuningScope) {
if (!snapshot) return;
const values: Record<string, number> = {};
for (const [key, raw] of Object.entries(form[scope] ?? {})) {
const v = Number(raw);
if (!Number.isFinite(v)) {
pushToast(`${key} is not a number.`, 'error');
return;
}
if (v !== currentValue(scope, key)) values[key] = v;
}
if (Object.keys(values).length === 0) {
pushToast('Nothing changed.');
return;
}
saving = scope;
try {
snapshot = await patchTuning(scope, values);
fillForm(snapshot);
pushToast('Saved — takes effect on the next scoring pass.');
} catch (e: unknown) {
pushToast(`Save failed: ${errMessage(e)}`, 'error');
} finally {
saving = null;
}
}
async function reset(scope: TuningScope) {
saving = scope;
try {
snapshot = await resetTuning(scope);
fillForm(snapshot);
pushToast('Reset to shipped defaults.');
} catch (e: unknown) {
pushToast(`Reset failed: ${errMessage(e)}`, 'error');
} finally {
saving = null;
}
}
// Trends (#1251): weekly skip-rate sparklines per surface with
// knob-turn markers — the verify half of the tune→verify loop.
let trends = $state<TrendsResponse | null>(null);
let trendsFailed = $state(false);
$effect(() => {
getTrends()
.then((t) => {
trends = t;
})
.catch(() => {
trendsFailed = true;
});
});
const SPARK_W = 220;
const SPARK_H = 36;
const TREND_LOW_VOLUME = 20;
// The week axis is the sorted union of every series' buckets, so all
// sparklines and markers share one x scale.
const weekAxis = $derived.by(() => {
if (!trends) return [] as string[];
const set = new Set<string>();
for (const s of trends.series) for (const p of s.points) set.add(p.week_start);
return [...set].sort();
});
function xFor(week: string): number {
const i = weekAxis.indexOf(week);
if (i < 0 || weekAxis.length < 2) return 0;
return (i / (weekAxis.length - 1)) * SPARK_W;
}
// Polyline of the series' weekly skip rate on a fixed [0,1] y-scale
// (higher = worse, drawn upward) so rows are visually comparable.
function sparkPoints(s: TrendSeries): string {
return s.points
.map((p) => `${xFor(p.week_start).toFixed(1)},${(SPARK_H - p.skip_rate * SPARK_H).toFixed(1)}`)
.join(' ');
}
// A marker lands on the latest axis week that starts at/before it.
function markerX(m: TrendMarker): number {
const day = m.changed_at.slice(0, 10);
let idx = -1;
for (let i = 0; i < weekAxis.length; i++) {
if (weekAxis[i] <= day) idx = i;
}
if (idx < 0 || weekAxis.length < 2) return 0;
return (idx / (weekAxis.length - 1)) * SPARK_W;
}
function windowTasteHitRate(s: TrendSeries): number {
let plays = 0;
let hits = 0;
for (const p of s.points) {
plays += p.plays;
hits += p.taste_hit_rate * p.plays;
}
return plays > 0 ? hits / plays : 0;
}
function latest(s: TrendSeries): { skip: number; completion: number } {
const last = s.points[s.points.length - 1];
return last ? { skip: last.skip_rate, completion: last.avg_completion } : { skip: 0, completion: 0 };
}
function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`;
}
function markerSummary(m: TrendMarker): string {
const when = m.changed_at.slice(0, 10);
if (m.action === 'reset') return `${when}${m.scope} reset to defaults`;
const fields = (m.changes ?? []).map((c) => `${c.field} ${c.old}${c.new}`).join(', ');
return `${when}${m.scope}: ${fields}`;
}
</script>
<svelte:head>
<title>{pageTitle('Tuning')}</title>
</svelte:head>
<div class="space-y-6 p-4">
<div>
<h1 class="text-xl font-semibold">Recommendation tuning</h1>
<p class="mt-1 max-w-3xl text-sm text-text-secondary">
The defaults-discovery lab. Changes apply live — radio on the next request, daily mixes on
the next rebuild — and every change is recorded so the metrics page can tie outcome shifts
to knob turns. Found-good values get baked into shipped defaults; a dot marks knobs that
currently deviate from them.
</p>
</div>
{#if loadFailed}
<p class="text-sm text-text-secondary">Couldn't load tuning settings.</p>
{:else if !snapshot}
<p class="text-sm text-text-secondary">Loading…</p>
{:else}
<section class="grid gap-4 lg:grid-cols-2">
{#each profileScopes as p (p.scope)}
<div class="space-y-3 rounded border border-border bg-surface p-4">
<div>
<h2 class="text-lg font-semibold">{p.label}</h2>
<p class="text-xs text-text-secondary">{p.blurb}</p>
</div>
<div class="space-y-2">
{#each weightFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="{p.scope}-{f.key}" title={f.hint}>
{f.label}
{#if deviates(p.scope, f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.profiles[p.scope][f.key]})"
></span>
{/if}
</label>
<input
id="{p.scope}-{f.key}"
type="number"
step="0.1"
bind:value={form[p.scope][f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save(p.scope)}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save {p.label.toLowerCase()}
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset(p.scope)}
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</div>
{/each}
</section>
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
<div>
<h2 class="text-lg font-semibold">Taste profile build</h2>
<p class="text-xs text-text-secondary">
How plays become the learned taste profile: the influence half-life and the
completion→engagement curve (must stay ordered: hard-skip &lt; neutral &lt; full).
</p>
</div>
<div class="space-y-2">
{#each tasteFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="taste-{f.key}" title={f.hint}>
{f.label}
{#if deviates('taste', f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.taste[f.key]})"
></span>
{/if}
</label>
<input
id="taste-{f.key}"
type="number"
step={f.key === 'half_life_days' ? '1' : '0.05'}
bind:value={form.taste[f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save('taste')}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save taste
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset('taste')}
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</section>
{/if}
<!-- Weekly trends (#1251): the verify half. Sparklines share one
week axis; dashed ticks mark knob turns so cause→effect reads
off the chart. -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<div>
<h2 class="text-lg font-semibold">Weekly trends</h2>
<p class="text-xs text-text-secondary">
Skip rate per surface over the last {trends?.weeks ?? 12} weeks (lower is better; all
users aggregated, rates only). Dashed ticks mark tuning changes. Taste hit is the share
of plays whose artist fits the current taste profile.
</p>
</div>
{#if trendsFailed}
<p class="text-sm text-text-secondary">Couldn't load trends.</p>
{:else if !trends}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if trends.series.length === 0}
<p class="text-sm text-text-secondary">
No plays recorded yet — trends appear once listening accumulates.
</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</th>
<th class="py-1 font-medium">Skip rate by week</th>
<th class="py-1 text-right font-medium">Plays</th>
<th class="py-1 text-right font-medium">Latest skip</th>
<th class="py-1 text-right font-medium">Latest completion</th>
<th class="py-1 text-right font-medium">Taste hit</th>
</tr>
</thead>
<tbody>
{#each trends.series as s (s.key)}
<tr
class="border-t border-border"
class:opacity-60={s.plays < TREND_LOW_VOLUME}
title={s.plays < TREND_LOW_VOLUME
? 'Fewer than 20 plays in the window — treat as anecdote, not signal.'
: undefined}
>
<td class="py-1.5 pr-2">{s.label}</td>
<td class="py-1.5">
<svg
width={SPARK_W}
height={SPARK_H}
viewBox="0 0 {SPARK_W} {SPARK_H}"
role="img"
aria-label="{s.label} weekly skip rate"
data-testid="sparkline-{s.key}"
>
<line x1="0" y1={SPARK_H - 0.5} x2={SPARK_W} y2={SPARK_H - 0.5}
stroke="currentColor" opacity="0.15" />
{#each trends.markers as m, i (i)}
<line
x1={markerX(m)} y1="0" x2={markerX(m)} y2={SPARK_H}
stroke="currentColor" opacity="0.35" stroke-dasharray="2,2"
>
<title>{markerSummary(m)}</title>
</line>
{/each}
{#if s.points.length > 1}
<polyline
class="text-accent"
points={sparkPoints(s)}
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
{:else if s.points.length === 1}
<circle
class="text-accent"
cx={xFor(s.points[0].week_start)}
cy={SPARK_H - s.points[0].skip_rate * SPARK_H}
r="2" fill="currentColor"
/>
{/if}
</svg>
</td>
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).skip)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).completion)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(windowTasteHitRate(s))}</td>
</tr>
{/each}
</tbody>
</table>
{#if trends.markers.length > 0}
<div class="space-y-1">
<h3 class="text-sm font-medium">Tuning changes in this window</h3>
<ul class="space-y-0.5 text-xs text-text-secondary">
{#each trends.markers as m, i (i)}
<li>{markerSummary(m)}</li>
{/each}
</ul>
</div>
{/if}
{/if}
</section>
</div>
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { TuningSnapshot, TrendsResponse } from '$lib/api/tuning';
vi.mock('$lib/api/tuning', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
getTuning: vi.fn(),
patchTuning: vi.fn(),
resetTuning: vi.fn(),
getTrends: vi.fn()
};
});
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
import TuningPage from './+page.svelte';
import { getTuning, patchTuning, resetTuning, getTrends } from '$lib/api/tuning';
const weights = (over: Partial<Record<string, number>> = {}) => ({
base_weight: 1,
like_boost: 2,
recency_weight: 1,
skip_penalty: 2,
jitter_magnitude: 0.1,
context_weight: 0.5,
similarity_weight: 1.5,
taste_weight: 1.5,
...over
});
const taste = (over: Partial<Record<string, number>> = {}) => ({
half_life_days: 75,
engagement_hard_skip: 0.05,
engagement_neutral: 0.3,
engagement_full: 0.9,
...over
});
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(),
shipped: {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste()
},
...over
} as TuningSnapshot;
}
const emptyTrends: TrendsResponse = { weeks: 12, series: [], markers: [] };
beforeEach(() => {
vi.clearAllMocks();
(getTrends as ReturnType<typeof vi.fn>).mockResolvedValue(emptyTrends);
});
describe('Admin tuning page', () => {
test('renders both profiles and the taste card with current values', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.getByText('Daily mixes')).toBeInTheDocument();
expect(screen.getByText('Taste profile build')).toBeInTheDocument();
const radioTaste = screen.getByLabelText(/taste weight/i, {
selector: '#radio-taste_weight'
}) as HTMLInputElement;
expect(radioTaste.value).toBe('1');
const halfLife = document.getElementById('taste-half_life_days') as HTMLInputElement;
expect(halfLife.value).toBe('75');
});
test('save sends only the changed fields for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
const input = document.getElementById('radio-taste_weight') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '2.5' } });
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('radio', { taste_weight: 2.5 })
);
});
test('save with no changes calls nothing and says so', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
expect(patchTuning).not.toHaveBeenCalled();
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
});
test('reset calls resetTuning for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
await fireEvent.click(resetButtons[resetButtons.length - 1]);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
});
test('marks knobs that deviate from shipped defaults', async () => {
const snap = snapshot();
snap.profiles.daily_mix = weights({ similarity_weight: 4 });
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Daily mixes')).toBeInTheDocument());
expect(
screen.getByTitle(/deviates from the shipped default \(1\.5\)/i)
).toBeInTheDocument();
});
test('renders weekly trend rows with sparklines and knob-turn markers (#1251)', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(getTrends as ReturnType<typeof vi.fn>).mockResolvedValue({
weeks: 12,
series: [
{
key: 'radio',
label: 'Radio',
intent: 'go_to',
plays: 40,
points: [
{
week_start: '2026-06-22',
plays: 25,
skips: 5,
skip_rate: 0.2,
avg_completion: 0.8,
taste_hit_rate: 0.6
},
{
week_start: '2026-06-29',
plays: 15,
skips: 6,
skip_rate: 0.4,
avg_completion: 0.7,
taste_hit_rate: 0.5
}
]
},
{
key: 'discover',
label: 'Discover',
intent: 'discovery',
plays: 5,
points: [
{
week_start: '2026-06-29',
plays: 5,
skips: 3,
skip_rate: 0.6,
avg_completion: 0.4,
taste_hit_rate: 0.2
}
]
}
],
markers: [
{
changed_at: '2026-06-30T10:00:00Z',
scope: 'radio',
action: 'update',
changes: [{ field: 'taste_weight', old: 1, new: 2 }]
}
]
} satisfies TrendsResponse);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Weekly trends')).toBeInTheDocument());
expect(screen.getByTestId('sparkline-radio')).toBeInTheDocument();
expect(screen.getByTestId('sparkline-discover')).toBeInTheDocument();
// Latest skip rate column for radio = 40% (also discover's latest
// completion, hence getAllBy).
expect(screen.getAllByText('40%').length).toBeGreaterThan(0);
// The knob turn is listed under the chart AND tooltipped on each
// sparkline's marker tick, hence getAllBy.
expect(
screen.getAllByText(/2026-06-30 — radio: taste_weight 1→2/).length
).toBeGreaterThan(0);
// Low-volume series (5 plays) is dimmed as anecdote.
expect(screen.getByTitle(/fewer than 20 plays in the window/i)).toBeInTheDocument();
});
test('empty trends show the accumulation hint', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() =>
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
);
});
});
+146 -11
View File
@@ -10,8 +10,9 @@
} from '$lib/api/listenbrainz'; } from '$lib/api/listenbrainz';
import { import {
createRecommendationMetricsQuery, createRecommendationMetricsQuery,
sourceLabel, type RecommendationMetrics,
type RecommendationMetrics type SurfaceIntent,
type SurfaceMetric
} from '$lib/api/metrics'; } from '$lib/api/metrics';
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte'; import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
import { player, setCrossfade } from '$lib/player/store.svelte'; import { player, setCrossfade } from '$lib/player/store.svelte';
@@ -30,6 +31,46 @@
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>; const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>; const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
// Per-intent expectation copy: each surface band is judged against its
// job — discovery mixes are supposed to run hotter skip rates.
const intentHints: Record<SurfaceIntent, string> = {
go_to: 'Everyday surfaces — these should beat your own picking.',
discovery: 'Exploration surfaces — higher skip rates here are expected and healthy.',
direct: 'Music you chose yourself — naturally close to the baseline.'
};
function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`;
}
// Delta in percentage points vs the baseline, signed ("+12" / "5").
function deltaPts(value: number, baseline: number): string {
const pts = Math.round((value - baseline) * 100);
return pts > 0 ? `+${pts}` : `${pts}`;
}
// A surface's skip delta is "worse" when it skips more than the
// baseline; completion delta is "worse" when it completes less.
function skipDeltaClass(m: SurfaceMetric, baseline: SurfaceMetric): string {
return m.skip_rate > baseline.skip_rate ? 'text-danger' : 'text-text-secondary';
}
function completionDeltaClass(m: SurfaceMetric, baseline: SurfaceMetric): string {
return m.avg_completion < baseline.avg_completion ? 'text-danger' : 'text-text-secondary';
}
// Pick-kind breakdowns are collapsed by default (#1270): with every
// system mix stamping provenance, always-open sub-rows would triple
// the table height and bury the surface-level comparison.
let expandedBreakdowns = $state(new Set<string>());
function toggleBreakdown(key: string) {
const next = new Set(expandedBreakdowns);
if (next.has(key)) next.delete(key);
else next.add(key);
expandedBreakdowns = next;
}
const tokenMutation = createTokenMutation(queryClient); const tokenMutation = createTokenMutation(queryClient);
const enabledMutation = createEnabledMutation(queryClient); const enabledMutation = createEnabledMutation(queryClient);
@@ -278,14 +319,32 @@
<h2 class="text-lg font-semibold">Recommendation metrics</h2> <h2 class="text-lg font-semibold">Recommendation metrics</h2>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
How plays launched from each recommendation surface land, over the last How plays launched from each recommendation surface land, over the last
{$metrics.data?.window_days ?? 30} days. Lower skip rate and higher average {$metrics.data?.window_days ?? 30} days — compared against the baseline of
completion mean the surface is hitting. music you picked yourself. Deltas are percentage points vs that baseline.
</p> </p>
{#if $metrics.isPending} {#if $metrics.isPending}
<p class="text-sm text-text-secondary">Loading…</p> <p class="text-sm text-text-secondary">Loading…</p>
{:else if $metrics.isError} {:else if $metrics.isError}
<p class="text-sm text-text-secondary">Couldn't load metrics.</p> <p class="text-sm text-text-secondary">Couldn't load metrics.</p>
{:else if $metrics.data && $metrics.data.sources.length > 0} {:else if $metrics.data && ($metrics.data.groups.length > 0 || $metrics.data.baseline)}
{@const baseline = $metrics.data.baseline}
{#if baseline}
<p class="rounded bg-background px-3 py-2 text-sm">
<span class="font-medium">Baseline — manual plays:</span>
<span class="text-text-secondary">
{baseline.plays} plays · {pct(baseline.skip_rate)} skip ·
{pct(baseline.avg_completion)} completion
</span>
</p>
{:else}
<p class="text-sm text-text-secondary">
No manual plays in this window yet, so deltas are hidden — raw rates only.
</p>
{/if}
{#each $metrics.data.groups as group (group.intent)}
<div class="space-y-1">
<h3 class="text-sm font-medium">{group.label}</h3>
<p class="text-xs text-text-secondary">{intentHints[group.intent]}</p>
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-left text-text-secondary"> <tr class="text-left text-text-secondary">
@@ -296,19 +355,95 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each $metrics.data.sources as m (m.source)} {#each group.surfaces as m (m.key)}
<tr class="border-t border-border"> <tr
<td class="py-1">{sourceLabel(m.source)}</td> class="border-t border-border"
class:opacity-60={m.low_confidence}
title={m.low_confidence
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
: undefined}
>
<td class="py-1">
{#if (m.breakdown ?? []).length > 0}
<button
type="button"
class="inline-flex items-center gap-1 rounded outline-none focus-visible:ring-1 focus-visible:ring-accent"
aria-expanded={expandedBreakdowns.has(m.key)}
onclick={() => toggleBreakdown(m.key)}
>
<span class="text-xs text-text-secondary" aria-hidden="true">
{expandedBreakdowns.has(m.key) ? '▾' : '▸'}
</span>
{m.label}
</button>
{:else}
{m.label}
{/if}{#if m.low_confidence}<span class="ml-1 text-xs text-text-secondary">· low data</span>{/if}
</td>
<td class="py-1 text-right tabular-nums">{m.plays}</td> <td class="py-1 text-right tabular-nums">{m.plays}</td>
<td class="py-1 text-right tabular-nums">{(m.skip_rate * 100).toFixed(0)}%</td> <td class="py-1 text-right tabular-nums">
<td class="py-1 text-right tabular-nums">{(m.avg_completion * 100).toFixed(0)}%</td> {pct(m.skip_rate)}
{#if baseline}
<span class="ml-1 text-xs {skipDeltaClass(m, baseline)}">
{deltaPts(m.skip_rate, baseline.skip_rate)}
</span>
{/if}
</td>
<td class="py-1 text-right tabular-nums">
{pct(m.avg_completion)}
{#if baseline}
<span class="ml-1 text-xs {completionDeltaClass(m, baseline)}">
{deltaPts(m.avg_completion, baseline.avg_completion)}
</span>
{/if}
</td>
</tr> </tr>
<!-- Pick-kind provenance sub-rows (#1249/#1270): each
surface splits into the populations its builder
stamped — For You's taste vs freshness injection,
Discover's buckets, the tiered mixes' tiers — the
numbers that say WHERE a surface's skips come from.
Toggled per surface to keep the default view flat. -->
{#if expandedBreakdowns.has(m.key)}
{#each m.breakdown ?? [] as b (b.key)}
<tr
class="border-t border-border/50 text-text-secondary"
class:opacity-60={b.low_confidence}
title={b.low_confidence
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
: undefined}
>
<td class="py-1 pl-4 text-xs">
{b.label}{#if b.low_confidence}<span class="ml-1">· low data</span>{/if}
</td>
<td class="py-1 text-right text-xs tabular-nums">{b.plays}</td>
<td class="py-1 text-right text-xs tabular-nums">
{pct(b.skip_rate)}
{#if baseline}
<span class="ml-1 {skipDeltaClass(b, baseline)}">
{deltaPts(b.skip_rate, baseline.skip_rate)}
</span>
{/if}
</td>
<td class="py-1 text-right text-xs tabular-nums">
{pct(b.avg_completion)}
{#if baseline}
<span class="ml-1 {completionDeltaClass(b, baseline)}">
{deltaPts(b.avg_completion, baseline.avg_completion)}
</span>
{/if}
</td>
</tr>
{/each}
{/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div>
{/each}
{:else} {:else}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
No recommendation plays yet. Play something from For You, Discover, or a mix. No plays recorded yet. Play something from For You, Discover, or a mix.
</p> </p>
{/if} {/if}
</section> </section>
+2 -3
View File
@@ -28,11 +28,10 @@ vi.mock('$lib/api/listenbrainz', () => {
vi.mock('$lib/api/metrics', () => ({ vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({ createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => { subscribe: (run: (v: unknown) => void) => {
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } }); run({ isPending: false, isError: false, data: { window_days: 30, baseline: null, groups: [] } });
return () => {}; return () => {};
} }
}), })
sourceLabel: (s: string) => s
})); }));
beforeEach(() => { beforeEach(() => {
+87 -4
View File
@@ -20,14 +20,19 @@ vi.mock('$lib/api/me', () => ({
regenerateAPIToken: vi.fn() regenerateAPIToken: vi.fn()
})); }));
// Mutable holder so individual tests can inject populated metrics;
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
const metricsMock = vi.hoisted(() => ({
data: { window_days: 30, baseline: null, groups: [] } as unknown
}));
vi.mock('$lib/api/metrics', () => ({ vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({ createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => { subscribe: (run: (v: unknown) => void) => {
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } }); run({ isPending: false, isError: false, data: metricsMock.data });
return () => {}; return () => {};
} }
}), })
sourceLabel: (s: string) => s
})); }));
import SettingsPage from './+page.svelte'; import SettingsPage from './+page.svelte';
@@ -53,7 +58,10 @@ function mockMutationStore(mutateFn: (vars: unknown) => void = vi.fn()) {
return readable({ isPending: false, mutate: mutateFn, mutateAsync: vi.fn() }); return readable({ isPending: false, mutate: mutateFn, mutateAsync: vi.fn() });
} }
afterEach(() => vi.clearAllMocks()); afterEach(() => {
vi.clearAllMocks();
metricsMock.data = { window_days: 30, baseline: null, groups: [] };
});
describe('Settings page — ListenBrainz', () => { describe('Settings page — ListenBrainz', () => {
test('token-not-set state renders input + Save button', async () => { test('token-not-set state renders input + Save button', async () => {
@@ -178,6 +186,81 @@ describe('Settings page — Password card', () => {
}); });
}); });
describe('Settings page — Recommendation metrics card', () => {
const metric = (key: string, label: string, over: Record<string, unknown> = {}) => ({
key,
label,
plays: 30,
skips: 3,
skip_rate: 0.1,
avg_completion: 0.9,
low_confidence: false,
...over
});
test('breakdown rows are collapsed by default and expand on toggle (#1249/#1270)', async () => {
setupPage();
metricsMock.data = {
window_days: 30,
baseline: metric('manual', 'Manual library plays', { plays: 100 }),
groups: [
{
intent: 'go_to',
label: 'Go-to surfaces',
surfaces: [
metric('for_you', 'For You', {
plays: 45,
breakdown: [
metric('for_you_taste', 'Taste picks'),
metric('for_you_fresh', 'Fresh picks', {
plays: 10,
skip_rate: 0.4,
low_confidence: true
}),
metric('for_you_unattributed', 'Earlier plays', { plays: 5 })
]
})
]
}
]
};
render(SettingsPage);
const toggle = await screen.findByRole('button', { name: /for you/i });
// Collapsed by default: every stamping mix now carries a breakdown,
// so always-open sub-rows would swamp the surface-level view.
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
await fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
await fireEvent.click(toggle);
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
});
test('surfaces without a breakdown render no toggle and no sub-rows', async () => {
setupPage();
metricsMock.data = {
window_days: 30,
baseline: null,
groups: [
{
intent: 'go_to',
label: 'Go-to surfaces',
surfaces: [metric('radio', 'Radio')]
}
]
};
render(SettingsPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /radio/i })).not.toBeInTheDocument();
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
});
});
describe('Settings page — API Token card', () => { describe('Settings page — API Token card', () => {
test('first click on Regenerate shows "Click again to confirm"', async () => { test('first click on Regenerate shows "Click again to confirm"', async () => {
setupPage(); setupPage();