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,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
|
||||
}
|
||||
Reference in New Issue
Block a user