Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
5.2 KiB
Go
130 lines
5.2 KiB
Go
package recsettings
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
|
)
|
|
|
|
// A track that sounds nothing like the seed, but which the user liked and has
|
|
// not played lately, must not outrank a perfect similarity match: Songs-like
|
|
// should stay in the seed's neighbourhood.
|
|
//
|
|
// 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, an admin 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)
|
|
}
|
|
}
|
|
}
|