From 199fec2058bfba2cb92239552233951f995a4ae1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 14 Jul 2026 10:07:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(taste):=20household=20co-play=20similarity?= =?UTF-8?q?=20=E2=80=94=20#1533?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone #160 Opt 5. A collaborative candidate arm: tracks by artists co-played across the instance with the seed's artist. Minstrel is a single shared-library, multi-user server (no per-user library ACL — verified: no owner/share/group model), so the "household" is the whole instance's user set; the rule #47 scoping is satisfied by the shared-library boundary. Single-user servers produce no edges. - No migration: source='user_cooccurrence' was pre-whitelisted in the 0009 similarity CHECK from day one. - internal/db/queries/coplay.sql: Delete + Insert artist co-play edges. Score = Jaccard of the two artists' distinct-player sets (controls for globally-popular artists); >= 2 co-players AND Jaccard >= floor kept (the floor also self-limits hub artists). Completed plays, 365d window. - internal/coplay: periodic worker (6h) that atomic-replaces the user_cooccurrence edge set from play_events — pure local SQL, no external calls. Wired in main.go alongside the similarity worker. - LoadRadioCandidatesV2: new coplay_artists arm (source='user_cooccurrence', seed-artist based, 0.5 damp like similar_artists) + $11 limit; CandidateSourceLimits.UserCoplay (default 20, For-You 40). - Integration tests: perfect-overlap Jaccard=1.0 edge + single-user empty-set gate. Device axis and AcousticBrainz (Opt 4) are separately tracked; this closes the milestone-#160 sequential options. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/minstrel/main.go | 7 + internal/coplay/worker.go | 86 +++++++++++ internal/coplay/worker_integration_test.go | 162 +++++++++++++++++++++ internal/db/dbq/coplay.sql.go | 78 ++++++++++ internal/db/dbq/recommendation.sql.go | 24 ++- internal/db/queries/coplay.sql | 55 +++++++ internal/db/queries/recommendation.sql | 22 ++- internal/playlists/system.go | 3 + internal/recommendation/candidates.go | 6 + 9 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 internal/coplay/worker.go create mode 100644 internal/coplay/worker_integration_test.go create mode 100644 internal/db/dbq/coplay.sql.go create mode 100644 internal/db/queries/coplay.sql diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 826e2aed..b7f1248e 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -15,6 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/config" + "git.fabledsword.com/bvandeusen/minstrel/internal/coplay" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" @@ -206,6 +207,12 @@ func run() error { similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity")) go similarityWorker.Run(ctx) + // Start the household co-play worker (#1533). Recomputes artist–artist + // co-occurrence edges (source='user_cooccurrence') from play_events every + // 6h — a collaborative candidate arm for the radio/mix pools. Pure local + // SQL, no external calls; empty on single-user servers. + go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx) + // Start the tag-enrichment worker (#1490). Reconciles the compiled-in // tag providers with tag_provider_settings, bumps the sources version if // the provider set changed (re-opening settled rows), then drains tracks diff --git a/internal/coplay/worker.go b/internal/coplay/worker.go new file mode 100644 index 00000000..ed2c0435 --- /dev/null +++ b/internal/coplay/worker.go @@ -0,0 +1,86 @@ +// Package coplay builds household co-play similarity edges (#1533, milestone +// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no +// per-user library ACL), so the "household" is the whole instance's user set. +// A periodic worker recomputes artist–artist co-occurrence from play_events +// entirely in SQL (no external API) and stores the edges in artist_similarity +// under source='user_cooccurrence' — the pre-provisioned co-occurrence source +// from the 0009 schema — which the radio/mix candidate pool consumes as a +// collaborative arm. Single-user servers produce no edges (the >= 2 co-player +// gate), so the feature is a graceful no-op there. +package coplay + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// minJaccard is the co-play edge floor: prune weak pairs and self-limit hub +// artists, whose large combined-audience denominators pull their Jaccard down. +// 0.1 keeps only pairs whose co-player overlap is a real fraction of their +// combined audience. +const minJaccard = 0.1 + +// Worker periodically rebuilds the household co-play edge set. Pure local SQL, +// so there are no rate limits — but the pair self-join is roughly +// O(users × artists_per_user²), so it ticks slowly: co-listening shifts over +// days, not minutes, and a frequent rebuild would burn CPU for no fresher +// signal. +type Worker struct { + pool *pgxpool.Pool + logger *slog.Logger + tick time.Duration +} + +// NewWorker constructs a worker with the production default 6h tick. +func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker { + return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour} +} + +// Run rebuilds once at startup (so edges exist without waiting a full tick), +// then every w.tick until ctx is cancelled. Runs in its own goroutine, so a +// slow initial rebuild never blocks boot. +func (w *Worker) Run(ctx context.Context) { + if err := w.rebuild(ctx); err != nil { + w.logger.Error("coplay: initial rebuild failed", "err", err) + } + t := time.NewTicker(w.tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := w.rebuild(ctx); err != nil { + w.logger.Error("coplay: rebuild failed", "err", err) + } + } + } +} + +// rebuild atomic-replaces the user_cooccurrence edge set inside one +// transaction — delete the old edges, recompute from current play history — so +// the candidate pool never observes a half-built set. +func (w *Worker) rebuild(ctx context.Context) error { + tx, err := w.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + q := dbq.New(tx) + if err := q.DeleteArtistCoplayEdges(ctx); err != nil { + return err + } + if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + w.logger.Debug("coplay: edges rebuilt") + return nil +} diff --git a/internal/coplay/worker_integration_test.go b/internal/coplay/worker_integration_test.go new file mode 100644 index 00000000..72737eaf --- /dev/null +++ b/internal/coplay/worker_integration_test.go @@ -0,0 +1,162 @@ +package coplay + +import ( + "context" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); 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 discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID { + t.Helper() + u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user %s: %v", name, err) + } + return u.ID +} + +// seedArtistTrack creates an artist + album + one track, returning both ids. +func seedArtistTrack(t *testing.T, q *dbq.Queries, name string) (pgtype.UUID, pgtype.UUID) { + t.Helper() + ctx := context.Background() + a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name}) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: a.ID}) + if err != nil { + t.Fatalf("seed album: %v", err) + } + tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: name, AlbumID: al.ID, ArtistID: a.ID, + DurationMs: 1000, FilePath: "/tmp/coplay-" + name + ".mp3", + FileSize: 1, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("seed track: %v", err) + } + return a.ID, tr.ID +} + +func play(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) { + t.Helper() + ctx := context.Background() + at := time.Now() + var sessionID pgtype.UUID + if err := pool.QueryRow(ctx, + `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) + VALUES ($1, $2, $2, 'coplay-test') RETURNING id`, + userID, at).Scan(&sessionID); err != nil { + t.Fatalf("insert play_session: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped) + VALUES ($1, $2, $3, $4, false)`, + userID, trackID, sessionID, at); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} + +func coplayEdgeCount(t *testing.T, pool *pgxpool.Pool) int { + t.Helper() + var n int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM artist_similarity WHERE source='user_cooccurrence'`).Scan(&n); err != nil { + t.Fatalf("count edges: %v", err) + } + return n +} + +// TestCoplayWorker_BuildsEdges: two users who both play two artists produce a +// perfect-overlap co-play edge (Jaccard 1.0) in each direction. +func TestCoplayWorker_BuildsEdges(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + q := dbq.New(pool) + + u1 := seedUser(t, q, "cop1") + u2 := seedUser(t, q, "cop2") + aA, tA := seedArtistTrack(t, q, "ArtA") + aB, tB := seedArtistTrack(t, q, "ArtB") + + for _, u := range []pgtype.UUID{u1, u2} { + play(t, pool, u, tA) + play(t, pool, u, tB) + } + + if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil { + t.Fatalf("rebuild: %v", err) + } + + var score float64 + if err := pool.QueryRow(ctx, + `SELECT score FROM artist_similarity + WHERE artist_a_id=$1 AND artist_b_id=$2 AND source='user_cooccurrence'`, + aA, aB).Scan(&score); err != nil { + t.Fatalf("expected co-play edge ArtA→ArtB: %v", err) + } + // Jaccard of perfectly-overlapping player sets (both users play both): + // coplayers=2, players each=2 → 2/(2+2-2) = 1.0. + if score != 1.0 { + t.Errorf("edge score = %v, want 1.0 (perfect overlap)", score) + } + if got := coplayEdgeCount(t, pool); got != 2 { + t.Errorf("edge count = %d, want 2 (A→B and B→A)", got) + } +} + +// TestCoplayWorker_SingleUserNoEdges: one user's own co-listening yields no +// edges — the >= 2 distinct-co-player gate keeps single-user servers empty. +func TestCoplayWorker_SingleUserNoEdges(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + q := dbq.New(pool) + + u := seedUser(t, q, "solo") + _, tA := seedArtistTrack(t, q, "SoloA") + _, tB := seedArtistTrack(t, q, "SoloB") + play(t, pool, u, tA) + play(t, pool, u, tB) + + if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil { + t.Fatalf("rebuild: %v", err) + } + if got := coplayEdgeCount(t, pool); got != 0 { + t.Errorf("single-user edge count = %d, want 0 (>= 2 co-player gate)", got) + } +} diff --git a/internal/db/dbq/coplay.sql.go b/internal/db/dbq/coplay.sql.go new file mode 100644 index 00000000..f19fef6a --- /dev/null +++ b/internal/db/dbq/coplay.sql.go @@ -0,0 +1,78 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: coplay.sql + +package dbq + +import ( + "context" +) + +const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec + +DELETE FROM artist_similarity WHERE source = 'user_cooccurrence' +` + +// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a +// single shared-library, multi-user server (no per-user library ACL), so the +// "household" is the whole instance's user set. These queries recompute +// artist–artist co-occurrence edges from play_events and store them in +// artist_similarity under the pre-provisioned source = 'user_cooccurrence' +// (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then +// re-INSERTs so edges that fall below threshold disappear. Single-user servers +// produce no edges (the >= 2 co-player gate), so the arm is empty there. +func (q *Queries) DeleteArtistCoplayEdges(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteArtistCoplayEdges) + return err +} + +const insertArtistCoplayEdges = `-- name: InsertArtistCoplayEdges :exec +WITH user_artists AS ( + SELECT DISTINCT pe.user_id, t.artist_id + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.was_skipped = false + AND pe.started_at > now() - interval '365 days' +), +artist_players AS ( + SELECT artist_id, count(*)::float8 AS players + FROM user_artists + GROUP BY artist_id +), +pairs AS ( + SELECT ua.artist_id AS a_id, + ub.artist_id AS b_id, + count(*)::float8 AS coplayers + FROM user_artists ua + JOIN user_artists ub + ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id + GROUP BY ua.artist_id, ub.artist_id + HAVING count(*) >= 2 +), +scored AS ( + SELECT p.a_id, p.b_id, + p.coplayers / (pa.players + pb.players - p.coplayers) AS score + FROM pairs p + JOIN artist_players pa ON pa.artist_id = p.a_id + JOIN artist_players pb ON pb.artist_id = p.b_id +) +INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at) +SELECT a_id, b_id, score, 'user_cooccurrence', now() +FROM scored +WHERE score >= $1 +ON CONFLICT (artist_a_id, artist_b_id, source) +DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at +` + +// Two artists are "co-played" when the same users play both. score is the +// Jaccard of their distinct-player sets — coplayers / (playersA + playersB − +// coplayers) in (0,1] — which controls for globally-popular artists (an artist +// everyone plays would otherwise co-occur with everything). Only pairs with +// >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak +// edges and self-limits hub artists, whose large denominators drag their +// Jaccard down) are kept. Completed plays only, 365-day window. +func (q *Queries) InsertArtistCoplayEdges(ctx context.Context, score float64) error { + _, err := q.db.Exec(ctx, insertArtistCoplayEdges, score) + return err +} diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 8ea0516d..f95f3125 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -835,6 +835,22 @@ taste_overlap AS ( ORDER BY tpa.weight DESC, t.id LIMIT $10 ), +coplay_artists AS ( + -- Household co-play (#1533): tracks by artists co-played across the + -- instance with the seed's artist (source='user_cooccurrence', built by + -- the coplay worker). Mirrors similar_artists but from local co-occurrence + -- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as + -- similar_artists since it's artist-level. + SELECT t.id AS track_id, asim.score * 0.5 AS sim_score + FROM artist_similarity asim + JOIN tracks t ON t.artist_id = asim.artist_b_id + JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id + WHERE asim.source = 'user_cooccurrence' + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ORDER BY asim.score DESC, random() + LIMIT $11 +), random_fill AS ( SELECT t.id AS track_id, 0.0::float8 AS sim_score FROM tracks t @@ -846,6 +862,7 @@ random_fill AS ( UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM likes_overlap UNION SELECT track_id FROM taste_overlap + UNION SELECT track_id FROM coplay_artists ) ORDER BY random() LIMIT $9 @@ -864,6 +881,7 @@ FROM ( UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap UNION ALL SELECT track_id, sim_score FROM taste_overlap + UNION ALL SELECT track_id, sim_score FROM coplay_artists UNION ALL SELECT track_id, sim_score FROM random_fill ) u JOIN tracks t ON t.id = u.track_id @@ -894,6 +912,7 @@ type LoadRadioCandidatesV2Params struct { Limit_4 int32 Limit_5 int32 Limit_6 int32 + Limit_7 int32 } type LoadRadioCandidatesV2Row struct { @@ -914,7 +933,9 @@ type LoadRadioCandidatesV2Row struct { // $10 taste_overlap K (#796 phase 2b — tracks by the user's top // positively-weighted taste-profile artists, so taste-relevant tracks // enter the pool even when the similarity/random arms miss them; scored -// in Go via TasteMatch, so sim_score here is 0 pool-inclusion). +// in Go via TasteMatch, so sim_score here is 0 pool-inclusion), +// $11 coplay_artists K (#1533 — tracks by artists co-played across the +// instance with the seed's artist; source='user_cooccurrence'). // // Returns same shape as LoadRadioCandidates plus similarity_score column. func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { @@ -929,6 +950,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid arg.Limit_4, arg.Limit_5, arg.Limit_6, + arg.Limit_7, ) if err != nil { return nil, err diff --git a/internal/db/queries/coplay.sql b/internal/db/queries/coplay.sql new file mode 100644 index 00000000..d2e07c0a --- /dev/null +++ b/internal/db/queries/coplay.sql @@ -0,0 +1,55 @@ +-- Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a +-- single shared-library, multi-user server (no per-user library ACL), so the +-- "household" is the whole instance's user set. These queries recompute +-- artist–artist co-occurrence edges from play_events and store them in +-- artist_similarity under the pre-provisioned source = 'user_cooccurrence' +-- (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then +-- re-INSERTs so edges that fall below threshold disappear. Single-user servers +-- produce no edges (the >= 2 co-player gate), so the arm is empty there. + +-- name: DeleteArtistCoplayEdges :exec +DELETE FROM artist_similarity WHERE source = 'user_cooccurrence'; + +-- name: InsertArtistCoplayEdges :exec +-- Two artists are "co-played" when the same users play both. score is the +-- Jaccard of their distinct-player sets — coplayers / (playersA + playersB − +-- coplayers) in (0,1] — which controls for globally-popular artists (an artist +-- everyone plays would otherwise co-occur with everything). Only pairs with +-- >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak +-- edges and self-limits hub artists, whose large denominators drag their +-- Jaccard down) are kept. Completed plays only, 365-day window. +WITH user_artists AS ( + SELECT DISTINCT pe.user_id, t.artist_id + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.was_skipped = false + AND pe.started_at > now() - interval '365 days' +), +artist_players AS ( + SELECT artist_id, count(*)::float8 AS players + FROM user_artists + GROUP BY artist_id +), +pairs AS ( + SELECT ua.artist_id AS a_id, + ub.artist_id AS b_id, + count(*)::float8 AS coplayers + FROM user_artists ua + JOIN user_artists ub + ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id + GROUP BY ua.artist_id, ub.artist_id + HAVING count(*) >= 2 +), +scored AS ( + SELECT p.a_id, p.b_id, + p.coplayers / (pa.players + pb.players - p.coplayers) AS score + FROM pairs p + JOIN artist_players pa ON pa.artist_id = p.a_id + JOIN artist_players pb ON pb.artist_id = p.b_id +) +INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at) +SELECT a_id, b_id, score, 'user_cooccurrence', now() +FROM scored +WHERE score >= $1 +ON CONFLICT (artist_a_id, artist_b_id, source) +DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 954e1d28..3cce5883 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -42,7 +42,9 @@ WHERE t.id <> $2 -- $10 taste_overlap K (#796 phase 2b — tracks by the user's top -- positively-weighted taste-profile artists, so taste-relevant tracks -- enter the pool even when the similarity/random arms miss them; scored --- in Go via TasteMatch, so sim_score here is 0 pool-inclusion). +-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion), +-- $11 coplay_artists K (#1533 — tracks by artists co-played across the +-- instance with the seed's artist; source='user_cooccurrence'). -- Returns same shape as LoadRadioCandidates plus similarity_score column. WITH @@ -126,6 +128,22 @@ taste_overlap AS ( ORDER BY tpa.weight DESC, t.id LIMIT $10 ), +coplay_artists AS ( + -- Household co-play (#1533): tracks by artists co-played across the + -- instance with the seed's artist (source='user_cooccurrence', built by + -- the coplay worker). Mirrors similar_artists but from local co-occurrence + -- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as + -- similar_artists since it's artist-level. + SELECT t.id AS track_id, asim.score * 0.5 AS sim_score + FROM artist_similarity asim + JOIN tracks t ON t.artist_id = asim.artist_b_id + JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id + WHERE asim.source = 'user_cooccurrence' + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ORDER BY asim.score DESC, random() + LIMIT $11 +), random_fill AS ( SELECT t.id AS track_id, 0.0::float8 AS sim_score FROM tracks t @@ -137,6 +155,7 @@ random_fill AS ( UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM likes_overlap UNION SELECT track_id FROM taste_overlap + UNION SELECT track_id FROM coplay_artists ) ORDER BY random() LIMIT $9 @@ -155,6 +174,7 @@ FROM ( UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap UNION ALL SELECT track_id, sim_score FROM taste_overlap + UNION ALL SELECT track_id, sim_score FROM coplay_artists UNION ALL SELECT track_id, sim_score FROM random_fill ) u JOIN tracks t ON t.id = u.track_id diff --git a/internal/playlists/system.go b/internal/playlists/system.go index fc00e7a7..d12a1e3e 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -546,6 +546,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits { // deep slice of the user's top taste-profile artists into the pool // (#796 phase 2b). Empty for cold-start users (no profile yet). TasteOverlap: 80, + // Household co-play (#1533): a deeper collaborative slice for the + // taste surfaces. Empty on single-user servers. + UserCoplay: 40, } } diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 9a2a6afe..6ddecc34 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -82,6 +82,10 @@ type CandidateSourceLimits struct { // weighted taste-profile artists. 0 disables the arm (e.g. cold-start // users have an empty profile, so it contributes nothing anyway). TasteOverlap int + // UserCoplay (#1533): tracks by artists co-played across the instance + // with the seed's artist (source='user_cooccurrence'). Empty on + // single-user servers, so it contributes nothing there. + UserCoplay int } // DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. @@ -93,6 +97,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits { LikesOverlap: 20, RandomFill: 30, TasteOverlap: 20, + UserCoplay: 20, } } @@ -125,6 +130,7 @@ func LoadCandidatesFromSimilarity( Limit_4: int32(limits.LikesOverlap), Limit_5: int32(limits.RandomFill), Limit_6: int32(limits.TasteOverlap), + Limit_7: int32(limits.UserCoplay), }) if err != nil { return nil, err