Recommendation Resolvers¶
This document explains how seed input becomes a ranked recommendation list.
Entry Points¶
- CLI command:
musicseed-cli recommendincli/src/musicseed_cli/commands/recommend.py. - Public services:
core/src/musicseed/services/recommend.pyandpopulate.py, shared by CLI, API handlers, and MCP tools. - Orchestration:
core/src/musicseed/recommender/playlist.py. - Eligible scoring/selection:
core/src/musicseed/recommender/retrieval.py. - Historical bounded reference (offline comparisons only):
core/src/musicseed/recommender/candidates.py. - Scoring:
core/src/musicseed/recommender/scoring.py. - Sonic vectors (read from the local
track_vectorsstore):core/src/musicseed/sonic.py.
Seed Resolution¶
Seeds can be provided as database IDs or text.
--seed-idresolves directly byTrack.id.--seed "Artist - Title"first attempts exact lowercase artist and title matching.- Text without
Artist - Titlematches by title. - Ambiguous text matches raise an error and include candidate IDs.
This is intentional. For a personal music library, choosing the wrong seed silently is worse than asking the user for a better seed.
Seed Profile¶
Resolved seed tracks are combined into a SeedProfile:
- Track IDs to exclude from recommendations.
- Average of the seeds' Plex sonic vectors (looked up by
plex_id); absent when no seed has one. - Union of seed styles and genres.
- Average seed year.
- Average seed popularity on a 0-100 scale.
Multiple seeds should represent a shared target vibe. If a change makes multi-seed behavior less
predictable, update this doc and the --explain output.
Seed Aggregation Method¶
recommend_tracks() takes a method ("average" or "frequency") shared by the recommend
flow and playlist populate. Average is the default.
- Average scores every eligible track against the seeds' combined
SeedProfilein one library scan. Fast and stable, but a single strong seed can dominate the profile. - Frequency scores each seed as its own single-track profile (one scan per seed), then ranks
each candidate by the average of its per-seed scores, with vote count as a tiebreaker. The
whole seed set is excluded before per-seed vote budgets (
per_seed_limit, default 30), and the result'ssourceslists the voting seed IDs. Prefer average for large seed sets; this costs one scalar scan per seed.
Both methods share the same candidate eligibility, year filters, artist cap, and min_score
cutoff; min_score applies after per-seed votes are averaged in frequency mode.
Eligible Library¶
score_eligible_tracks() scores every eligible non-seed track using scalar SQL batches rather
than a bounded source shortlist. Years are filtered before scoring; all seed/excluded IDs are
removed before tags, scoring and selection budgets. Only seeds and selected tracks load ORM
relationships. recommend_from_profile() is shared by normal and playlist recommendation flows.
The historical build_candidate_pool() remains an offline diagnostic reference, not a production
fallback. Its source limits can miss a perfect style match or consume a budget with seeds.
See the measured retrieval decision for benchmarks, memory bounds,
frequency-mode limits and explicit schema/enrichment decisions.
For deterministic offline comparisons, known retrieval gaps, and an owner listening protocol, see recommendation evaluation. Synthetic results do not establish musical preference.
Scoring¶
score_signals() computes component scores and a weighted total; calculate_score() is the
ORM adapter to the same function:
sonic: cosine similarity normalized to 0-1.popularity: proximity to seed popularity.style: Jaccard overlap.genre: Jaccard overlap.era: proximity within a 50-year window.novelty: inverse function of play count.
Missing sonic/popularity/year uses neutral 0.5. Missing seed tags also use 0.5; missing
candidate tags with a tagged seed retain the historical zero score, labelled missing rather
than observed. ScoreBreakdown.availability distinguishes observed, neutral_missing,
not_applicable, missing, mixed, and legacy unknown evidence. Frequency-populate preserves
uniform statuses and marks differing vote statuses mixed, without changing numeric averaging.
CLI --explain and web tooltips surface all these states; see
the domain guide for the invalid-vector bug-fix boundary.
Default weights live in Weights and mirror RecommendationWeights in config:
sonic=0.30
popularity=0.15
style=0.10
genre=0.15
era=0.05
novelty=0.10
Weights are normalized by their sum at scoring time, so removing a signal shifts relative weight to the remaining signals proportionally — no manual rebalancing needed.
Mood was removed from scoring because it introduced noise for this library. Plex mood tags are
still stored and visible in status, but excluded from Weights, ScoreBreakdown, SeedProfile,
and build_candidate_pool().
Service and approval boundaries¶
Recommendation/populate services map ORM tracks to JSON-safe DTOs while sessions are open. Artist, album, year, popularity, local/Plex IDs, scores, sources and normal recommendation coverage remain serializable after session closure and engine disposal. Only internal recommendation objects carry ORM tracks.
A preview followed by confirmation is not a second recommendation request. CLI, web, and MCP creation write the preview's seed IDs plus its approved recommendation IDs in order; populate writes the approved IDs (including any user pruning). Empty or malformed API selections fail. Stale local IDs or tracks without a Plex mapping reject the whole write before any Plex mutation, rather than silently writing a subset. Duplicate IDs are collapsed in first-occurrence order.
Exact-selection create retries reuse only the same name and ordered contents; other name
collisions fail. Populate skips tracks already present in Plex. Core and MCP return both
added_count and already_present_count; the HTTP adapter currently returns added_count.
The API's create and populate write routes now require track_ids; seed-only/selection-free
clients must preview first. Core retains explicit generate-and-write entry points for callers
that intentionally do not implement a preview workflow.
Selection¶
After scoring:
- Seed tracks and out-of-window years have already been excluded.
min_scorerejects individual low-score rows; it does not stop the ID-ordered scalar scan.ConstrainedTopKretains the exact toplimitundermax_tracks_per_artist, replacing the relevant worst selection when a better candidate arrives. This is equivalent to globally sorting then applying the artist cap, but retains only O(limit) scores.- Results are returned by total score descending, then local ID ascending. Frequency adds vote count descending before the ID tiebreaker; it excludes the entire playlist before each per-seed budget and counts distinct seeds only.
- Only the selected tracks are materialized for DTO projection inside the service session.
min_score defaults to None (no cutoff). When supplied via --min-score, it must be in
[0.0, 1.0]. The result may contain fewer than limit tracks when the threshold is active.
Artist diversity should remain a final constraint unless there is a clear reason to make it part of scoring.
Explainability¶
--explain should expose enough detail to answer:
- Which retrieval path produced this candidate? Normal/average sources now say
eligible; frequency sources are voting seed IDs, not invented signal observations. - Which score components were strong or weak?
- Did a selection constraint affect the final playlist?
When adding a signal, update:
- Candidate source labels.
ScoreBreakdown.- CLI explain output.
- This document.
Safe Change Checklist¶
- Run
python3 -m compileall -q src/musicseed. - Run
uv run ruff check srcif dependencies are available. - Use the read-only preview:
musicseed-cli recommend --seed-id 123 --limit 20 --explain.recommendnever writes playlists and has no--dry-runflag. - Confirm tracks without sonic vectors, missing popularity, and missing tags do not crash scoring.
- Confirm recommendations still work without Plex source databases/network after local vector import. Missing local vectors use neutral sonic scores; source databases are import-time dependencies.
- Confirm ambiguous seed text still fails clearly.