Compare commits
4
Commits
v2026.08.01
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27029f674 | ||
|
|
14aa22198f | ||
|
|
cf7b489fec | ||
|
|
8483948f23 |
+25
-1
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
|
||||
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||
suspend fun maxPosition(playlistId: String): Int?
|
||||
|
||||
/** Replace-all pattern for a playlist; called after a sync delta lands. */
|
||||
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||
suspend fun deleteByPlaylist(playlistId: String)
|
||||
|
||||
/**
|
||||
* Replaces a playlist's whole membership in ONE transaction; called
|
||||
* after a refresh or a sync delta lands.
|
||||
*
|
||||
* Atomic on purpose. Room's InvalidationTracker only notifies observers
|
||||
* after the transaction commits, so [observeByPlaylist] never sees the
|
||||
* empty gap between the delete and the re-insert. Un-transacted, that
|
||||
* gap is a real observed state — it's what made every Home row visibly
|
||||
* collapse to empty and refill before issue #2327 fixed the equivalent
|
||||
* write in `CachedHomeIndexDao`.
|
||||
*
|
||||
* Nothing observes [observeByPlaylist] live today, so this is
|
||||
* pre-emptive: it means making playlist detail cache-first later can't
|
||||
* silently reintroduce that flicker.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replacePlaylistTracks(
|
||||
playlistId: String,
|
||||
rows: List<CachedPlaylistTrackEntity>,
|
||||
) {
|
||||
deleteByPlaylist(playlistId)
|
||||
if (rows.isNotEmpty()) upsertAll(rows)
|
||||
}
|
||||
|
||||
@Query(
|
||||
"DELETE FROM cached_playlist_tracks " +
|
||||
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
|
||||
|
||||
+3
-3
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
|
||||
throw e
|
||||
}
|
||||
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
||||
playlistTrackDao.deleteByPlaylist(id)
|
||||
playlistTrackDao.upsertAll(
|
||||
wire.tracks.mapNotNull { row ->
|
||||
playlistTrackDao.replacePlaylistTracks(
|
||||
playlistId = id,
|
||||
rows = wire.tracks.mapNotNull { row ->
|
||||
row.trackId?.let { trackId ->
|
||||
CachedPlaylistTrackEntity(
|
||||
playlistId = id,
|
||||
|
||||
+24
-11
@@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images:
|
||||
|
||||
```
|
||||
git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
||||
git.fabledsword.com/bvandeusen/ci-android:36
|
||||
```
|
||||
|
||||
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`).
|
||||
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`).
|
||||
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
|
||||
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
|
||||
|
||||
**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter
|
||||
client with the native Android app and `flutter.yml` was removed; `ci-android`
|
||||
took its place. `flutter_client/` is still in the tree but nothing builds it.
|
||||
CI-Runner still publishes `ci-flutter` and will retire it once that directory
|
||||
goes — so if the Flutter client is ever revived, say so there first.
|
||||
|
||||
## Image deps used
|
||||
|
||||
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
||||
- **docker buildx** — release container build + push in `release.yml`.
|
||||
- **curl** — release-asset polling / upload in `release.yml`.
|
||||
|
||||
### From `ci-flutter:3.44`
|
||||
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
|
||||
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
|
||||
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
|
||||
- **Java 25** — Gradle / Android build.
|
||||
### From `ci-android:36`
|
||||
- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
|
||||
`android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
|
||||
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
|
||||
to silence Gradle's launcher-JVM restricted-method warning.
|
||||
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
|
||||
No NDK: the native client has no C/C++ sources (this is why it isn't on
|
||||
`ci-flutter`).
|
||||
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
|
||||
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
|
||||
and CI checks agree.
|
||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path.
|
||||
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
||||
`android-release` job.
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
@@ -39,10 +52,10 @@ None.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions.
|
||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
|
||||
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
|
||||
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||
```yaml
|
||||
|
||||
@@ -1022,20 +1022,66 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
}
|
||||
|
||||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||||
WITH seeds AS (
|
||||
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||
--
|
||||
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||
-- from stops contributing instead of accumulating forever.
|
||||
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||
-- (new account, or before the first daily recompute).
|
||||
--
|
||||
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||
--
|
||||
-- Candidates already in the library, or already requested and not terminal,
|
||||
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||
WITH artist_plays AS (
|
||||
-- Completed plays only. The previous seed query counted every play_event,
|
||||
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
profile_seeds AS (
|
||||
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||
FROM taste_profile_artists tpa
|
||||
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||
),
|
||||
fallback_seeds AS (
|
||||
SELECT a.id AS artist_id,
|
||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
AS raw_signal
|
||||
FROM artists a
|
||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
LEFT JOIN play_events pe
|
||||
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||
GROUP BY a.id, gla.artist_id
|
||||
),
|
||||
seeds AS (
|
||||
SELECT s.artist_id,
|
||||
ln(1.0 + s.raw_signal) AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||
FROM (
|
||||
SELECT artist_id, raw_signal FROM profile_seeds
|
||||
UNION ALL
|
||||
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||
) s
|
||||
LEFT JOIN general_likes_artists gla
|
||||
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||
WHERE s.raw_signal > 0
|
||||
),
|
||||
contributions AS (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
|
||||
@@ -258,27 +258,66 @@ ORDER BY started_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: SuggestArtistsForUser :many
|
||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
||||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
||||
-- artist_similarity_unmatched and filters out candidates already in
|
||||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
||||
WITH seeds AS (
|
||||
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||
--
|
||||
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||
-- from stops contributing instead of accumulating forever.
|
||||
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||
-- (new account, or before the first daily recompute).
|
||||
--
|
||||
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||
--
|
||||
-- Candidates already in the library, or already requested and not terminal,
|
||||
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
|
||||
WITH artist_plays AS (
|
||||
-- Completed plays only. The previous seed query counted every play_event,
|
||||
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
profile_seeds AS (
|
||||
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||
FROM taste_profile_artists tpa
|
||||
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||
),
|
||||
fallback_seeds AS (
|
||||
SELECT a.id AS artist_id,
|
||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
AS raw_signal
|
||||
FROM artists a
|
||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
LEFT JOIN play_events pe
|
||||
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||
GROUP BY a.id, gla.artist_id
|
||||
),
|
||||
seeds AS (
|
||||
SELECT s.artist_id,
|
||||
ln(1.0 + s.raw_signal) AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||
FROM (
|
||||
SELECT artist_id, raw_signal FROM profile_seeds
|
||||
UNION ALL
|
||||
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||
) s
|
||||
LEFT JOIN general_likes_artists gla
|
||||
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||
WHERE s.raw_signal > 0
|
||||
),
|
||||
contributions AS (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
||||
// resolved to artist names.
|
||||
// suggestions.go is the per-user artist-suggestion service behind the
|
||||
// Discover request surface. Seeds from the taste profile (falling back to
|
||||
// likes + completed plays for a user who has none yet), projects those seeds
|
||||
// through artist_similarity_unmatched via a single CTE, and returns top-N
|
||||
// out-of-library candidates with top-3 attribution seeds resolved to names.
|
||||
//
|
||||
// Originally M5c, which seeded from raw likes + plays. That signal grew
|
||||
// without bound and counted skips as engagement, so a few heavily-played
|
||||
// artists monopolized every slot and the surface entrenched harder the more
|
||||
// the user listened. Reworked to seed from the taste profile in issue #2367;
|
||||
// see internal/db/queries/recommendation.sql for the tiering.
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -32,8 +43,12 @@ type SeedContribution struct {
|
||||
}
|
||||
|
||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
||||
// recency-decay half-life for plays (default 30, operator-tunable).
|
||||
// capped at 50 (default 12 when out of range).
|
||||
//
|
||||
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
|
||||
// seed path only — the likes + completed-plays fallback used while the user
|
||||
// has no taste-profile rows yet. Once the profile is populated it seeds
|
||||
// instead, carrying its own decay, so this knob stops applying.
|
||||
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 12
|
||||
@@ -42,10 +57,14 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
halfLifeDays = 30
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
// Over-fetch so there is something to rotate through. A deterministic
|
||||
// top-N over a score ordering shows the same faces every day until one is
|
||||
// requested away — the tail of the ranking was unreachable (#2367
|
||||
// mechanism 1). Selection from this pool happens in selectSuggestions.
|
||||
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: halfLifeDays,
|
||||
Limit: int32(limit),
|
||||
Limit: int32(poolSizeFor(limit)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: query: %w", err)
|
||||
@@ -98,5 +117,133 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
Attribution: attribution,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
return selectSuggestions(out, limit, rotationDay(time.Now())), nil
|
||||
}
|
||||
|
||||
// Pool multiplier: how many scored candidates to fetch per slot shown, so the
|
||||
// rotation has somewhere to rotate. 4x keeps a day's deck genuinely different
|
||||
// from yesterday's without pulling the whole long tail (whose scores are noise)
|
||||
// into every request.
|
||||
const suggestionPoolFactor = 4
|
||||
|
||||
// Hard ceiling on the fetched pool. The scored tail past this is low-signal, so
|
||||
// paying for it would buy churn rather than better suggestions.
|
||||
const suggestionPoolMax = 60
|
||||
|
||||
func poolSizeFor(limit int) int {
|
||||
n := limit * suggestionPoolFactor
|
||||
if n > suggestionPoolMax {
|
||||
return suggestionPoolMax
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// rotationDay is the bucket the daily rotation hashes against. Server-local
|
||||
// date, matching the `current_date` the Home rows already rotate on, so the
|
||||
// whole product turns over at the same moment.
|
||||
func rotationDay(now time.Time) string {
|
||||
return now.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// selectSuggestions turns the scored pool into the response.
|
||||
//
|
||||
// Pure by design — no DB, no clock — so the rotation and diversity rules are
|
||||
// unit-testable without Postgres. Callers pass the day bucket in.
|
||||
//
|
||||
// Three rules, in order:
|
||||
//
|
||||
// 1. Diversity cap. Walking in score order, a candidate is dropped once its
|
||||
// dominant seed already owns maxPerSeed slots. Nothing capped this before,
|
||||
// so all twelve slots could be neighbours of one artist and the surface
|
||||
// read as a single narrow cluster (#2367 mechanism 4).
|
||||
// 2. Head. The best few by score always lead, so the strongest matches are
|
||||
// never rotated out of sight. Mirrors For You's head/tail shape.
|
||||
// 3. Tail. The rest of the slots are drawn from the remaining pool ordered by
|
||||
// md5(mbid + day) — the same daily-stable idiom the Home rows use. Stable
|
||||
// within a day, different tomorrow, and it needs no stored state.
|
||||
func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistSuggestion {
|
||||
if len(pool) <= limit {
|
||||
return pool
|
||||
}
|
||||
preferred, overflow := partitionPerSeed(pool, maxPerSeedFor(limit))
|
||||
|
||||
headCount := limit / 3
|
||||
if headCount > len(preferred) {
|
||||
headCount = len(preferred)
|
||||
}
|
||||
out := make([]ArtistSuggestion, 0, limit)
|
||||
out = append(out, preferred[:headCount]...)
|
||||
|
||||
// Hash once per candidate, not once per comparison.
|
||||
rest := preferred[headCount:]
|
||||
tail := make([]rotatable, 0, len(rest))
|
||||
for _, s := range rest {
|
||||
tail = append(tail, rotatable{key: rotationKey(s.MBID, day), suggestion: s})
|
||||
}
|
||||
sort.SliceStable(tail, func(i, j int) bool { return tail[i].key < tail[j].key })
|
||||
for _, r := range tail {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
out = append(out, r.suggestion)
|
||||
}
|
||||
// Diversity is a PREFERENCE, not a quota that may starve the deck. A user
|
||||
// whose whole pool hangs off one or two seeds would otherwise get a
|
||||
// three-card surface, which is worse than the monoculture we were avoiding
|
||||
// (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up
|
||||
// in score order from what the cap set aside.
|
||||
for _, s := range overflow {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rotatable pairs a candidate with its precomputed daily rotation key.
|
||||
type rotatable struct {
|
||||
key string
|
||||
suggestion ArtistSuggestion
|
||||
}
|
||||
|
||||
// maxPerSeedFor keeps roughly a quarter of the deck attributable to any single
|
||||
// seed artist — enough for a strong affinity to be well represented, not enough
|
||||
// for it to BE the deck. Floored at 1 so a small limit still returns something.
|
||||
func maxPerSeedFor(limit int) int {
|
||||
n := limit / 4
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// partitionPerSeed splits the pool by whether each candidate's dominant
|
||||
// (highest-contributing) seed still has allowance left. Input must be in score
|
||||
// order; both outputs preserve it.
|
||||
//
|
||||
// Candidates with no attribution are always preferred — there is no seed to
|
||||
// attribute them to, so they cannot be the cause of a monoculture.
|
||||
func partitionPerSeed(pool []ArtistSuggestion, maxPerSeed int) (preferred, overflow []ArtistSuggestion) {
|
||||
perSeed := make(map[pgtype.UUID]int, len(pool))
|
||||
preferred = make([]ArtistSuggestion, 0, len(pool))
|
||||
for _, s := range pool {
|
||||
if len(s.Attribution) == 0 {
|
||||
preferred = append(preferred, s)
|
||||
continue
|
||||
}
|
||||
dominant := s.Attribution[0].ArtistID
|
||||
if perSeed[dominant] >= maxPerSeed {
|
||||
overflow = append(overflow, s)
|
||||
continue
|
||||
}
|
||||
perSeed[dominant]++
|
||||
preferred = append(preferred, s)
|
||||
}
|
||||
return preferred, overflow
|
||||
}
|
||||
|
||||
func rotationKey(mbid, day string) string {
|
||||
sum := md5.Sum([]byte(mbid + day))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -330,3 +330,145 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
||||
|
||||
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||
userID, artistID, weight,
|
||||
); err != nil {
|
||||
t.Fatalf("set taste weight: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
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, 'skip-test') RETURNING id`,
|
||||
userID, startedAt,
|
||||
).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, true)`,
|
||||
userID, trackID, sessionID, startedAt,
|
||||
); err != nil {
|
||||
t.Fatalf("insert skipped play_event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
||||
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
||||
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seed := seedArtist(t, pool, "Profile Seed", "")
|
||||
|
||||
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
|
||||
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
|
||||
}
|
||||
if out[0].MBID != "out-mbid" {
|
||||
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
|
||||
}
|
||||
if out[0].Score <= 0 {
|
||||
t.Errorf("score = %v, want > 0", out[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the profile has any positive row, tier 2 is not consulted — so an artist
|
||||
// the user played but that the taste engine did not keep does NOT seed. That is
|
||||
// the point of the rewrite: the taste engine decides what counts as affinity.
|
||||
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
kept := seedArtist(t, pool, "Kept By Taste", "")
|
||||
dropped := seedArtist(t, pool, "Dropped By Taste", "")
|
||||
|
||||
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
|
||||
|
||||
// `dropped` has real completed plays but no profile row.
|
||||
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
|
||||
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
|
||||
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
|
||||
|
||||
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
|
||||
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
|
||||
}
|
||||
if out[0].MBID != "kept-cand" {
|
||||
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-positive taste weight is not affinity. Guarded by a second, positive
|
||||
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
|
||||
// tier 2 and the assertion would pass for the wrong reason.
|
||||
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
positive := seedArtist(t, pool, "Still Liked", "")
|
||||
abandoned := seedArtist(t, pool, "Abandoned", "")
|
||||
|
||||
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
|
||||
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
|
||||
|
||||
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
|
||||
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
for _, s := range out {
|
||||
if s.MBID == "neg-cand" {
|
||||
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
|
||||
}
|
||||
}
|
||||
if len(out) != 1 || out[0].MBID != "pos-cand" {
|
||||
t.Errorf("out = %+v, want only pos-cand", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
||||
// skipping an artist repeatedly increased its signal and pushed more of its
|
||||
// neighbours at the user (#2367 mechanism 3).
|
||||
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
skipped := seedArtist(t, pool, "Only Skipped", "")
|
||||
|
||||
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
|
||||
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
|
||||
for i := 0; i < 5; i++ {
|
||||
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
||||
}
|
||||
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// selectSuggestions is deliberately pure (no DB, no clock), so the rotation and
|
||||
// diversity rules from slice #2373 are covered here in the fast lane rather
|
||||
// than behind the integration gate.
|
||||
|
||||
func seedID(n byte) pgtype.UUID {
|
||||
var u pgtype.UUID
|
||||
u.Bytes[0] = n
|
||||
u.Valid = true
|
||||
return u
|
||||
}
|
||||
|
||||
// candidate builds a pool entry attributed to the given dominant seed.
|
||||
func candidate(mbid string, score float64, dominant byte) ArtistSuggestion {
|
||||
return ArtistSuggestion{
|
||||
MBID: mbid,
|
||||
Name: mbid,
|
||||
Score: score,
|
||||
Attribution: []SeedContribution{
|
||||
{ArtistID: seedID(dominant), Contribution: score},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// poolOf returns n candidates in descending score order, spread across
|
||||
// `seeds` distinct dominant seeds.
|
||||
func poolOf(n int, seeds int) []ArtistSuggestion {
|
||||
out := make([]ArtistSuggestion, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, candidate(fmt.Sprintf("mbid-%02d", i), 1.0-float64(i)*0.01, byte(i%seeds)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mbids(in []ArtistSuggestion) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, s.MBID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSelectSuggestions_ReturnsPoolUnchangedWhenNotOverfetched(t *testing.T) {
|
||||
pool := poolOf(5, 5)
|
||||
got := selectSuggestions(pool, 12, "2026-08-01")
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("len = %d, want 5 (nothing to rotate)", len(got))
|
||||
}
|
||||
if got[0].MBID != "mbid-00" {
|
||||
t.Errorf("first = %q, want mbid-00", got[0].MBID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectSuggestions_FillsTheRequestedLimit(t *testing.T) {
|
||||
got := selectSuggestions(poolOf(48, 8), 12, "2026-08-01")
|
||||
if len(got) != 12 {
|
||||
t.Fatalf("len = %d, want 12", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// The reported symptom: the deck must change day to day without the user
|
||||
// requesting anything.
|
||||
func TestSelectSuggestions_RotatesAcrossDays(t *testing.T) {
|
||||
pool := poolOf(48, 8)
|
||||
day1 := mbids(selectSuggestions(pool, 12, "2026-08-01"))
|
||||
day2 := mbids(selectSuggestions(pool, 12, "2026-08-02"))
|
||||
|
||||
if fmt.Sprint(day1) == fmt.Sprint(day2) {
|
||||
t.Fatalf("deck identical across days: %v", day1)
|
||||
}
|
||||
// ...but stable WITHIN a day, or the surface would reshuffle on every
|
||||
// pull-to-refresh, which reads as broken rather than fresh.
|
||||
again := mbids(selectSuggestions(pool, 12, "2026-08-01"))
|
||||
if fmt.Sprint(day1) != fmt.Sprint(again) {
|
||||
t.Errorf("same day differed:\n %v\n %v", day1, again)
|
||||
}
|
||||
}
|
||||
|
||||
// The strongest matches should never rotate out of sight.
|
||||
func TestSelectSuggestions_HeadIsStableTopScorers(t *testing.T) {
|
||||
pool := poolOf(48, 8)
|
||||
for _, day := range []string{"2026-08-01", "2026-08-02", "2026-09-15"} {
|
||||
got := selectSuggestions(pool, 12, day)
|
||||
if got[0].MBID != "mbid-00" {
|
||||
t.Errorf("day %s: first = %q, want mbid-00 (top score leads)", day, got[0].MBID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #2367 mechanism 4: all twelve slots could be neighbours of one artist.
|
||||
//
|
||||
// The pool is deliberately SKEWED — one seed owns the entire top of the score
|
||||
// ranking — because that is the only shape where a cap can bite. With scores
|
||||
// spread evenly across seeds the top-N is already diverse and the cap is
|
||||
// untestable (an earlier version of this test asserted against an even pool and
|
||||
// could not fail).
|
||||
func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) {
|
||||
const dominantRun = 20
|
||||
pool := make([]ArtistSuggestion, 0, 48)
|
||||
for i := 0; i < dominantRun; i++ { // seed 0 owns the 20 best scores
|
||||
pool = append(pool, candidate(fmt.Sprintf("dom-%02d", i), 1.0-float64(i)*0.01, 0))
|
||||
}
|
||||
for i := 0; i < 28; i++ { // seeds 1..9 hold everything below
|
||||
pool = append(pool, candidate(fmt.Sprintf("oth-%02d", i), 0.80-float64(i)*0.01, byte(1+i%9)))
|
||||
}
|
||||
|
||||
got := selectSuggestions(pool, 12, "2026-08-01")
|
||||
if len(got) != 12 {
|
||||
t.Fatalf("len = %d, want 12", len(got))
|
||||
}
|
||||
|
||||
perSeed := map[pgtype.UUID]int{}
|
||||
for _, s := range got {
|
||||
perSeed[s.Attribution[0].ArtistID]++
|
||||
}
|
||||
// Uncapped, the top 12 by score would be 12 of seed 0's neighbours.
|
||||
if n := perSeed[seedID(0)]; n > maxPerSeedFor(12) {
|
||||
t.Errorf("dominant seed owns %d of 12 slots, want <= %d: %v",
|
||||
n, maxPerSeedFor(12), mbids(got))
|
||||
}
|
||||
if len(perSeed) < 4 {
|
||||
t.Errorf("deck spans only %d seeds, want >= 4: %v", len(perSeed), mbids(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Diversity must not starve the deck (rule #131): a pool hanging off a single
|
||||
// seed should still fill, not collapse to maxPerSeed entries.
|
||||
func TestSelectSuggestions_SingleSeedPoolStillFills(t *testing.T) {
|
||||
got := selectSuggestions(poolOf(30, 1), 12, "2026-08-01")
|
||||
if len(got) != 12 {
|
||||
t.Fatalf("len = %d, want 12 (cap must not starve the deck)", len(got))
|
||||
}
|
||||
if got[0].MBID != "mbid-00" {
|
||||
t.Errorf("first = %q, want mbid-00", got[0].MBID)
|
||||
}
|
||||
}
|
||||
|
||||
// A candidate with no attribution has no seed to blame, so it must never be
|
||||
// held back by the diversity cap.
|
||||
func TestPartitionPerSeed_UnattributedNeverCapped(t *testing.T) {
|
||||
pool := []ArtistSuggestion{
|
||||
candidate("a", 0.9, 1),
|
||||
candidate("b", 0.8, 1),
|
||||
{MBID: "orphan", Score: 0.1},
|
||||
}
|
||||
preferred, overflow := partitionPerSeed(pool, 1)
|
||||
if len(preferred) != 2 || preferred[1].MBID != "orphan" {
|
||||
t.Errorf("preferred = %v, want [a orphan]", mbids(preferred))
|
||||
}
|
||||
if len(overflow) != 1 || overflow[0].MBID != "b" {
|
||||
t.Errorf("overflow = %v, want [b]", mbids(overflow))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizeFor_OverfetchesButIsBounded(t *testing.T) {
|
||||
if got := poolSizeFor(12); got != 48 {
|
||||
t.Errorf("poolSizeFor(12) = %d, want 48", got)
|
||||
}
|
||||
if got := poolSizeFor(50); got != suggestionPoolMax {
|
||||
t.Errorf("poolSizeFor(50) = %d, want the %d ceiling", got, suggestionPoolMax)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user