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