Compare commits

..
15 Commits
Author SHA1 Message Date
bvandeusen 439c8625d5 Fix the rebundle step: a non-matching grep must not kill it (#130)
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 3m1s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 10:49:40 -04:00
bvandeusen 1d67c160b2 Versioning rework, a dev channel, and the miniplayer gap (#129)
release / Build signed APK (releases and dev) (push) Skipped
android / Build + lint + test (push) Failing after 4s
release / Build + push container image (push) Failing after 5s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m24s
test-go / integration (push) Successful in 6m25s
2026-09-10 08:33:26 -04:00
bvandeusen 237380b122 New brand mark, and both clients stop fetching their fonts at runtime (#128)
test-web / test (push) Successful in 54s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m36s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-09 14:36:15 -04:00
bvandeusen 4f077736b6 Merge PR #127: Sonos queue verification, cast double-download fix, stutter instrumentation
android / Build + lint + test (push) Successful in 4m16s
release / Build signed APK (tag releases only) (push) Successful in 5m25s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Successful in 1s
2026-08-18 10:50:10 -04:00
bvandeusen 727f68950e Merge PR #126: missing-file lifecycle, UPnP stall recovery, Android browse parity, Flutter client removed
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
2026-08-17 16:28:13 -04:00
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusen 324059b2bd Merge pull request 'Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268)' (#116) from dev into main
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m30s
android / Build + lint + test (push) Successful in 5m1s
test-go / integration (push) Successful in 5m29s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 17s
2026-08-03 08:38:24 -04:00
bvandeusen 1138d75a45 Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m33s
android / Build + lint + test (push) Successful in 4m30s
2026-08-01 12:23:37 -04:00
36 changed files with 240 additions and 2371 deletions
+5 -19
View File
@@ -6,20 +6,9 @@
**/build
web/build
# The Android client — built by its own job, never from this context. The APK
# reaches the image through client/, downloaded as a CI artifact, so nothing
# here reads android/ sources.
#
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
# that tree was deleted, leaving a comment describing an exclusion that was no
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
# has been entering the context and busting the `COPY . .` layer on every
# Android-only change.
android/
# Local `make build` output — an 18 MB binary the image never uses, since the
# builder stage compiles its own.
bin/
# Flutter mobile client — built separately on developer machines / Flutter CI.
# Including it in the Go build context wastes ~70 files and invalidates the
# `COPY . .` layer cache on every Flutter-only change.
# Docs and IDE noise
docs/
@@ -37,8 +26,5 @@ docs/
!.env.example
# CI workflow files don't need to ship in the image.
#
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
# Actions reads `.gitea/`, so the one directory that actually exists was the
# one not excluded, and every workflow edit invalidated the context.
.gitea/
.forgejo/
.github/
+9 -6
View File
@@ -80,12 +80,15 @@ jobs:
- name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Stock action: it works on this forge since the runner moved to
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
# success while Gitea serves artifacts back only through the v4 API, and
# it is what left 72 unreachable artifacts on this repo (Scribe 2270).
uses: actions/upload-artifact@v7
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on the hostname (no server setting
# reaches that check), and @v3 is worse — it reports success while Gitea
# serves artifacts back only through the v4 API, so the upload is stored
# and invisible to every retrieval path. @v3 is what left 72 unreachable
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
# See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk
+49 -115
View File
@@ -2,31 +2,11 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry.
#
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
# workflow_dispatch → manual trigger (same rules based on the ref)
#
# That is the whole tag map, and it is family rule 145 + 147 as written.
#
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
# without a release ceremony. It is minted only on main, where rollback is
# actually worth having: merges are gated (rule 2) so they number in the dozens
# per year, while on dev they would be one per push, forever, for a channel
# whose entire contract is that it moves.
#
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
# a version tag nobody pinned while the rollback unit the rule names did not
# exist here at all. Git and the build's own self-reported version answer
# "which build is this"; a third name for the same thing is upkeep for a model
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
# of versioning for their update process."
#
# There is no :main either. :latest tracks main's tip with no gate between them
# (rule 147), so a second name for the same image sends readers looking for a
# distinction that does not exist.
#
# The dev channel exists so testing a build does not require shipping one.
# Before it, the only way to get an APK onto a phone was to cut a release,
# which made `main` the staging area by default. `:dev` carries its own
@@ -232,12 +212,13 @@ jobs:
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
# will never serve back. This is the producing half of a pair:
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
# any download v4+ on this forge (every combination tested 2026-09-10,
# Scribe spike #3843), so the two pins need not move together.
uses: actions/upload-artifact@v7
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# hostname, @v3 uploads something Gitea will never serve back. This is
# the producing half of a pair: image-release downloads `minstrel-apk`
# below with the matching download-artifact mirror. Both must stay on
# the v4 protocol — mixing a v3 upload with a v4 download (or the
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk
@@ -331,16 +312,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history, and rule 149 names this specifically: any job that
# DERIVES the version name needs it, because a shallow clone changes
# what git-derived values resolve to WITHOUT failing — a too-low
# value, silently, with every lane green.
#
# This job was depth-1 while it took the version from GITHUB_REF. It
# now runs ci/version.sh itself, because with :<version> image tags
# gone the server's self-reported version is the only thing that says
# which build an image is.
fetch-depth: 0
# Shallow is fine here. This job used to need full history + tags to
# re-derive the bundled APK's version from the tagged commit; it now
# downloads the sidecar the release recorded, and touches git for
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
fetch-depth: 1
- name: Detect buildable project
id: guard
@@ -358,68 +334,30 @@ jobs:
if: steps.guard.outputs.ready == 'true'
shell: bash
run: |
set -euo pipefail
# THE VERSION, and it is derived the same way on every ref — the
# branch decides the CHANNEL, never the version (family rule 149).
#
# This used to be three different things: the literal string "main"
# on main, "dev" on dev, and the tag name on a tag. None of them
# ordered, and the first two were the same string forever — two dev
# images eight weeks apart were indistinguishable in the UI. That
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
# build; with version image tags gone, this IS how an operator tells
# which build a container is running.
#
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
# so the empty check below is actually reachable. A grep here would
# kill the step at the assignment under the runner's pipefail — the
# exact bug that took down the first main build after the version
# rework.
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
if [ -z "${VERSION}" ]; then
echo "::error::could not derive a build version from ci/version.sh"
exit 1
fi
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
# A release refreshes the CHANNEL and mints nothing else.
#
# The tag build exists to produce the signed APK and attach it to
# the release; the image it rebuilds is the SAME SOURCE as the main
# build minutes earlier, differing only in which APK is baked in.
# Rule 145 is explicit about that case: when the same source is
# rebuilt with different contents, publish the moving channel tag
# and never a commit-addressable one.
#
# :latest must move here rather than waiting for the next main
# push, or the channel would carry the PREVIOUS release's APK
# indefinitely — a channel that cannot refresh itself (rule 146).
CHANNEL=stable
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
VERSION="${GITHUB_REF#refs/tags/}"
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "::notice::Release build: ${VERSION} + latest"
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
# The rolling test channel, and :dev ALONE — deliberately no
# per-commit tag. A rolling channel is rolling by definition, so a
# commit-addressable image here would be a rollback target nobody
# has ever pulled, accumulating in the registry forever. Recovery
# on dev is to fix forward.
CHANNEL=dev
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build ${VERSION}: :dev"
echo "version=dev" >> "$GITHUB_OUTPUT"
echo "::notice::Dev-branch build: :dev"
else
# The production line: :latest tracks main's tip (rule 147) and
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
# the family's other repos, so a rollback target is addressable
# straight from the commit anyone is reading.
CHANNEL=stable
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
# Main is the protected, post-PR-merge branch. Treat it as the
# rolling stable channel — every main push moves :latest.
# Pinned consumers can target :vYYYY.MM.DD.HHMM, which never
# moves; everyone else gets the newest main.
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build: :main + :latest"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
- name: Registry login
if: steps.guard.outputs.ready == 'true'
shell: bash
@@ -434,11 +372,20 @@ jobs:
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
# Consuming half of the pair: stock download-artifact, which works here for
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
# out of the bundle; snippet #2271). v8 runs on node24, which every
# CI-runner image carries — the runner uses the image's own node.
uses: actions/download-artifact@v8
# Consuming half of the pair — never actions/download-artifact. Same fork,
# same reason: upstream's client-side GHES check rejects this hostname
# before it connects. bvandeusen/download-artifact mirrors
# code.forgejo.org/forgejo/download-artifact.
#
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# the action's own version number — the two actions release on unrelated
# cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
with:
name: minstrel-apk
path: client/
@@ -531,7 +478,6 @@ jobs:
run: |
docker buildx build \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
--push ${{ steps.tags.outputs.args }} .
# Verifies a tag release actually ended up complete, and names the specific
@@ -542,8 +488,8 @@ jobs:
# `failure` with none executed and image-release showed `skipped`. The run was
# red, but the *release page rendered fine*, and `main`'s own push build had
# already moved `:latest`, so the code was deployable and nothing looked
# obviously wrong. The release was simply missing its APK and its image,
# which is easy to skim past.
# obviously wrong. The release was simply missing its APK and its immutable
# `:vYYYY.MM.DD` image, which is easy to skim past.
#
# This job cannot prevent that (the cause was a runner failing to launch, not
# anything in this file). What it does is turn an incomplete release into an
@@ -594,30 +540,18 @@ jobs:
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
# it runs even when image-release failed, so without this the guard would
# cheerfully verify an incomplete release.
#
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
# 2026-09-10. Version image tags are no longer published (rule 145), so
# that assertion would now fail every release for a tag nothing mints.
# The rollback target it was really protecting is the :<sha> image, which
# main's own build published for this same commit before the tag was cut.
#
# Checking it here earns its keep twice over: it still catches an image
# push that silently did not happen, and it additionally proves the
# ORDERING — a tag cut on a commit whose main build never completed has
# no rollback target, and that is worth failing on rather than
# discovering during an incident.
- name: Rollback image must exist for the tagged commit
- name: Immutable image tag must exist
shell: bash
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
exit 1
fi
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
echo "::notice::image verified: ${IMAGE}:${TAG}"
-5
View File
@@ -12,11 +12,6 @@
# Test binary, built with `go test -c`
*.test
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
# binary committed by accident, last refreshed by a commit about web test
# mocks, and re-dirtied by every local build since.
bin/
# Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow.
client/minstrel.apk
+4 -13
View File
@@ -15,21 +15,12 @@ COPY . .
# Overwrite the committed placeholder with the freshly-built SPA assets.
COPY --from=web /web/build ./web/build
ENV CGO_ENABLED=0
# Version stamping. release.yml passes the DERIVED version name
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
# to "dev"/"local". Both are surfaced at /healthz.
#
# These are two values on purpose (family rule 149): the same commit built on
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
# the channel into the version string is what the rule forbids — the version
# used to BE the channel word here ("main"/"dev"), which meant two dev images
# eight weeks apart were indistinguishable.
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
# build-arg; local `docker build` falls back to "dev". Surfaced at
# /healthz for operator-side image-version verification.
ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \
-ldflags="-s -w \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
-o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim
+7 -10
View File
@@ -112,21 +112,18 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
- `:latest`production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
- `:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
- `:latest`the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
Every `:latest`, `:vYYYY.MM.DD.HHMM` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All of them are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
## Specs
@@ -160,7 +157,7 @@ Two concurrent dev processes:
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
- `main` is **protected** — changes land via PR from `dev`.
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
Task and milestone tracking: Fable (`Minstrel` project, id 12).
Executable
BIN
View File
Binary file not shown.
+47 -20
View File
@@ -62,30 +62,57 @@ None.
- **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 — `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 — stock `actions/upload-artifact@v7` and `actions/download-artifact@v8`; never `@v3`.**
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
```yaml
uses: actions/upload-artifact@v7
uses: actions/download-artifact@v8
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
```
Stock works on this forge since the runner moved to gitea/runner 3.x, which
edits the actions' client-side `isGhes()` refusal out of their bundles. Proven
on 2026-09-10 for upload v4v7 and download v4v8 (Scribe spike #3843). Until
then this repo pinned SHA mirrors of the Forgejo project's forks, because
upstream threw on the hostname before it opened a connection (Scribe 2255).
Upstream's `@v4+` cannot work against this instance and no server-side change
will help: `isGhes()` rejects any hostname that isn't `github.com` /
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
server is never asked what it supports. `@v3` is worse — it reports success,
and Gitea then serves artifacts back only through the v4 API
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
to every retrieval path. A green job producing nothing retrievable; that is how
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
stored but invisible to every retrieval path. That is how 72 unreachable
artifacts accumulated on this repo (Scribe 2270).
Both are pull mirrors of the Forgejo project's forks
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
disabling that check), mirrored so CI depends on commits we hold and pinned by
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
otherwise silently change what runs.
**Pairing no longer needs managing.** This entry used to pin upload v5 against
download v6 so both bundled `@actions/artifact` ^4.0.0, warning that a mismatch
across `release.yml`'s producer/consumer pair would list empty. Tested, and not
true on this instance: every download major v4v8 read the artifacts of every
upload major v4v7, by name and by pattern (CI-runner run 6312). The only real
protocol break is v3 → v4. node24 is no longer a concern either — every
CI-runner image carries Node 24 and the runner runs actions with the image's
`node`.
**Match the pins on `@actions/artifact`, not on the actions' own version
numbers.** The two actions release on unrelated cadences, so equal version
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
matters because `release.yml` is a producer/consumer pair — `android-release`
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
across it yields an empty listing rather than an error, exactly the silent
failure this entry exists to prevent.
| tag | `@actions/artifact` | runtime |
|---|---|---|
| upload v4 | ^2.1.1 | node20 |
| **upload v5** ← pinned | **^4.0.0** | node20 |
| download v4 | ^2.1.1 | node20 |
| download v5 | ^2.3.2 | node20 |
| **download v6** ← pinned | **^4.0.0** | node20 |
| download v7 | ^5.0.0 | **node24** |
The only true protocol break in this history was **v3 → v4** (upstream:
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
below are not supported"); v4-and-up are one family. Later majors are mostly
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
at 500 artifacts; download v5 made by-ID extraction match by-name.
**Do not jump the download pin to v7.** That major is a runner requirement, not
a feature change: it moves to `runs.using: node24` and upstream states it
"requires a minimum Actions Runner version of 2.327.1 … if you are using
self-hosted runners, ensure they are updated before upgrading." act_runner is
not GitHub's runner and makes no such version claim, so node24 is unverified
here. Everything currently pinned is node20.
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
an upload that matches nothing fails its own job instead of failing the
+2 -77
View File
@@ -2,8 +2,7 @@
#
# Derives the three values a build is stamped with, and the tag that names it.
#
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
# name=YYYY.MM.DD.HHMM label for people, from the COMMIT's timestamp
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
# tag=v<name> what a release of this commit must be called
#
@@ -34,83 +33,9 @@ readonly REF="${1:-HEAD}"
# Both clocks are overridable so a test can pin them. Nothing but tests should
# set these — the defaults are the real derivation.
# The paths that do NOT ship, in either artifact. Everything else counts.
#
# A DENYLIST, and the direction is the whole point. As an allowlist, the list
# has to be updated by whoever adds a directory and nothing fails if they
# don't — so the failure mode is a changed artifact keeping its old version,
# silently, on a green run. That is a build lying about what it is. Inverted,
# new content counts by default and the only way to wrongly EXCLUDE something
# is to name it here deliberately.
#
# The two error directions are not symmetric, which is why this is not taste:
# wrongly excluded → changed artifact, unchanged version. A silent lie.
# wrongly included → version moves when nothing shipped. Cosmetic noise in
# a string nobody sorts.
#
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
# is shorter than it looks like it should be. The server image ships cmd/,
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
# android/. Neither ships the other's sources — but excluding android/ here
# would stop an Android-only commit from moving the APK's OWN version, which
# is the dangerous direction. So this is the union: exclude only what ships in
# NEITHER, and accept that an Android commit also nudges the server's reported
# version. Over-inclusion across the two, which is the harmless direction.
#
# The family's other repos (roundtable / roundtable-android) each keep a
# tighter list because they are separate repos with one artifact apiece. Do
# not copy theirs onto this one.
readonly SHIPPED=(
.
':!.gitea' # CI workflows — including this script's own caller
':!ci' # CI scripts — including this script
':!docs'
':!tools' # asset/font generators; their OUTPUT ships, they do not
':!deploy' # test-database bootstrap SQL
':!bin' # local `make build` output
':!*.md'
':!Makefile'
':!docker-compose.yml'
':!.env.example'
':!.gitignore'
':!.dockerignore'
':!renovate.json'
':!.golangci.yml'
# TESTS DO NOT SHIP, so they must not re-version an artifact.
#
# Named as globs rather than a directory because this repo has no tests/
# tree to exclude: Go tests sit inline beside the code they cover, and the
# web suite sits beside its modules. `go build` drops *_test.go outright and
# the Vite build never imports a .test.ts, so neither reaches an artifact.
#
# A commit touching a test AND its source still moves the version — the
# source path matches on its own. Only a test-ONLY commit is inert, which is
# the whole intent.
#
# Patterns match what exists today and nothing speculative: there are no
# .spec.* files, no __tests__/ directories and no androidTest/ tree. If any
# appear they will re-version until named here, which is the harmless
# direction and the reason this list is a denylist.
':!*_test.go' # 158 files, inline beside the code
':!*.test.ts' # 114 files
':!*.test.js'
':!android/app/src/test' # JVM unit tests; no androidTest tree exists
':!web/vitest.config.ts' # test-harness config, not build config
':!web/vitest.setup.ts'
)
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
if [ -z "${commit_epoch}" ]; then
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
# Loudly, on purpose. A silent fallback here is the landmine this whole
# script exists to avoid: a plausible-looking version that is quietly wrong,
# on a green run. Realistically this means a shallow clone (no commit in
# range touches the shipped set) rather than a repo of pure CI config.
if [ -z "${commit_epoch}" ]; then
echo "version.sh: no commit under '${REF}' touches the shipped file set — shallow clone? (needs fetch-depth: 0)" >&2
exit 1
fi
commit_epoch="$(git log --format=%ct -1 "${REF}")"
fi
now_epoch="${MINSTREL_NOW_EPOCH:-$(date -u +%s)}"
+4 -6
View File
@@ -97,16 +97,14 @@ type tuningSnapshot struct {
func (h *handlers) tuningSnapshot() tuningSnapshot {
var out tuningSnapshot
out.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
recsettings.ScopeSongsLike: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeSongsLike)),
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
}
out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Discover = discoverRespFrom(h.recSettings.Discover())
out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
recsettings.ScopeSongsLike: weightsRespFrom(recsettings.ShippedSongsLikeWeights()),
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
}
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
+6 -12
View File
@@ -24,7 +24,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
@@ -56,7 +55,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
streamSecret: streamSecret,
netSettings: netSettings,
reacqSettings: reacqSettings,
librarySize: recommendation.NewLibrarySize(nil),
}
r.Route("/api", func(api chi.Router) {
@@ -270,16 +268,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
}
type handlers struct {
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
recSettings *recsettings.Service
rng func() float64
// librarySize memoises the track count that sizes the candidate pool
// (#3880). Held here rather than counted per request: the count is a
// full table scan, and library size only moves when a scan runs.
librarySize *recommendation.LibrarySize
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
recSettings *recsettings.Service
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
+2 -24
View File
@@ -87,24 +87,10 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
// Size the pool to the library (#3880). A fixed ~170 candidates samples a
// shrinking fraction of a growing collection, which is what made the
// recommendations feel less relevant as the library grew. Degrades to the
// base limits if the count is unavailable — never fails the request over a
// sizing hint.
librarySize := h.librarySize.Get(r.Context(), func(ctx context.Context) (int64, error) {
return recommendation.CountLibraryTracks(ctx, q)
})
limits := recommendation.ScaleForLibrary(
recommendation.DefaultCandidateSourceLimits(), librarySize,
)
limits := recommendation.DefaultCandidateSourceLimits()
candidates, err := recommendation.LoadCandidatesFromSimilarity(
r.Context(), q, user.ID, seedID,
h.recCfg.RecentlyPlayedHours, currentVec, exclude, limits,
// A fresh seed per request (#3889): radio is a new session each time
// and SHOULD draw differently. The system mixes are the surfaces that
// promise repeatability; this is not one of them.
strconv.FormatInt(time.Now().UnixNano(), 36),
)
if err != nil {
h.logger.Warn("api: radio: similarity-pool failed; falling back to whole-library", "err", err)
@@ -122,15 +108,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
// Scoring weights come from the DB-backed tuning lab (#1250) —
// read per request so an admin change takes effect live.
weights := h.recSettings.Weights(recsettings.ScopeRadio)
// Diversity caps (#3882). Radio had none while every sibling surface did,
// which is how a whole session could come back from one artist. Scaled to
// the requested length so a 20-track radio and a 200-track one are capped
// alike; Shuffle relaxes them rather than returning a short radio.
//
// limit-1 because the seed track occupies the first slot and is prepended
// below — the caps govern the tracks that FOLLOW it.
caps := recommendation.RadioDiversityCaps(limit - 1)
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1, caps)
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
out := make([]TrackRef, 0, len(picks)+1)
out = append(out, trackRefFrom(track, album.Title, artist.Name))
+16 -32
View File
@@ -829,7 +829,7 @@ similar_artists AS (
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'listenbrainz'
AND t.id NOT IN (SELECT id FROM excluded_ids)
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
ORDER BY asim.score DESC, random()
LIMIT $6
),
tag_overlap AS (
@@ -857,7 +857,7 @@ likes_overlap AS (
WHERE t.id = gl.track_id
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
)
ORDER BY md5(gl.track_id::text || $12::text)
ORDER BY random()
LIMIT $8
),
taste_overlap AS (
@@ -884,7 +884,7 @@ coplay_artists AS (
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS (
@@ -900,7 +900,7 @@ random_fill AS (
UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
)
ORDER BY md5(t.id::text || $12::text)
ORDER BY random()
LIMIT $9
)
SELECT
@@ -938,18 +938,17 @@ GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
`
type LoadRadioCandidatesV2Params struct {
UserID pgtype.UUID
ID pgtype.UUID
Column3 interface{}
Column4 []pgtype.UUID
Limit int32
Limit_2 int32
Limit_3 int32
Limit_4 int32
Limit_5 int32
Limit_6 int32
Limit_7 int32
Column12 string
UserID pgtype.UUID
ID pgtype.UUID
Column3 interface{}
Column4 []pgtype.UUID
Limit int32
Limit_2 int32
Limit_3 int32
Limit_4 int32
Limit_5 int32
Limit_6 int32
Limit_7 int32
}
type LoadRadioCandidatesV2Row struct {
@@ -972,22 +971,8 @@ type LoadRadioCandidatesV2Row struct {
// enter the pool even when the similarity/random arms miss them; scored
// 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'),
// $12 order_seed (text) — see below.
// instance with the seed's artist; source='user_cooccurrence').
//
// $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned
// a stable set only while their LIMIT exceeded the rows eligible for them: at
// that point they returned all of them and the order stopped mattering,
// because the caller sorts by track id before scoring. Below that threshold
// they returned a random SUBSET, and two builds on the same day drew
// different ones — so "daily determinism" held by accident, and only for
// libraries smaller than the limits.
//
// md5(id || seed) keeps the intent — an arbitrary spread that changes when
// the seed does — while making it reproducible for a given seed. The CALLER
// decides what that means: system mixes pass a per-(user, day) string and get
// the determinism they promise; radio passes a fresh value per request and
// keeps varying, which is what a radio should do.
// Returns same shape as LoadRadioCandidates plus similarity_score column.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
@@ -1002,7 +987,6 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
arg.Limit_5,
arg.Limit_6,
arg.Limit_7,
arg.Column12,
)
if err != nil {
return nil, err
@@ -1,16 +0,0 @@
-- Drop the rows the narrower constraints are about to forbid, or re-adding
-- them fails against existing data (the 0051 down-migration pattern).
DELETE FROM recommendation_weight_profiles WHERE profile = 'songs_like';
DELETE FROM recommendation_tuning_audit WHERE scope = 'songs_like';
ALTER TABLE recommendation_tuning_audit
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
ALTER TABLE recommendation_tuning_audit
ADD CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover'));
ALTER TABLE recommendation_weight_profiles
DROP CONSTRAINT recommendation_weight_profiles_profile_check;
ALTER TABLE recommendation_weight_profiles
ADD CONSTRAINT recommendation_weight_profiles_profile_check
CHECK (profile IN ('radio', 'daily_mix'));
@@ -1,37 +0,0 @@
-- 0057_songs_like_tuning.up.sql — a THIRD weight profile, for Songs-like
-- (Scribe #3881, milestone #398).
--
-- Songs-like shared the `daily_mix` profile with For-You, and that is the bug.
-- The two surfaces want opposite things: For-You is a broad "what will they
-- enjoy today", Songs-like answers "what sounds like THIS", and under one set
-- of weights the broad answer wins. Operator, 2026-09-10: "I'm expecting to
-- get a consistent sound and style from the experience... I was getting a
-- seeming wide variety of music from each one when I was hoping to stay in a
-- certain neighborhood."
--
-- Under the shared daily_mix weights, an UNRELATED track the user had liked
-- and not played recently scored 1.0 + 2.0 + 1.0 = 4.0 before taste, while a
-- PERFECT similarity match they had not liked scored 1.0 + 1.5 = 2.5. Liking
-- something outranked sounding like the seed. Splitting the profile is what
-- lets similarity dominate here without making For-You narrow.
--
-- Rows are seeded by the recsettings boot reconcile, not here, so shipped
-- defaults live in exactly one place (Go) — same as 0040.
-- Rule #36: a new value for a CHECK-gated column needs the constraint
-- rewritten in the SAME change, or the first row written under the new
-- profile fails at runtime rather than at migrate time.
ALTER TABLE recommendation_weight_profiles
DROP CONSTRAINT recommendation_weight_profiles_profile_check;
ALTER TABLE recommendation_weight_profiles
ADD CONSTRAINT recommendation_weight_profiles_profile_check
CHECK (profile IN ('radio', 'daily_mix', 'songs_like'));
-- The audit table gates the same name on a separate constraint. Missing this
-- one would let the profile be seeded and then fail on the first knob turn —
-- green at boot, 500 on first use.
ALTER TABLE recommendation_tuning_audit
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
ALTER TABLE recommendation_tuning_audit
ADD CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover', 'songs_like'));
+5 -20
View File
@@ -45,22 +45,7 @@ WHERE t.id <> $2
-- enter the pool even when the similarity/random arms miss them; scored
-- 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'),
-- $12 order_seed (text) — see below.
--
-- $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned
-- a stable set only while their LIMIT exceeded the rows eligible for them: at
-- that point they returned all of them and the order stopped mattering,
-- because the caller sorts by track id before scoring. Below that threshold
-- they returned a random SUBSET, and two builds on the same day drew
-- different ones — so "daily determinism" held by accident, and only for
-- libraries smaller than the limits.
--
-- md5(id || seed) keeps the intent — an arbitrary spread that changes when
-- the seed does — while making it reproducible for a given seed. The CALLER
-- decides what that means: system mixes pass a per-(user, day) string and get
-- the determinism they promise; radio passes a fresh value per request and
-- keeps varying, which is what a radio should do.
-- instance with the seed's artist; source='user_cooccurrence').
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
WITH
@@ -102,7 +87,7 @@ similar_artists AS (
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'listenbrainz'
AND t.id NOT IN (SELECT id FROM excluded_ids)
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
ORDER BY asim.score DESC, random()
LIMIT $6
),
tag_overlap AS (
@@ -130,7 +115,7 @@ likes_overlap AS (
WHERE t.id = gl.track_id
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
)
ORDER BY md5(gl.track_id::text || $12::text)
ORDER BY random()
LIMIT $8
),
taste_overlap AS (
@@ -157,7 +142,7 @@ coplay_artists AS (
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS (
@@ -173,7 +158,7 @@ random_fill AS (
UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
)
ORDER BY md5(t.id::text || $12::text)
ORDER BY random()
LIMIT $9
)
SELECT
+15 -114
View File
@@ -219,30 +219,7 @@ var (
// uniform with radio pending trend data.
ContextTimeWeight: 1.0,
}
// Songs-like's own profile (#3881). Pre-push literal only; shipped
// defaults live in recsettings.ShippedSongsLikeWeights and must stay in
// sync with it, exactly as systemMixWeights does above.
//
// SimilarityWeight dominates here and every seed-INDEPENDENT term is
// demoted, which is the whole difference between this surface and For-You.
// See ShippedSongsLikeWeights for the property the numbers encode.
songsLikeWeights = recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 0.5,
RecencyWeight: 0.25,
SkipPenalty: 2.0,
JitterMagnitude: 0.05,
ContextWeight: 0.5,
SimilarityWeight: 4.0,
TasteWeight: 0.25,
ContextTimeWeight: 0.5,
}
systemTasteConfig = taste.DefaultConfig()
// Sizes the candidate pool to the library (#3880). Cached because the
// count is a full table scan and the daily build runs it once per user;
// within the TTL every user in a build shares one count.
systemLibrarySize = recommendation.NewLibrarySize(nil)
)
// SetSystemMixWeights installs the current daily_mix scoring weights.
@@ -253,20 +230,6 @@ func SetSystemMixWeights(w recommendation.ScoringWeights) {
systemMixWeights = w
}
// SetSongsLikeWeights installs the songs_like scoring profile (#3881).
// Same push model as SetSystemMixWeights — recsettings calls it on boot and
// after every knob turn, so a tuning change takes effect on the next daily
// build with no restart.
//
// Separate from systemMixWeights because Songs-like and For-You want opposite
// things: For-You roams, Songs-like must not. Sharing one profile is what made
// "Songs like X" wander.
func SetSongsLikeWeights(w recommendation.ScoringWeights) {
systemTuningMu.Lock()
defer systemTuningMu.Unlock()
songsLikeWeights = w
}
// SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as
// SetSystemMixWeights.
@@ -276,23 +239,6 @@ func SetTasteConfig(c taste.Config) {
systemTasteConfig = c
}
// dailyOrderSeed is the value the randomised candidate arms order by (#3889).
//
// Per (user, day) so a same-day rebuild draws the SAME set — which is what
// TestBuildSystemPlaylists_DailyNonceDeterminism asserts and what those arms
// only ever achieved by accident before, when their limits happened to exceed
// the eligible rows. It changes on the day boundary, so the mixes still move
// daily.
func dailyOrderSeed(userID pgtype.UUID, dateStr string) string {
return uuidStringPL(userID) + ":" + dateStr
}
func currentSongsLikeWeights() recommendation.ScoringWeights {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
return songsLikeWeights
}
func currentSystemMixWeights() recommendation.ScoringWeights {
systemTuningMu.RLock()
defer systemTuningMu.RUnlock()
@@ -446,13 +392,7 @@ func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
// deterministic per (user, day) but rotates across days. Pure — no
// truncation, no cap.
// weights is a parameter rather than a read of currentSystemMixWeights()
// because this sort IS the selection: the caller caps and truncates in the
// order this returns, so whatever profile ranks here decides which tracks
// reach the playlist. Scoring with daily_mix here and re-scoring with
// songs_like afterwards would have let the new profile relabel tracks it had
// no part in choosing — inert where it matters (#3881).
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, weights recommendation.ScoringWeights) []recommendation.Candidate {
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
type scored struct {
c recommendation.Candidate
@@ -471,6 +411,7 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
sort.SliceStable(ordered, func(i, j int) bool {
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
})
weights := currentSystemMixWeights()
pairs := make([]scored, len(ordered))
for i, c := range ordered {
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
@@ -640,7 +581,6 @@ func produceForYou(
zeroVec,
seeds,
systemForYouSourceLimits(),
dailyOrderSeed(userID, dateStr),
)
if cerr != nil {
logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
@@ -692,13 +632,6 @@ func produceSeedMixes(
seedPool := pickSeedArtistsFromRows(seedRowsLocal)
seeds := pickSeedArtistsForDay(seedPool, userID, dateStr)
// Once per build rather than once per seed artist — six mixes would
// otherwise mean six full-table counts for a number that cannot have
// changed between them.
librarySize := systemLibrarySize.Get(ctx, func(c context.Context) (int64, error) {
return recommendation.CountLibraryTracks(c, q)
})
out := make([]builtPlaylist, 0, len(seeds))
for _, artistID := range seeds {
artistRow, aerr := q.GetArtistByID(ctx, artistID)
@@ -714,47 +647,23 @@ func produceSeedMixes(
continue
}
zeroVec := recommendation.SessionVector{Seed: true}
// Songs-like's own pool shape, not the default (#3881). Same total
// size; the composition shifts toward arms that actually measure
// distance from the seed. The default gave ~29% of candidates a
// sim_score of literally 0.
//
// Then scaled to the library (#3880): a bigger collection should put
// more genuinely-similar candidates in reach, not the same ~170
// regardless. The weights still rank sim_score-0 arms last, so the
// growth reaches coherence rather than working against it.
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
recommendation.ScaleForLibrary(
recommendation.SongsLikeCandidateSourceLimits(), librarySize,
),
dailyOrderSeed(userID, dateStr),
recommendation.DefaultCandidateSourceLimits(),
)
if cerr != nil {
logger.Warn("system playlist: seed candidates load failed; skipping",
"artist_id", uuidStringPL(artistID), "err", cerr)
continue
}
// The seed artist's own songs are ELIGIBLE here, deliberately.
//
// This used to filter them out — "Songs like X excludes X's own
// songs" — which reads as obviously right and is not. The seed is a
// TRACK, and the tracks most likely to sound like it are usually the
// rest of that artist's catalogue; excluding them threw away the
// nearest neighbours of the very thing the mix is built around, and
// then reached further out to replace them. On a surface whose whole
// job is staying in one neighbourhood, that is backwards.
//
// Operator, 2026-09-10: "it should also be able to include music from
// the same artist."
//
// Domination is bounded by the cap rather than by exclusion, which is
// the distinction that makes this safe: capCandidatesByAlbumAndArtist
// inside pickTopN allows at most discoverMaxTracksPerArtist (3) of a
// 25-track mix — 12%, a presence rather than a takeover. The seed
// track itself still cannot appear; it is passed as an exclusion to
// LoadCandidatesFromSimilarity above.
tracks := pickTopN(cands, userID, dateStr, now, systemMixLength)
// "Songs like X" excludes X's own songs.
filtered := make([]recommendation.Candidate, 0, len(cands))
for _, c := range cands {
if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) {
filtered = append(filtered, c)
}
}
tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength)
if len(tracks) == 0 {
continue
}
@@ -929,20 +838,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
// truncates to n. Used by Songs-like-X (and as the fallback inside
// pickHeadAndTail for small pools).
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
// songs_like, not daily_mix (#3881). produceSeedMixes is this function's
// only caller, so the switch moves exactly one surface — For-You ranks
// through pickHeadAndTail and keeps the broader daily_mix profile.
//
// The SAME profile does the selection sort and the final score. Passing
// one and using the other is the subtle version of this bug: the playlist
// would still be chosen by daily_mix and merely wear songs_like numbers.
weights := currentSongsLikeWeights()
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted)
if len(capped) > n {
capped = capped[:n]
}
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
out := make([]rankedCandidate, len(capped))
for i, c := range capped {
out[i] = rankedCandidate{
@@ -971,11 +873,10 @@ func pickHeadAndTail(
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
) []rankedCandidate {
// daily_mix — For-You is the broad surface and keeps the roaming profile.
weights := currentSystemMixWeights()
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
weights := currentSystemMixWeights()
total := headN + tailN
if len(capped) <= total {
-137
View File
@@ -2,10 +2,8 @@ package playlists_test
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
@@ -289,141 +287,6 @@ func TestBuildSystemPlaylists_Concurrency(t *testing.T) {
}
}
// seedSharedArtistLibrary seeds artists that genuinely OWN several tracks.
//
// seedActiveLibrary cannot be used for this: its helper documents that
// "artist and album are not deduplicated across calls (mbid-less upsert)",
// so every track gets its own artist row and a seed artist always has
// exactly one track — the seed itself, which is excluded. A same-artist
// assertion against that fixture can never pass no matter what the code
// does, which is how the first version of this test failed.
//
// Albums are not deduplicated either, for the same mbid-less reason, so each
// track ends up under its own album row however the titles are written. That
// is convenient here rather than a problem: it means the per-ALBUM cap (2)
// never binds, and the per-ARTIST cap (3) is unambiguously the thing under
// test. Do not "fix" the album titles into something shared without checking
// which cap you are then measuring.
func seedSharedArtistLibrary(
t *testing.T, pool *pgxpool.Pool, name string, numArtists, tracksPerArtist int,
) (dbq.User, []pgtype.UUID) {
t.Helper()
q := dbq.New(pool)
ctx := context.Background()
u := seedUser(t, pool, name)
now := time.Now().UTC()
dir := t.TempDir()
artistIDs := make([]pgtype.UUID, 0, numArtists)
for a := 0; a < numArtists; a++ {
artistName := name + "-shared-" + string(rune('A'+a))
ar, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: artistName, SortName: artistName})
if err != nil {
t.Fatalf("seed artist: %v", err)
}
artistIDs = append(artistIDs, ar.ID)
for k := 0; k < tracksPerArtist; k++ {
albumTitle := fmt.Sprintf("%s - Album %d", artistName, k/2)
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{
Title: albumTitle, SortTitle: albumTitle, ArtistID: ar.ID,
})
if err != nil {
t.Fatalf("seed album: %v", err)
}
tk, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: fmt.Sprintf("%s-t%d", artistName, k), AlbumID: al.ID, ArtistID: ar.ID,
DurationMs: 1000,
FilePath: filepath.Join(dir, fmt.Sprintf("%s-%d-%d.mp3", name, a, k)),
FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track: %v", err)
}
// Well clear of the recently-played exclusion window, which uses
// the DATABASE clock rather than the build's `now`.
for pl := 0; pl < 3; pl++ {
seedPlayEvent(t, pool, u.ID, tk.ID,
now.Add(-time.Duration(24+a*10+k+pl)*time.Hour), false)
}
}
}
return u, artistIDs
}
// The seed artist's own tracks are ELIGIBLE for its "Songs like" mix, and are
// bounded by the diversity cap rather than excluded outright (#3881).
//
// produceSeedMixes used to filter them out — "Songs like X excludes X's own
// songs" — which threw away the nearest neighbours of the seed TRACK and then
// reached further out to replace them. Operator, 2026-09-10: "it should also
// be able to include music from the same artist."
//
// Asserted end-to-end rather than by reading the source, because the guard has
// to survive the filter coming back in a different shape — and because an
// absence check would now match the comment explaining why the filter is gone.
func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
u, _ := seedSharedArtistLibrary(t, pool, "seedartist", 4, 6)
ctx := context.Background()
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, t.TempDir()); err != nil {
t.Fatalf("build: %v", err)
}
rows, err := pool.Query(ctx, `
SELECT count(*) FILTER (WHERE t.artist_id = p.seed_artist_id) AS own,
count(*) AS total
FROM playlists p
JOIN playlist_tracks pt ON pt.playlist_id = p.id
JOIN tracks t ON t.id = pt.track_id
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
GROUP BY p.id
`, u.ID)
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
// discoverMaxTracksPerArtist, which this package_test cannot reference.
// Duplicated deliberately: if the cap moves, this failing is the point.
const maxPerArtist = 3
mixes, withOwn := 0, 0
for rows.Next() {
var own, total int
if err := rows.Scan(&own, &total); err != nil {
t.Fatalf("scan: %v", err)
}
mixes++
if own > 0 {
withOwn++
}
// The bound is what makes inclusion safe. Without it, "include the
// seed artist" becomes "the mix is mostly the seed artist", which is
// the radio failure (#3882) arriving on a different surface.
if own > maxPerArtist {
t.Errorf("a songs_like mix carries %d tracks by its own seed artist "+
"out of %d; the per-artist cap (%d) is not being applied",
own, total, maxPerArtist)
}
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
if mixes == 0 {
t.Fatal("no songs_like_artist mixes were built, so this test asserts nothing")
}
if withOwn == 0 {
t.Errorf("none of the %d songs_like mixes contains a single track by its own "+
"seed artist — the tracks most likely to sound like the seed are being "+
"excluded from the surface whose job is sounding like the seed", mixes)
}
}
func TestBuildSystemPlaylists_DailyNonceDeterminism(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
-1
View File
@@ -102,7 +102,6 @@ func buildYouMightLike(
cands, err := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seed, 1, zeroVec,
[]pgtype.UUID{seed}, ymlLimits,
dailyOrderSeed(userID, dateStr),
)
if err != nil {
logger.Warn("you-might-like: candidate load failed; skipping",
-83
View File
@@ -105,87 +105,11 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
}
}
// SongsLikeCandidateSourceLimits is the pool shape for "Songs like {X}"
// (#3881). Same total size as the default (~170) — the composition is what
// changes, shifted hard toward arms that actually measure distance from the
// seed.
//
// The surface answers "what sounds like THIS", and it shared the default
// pool with For-You, which answers the much broader "what will they enjoy
// today". Under the default, 50 of ~170 candidates carried sim_score = 0 by
// construction — `taste_overlap` (tracks by the user's top taste artists) and
// `random_fill` (literally any track not already in the pool), both of which
// are seed-INDEPENDENT. Nearly a third of the pool had no relationship to the
// seed at all, and the operator saw it: "I was getting a seeming wide variety
// of music from each one when I was hoping to stay in a certain neighborhood."
//
// TIERED, per rule 131 — a system mix degrades, it never vanishes:
//
// tier 1 lb_similar real track-level similarity. The exact promise.
// tier 2 similar_artist, tag_overlap, coplay, likes_overlap
// seed-RELATED but weaker signal.
// tier 3 taste_overlap, random_fill
// seed-independent. The floor, and nothing more.
//
// The tiering is enforced by SCORE rather than by a fallback ladder: tier-3
// arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
// everything seed-independent demoted) they rank below any real match and
// surface only when tiers 12 cannot fill the mix. That is the rule's
// "fill from tier 1 first, reach down only when a tier cannot fill".
//
// Which is exactly why tier 3 is REDUCED rather than removed. Zeroing those
// two arms was the first instinct and it is the vanish-or-nothing shape rule
// 131 exists to forbid: a seed whose artist has thin ListenBrainz coverage
// would produce a short mix or none at all, and "no playlist" is a worse
// answer than "a few tracks further from the seed than we would like".
//
// The seed-independent arms are trimmed hardest, because on this surface they
// are noise: `taste_overlap` (tracks by the user's top taste artists) and
// `random_fill` (any track not already in the pool) both carry
// `0.0::float8 AS sim_score`, so nearly a third of the default pool had no
// relationship to the seed at all.
//
// THESE TRIMS WERE BLOCKED UNTIL #3889. `likes_overlap` and `random_fill`
// used to end in a bare `ORDER BY random()`, which made their output a stable
// SET only while the limit exceeded the eligible rows — so SHRINKING them
// changed pool membership between same-day rebuilds and broke daily
// determinism. Those arms now order by md5(id || seed), so a smaller limit
// takes a smaller but REPRODUCIBLE slice, and the trim is safe.
//
// Reduced, never removed. Rule 131: the two seed-independent arms are the
// tier-3 floor, and zeroing them would leave a seed with thin ListenBrainz
// coverage producing a short mix or none at all. The weights (SimilarityWeight
// 4.0, everything seed-independent demoted) keep them ranked last, so they
// surface only when the closer tiers cannot fill the mix.
//
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its
// SQL assigns a FLAT 0.6 sim_score (recommendation.sql) rather than measuring
// anything. It is a collaborative signal wearing similarity's clothes, and a
// raised SimilarityWeight amplifies it — if real ListenBrainz scores commonly
// land below 0.6 it would outrank genuine matches. Halved pending the
// fill-rate measurement in #3879; the honest fix is to stop it claiming a
// similarity score it never computed.
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
return CandidateSourceLimits{
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
SimilarArtist: 40, // tier 2 — raised; growing is always safe
TagOverlap: 20, // tier 2
UserCoplay: 20, // tier 2
LikesOverlap: 10, // tier 2, halved — flat 0.6 sim_score, see above
TasteOverlap: 10, // tier 3 floor — halved, not removed
RandomFill: 10, // tier 3 floor — cut hard, never to zero
}
}
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
//
// orderSeed decides whether the randomised arms repeat their draw — see
// Column12 below and #3889. Pass a stable per-(user, day) value where the
// selection must be reproducible, and a varying one where it should not be.
//
// Caller (radio handler) falls back to LoadCandidates on error.
func LoadCandidatesFromSimilarity(
ctx context.Context,
@@ -195,7 +119,6 @@ func LoadCandidatesFromSimilarity(
currentVector SessionVector,
exclude []pgtype.UUID,
limits CandidateSourceLimits,
orderSeed string,
) ([]Candidate, error) {
if exclude == nil {
exclude = []pgtype.UUID{}
@@ -212,12 +135,6 @@ func LoadCandidatesFromSimilarity(
Limit_5: int32(limits.RandomFill),
Limit_6: int32(limits.TasteOverlap),
Limit_7: int32(limits.UserCoplay),
// #3889. Four arms used to end in a bare ORDER BY random(), which made
// their output a stable SET only while the limit exceeded the eligible
// rows. They now order by md5(id || this), so the caller decides
// whether the draw repeats: a per-(user, day) seed for the system
// mixes that promise daily determinism, a fresh one per radio request.
Column12: orderSeed,
})
if err != nil {
return nil, err
+11 -109
View File
@@ -2,10 +2,6 @@ package recommendation
import (
"context"
"fmt"
"reflect"
"sort"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
@@ -54,7 +50,7 @@ func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
target := f.tracks[1]
helperLBSimilarity(t, f, seed.ID, target.ID, 0.85)
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -86,7 +82,7 @@ func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T
})
helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8)
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -110,7 +106,7 @@ func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) {
helperSetTrackGenre(t, f, seed.ID, "Rock; Pop")
helperSetTrackGenre(t, f, target.ID, "Rock")
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -137,7 +133,7 @@ func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) {
t.Fatalf("like: %v", err)
}
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -159,7 +155,7 @@ func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) {
f := newFixture(t, 10) // 10 tracks; no similarity data
seed := f.tracks[0]
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -180,7 +176,7 @@ func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) {
excluded := f.tracks[1].ID
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true},
[]pgtype.UUID{excluded}, defaultLimits(), "test-seed",
[]pgtype.UUID{excluded}, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -196,7 +192,7 @@ func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) {
f := newFixture(t, 5)
seed := f.tracks[0]
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -226,7 +222,7 @@ func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) {
t.Fatalf("play_event: %v", err)
}
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -246,7 +242,7 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap
helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -299,7 +295,7 @@ func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
// Only the taste_overlap arm is enabled.
limits := CandidateSourceLimits{TasteOverlap: 10}
got, err := LoadCandidatesFromSimilarity(
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, "test-seed",
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -325,7 +321,7 @@ func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
f := newFixture(t, 1) // just the seed
seed := f.tracks[0]
got, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
)
if err != nil {
t.Fatalf("load: %v", err)
@@ -335,97 +331,3 @@ func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
t.Errorf("got %d candidates from seed-only library, want 0", len(got))
}
}
// The randomised arms must draw REPRODUCIBLY for a given seed (#3889).
//
// Four arms used to end in a bare `ORDER BY random()`. That returned a stable
// set only while the arm's LIMIT exceeded the rows eligible for it — at that
// point it returned all of them and the order stopped mattering, because the
// caller sorts by track id before scoring. Below that threshold it returned a
// random SUBSET, so two calls drew different candidates.
//
// It therefore held by ACCIDENT, and only for libraries smaller than the
// limits. Any real library is larger, so same-day rebuilds had been drawing
// different mixes since the arm was written — invisible, because a mix that
// changes after a refresh looks like a feature.
//
// Limits deliberately smaller than the fixture, because that is the only
// regime where the bug existed at all: with limits above the eligible count
// the old code passes this too.
func TestLoadCandidatesFromSimilarity_SameSeedDrawsTheSameSet(t *testing.T) {
f := newFixture(t, 12)
seed := f.tracks[0]
tight := CandidateSourceLimits{
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
}
ids := func(cs []Candidate) []string {
out := make([]string, 0, len(cs))
for _, c := range cs {
out = append(out, fmt.Sprintf("%x", c.Track.ID.Bytes))
}
sort.Strings(out) // membership, not order — order is settled downstream
return out
}
first, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(first) == 0 {
t.Fatal("no candidates, so this test asserts nothing")
}
for i := 0; i < 3; i++ {
again, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
)
if err != nil {
t.Fatalf("load %d: %v", i, err)
}
if !reflect.DeepEqual(ids(first), ids(again)) {
t.Fatalf("same seed drew a different set on call %d:\n first %v\n again %v",
i, ids(first), ids(again))
}
}
}
// ...and a different seed is free to draw differently, or the ordering would
// be fixed rather than seeded and every day would serve the same mix.
//
// Asserted as "not pinned to one answer" rather than "always differs": with a
// small fixture two seeds can legitimately collide, so requiring a difference
// on any single pair would be flaky. Several seeds producing exactly one
// distinct set is the real regression — that is what a constant ORDER BY
// looks like.
func TestLoadCandidatesFromSimilarity_DifferentSeedsCanDrawDifferently(t *testing.T) {
f := newFixture(t, 12)
seed := f.tracks[0]
tight := CandidateSourceLimits{
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
}
seen := map[string]bool{}
for _, orderSeed := range []string{"a", "b", "c", "d", "e", "f"} {
cs, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, orderSeed,
)
if err != nil {
t.Fatalf("load %q: %v", orderSeed, err)
}
ids := make([]string, 0, len(cs))
for _, c := range cs {
ids = append(ids, fmt.Sprintf("%x", c.Track.ID.Bytes))
}
sort.Strings(ids)
seen[strings.Join(ids, ",")] = true
}
if len(seen) < 2 {
t.Errorf("six different seeds produced %d distinct set(s); the ordering is not "+
"varying with the seed at all", len(seen))
}
}
-188
View File
@@ -1,188 +0,0 @@
package recommendation
import (
"context"
"math"
"sync"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Scaling the candidate pool to the library (#3880).
//
// DefaultCandidateSourceLimits returns what its own comment calls "the v1
// hardcoded constants per spec" — ~170 candidates, identical for a 500-track
// library and a 100,000-track one. Operator, 2026-09-10: "is the pool that we
// draw from somehow scaled to the amount of music in the library... my earlier
// understanding of the tuning and work may have been skewed by what was in my
// library."
//
// It was not, and the consequence compounds. The pool samples a shrinking
// FRACTION of the library as it grows — 17% of 1,000 tracks, 1.7% of 10,000,
// 0.17% of 100,000 — so the ceiling on how much of a collection can ever
// surface stays flat while the collection does not. RandomFill, whose entire
// job is exploration, becomes a thinner and noisier slice of a more diverse
// corpus at exactly the moment diversity rises.
const (
// libraryScaleReference is the library size at which the base limits
// apply unchanged. Below it nothing scales, so small libraries keep
// today's behaviour exactly.
//
// ASSUMED, NOT MEASURED. The size the v1 constants were actually tuned
// against is unrecorded; 5,000 is a plausible mid-size library and a
// deliberately conservative place to start growing. #3879 should replace
// this with the real number, and until it does, this constant is the
// single thing to change.
libraryScaleReference = 5000
// maxLibraryScale bounds growth so a very large library does not turn
// every recommendation query into a huge scan. At 4x the pool tops out
// around 500 candidates, which is still cheap to score in memory.
maxLibraryScale = 4.0
)
// libraryScale is sqrt rather than linear on purpose. Linear growth would put
// a 100,000-track library at a 3,400-candidate pool — a slow query, slow
// scoring, and far past the point where more candidates improve the answer.
// Square-root growth keeps the pool meaningfully proportional while staying
// bounded: 1x at the reference, 2x at four times it, 4x at sixteen times.
//
// Never returns below 1: the base limits are a floor, not a midpoint. That
// also keeps this safe against #3889 — growing an arm ordered by unseeded
// random() is fine, shrinking one is what breaks same-day determinism.
func libraryScale(libraryTracks int64) float64 {
if libraryTracks <= libraryScaleReference {
return 1.0
}
f := math.Sqrt(float64(libraryTracks) / float64(libraryScaleReference))
return math.Min(f, maxLibraryScale)
}
// ScaleForLibrary grows the library-bounded arms of a limit set for a library
// of the given size, and leaves the rest alone.
//
// THE SCALING IS NOT UNIFORM, and that is the substance of it rather than a
// refinement. An arm's limit only matters if there are rows for it to cut off,
// so what an arm is BOUNDED BY decides whether library size can help it:
//
// scaled LBSimilar, SimilarArtist, TagOverlap — bounded by similarity
// and tag data, which grows as the library does. A bigger library
// means more of ListenBrainz's returned MBIDs survive the
// local-library filter in similarity/worker.go.
// scaled RandomFill — the exploration arm, and the one the complaint is
// really about. This is where "fraction of the library" lives.
// unscaled LikesOverlap — bounded by the USER's likes.
// unscaled UserCoplay — bounded by the INSTANCE's co-play graph.
// unscaled TasteOverlap — bounded by the taste profile's artists.
//
// Raising the last three would not sample more of the library; it would
// sample more of a set that did not grow, which is churn rather than reach.
// Leaving TasteOverlap alone has a second benefit: it and RandomFill are the
// two arms carrying sim_score 0, so this does not inflate the
// seed-independent share as fast as a uniform scale would.
func ScaleForLibrary(base CandidateSourceLimits, libraryTracks int64) CandidateSourceLimits {
f := libraryScale(libraryTracks)
grow := func(n int) int { return int(float64(n) * f) } // f >= 1, so never shrinks
return CandidateSourceLimits{
LBSimilar: grow(base.LBSimilar),
SimilarArtist: grow(base.SimilarArtist),
TagOverlap: grow(base.TagOverlap),
RandomFill: grow(base.RandomFill),
LikesOverlap: base.LikesOverlap,
UserCoplay: base.UserCoplay,
TasteOverlap: base.TasteOverlap,
}
}
// CountLibraryTracks returns the whole library's track count.
//
// Reuses CountTracksMatching with an empty pattern — `title ILIKE '%%'`
// matches every row — rather than adding a query, because a new one would
// need sqlc regeneration (rule 28, and see #3889 for where that blocks).
// The user id is left invalid so the quarantine anti-join is skipped: this
// number sizes a pool, and a per-user view of it would be false precision.
func CountLibraryTracks(ctx context.Context, q *dbq.Queries) (int64, error) {
return q.CountTracksMatching(ctx, dbq.CountTracksMatchingParams{
Column1: "",
UserID: pgtype.UUID{}, // invalid → NULL → no quarantine filter
})
}
// librarySizeTTL is how long a counted size is reused. Library size changes
// only when a scan runs, so minutes are plenty — and the count is a full
// table scan (the ILIKE defeats every index), which is why it is not done
// per request.
const librarySizeTTL = 5 * time.Minute
// librarySizeTimeout bounds the count itself — see loadWithDeadline.
const librarySizeTimeout = 3 * time.Second
// LibrarySize memoises the library track count.
//
// Degrades rather than fails: a count that errors or times out leaves the
// previous value in place, and a zero (never yet counted) scales to the base
// limits — which is exactly today's behaviour. Nothing about sizing a
// candidate pool justifies failing the request it is sizing.
type LibrarySize struct {
mu sync.Mutex
now func() time.Time // injectable for tests
at time.Time
size int64
}
// NewLibrarySize returns an empty cache. The zero value works too; this
// exists so tests can pin the clock.
func NewLibrarySize(now func() time.Time) *LibrarySize {
return &LibrarySize{now: now}
}
// Get returns the cached size, refreshing through load when stale.
//
// A NIL RECEIVER IS VALID and means "no cache": the count still runs, it is
// just not memoised. That is deliberate rather than defensive habit. The api
// handlers struct is built directly by a dozen tests that cannot know about
// every field, and a nil here previously panicked inside a radio request —
// turning a missing pool-sizing HINT into a 500. Uncached-but-correct is the
// right failure for something whose whole contract is that it degrades.
func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64, error)) int64 {
if l == nil {
n, err := loadWithDeadline(ctx, load)
if err != nil {
return 0 // scales to the base limits
}
return n
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now
if l.now != nil {
now = l.now
}
if !l.at.IsZero() && now().Sub(l.at) < librarySizeTTL {
return l.size
}
n, err := loadWithDeadline(ctx, load)
if err != nil {
// Keep the last known value and re-try at the next call rather than
// stamping `at`, so a transient failure does not pin a stale number
// for the whole TTL.
return l.size
}
l.size, l.at = n, now()
return l.size
}
// loadWithDeadline bounds the count. Rule 156: the caller is a user waiting
// on a radio, and a pool-sizing hint is never worth hanging for.
func loadWithDeadline(ctx context.Context, load func(context.Context) (int64, error)) (int64, error) {
cctx, cancel := context.WithTimeout(ctx, librarySizeTimeout)
defer cancel()
return load(cctx)
}
@@ -1,216 +0,0 @@
package recommendation
import (
"context"
"errors"
"testing"
"time"
)
// Small libraries must behave exactly as before. Scaling is meant to help a
// growing collection, not to change what every existing install already does.
func TestScaleForLibrary_SmallLibrariesAreUntouched(t *testing.T) {
base := DefaultCandidateSourceLimits()
for _, size := range []int64{0, 1, 500, libraryScaleReference} {
if got := ScaleForLibrary(base, size); got != base {
t.Errorf("library of %d changed the limits: %+v, want %+v", size, got, base)
}
}
}
// The complaint, restated as a property: a bigger library must reach further
// into itself. Operator, 2026-09-10 — "is the pool that we draw from somehow
// scaled to the amount of music in the library".
func TestScaleForLibrary_BigLibrariesGetABiggerPool(t *testing.T) {
base := DefaultCandidateSourceLimits()
small := ScaleForLibrary(base, libraryScaleReference)
big := ScaleForLibrary(base, libraryScaleReference*16)
if big.RandomFill <= small.RandomFill {
t.Errorf("RandomFill %d did not grow for a 16x library (was %d); that arm "+
"IS the fraction-of-library problem", big.RandomFill, small.RandomFill)
}
if big.LBSimilar <= small.LBSimilar {
t.Errorf("LBSimilar %d did not grow for a 16x library (was %d)",
big.LBSimilar, small.LBSimilar)
}
}
// THE SUBSTANCE: scaling is per-arm, decided by what each arm is BOUNDED BY.
// Raising a limit only helps if there are rows for it to cut off, so arms
// bounded by the user's likes, the instance's co-play graph or the taste
// profile gain nothing from a bigger library — raising them would sample more
// of a set that did not grow.
//
// A uniform scale would look right and be wrong, which is why this is pinned
// separately from "the pool got bigger".
func TestScaleForLibrary_OnlyLibraryBoundedArmsGrow(t *testing.T) {
base := DefaultCandidateSourceLimits()
big := ScaleForLibrary(base, libraryScaleReference*16)
for _, tc := range []struct {
arm string
got, want int
why string
}{
{"LikesOverlap", big.LikesOverlap, base.LikesOverlap, "bounded by the user's likes"},
{"UserCoplay", big.UserCoplay, base.UserCoplay, "bounded by the instance's co-play graph"},
{"TasteOverlap", big.TasteOverlap, base.TasteOverlap, "bounded by the taste profile's artists"},
} {
if tc.got != tc.want {
t.Errorf("%s scaled to %d (base %d) but is %s — a bigger library gives it "+
"nothing more to return", tc.arm, tc.got, tc.want, tc.why)
}
}
}
// Growth is bounded, or a huge library turns every recommendation query into
// a huge scan. sqrt keeps it proportional; the ceiling keeps it affordable.
func TestScaleForLibrary_GrowthIsBounded(t *testing.T) {
base := DefaultCandidateSourceLimits()
huge := ScaleForLibrary(base, 100_000_000)
if huge.RandomFill > int(float64(base.RandomFill)*maxLibraryScale) {
t.Errorf("RandomFill %d exceeds the %.0fx ceiling on base %d",
huge.RandomFill, maxLibraryScale, base.RandomFill)
}
// sqrt, not linear — and the test point matters. At SIXTEEN times the
// reference both curves land on 4x, because linear has already been
// clamped by the ceiling; asserting there proves nothing. Four times the
// reference is below the ceiling for both, so the curves separate: sqrt
// gives 2x, linear would give 4x.
four := ScaleForLibrary(base, libraryScaleReference*4)
if four.RandomFill != base.RandomFill*2 {
t.Errorf("4x library gave RandomFill %d, want %d — sqrt growth (linear "+
"would give %d)", four.RandomFill, base.RandomFill*2, base.RandomFill*4)
}
}
// Never below the base. The base limits are a floor, not a midpoint — and
// #3889 makes this load-bearing rather than tidy: shrinking an arm ordered by
// unseeded random() changes pool membership between same-day rebuilds.
func TestScaleForLibrary_NeverShrinksAnArm(t *testing.T) {
base := DefaultCandidateSourceLimits()
for _, size := range []int64{0, 1, 100, 4999, 5001, 1_000_000} {
got := ScaleForLibrary(base, size)
for _, tc := range []struct {
arm string
got, want int
}{
{"LBSimilar", got.LBSimilar, base.LBSimilar},
{"SimilarArtist", got.SimilarArtist, base.SimilarArtist},
{"TagOverlap", got.TagOverlap, base.TagOverlap},
{"RandomFill", got.RandomFill, base.RandomFill},
{"LikesOverlap", got.LikesOverlap, base.LikesOverlap},
{"UserCoplay", got.UserCoplay, base.UserCoplay},
{"TasteOverlap", got.TasteOverlap, base.TasteOverlap},
} {
if tc.got < tc.want {
t.Errorf("library %d shrank %s to %d (base %d)", size, tc.arm, tc.got, tc.want)
}
}
}
}
// A pool-sizing hint must never fail the request it is sizing. A count that
// errors leaves the previous value in place, and a never-counted cache
// returns 0 — which scales to the base limits, i.e. exactly today's
// behaviour.
func TestLibrarySize_DegradesOnFailure(t *testing.T) {
clock := time.Now()
c := NewLibrarySize(func() time.Time { return clock })
boom := func(context.Context) (int64, error) { return 0, errors.New("db is down") }
if got := c.Get(context.Background(), boom); got != 0 {
t.Errorf("first failure returned %d, want 0 (which scales to the base limits)", got)
}
if got := ScaleForLibrary(DefaultCandidateSourceLimits(), 0); got != DefaultCandidateSourceLimits() {
t.Error("a zero library size did not scale to the base limits")
}
// A good count, then a failure: the last known value survives.
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 40_000, nil }); got != 40_000 {
t.Fatalf("got %d, want 40000", got)
}
clock = clock.Add(librarySizeTTL + time.Second)
if got := c.Get(context.Background(), boom); got != 40_000 {
t.Errorf("a failed refresh returned %d, discarding the last known 40000", got)
}
}
// The count is a full table scan, so it must not run per request.
func TestLibrarySize_CachesWithinTheTTL(t *testing.T) {
clock := time.Now()
c := NewLibrarySize(func() time.Time { return clock })
calls := 0
load := func(context.Context) (int64, error) { calls++; return 1234, nil }
for i := 0; i < 5; i++ {
c.Get(context.Background(), load)
}
if calls != 1 {
t.Errorf("counted %d times within the TTL, want 1 — this is a full table scan", calls)
}
clock = clock.Add(librarySizeTTL + time.Second)
c.Get(context.Background(), load)
if calls != 2 {
t.Errorf("counted %d times after the TTL expired, want 2 — the size never refreshes", calls)
}
}
// A transient failure must not pin a stale value for the whole TTL: the
// failed refresh does not stamp the clock, so the next call retries.
func TestLibrarySize_RetriesAfterAFailedRefresh(t *testing.T) {
clock := time.Now()
c := NewLibrarySize(func() time.Time { return clock })
c.Get(context.Background(), func(context.Context) (int64, error) { return 100, nil })
clock = clock.Add(librarySizeTTL + time.Second)
c.Get(context.Background(), func(context.Context) (int64, error) { return 0, errors.New("blip") })
// Immediately after, with no clock movement — a stamped failure would
// serve the stale 100 until the TTL expired again.
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 900, nil }); got != 900 {
t.Errorf("got %d after a failed refresh, want 900 — the failure pinned a stale value", got)
}
}
// A nil cache must not panic, and must still be CORRECT — uncached, not
// broken.
//
// This is not a hypothetical hardening. internal/api builds its handlers
// struct directly in a dozen tests, none of which know about every field, so
// librarySize arrives nil there. The first version of this panicked inside
// handleRadio and took down TestHandleRadio_ColdStart_OnlySeedReturned with a
// SIGSEGV — turning a missing pool-sizing HINT into a request-killing crash,
// which is the opposite of what a value that "degrades rather than fails" is
// supposed to do.
func TestLibrarySize_NilReceiverStillCounts(t *testing.T) {
var c *LibrarySize // deliberately not constructed
calls := 0
got := c.Get(context.Background(), func(context.Context) (int64, error) {
calls++
return 40_000, nil
})
if got != 40_000 {
t.Errorf("nil cache returned %d, want 40000 — it should still count, just not memoise", got)
}
if calls != 1 {
t.Errorf("nil cache called the loader %d times, want 1", calls)
}
// Uncached: a second call counts again rather than reusing anything.
c.Get(context.Background(), func(context.Context) (int64, error) { calls++; return 40_000, nil })
if calls != 2 {
t.Errorf("nil cache memoised across calls (%d loads); it has nowhere to store a value", calls)
}
// And it still degrades on error rather than panicking.
if got := c.Get(context.Background(), func(context.Context) (int64, error) {
return 0, errors.New("db is down")
}); got != 0 {
t.Errorf("nil cache returned %d on a failed count, want 0 (base limits)", got)
}
}
+6 -92
View File
@@ -4,8 +4,6 @@ import (
"sort"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -15,72 +13,15 @@ type Candidate struct {
Inputs ScoringInputs
}
// DiversityCaps bounds how much of one selection a single artist or album
// may occupy. A zero value means "no cap", which is what Shuffle did
// unconditionally before #3882.
type DiversityCaps struct {
MaxPerArtist int
MaxPerAlbum int
}
// radioCapArtistPer25 / radioCapAlbumPer25 hold the caps at the same
// PROPORTION the system mixes already use — 3 artist / 2 album tracks in a
// 25-track mix — so a 20-track radio and a 200-track one feel alike rather
// than one of them being effectively uncapped.
//
// A fixed count cannot do that. Three-per-artist is a reasonable 12% of a
// 25-track mix and an absurd 1.5% of a 200-track radio, where it would put
// the selection permanently in the relaxation path below and quietly undo
// the cap it was meant to enforce.
const (
radioCapArtistPer25 = 3
radioCapAlbumPer25 = 2
)
// RadioDiversityCaps scales the diversity caps to the requested radio
// length. Floors of 2 and 1 keep a very short radio from being capped into
// a single track per artist, which would be its own kind of wrong.
func RadioDiversityCaps(limit int) DiversityCaps {
artist := limit * radioCapArtistPer25 / 25
if artist < 2 {
artist = 2
}
album := limit * radioCapAlbumPer25 / 25
if album < 1 {
album = 1
}
return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album}
}
// Shuffle scores each candidate, sorts descending by score, and returns the
// top `limit` candidates, preferring artist/album diversity. limit <= 0
// returns nil; nil input returns nil. Pure — no IO, no global state beyond
// the rng callback.
//
// THE COUNT IS NEVER REDUCED BY THE CAPS. Selection runs in two passes: the
// first takes candidates that fit under the caps, and the second fills any
// remaining slots from those the first pass skipped, still in score order.
// So the result holds min(limit, len(candidates)) either way — the caps
// change WHICH tracks are chosen, never HOW MANY.
//
// That two-pass shape is the whole design, and a hard cap would have been
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
// pool concentrated on a few artists would return six tracks and call it a
// radio. Rule 131's principle — degrade, never vanish — applies past the
// system mixes it was written for.
//
// Before #3882 there was no cap here at all, while discover.go,
// you_might_like.go and home.go all had one. That asymmetry is what let a
// radio session come back entirely from a single artist: nothing between
// the pool and the output bounded any artist's share, so a pool dominated
// by one artist produced an output dominated by it too.
// Shuffle scores each candidate, sorts descending by score, and returns
// the top `limit` candidates. limit <= 0 returns nil; nil input returns
// nil. Pure — no IO, no global state beyond the rng callback.
func Shuffle(
candidates []Candidate,
weights ScoringWeights,
now time.Time,
rng func() float64,
limit int,
caps DiversityCaps,
) []Candidate {
if len(candidates) == 0 || limit <= 0 {
return nil
@@ -99,36 +40,9 @@ func Shuffle(
if limit > len(scored) {
limit = len(scored)
}
out := make([]Candidate, 0, limit)
deferred := make([]Candidate, 0, len(scored)-limit)
artistCount := map[pgtype.UUID]int{}
albumCount := map[pgtype.UUID]int{}
for _, s := range scored {
if len(out) == limit {
break
}
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
if overArtist || overAlbum {
// Held back, not discarded — pass two may still need it.
deferred = append(deferred, s.c)
continue
}
artistCount[s.c.Track.ArtistID]++
albumCount[s.c.Track.AlbumID]++
out = append(out, s.c)
}
// Pass two: the caps could not fill the request, so relax them rather
// than hand back a short radio. Still score order, so the best of the
// held-back candidates go first.
for _, c := range deferred {
if len(out) == limit {
break
}
out = append(out, c)
out := make([]Candidate, limit)
for i := 0; i < limit; i++ {
out[i] = scored[i].c
}
return out
}
@@ -1,197 +0,0 @@
package recommendation
import (
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// candBy builds a candidate with a real artist and album identity, which
// `cand` deliberately leaves zero.
func candBy(t *testing.T, id, artist, album string, in ScoringInputs) Candidate {
t.Helper()
tr := dbq.Track{Title: id}
if err := tr.ID.Scan("00000000-0000-0000-0000-" + id); err != nil {
t.Fatalf("track id %q: %v", id, err)
}
if err := tr.ArtistID.Scan("00000000-0000-0000-0001-" + artist); err != nil {
t.Fatalf("artist id %q: %v", artist, err)
}
if err := tr.AlbumID.Scan("00000000-0000-0000-0002-" + album); err != nil {
t.Fatalf("album id %q: %v", album, err)
}
return Candidate{Track: tr, Inputs: in}
}
// artistKey is the map key artistsOf produces for a given fixture artist,
// derived the same way the fixture builds the UUID. Hand-writing the hex is
// how the first version of this test went wrong: the artist id is not all
// zeros — it carries 0001 in its fourth group — so the literal did not match
// and the assertion measured nothing.
func artistKey(t *testing.T, artist string) string {
t.Helper()
var id pgtype.UUID
if err := id.Scan("00000000-0000-0000-0001-" + artist); err != nil {
t.Fatalf("artist id %q: %v", artist, err)
}
return fmt.Sprintf("%x", id.Bytes)
}
// artistsOf counts how many picks each artist contributed.
func artistsOf(picks []Candidate) map[string]int {
out := map[string]int{}
for _, p := range picks {
out[fmt.Sprintf("%x", p.Track.ArtistID.Bytes)]++
}
return out
}
// THE BUG. Operator, 2026-09-10: started radio from a song and "literally
// all of the songs in the playlist after that were from a single artist".
//
// One artist's tracks all outscore everything else, and there are more of
// them than the radio has room for. Without a cap the output is entirely
// that artist — nothing between the pool and the result bounded its share.
func TestShuffle_CapStopsOneArtistTakingTheWholeRadio(t *testing.T) {
var cs []Candidate
// 20 tracks by artist A, all liked so they sort to the top.
for i := 0; i < 20; i++ {
cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a",
fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true}))
}
// 10 tracks by 10 other artists, none liked, so they all rank below.
for i := 0; i < 10; i++ {
cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i),
fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i),
ScoringInputs{IsGeneralLiked: false}))
}
const limit = 10
caps := DiversityCaps{MaxPerArtist: 3}
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, caps)
if len(picks) != limit {
t.Fatalf("len = %d, want %d", len(picks), limit)
}
byArtist := artistsOf(picks)
artistA := artistKey(t, "00000000000a")
if got := byArtist[artistA]; got != 3 {
t.Errorf("artist A contributed %d of %d picks, cap is 3", got, limit)
}
if len(byArtist) < 8 {
t.Errorf("only %d distinct artists in a %d-track radio; the cap is not "+
"spreading the selection", len(byArtist), limit)
}
// The same pool with NO cap is the regression this exists to catch. If
// this stops holding, the test above is no longer proving anything.
uncapped := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), limit, DiversityCaps{})
if artistsOf(uncapped)[artistA] != limit {
t.Errorf("uncapped selection was not single-artist, so this fixture no " +
"longer reproduces the bug being fixed")
}
}
// A CAP IS A BOUND, NOT AN EXCLUSION. The operator asked for the opposite of
// removal: "again it should be able to add songs from the same artist."
func TestShuffle_CappedArtistIsStillRepresented(t *testing.T) {
var cs []Candidate
for i := 0; i < 20; i++ {
cs = append(cs, candBy(t, fmt.Sprintf("%012d", i), "00000000000a",
fmt.Sprintf("%012d", i), ScoringInputs{IsGeneralLiked: true}))
}
for i := 0; i < 10; i++ {
cs = append(cs, candBy(t, fmt.Sprintf("%012d", 100+i),
fmt.Sprintf("%012d", 200+i), fmt.Sprintf("%012d", 100+i),
ScoringInputs{IsGeneralLiked: false}))
}
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10,
DiversityCaps{MaxPerArtist: 3})
if artistsOf(picks)[artistKey(t, "00000000000a")] == 0 {
t.Error("the dominant artist was excluded entirely; the cap should bound " +
"its share, not remove it")
}
}
// RULE 131, applied past the system mixes it was written for: degrade, never
// vanish. A hard cap over a pool with few artists would hand back a six-track
// "radio" for a fifty-track request. The caps must change WHICH tracks are
// picked, never HOW MANY.
func TestShuffle_CapsNeverShortenTheResult(t *testing.T) {
for _, tc := range []struct {
name string
artists int
perArt int
limit int
}{
{"one artist owns the entire pool", 1, 30, 10},
{"two artists, tight cap", 2, 15, 20},
{"pool smaller than the request", 3, 2, 50},
} {
t.Run(tc.name, func(t *testing.T) {
var cs []Candidate
n := 0
for a := 0; a < tc.artists; a++ {
for k := 0; k < tc.perArt; k++ {
cs = append(cs, candBy(t, fmt.Sprintf("%012d", n),
fmt.Sprintf("%012d", 300+a), fmt.Sprintf("%012d", n),
ScoringInputs{}))
n++
}
}
want := tc.limit
if len(cs) < want {
want = len(cs)
}
picks := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), tc.limit,
DiversityCaps{MaxPerArtist: 3, MaxPerAlbum: 2})
if len(picks) != want {
t.Errorf("len = %d, want %d — the caps shortened the result instead "+
"of relaxing to fill it", len(picks), want)
}
// No duplicates: a candidate deferred in pass one must not also be
// taken in pass two.
seen := map[[16]byte]bool{}
for _, p := range picks {
if seen[p.Track.ID.Bytes] {
t.Fatalf("track %x appears twice; pass two re-added a pick", p.Track.ID.Bytes)
}
seen[p.Track.ID.Bytes] = true
}
})
}
}
// A fixed count cannot serve both a 20-track radio and a 200-track one: three
// per artist is 12% of the first and 1.5% of the second, which would leave the
// long radio permanently in the relaxation path and effectively uncapped.
func TestRadioDiversityCaps_ScaleWithTheRequestedLength(t *testing.T) {
short := RadioDiversityCaps(25)
long := RadioDiversityCaps(200)
if short.MaxPerArtist != 3 {
t.Errorf("a 25-track radio caps artists at %d, want 3 — the same "+
"proportion the system mixes use", short.MaxPerArtist)
}
if long.MaxPerArtist <= short.MaxPerArtist {
t.Errorf("a 200-track radio caps artists at %d, no higher than a 25-track "+
"one at %d; the cap is not scaling", long.MaxPerArtist, short.MaxPerArtist)
}
// Proportion held, not just "bigger".
if long.MaxPerArtist != 24 {
t.Errorf("200-track artist cap = %d, want 24 (3 per 25)", long.MaxPerArtist)
}
// Floors: a tiny radio must not be capped down to one track per artist.
tiny := RadioDiversityCaps(1)
if tiny.MaxPerArtist < 2 {
t.Errorf("tiny radio artist cap = %d, want at least 2", tiny.MaxPerArtist)
}
if tiny.MaxPerAlbum < 1 {
t.Errorf("tiny radio album cap = %d, want at least 1", tiny.MaxPerAlbum)
}
}
+5 -5
View File
@@ -21,7 +21,7 @@ func TestShuffle_LikedRanksAboveUnliked(t *testing.T) {
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
}
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
if out[0].Track.Title != "000000000002" {
t.Errorf("liked track did not rank first: %+v", out)
}
@@ -33,7 +33,7 @@ func TestShuffle_HighSkipRanksLast(t *testing.T) {
cand("000000000002", ScoringInputs{PlayCount: 10, SkipCount: 0}), // ratio 0
cand("000000000003", ScoringInputs{PlayCount: 10, SkipCount: 5}), // ratio 0.5
}
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
if out[0].Track.Title != "000000000002" || out[2].Track.Title != "000000000001" {
t.Errorf("skip-ratio ordering broken: %v", titles(out))
}
@@ -44,7 +44,7 @@ func TestShuffle_LimitTruncates(t *testing.T) {
for i := range cs {
cs[i] = cand("00000000000"+string(rune('a'+i%26)), ScoringInputs{})
}
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
out := Shuffle(cs, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
if len(out) != 10 {
t.Errorf("len = %d, want 10", len(out))
}
@@ -58,7 +58,7 @@ func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) {
cand("000000000001", ScoringInputs{IsGeneralLiked: false}),
cand("000000000002", ScoringInputs{IsGeneralLiked: true}),
}
out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10, DiversityCaps{})
out := Shuffle(cs, defaultWeights(), time.Now(), r.Float64, 10)
if out[0].Track.Title != "000000000002" {
t.Fatalf("iter %d: liked did not rank first; out=%v", i, titles(out))
}
@@ -66,7 +66,7 @@ func TestShuffle_JitterDoesNotFlipStructuralWinner(t *testing.T) {
}
func TestShuffle_Empty_ReturnsEmpty(t *testing.T) {
out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
out := Shuffle(nil, defaultWeights(), time.Now(), fixedRNG(0.5), 10)
if len(out) != 0 {
t.Errorf("len = %d, want 0", len(out))
}
@@ -1,61 +0,0 @@
package recommendation
import "testing"
// Songs-like's pool must lean on arms that MEASURE distance from the seed.
// The default gave ~29% of candidates a sim_score of literally 0
// (taste_overlap and random_fill are both `0.0::float8` in
// recommendation.sql), which is what let "Songs like X" wander.
func TestSongsLikeLimits_FavourTheArmsThatMeasureTheSeed(t *testing.T) {
d := DefaultCandidateSourceLimits()
s := SongsLikeCandidateSourceLimits()
if s.LBSimilar <= d.LBSimilar {
t.Errorf("LBSimilar %d is not above the default %d — the only arm that "+
"measures track-level distance from the seed should be favoured here",
s.LBSimilar, d.LBSimilar)
}
// The two seed-INDEPENDENT arms, which is the whole complaint.
zeroSimDefault := d.TasteOverlap + d.RandomFill
zeroSimSongsLike := s.TasteOverlap + s.RandomFill
if zeroSimSongsLike >= zeroSimDefault {
t.Errorf("seed-independent arms total %d, not reduced from the default %d; "+
"these carry sim_score 0 by construction", zeroSimSongsLike, zeroSimDefault)
}
}
// RULE 131: a system playlist degrades, it never vanishes.
//
// Zeroing the seed-independent arms was the first instinct and is exactly the
// vanish-or-nothing shape that rule forbids: a seed whose artist has thin
// ListenBrainz coverage would yield a short mix or none at all. They are the
// tier-3 FLOOR — reduced hard, never removed — and the songs_like weights are
// what keep them at the bottom of the ranking rather than out of the pool.
//
// "A few tracks further from the seed than we would like" beats "no playlist".
func TestSongsLikeLimits_KeepATierThreeFloor(t *testing.T) {
s := SongsLikeCandidateSourceLimits()
if s.RandomFill <= 0 {
t.Error("RandomFill is zero: a seed with thin similarity coverage now produces " +
"a short or empty mix instead of degrading (rule 131)")
}
if s.TasteOverlap <= 0 {
t.Error("TasteOverlap is zero: the graded floor is gone, leaving only random " +
"fill between a sparse seed and an empty playlist (rule 131)")
}
}
// The pool should stay roughly the size it was — this change is about
// COMPOSITION, not about starving the surface. A much smaller pool would also
// shrink what the per-artist cap has to work with.
func TestSongsLikeLimits_KeepThePoolRoughlyTheSameSize(t *testing.T) {
total := func(l CandidateSourceLimits) int {
return l.LBSimilar + l.SimilarArtist + l.TagOverlap + l.LikesOverlap +
l.RandomFill + l.TasteOverlap + l.UserCoplay
}
d, s := total(DefaultCandidateSourceLimits()), total(SongsLikeCandidateSourceLimits())
if s < d/2 {
t.Errorf("songs_like pool is %d against the default %d — less than half; "+
"this was meant to re-weight the pool, not starve it", s, d)
}
}
+9 -89
View File
@@ -40,13 +40,6 @@ const (
// never be read as taste signal (#2374) — filing it under taste would put
// it one careless join from the leak that design forbids.
ScopeDiscover = "discover"
// ScopeSongsLike is the "Songs like {X}" surface (#3881). It shared
// daily_mix with For-You until 2026-09-10, and that sharing WAS the bug:
// the two surfaces want opposite things. For-You answers "what will they
// enjoy today" and is supposed to roam; Songs-like answers "what sounds
// like THIS" and is the tightest surface in the product. One set of
// weights cannot serve both, and the broad answer was winning.
ScopeSongsLike = "songs_like"
)
// TasteTuning is the tunable subset of taste.Config: the engagement
@@ -99,76 +92,6 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
}
}
// ShippedSongsLikeWeights are the shipped songs_like-profile defaults
// (#3881). The whole point is that SIMILARITY DOMINATES; every other
// profile balances it against taste and engagement, and this one must not.
//
// The failure being corrected, arithmetic from the daily_mix profile that
// this surface used to share:
//
// unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
// PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
//
// Liking something outranked sounding like the seed, because LikeBoost (2.0)
// exceeded SimilarityWeight's entire range (1.5) and TasteWeight (1.5, and
// seed-independent) matched it outright.
//
// THE PROPERTY THESE NUMBERS ENCODE, which is what to preserve if they are
// retuned: the similarity term's range must exceed the combined range of
// every seed-INDEPENDENT differentiator, so that a closer match cannot be
// beaten on the strength of likes, freshness and taste alone.
//
// seed-independent spread = LikeBoost 0.5 + Recency 0.25
// + Taste 0.25 + ContextTime 0.5
// + jitter 0.05 = 1.55
// similarity spread = 0 → 4.0
//
// So a similarity advantage of ~0.39 (1.55/4.0) wins outright regardless of
// everything else, while tracks within that band still get ordered by what
// the user likes and has not heard lately. Tight, not deaf.
//
// BaseWeight stays 1.0: it is identical for every candidate and so
// differentiates nothing — it sets the floor, not the shape. SkipPenalty
// stays 2.0 because a track the user skips is still unwanted no matter how
// similar it is.
//
// These are DEFAULTS, not settings (rule 25) — the operator turns them in the
// admin tuning card and good values get baked back here. They are a
// defensible starting point rather than a measured optimum: the per-arm fill
// rates and the real sim_score distribution are still unknown (#3879), and
// `likes_overlap` contributes a flat 0.6 that a high SimilarityWeight
// amplifies. Expect to move these once that lands.
func ShippedSongsLikeWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0, // same for all candidates; differentiates nothing
LikeBoost: 0.5, // was 2.0 — a tie-break among similar tracks, not an override
RecencyWeight: 0.25, // was 1.0 — freshness must not outrank sounding right
SkipPenalty: 2.0, // unchanged — a skipped track stays unwanted
JitterMagnitude: 0.05, // was 0.1 — less shuffle on a coherence surface
ContextWeight: 0.5,
SimilarityWeight: 4.0, // was 1.5 — dominant, by design
TasteWeight: 0.25, // was 1.5 — seed-INDEPENDENT, so demoted hard
ContextTimeWeight: 0.5, // was 1.0
}
}
// shippedWeightsFor returns the shipped defaults for a weight-profile scope,
// or ok=false if the scope is not a weight profile. Single source for the
// three call sites (seed, update-validation, reset) so adding a fourth
// profile cannot be half-wired — which is how a scope ends up seedable but
// not resettable.
func shippedWeightsFor(scope string) (recommendation.ScoringWeights, bool) {
switch scope {
case ScopeRadio:
return ShippedRadioWeights(), true
case ScopeDailyMix:
return ShippedDailyMixWeights(), true
case ScopeSongsLike:
return ShippedSongsLikeWeights(), true
}
return recommendation.ScoringWeights{}, false
}
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
type DiscoverTuning struct {
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
@@ -242,9 +165,8 @@ func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service
func (s *Service) reconcile(ctx context.Context) error {
q := dbq.New(s.pool)
for profile, w := range map[string]recommendation.ScoringWeights{
ScopeRadio: ShippedRadioWeights(),
ScopeDailyMix: ShippedDailyMixWeights(),
ScopeSongsLike: ShippedSongsLikeWeights(),
ScopeRadio: ShippedRadioWeights(),
ScopeDailyMix: ShippedDailyMixWeights(),
} {
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
return fmt.Errorf("seed profile %q: %w", profile, err)
@@ -312,7 +234,6 @@ func (s *Service) reconcile(ctx context.Context) error {
// reads Weights(ScopeRadio) per request.
func (s *Service) push() {
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
playlists.SetSongsLikeWeights(s.Weights(ScopeSongsLike))
playlists.SetTasteConfig(s.TasteConfig())
}
@@ -376,7 +297,7 @@ type fieldChange struct {
// Unknown fields and out-of-range values reject the whole patch. A
// no-op patch (all values equal to current) writes no audit row.
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
if _, ok := shippedWeightsFor(profile); !ok {
if profile != ScopeRadio && profile != ScopeDailyMix {
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
}
current := s.Weights(profile)
@@ -419,18 +340,17 @@ func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64)
// Reset restores a scope to its shipped defaults, with one audit row
// carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error {
if shipped, ok := shippedWeightsFor(scope); ok {
// Every weight profile resets the same way; the per-scope defaults
// come from one place so a new profile cannot be seedable but not
// resettable. This was an if/else over two hard-coded scopes until
// songs_like made it three.
switch scope {
case ScopeRadio, ScopeDailyMix:
shipped := ShippedRadioWeights()
if scope == ScopeDailyMix {
shipped = ShippedDailyMixWeights()
}
changes := diffWeights(s.Weights(scope), shipped)
if len(changes) == 0 {
return nil
}
return s.persistProfile(ctx, scope, shipped, "reset", changes)
}
switch scope {
case ScopeTaste:
shipped := ShippedTasteTuning()
changes := diffTaste(s.Taste(), shipped)
@@ -1,130 +0,0 @@
package recsettings
import (
"testing"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
// The bug, reproduced as a ranking: a track that sounds nothing like the seed
// but which the user liked and has not played lately used to OUTRANK a perfect
// similarity match. Operator, 2026-09-10: "I was getting a seeming wide variety
// of music from each one when I was hoping to stay in a certain neighborhood."
//
// This is the whole point of the songs_like profile, so it is asserted as
// BEHAVIOUR — two candidates, which one wins — rather than by checking the
// weight numbers. Numbers get retuned; this property must survive that.
//
// It also pins the contrast: daily_mix is EXPECTED to fail this. If both
// profiles started ranking the same way, the split would have quietly become
// pointless and nothing else would notice.
func TestSongsLikeWeights_SimilarityBeatsAnUnrelatedLikedTrack(t *testing.T) {
now := time.Now().UTC()
stale := now.Add(-365 * 24 * time.Hour)
// A perfect similarity match the user has never liked and played recently.
// Everything except similarity is working against it.
perfectMatch := recommendation.ScoringInputs{
SimilarityScore: 1.0,
IsGeneralLiked: false,
LastPlayedAt: &now,
}
// Nothing to do with the seed, but liked and long unplayed — every
// seed-independent term in its favour.
unrelatedFavourite := recommendation.ScoringInputs{
SimilarityScore: 0.0,
IsGeneralLiked: true,
LastPlayedAt: &stale,
TasteMatchScore: 1.0,
}
// Jitter fixed at its midpoint so the comparison is about the weights.
noJitter := func() float64 { return 0.5 }
songsLike := ShippedSongsLikeWeights()
matchScore := recommendation.Score(perfectMatch, songsLike, now, noJitter)
favScore := recommendation.Score(unrelatedFavourite, songsLike, now, noJitter)
if matchScore <= favScore {
t.Errorf("songs_like ranks an unrelated liked track (%.3f) at or above a "+
"perfect similarity match (%.3f) — the mix will wander", favScore, matchScore)
}
// The contrast that makes the split worth having. If this ever passes,
// daily_mix has been tightened into songs_like and one of them is redundant.
daily := ShippedDailyMixWeights()
dMatch := recommendation.Score(perfectMatch, daily, now, noJitter)
dFav := recommendation.Score(unrelatedFavourite, daily, now, noJitter)
if dMatch > dFav {
t.Errorf("daily_mix now also puts similarity first (%.3f vs %.3f); the two "+
"profiles no longer differ, so songs_like is buying nothing", dMatch, dFav)
}
}
// The property the songs_like numbers encode, stated independently of them:
// the similarity term's range must exceed the combined range of every
// seed-INDEPENDENT differentiator, so a closer match cannot be beaten on
// likes, freshness and taste alone.
//
// BaseWeight is excluded deliberately — it is identical for every candidate
// and so differentiates nothing. SkipPenalty is excluded because it only ever
// pushes a candidate DOWN, and a skipped track should lose however similar.
func TestSongsLikeWeights_SimilarityOutrangesEverySeedIndependentTerm(t *testing.T) {
w := ShippedSongsLikeWeights()
// TasteMatchScore and ContextAffinityScore are in [-1,+1]; recencyDecay is
// in [0,1]; LikeBoost is all-or-nothing.
seedIndependent := w.LikeBoost + w.RecencyWeight + w.TasteWeight +
w.ContextTimeWeight + w.JitterMagnitude
if w.SimilarityWeight <= seedIndependent {
t.Errorf("SimilarityWeight %.2f does not outrange the seed-independent "+
"terms (%.2f) — likes/recency/taste can outvote sounding like the seed",
w.SimilarityWeight, seedIndependent)
}
}
// A scope that is seedable but not resettable is the half-wired shape this
// guards: the profile appears in the admin card, the operator turns a knob,
// and Reset then 404s on a scope the rest of the service knows about.
func TestShippedWeightsFor_CoversEveryWeightProfile(t *testing.T) {
for _, scope := range []string{ScopeRadio, ScopeDailyMix, ScopeSongsLike} {
if _, ok := shippedWeightsFor(scope); !ok {
t.Errorf("scope %q has no shipped defaults; it cannot be seeded or reset", scope)
}
}
// Non-weight scopes must NOT resolve here, or Reset would treat the taste
// singleton as a weight profile and write nonsense.
for _, scope := range []string{ScopeTaste, ScopeDiscover, "nonsense"} {
if _, ok := shippedWeightsFor(scope); ok {
t.Errorf("scope %q resolved as a weight profile and is not one", scope)
}
}
}
// Guards the sync the comment in playlists/system.go asks for: the pre-push
// literal there must match the shipped defaults here, or a build that has not
// yet been reconciled ranks differently from one that has.
func TestShippedSongsLikeWeights_AreDominatedBySimilarity(t *testing.T) {
w := ShippedSongsLikeWeights()
daily := ShippedDailyMixWeights()
if w.SimilarityWeight <= daily.SimilarityWeight {
t.Errorf("songs_like SimilarityWeight %.2f is not above daily_mix's %.2f",
w.SimilarityWeight, daily.SimilarityWeight)
}
for _, tc := range []struct {
name string
songs, day float64
}{
{"LikeBoost", w.LikeBoost, daily.LikeBoost},
{"TasteWeight", w.TasteWeight, daily.TasteWeight},
{"RecencyWeight", w.RecencyWeight, daily.RecencyWeight},
} {
if tc.songs >= tc.day {
t.Errorf("songs_like %s (%.2f) is not demoted below daily_mix (%.2f); "+
"these are the seed-independent terms that made the mix wander",
tc.name, tc.songs, tc.day)
}
}
}
+14 -444
View File
@@ -4,7 +4,6 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
@@ -179,86 +178,19 @@ func TestReleaseWorkflow_UsesTheSharedDerivation(t *testing.T) {
}
}
// jobBoundary matches a blank line followed by job-level (two-space)
// indentation — the end of the last step in a job.
var jobBoundary = regexp.MustCompile(`\n\n [^ \n]`)
// stepBody returns one workflow step's text, from its `- name:` line to the
// start of the next step or the next job.
//
// The naive cut — "up to the next `- name:`" — silently returns an EMPTY body
// for the last step in a job, and every assertion over it then passes
// vacuously. That is the failure rule 167 names: a check that reads as
// coverage while asserting on nothing. Cutting at a blank line followed by
// job-level indentation handles the last-step case, which is exactly where
// `Build and push` sits.
func stepBody(t *testing.T, yaml, name string) string {
// devArm returns the branch of "Compute image tags" that handles refs/heads/dev.
func devArm(t *testing.T, yaml string) string {
t.Helper()
i := strings.Index(yaml, "- name: "+name)
const marker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
i := strings.Index(yaml, marker)
if i < 0 {
t.Fatalf("no %q step in release.yml", name)
t.Fatal("no refs/heads/dev arm in Compute image tags — the dev channel is not wired")
}
body := yaml[i:]
end := len(body)
if j := strings.Index(body, "\n - name:"); j >= 0 {
end = j
rest := yaml[i+len(marker):]
if j := strings.Index(rest, "\n else"); j >= 0 {
return rest[:j]
}
// A blank line followed by EXACTLY two spaces and then content starts a
// new job or job-level comment. The "exactly" matters: a blank line inside
// a `run:` block is followed by ten-space indentation and would otherwise
// match, truncating the step mid-body — which is how this helper first
// sliced the verify step down to its first two lines.
if loc := jobBoundary.FindStringIndex(body); loc != nil && loc[0] < end {
end = loc[0]
}
body = body[:end]
if strings.TrimSpace(strings.TrimPrefix(body, "- name: "+name)) == "" {
t.Fatalf("step %q sliced to an empty body — the assertions below would pass vacuously", name)
}
return body
}
// releaseYAML reads the workflow once per test.
func releaseYAML(t *testing.T) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
return string(body)
}
// imageTagArms splits "Compute image tags" into its three ref branches. The
// whole tag policy lives in that if/elif/else, so the arms are the unit worth
// asserting on — a tag published from the wrong arm reaches the wrong
// audience, and every such mistake still builds and still pushes a valid
// image.
func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) {
t.Helper()
const (
tagMarker = `if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then`
devMarker = `elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then`
mainMarker = "\n else"
endMarker = "\n fi"
)
iTag := strings.Index(yaml, tagMarker)
iDev := strings.Index(yaml, devMarker)
iMain := -1
if iDev >= 0 {
if k := strings.Index(yaml[iDev:], mainMarker); k >= 0 {
iMain = iDev + k
}
}
if iTag < 0 || iDev < 0 || iMain < 0 {
t.Fatal("Compute image tags no longer has a tag/dev/main arm — the tag policy has been restructured, so these guards are pinning nothing")
}
iEnd := iMain
if k := strings.Index(yaml[iMain:], endMarker); k >= 0 {
iEnd = iMain + k
} else {
t.Fatal("no closing fi after the main arm")
}
return yaml[iTag:iDev], yaml[iDev:iMain], yaml[iMain:iEnd]
return rest
}
// The worst regression this wiring can produce: a dev push that also moves
@@ -266,7 +198,11 @@ func imageTagArms(t *testing.T, yaml string) (tagArm, devArm, mainArm string) {
// next pull. Nothing else in the suite would notice — the build stays green
// and the image is valid, it is simply the wrong audience.
func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
_, arm, _ := imageTagArms(t, releaseYAML(t))
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
arm := devArm(t, string(body))
if !strings.Contains(arm, "${IMAGE}:dev") {
t.Errorf("dev arm does not publish :dev\n%s", arm)
@@ -350,369 +286,3 @@ func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
}
}
}
// The rollback unit, and the reason it is worth a guard: it is invisible until
// the moment it is needed. Nothing pulls :<sha> during normal operation, so if
// this arm stopped minting one, every build would stay green and every image
// would be valid — and the absence would surface only during an incident, as
// "there is nothing to roll back to."
//
// Rules 145 and 147: main push → :latest + :<sha>.
func TestMainChannel_PublishesLatestAndTheRollbackUnit(t *testing.T) {
_, _, arm := imageTagArms(t, releaseYAML(t))
if !strings.Contains(arm, "${IMAGE}:latest") {
t.Errorf("main arm does not move :latest — production would stop tracking main's tip\n%s", arm)
}
if !strings.Contains(arm, "${IMAGE}:${GITHUB_SHA}") {
t.Errorf("main arm publishes no commit-addressable image; there is no rollback target for production commits\n%s", arm)
}
// Rule 147: :latest tracks main's tip, and a second name for the same
// image sends readers looking for a distinction that does not exist.
if strings.Contains(arm, "${IMAGE}:main") {
t.Errorf("main arm publishes :main — rule 147 says that tag should not exist\n%s", arm)
}
}
// A release refreshes the CHANNEL and mints nothing else (rules 145 + 146).
//
// The specific regression: re-adding :<sha> here. The tag build rebuilds the
// SAME SOURCE as main's build minutes earlier, differing only in which APK is
// baked in — so a :<sha> minted here would overwrite main's immutable rollback
// target with different contents, under the same name. That is the exact thing
// rule 145's immutability clause exists to prevent, and it is the half with
// the incidents behind it.
func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
arm, _, _ := imageTagArms(t, releaseYAML(t))
if !strings.Contains(arm, "${IMAGE}:latest") {
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
}
if strings.Contains(arm, "GITHUB_SHA") {
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
}
}
// No version-numbered image tags anywhere, on any arm (rule 145, and the
// operator's 2026-09-10 decision to drop them across every project).
//
// Asserted across the whole step rather than per-arm because the mistake this
// catches is re-adding one ANYWHERE, and the tag arm is only the likeliest
// spot. `${VERSION}` still legitimately appears in the step as the build's
// self-reported version, so the assertion has to name the image-tag form
// specifically rather than the variable — otherwise it would fire on correct
// code and get "fixed" by deleting the guard.
func TestNoVersionNumberedImageTags(t *testing.T) {
yaml := releaseYAML(t)
tagArm, devArm, mainArm := imageTagArms(t, yaml)
for _, tc := range []struct{ name, arm string }{
{"tag", tagArm}, {"dev", devArm}, {"main", mainArm},
} {
for _, forbidden := range []string{
"${IMAGE}:${VERSION}",
"${IMAGE}:v",
"${IMAGE}:${GITHUB_REF#refs/tags/}",
} {
if strings.Contains(tc.arm, forbidden) {
t.Errorf("%s arm publishes a version-numbered image tag (%q); git and the build's self-reported version answer \"which build is this\"\n%s",
tc.name, forbidden, tc.arm)
}
}
}
}
// The verify job asserted the :<version> image existed. With version tags
// gone that assertion would fail every release for a tag nothing mints — so
// this pins that it was re-pointed rather than deleted, since deleting it is
// the tempting way to make a failing guard go green.
func TestVerifyJob_ChecksTheRollbackImageNotAVersionTag(t *testing.T) {
yaml := releaseYAML(t)
if !strings.Contains(yaml, "- name: Rollback image must exist for the tagged commit") {
t.Fatal("the release-verification step that checks an image exists is gone; an image push that silently did not happen would now pass verification")
}
step := stepBody(t, yaml, "Rollback image must exist for the tagged commit")
if !strings.Contains(step, "${IMAGE}:${GITHUB_SHA}") {
t.Errorf("the verify step does not inspect the commit's rollback image\n%s", step)
}
if strings.Contains(step, "${IMAGE}:${TAG}") {
t.Errorf("the verify step still inspects a version-numbered image, which is no longer published — this would fail every release\n%s", step)
}
}
// The server's self-reported version is now the ONLY thing that identifies a
// build, so a lane that stamps a channel word instead of a version silently
// removes that ability. It used to stamp the literal "main"/"dev".
func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
yaml := releaseYAML(t)
step := stepBody(t, yaml, "Build and push")
for _, want := range []string{
"MINSTREL_VERSION=",
"MINSTREL_CHANNEL=",
} {
if !strings.Contains(step, want) {
t.Errorf("Build and push does not pass %s — the image cannot report which build it is\n%s", want, step)
}
}
// The version must come from the shared derivation, not from the ref.
// Reading it off GITHUB_REF is what produced "main" and "dev" as version
// strings, which is the regression this pins.
_, _, mainArm := imageTagArms(t, yaml)
if strings.Contains(mainArm, `version=main`) {
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
}
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart")
}
}
// gitRepo builds a throwaway repo and returns its path. Commit timestamps are
// pinned so the derivation is deterministic.
func gitRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("-c", "init.defaultBranch=main", "init", "-q")
run("config", "user.email", "t@example.invalid")
run("config", "user.name", "t")
return dir
}
// commitFile writes path and commits it with a pinned committer timestamp.
func commitFile(t *testing.T, dir, path, epoch string) {
t.Helper()
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
// Content must DIFFER from any earlier write to the same path, or git
// records nothing and the commit silently covers fewer files than the
// test believes. That is not hypothetical: it made the
// source-alongside-test case pass vacuously.
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", path}} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
}
// versionIn runs ci/version.sh inside dir, reading real git rather than the
// pinned-clock override, so the PATHSPEC is what is under test.
func versionIn(t *testing.T, dir string) (string, error) {
t.Helper()
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "HEAD")
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"MINSTREL_NOW_EPOCH=1789000920",
)
out, err := cmd.CombinedOutput()
return string(out), err
}
// The version names what SHIPPED, so a commit that changes nothing shippable
// must not move it.
//
// The failure this prevents is not the cosmetic one. The derivation is a
// denylist precisely so that new content counts by default: the direction that
// matters is a changed artifact keeping its OLD version, silently, on a green
// run. This test pins the cheap half of that (CI-only commits are inert) and,
// in the same breath, that a source commit still moves it — because a pathspec
// typo that excluded everything would satisfy the first assertion alone.
func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
const (
shipped = "1757443736" // 2025-09-09T18:48:56Z
ciOnly = "1789000920" // 2026-09-10T00:42:00Z, later
)
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", shipped)
commitFile(t, dir, ".gitea/workflows/release.yml", ciOnly)
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2025.09.09.1848") {
t.Errorf("a CI-only commit moved the version — the pathspec is not excluding it\n%s", out)
}
// ...and the pathspec must not be so broad it excludes everything.
commitFile(t, dir, "internal/server/other.go", ciOnly)
out, err = versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("a source commit did NOT move the version — the pathspec excludes too much, which is the silent-lie direction\n%s", out)
}
}
// android/ is deliberately NOT excluded, and that is the subtle half of the
// list. It ships in no server image — but it is the APK's entire source, and
// ONE script derives the version for both artifacts. Excluding it would stop
// an Android-only commit from moving the APK's own version, which is exactly
// the silent downgrade the versioning rework exists to prevent.
func TestVersionName_AndroidSourcesCount(t *testing.T) {
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", "1757443736")
commitFile(t, dir, "android/app/src/main/Thing.kt", "1789000920")
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("an Android commit did not move the version; the APK would ship new code under its old version name\n%s", out)
}
}
// No shipped commit in range means a shallow clone, and the script must refuse
// rather than emit something plausible. A wrong version builds, signs and
// publishes perfectly happily; it surfaces later as an update channel that has
// quietly stopped offering anything.
func TestVersionScript_RefusesWhenNothingShippedIsInRange(t *testing.T) {
dir := gitRepo(t)
commitFile(t, dir, "ci/version.sh", "1789000920")
out, err := versionIn(t, dir)
if err == nil {
t.Fatalf("script succeeded with no shipped commit in range; it should refuse\n%s", out)
}
if strings.Contains(out, "name=") {
t.Errorf("script emitted a version name while refusing — that value could still be consumed\n%s", out)
}
}
// commitFiles is commitFile for more than one path in a single commit.
func commitFiles(t *testing.T, dir, epoch string, paths ...string) {
t.Helper()
for _, rel := range paths {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte("content @"+epoch+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{{"add", "-A"}, {"commit", "-q", "-m", strings.Join(paths, " ")}} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
"GIT_AUTHOR_DATE=@"+epoch+" +0000",
"GIT_COMMITTER_DATE=@"+epoch+" +0000",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
}
// gitOutput runs a git command in dir and returns its combined output.
func gitOutput(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return string(out)
}
// Tests do not ship, so a test-only commit must not re-version an artifact.
//
// `go build` drops *_test.go outright and the Vite build never imports a
// .test.ts, so neither reaches an image or an APK. This repo has no tests/
// tree — Go tests sit inline beside the code — so the exclusions are globs,
// and a glob is easy to get subtly wrong in a way that still looks right.
func TestVersionName_TestsDoNotReVersionTheArtifact(t *testing.T) {
const (
shipped = "1757443736" // 2025.09.09.1848
later = "1789000920" // 2026.09.10.0042
)
for _, tc := range []struct{ name, path string }{
{"go test beside its source", "internal/server/thing_test.go"},
{"web unit test", "web/src/lib/api/admin.test.ts"},
{"web script test", "web/scripts/tokens-to-css.test.js"},
{"android JVM unit test", "android/app/src/test/java/A.kt"},
{"vitest harness config", "web/vitest.config.ts"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", shipped)
commitFile(t, dir, tc.path, later)
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2025.09.09.1848") {
t.Errorf("a commit touching only %s moved the version; tests do not ship\n%s", tc.path, out)
}
})
}
}
// The direction that actually costs something, and the reason the exclusions
// above are globs over FILES rather than over their directories.
//
// `':!internal'` would satisfy every assertion in the test above while
// silently excluding the entire server. This pins the opposite: a commit that
// changes a test AND the source under it must still move the version, because
// the source path matches on its own. Without this, a too-broad exclusion
// reads as a passing test suite and ships an artifact under a stale version.
func TestVersionName_ATestAlongsideItsSourceStillCounts(t *testing.T) {
const (
shipped = "1757443736"
later = "1789000920"
)
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", shipped)
commitFiles(t, dir, later,
"internal/server/thing.go",
"internal/server/thing_test.go",
)
// The commit must actually contain BOTH paths. Rewriting a file with
// identical bytes records nothing, and this assertion would then be
// checking a test-only commit while appearing to check a mixed one.
touched := gitOutput(t, dir, "show", "--name-only", "--format=", "HEAD")
for _, want := range []string{"internal/server/thing.go", "internal/server/thing_test.go"} {
if !strings.Contains(touched, want) {
t.Fatalf("fixture is wrong: HEAD does not contain %s\n%s", want, touched)
}
}
out, err := versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("a source change accompanied by a test change did NOT move the version — "+
"the exclusions are matching directories rather than test files\n%s", out)
}
}
-1
View File
@@ -218,7 +218,6 @@ func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": ServerVersion,
"channel": ServerChannel,
"min_client_version": MinClientVersion,
})
}
+6 -21
View File
@@ -5,27 +5,12 @@ package server
// older clients see version_too_old at /healthz and refuse to operate.
const MinClientVersion = "0.1.0"
// ServerVersion is the build's own version name — YYYY.MM.DD.HHMM, derived
// from the commit it was built from by ci/version.sh. Defaults to "dev" for
// local builds; overridden at link time via:
// ServerVersion is the deployed server image's version tag. Defaults to
// "dev" for local builds; overridden at link time via:
//
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=2026.09.10.1449'"
// -ldflags="-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=v2026.05.10.2'"
//
// release.yml passes it through the MINSTREL_VERSION build-arg → Dockerfile
// ldflag. Surfaced at /healthz so operators can verify which image their
// container is running without exec'ing into it.
//
// This carried the literal strings "main" and "dev" until 2026-09-10, which
// made every image on a channel report the same thing forever. It stopped
// being cosmetic when :vYYYY.MM.DD.HHMM image tags were retired (family rule
// 145): this is now the ONLY thing that says which build is running.
// release.yml passes the git tag through MINSTREL_VERSION build-arg →
// Dockerfile ldflag. Surfaced at /healthz so operators can verify which
// image their container is running without exec'ing into it.
var ServerVersion = "dev"
// ServerChannel is which line this build came off — "stable" or "dev", or
// "local" for a plain `docker build`.
//
// A SIBLING FIELD, never a suffix inside ServerVersion (family rule 149). The
// same commit built on both lanes reports the same version and differs only
// here; folding the two together is what makes a version string stop being
// comparable.
var ServerChannel = "local"
+3 -6
View File
@@ -34,17 +34,14 @@ export type DiscoverTuning = {
snooze_days: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'songs_like' | 'taste' | 'discover';
/** The weight-profile scopes, as distinct from the singleton-settings scopes. */
export type WeightProfileScope = 'radio' | 'daily_mix' | 'songs_like';
export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover';
export type TuningSnapshot = {
profiles: Record<WeightProfileScope, WeightProfile>;
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
shipped: {
profiles: Record<WeightProfileScope, WeightProfile>;
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
taste: TasteTuning;
discover: DiscoverTuning;
};
+9 -21
View File
@@ -6,36 +6,26 @@
// is actually running (came up debugging the in-app update flow when
// it wasn't obvious whether v2026.05.10.0 or .1 was deployed).
//
// This is now the ONLY place an operator can see which build they are on.
// Image tags stopped carrying the version on 2026-09-10 — :latest and :dev
// are rolling names and :<sha> answers "which commit", not "which build" —
// so the server's self-report is the answer.
//
// The channel is shown BESIDE the version, never spliced into it: the same
// commit built on both lanes reports an identical version and differs only
// in channel, so "2026.09.10.1449 · dev" and "2026.09.10.1449 · stable" are
// the same code on two lines. Suppressed for stable, which is the
// unremarkable case and would just be noise on every install.
//
// /healthz is unauthenticated, so the bare fetch works without
// credentials. Renders nothing on parse failure or pre-version
// images that don't include the field — graceful degradation.
type Health = { status: string; version?: string; channel?: string };
type Health = { status: string; version?: string };
let version = $state<string | null>(null);
let channel = $state<string | null>(null);
onMount(async () => {
try {
const res = await fetch('/healthz');
if (!res.ok) return;
const body = (await res.json()) as Partial<Health>;
if (!body.version) return;
version = body.version;
// Reported verbatim rather than validated against an enum — a build
// claiming something unexpected is better shown than dropped.
channel = body.channel && body.channel !== 'stable' ? body.channel : null;
if (body.version && body.version !== 'dev') {
version = body.version;
} else if (body.version === 'dev') {
// Local dev images report "dev" — show it so the operator
// can tell they're not on a release tag.
version = 'dev';
}
} catch {
// network / parse error — silent.
}
@@ -43,7 +33,5 @@
</script>
{#if version}
<p class="text-xs text-text-secondary">
Server {version}{#if channel}&nbsp;·&nbsp;{channel}{/if}
</p>
<p class="text-xs text-text-secondary">Server {version}</p>
{/if}
+3 -11
View File
@@ -6,7 +6,6 @@
resetTuning,
getTrends,
type TuningScope,
type WeightProfileScope,
type TuningSnapshot,
type WeightProfile,
type TasteTuning,
@@ -50,15 +49,9 @@
{ key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' }
];
const profileScopes: { scope: WeightProfileScope; label: string; blurb: string }[] = [
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, the discovery mixes, and "You might like".' },
{
scope: 'songs_like',
label: 'Songs like…',
blurb:
'The tightest surface: everything here should sound like the seed track. Similarity dominates on purpose — raising like/taste/recency here is what makes these mixes wander.'
}
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
];
let snapshot = $state<TuningSnapshot | null>(null);
@@ -71,11 +64,10 @@
const f: Record<string, Record<string, string>> = {
radio: {},
daily_mix: {},
songs_like: {},
taste: {},
discover: {}
};
for (const p of profileScopes.map((s) => s.scope)) {
for (const p of ['radio', 'daily_mix'] as const) {
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
}
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
+3 -33
View File
@@ -55,19 +55,11 @@ const discover = (over: Partial<Record<string, number>> = {}) => ({
// new scopes to BOTH this fixture and `shipped`.
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return {
profiles: {
radio: weights({ taste_weight: 1 }),
daily_mix: weights(),
songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 })
},
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(),
discover: discover(),
shipped: {
profiles: {
radio: weights({ taste_weight: 1 }),
daily_mix: weights(),
songs_like: weights({ similarity_weight: 4, like_boost: 0.5, taste_weight: 0.25 })
},
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(),
discover: discover()
},
@@ -83,12 +75,11 @@ beforeEach(() => {
});
describe('Admin tuning page', () => {
test('renders every weight profile and the taste card with current values', async () => {
test('renders both profiles and the taste card with current values', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.getByText('Daily mixes')).toBeInTheDocument();
expect(screen.getByText('Songs like…')).toBeInTheDocument();
expect(screen.getByText('Taste profile build')).toBeInTheDocument();
const radioTaste = screen.getByLabelText(/taste weight/i, {
selector: '#radio-taste_weight'
@@ -98,27 +89,6 @@ describe('Admin tuning page', () => {
expect(halfLife.value).toBe('75');
});
// Songs-like is a separate weight profile precisely so it can be tuned
// apart from For-You (#3881). If its card stopped rendering its OWN values
// — or quietly fell back to daily_mix's — the split would exist in the
// backend and be unreachable in the UI, which is the same as not shipping
// it (rule 27).
test('the songs-like card carries its own weights, not daily_mix\'s', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Songs like…')).toBeInTheDocument());
const sim = document.getElementById('songs_like-similarity_weight') as HTMLInputElement;
const like = document.getElementById('songs_like-like_boost') as HTMLInputElement;
expect(sim.value).toBe('4');
expect(like.value).toBe('0.5');
// The contrast that makes the card worth having: daily_mix must still show
// its own, different numbers on the same page.
const dailySim = document.getElementById('daily_mix-similarity_weight') as HTMLInputElement;
expect(dailySim.value).not.toBe(sim.value);
});
test('save sends only the changed fields for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());