feat(taste): household co-play similarity — #1533
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user