feat(taste): household co-play similarity — #1533
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m48s

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:
2026-07-14 10:07:19 -04:00
parent 65dd132b3d
commit 199fec2058
9 changed files with 441 additions and 2 deletions
+23 -1
View File
@@ -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