soulsync/docs/superpowers/specs/2026-06-14-best-quality-search-mode-design.md
dev 31d85e59c9 docs(spec): best-quality search mode design
Toggle to search all sources and download best→worst by actual audio
quality, vs today's priority-first (first satisfying source wins).
Reuses exhausted_download_sources for per-source budget removal; query
generator and budget counters untouched. Independent of retry_exhaustive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:54:42 +02:00

201 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Best-Quality Search Mode — Design
**Date:** 2026-06-14
**Branch:** (new, off current `fix/import-folder-artist-override-optin`)
**Status:** Approved approach (Option A), pending spec review
## Goal
Add a user-toggleable download strategy. Today hybrid search is **priority-first**:
`engine.search_with_fallback` walks the source chain in priority order and accepts
the **first** source that meets a quality target — so a passable Soulseek 16-bit FLAC
"wins" even when HiFi/Qobuz/Tidal could deliver a 24-bit version of the same track.
The new **best-quality** mode instead searches **all** configured sources, pools their
candidates, and works them **best→worst by actual audio quality**. Source priority
becomes only a tiebreaker between equally-good candidates.
## Hard constraints (from the user)
1. **Two independent toggles.** The new `search_mode` and the existing
`post_processing.retry_exhaustive` are orthogonal. The feature must behave
correctly in **all four** combinations (priority/best × default/exhaustive budget).
2. **Budget semantics preserved 1:1.** No change to how retries are counted:
- Default mode: single global cap `MAX_QUARANTINE_RETRIES = 5` across all sources.
- Exhaustive mode: per-source budget `query_count × retries_per_query`.
When a source **completely spends** its budget, **all of that source's candidates
are removed from the entire pooled list** (not just skipped once).
3. **Do not touch the query generator.** `matching_engine.generate_download_queries`
and the legacy query-building block in `download_track_worker` stay exactly as-is.
4. **`force_import` never fires on quality** (unchanged existing invariant — only
AcoustID mismatches can force-import).
## Key realization: the budget-removal mechanism already exists
When a source spends its budget, `monitor.requeue_quarantined_task_for_retry`
already adds it to the task's **`exhausted_download_sources`** set, and the worker
already passes that set as `exclude_sources` to its searches. Constraint #2's
"remove the source from the whole list" is therefore **not new logic** — best-quality
mode simply consults the *same* set when assembling/filtering its pooled candidate
list. No new budget bookkeeping is introduced.
## Architecture
### The one genuinely new piece: quality-dominant candidate ordering
`attempt_download_with_candidates` (core/downloads/candidates.py:71) sorts candidates
**confidence-first, then `quality_score`**. That is correct for priority mode (never
download a high-quality *wrong* file). For best-quality mode we keep the
"correct-first" guarantee — `get_valid_candidates` already drops candidates below the
match threshold, so **every candidate in the list is correctly matched** — but among
those valid candidates we want **profile quality rank to dominate**, with confidence as
the tiebreaker.
Add an opt-in parameter:
```python
attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
deps=None, *, quality_first=False)
```
- `quality_first=False` (default): today's sort, byte-for-byte unchanged.
- `quality_first=True`: sort key becomes
`(profile_quality_rank, confidence, upload_speed, -queue_length, free_slots, size)`,
i.e. profile quality dominates, all existing signals become tiebreakers.
`profile_quality_rank` is derived from the candidate's stamped `AudioQuality` against
the user's `ranked_targets` (reusing `core.quality.model` / `filter_and_rank` ordering).
Candidates with no usable quality info sort last (rank = worst), never dropped.
### New engine method: `search_all_sources`
Mirror of `search_with_fallback`, but it does **not** stop at the first satisfying
source. It iterates every configured, non-excluded source in the chain, runs each
source's `search`, and returns the **combined** raw track list (each track already
quality-stamped by its client's `set_quality`, as today). Per-source exceptions are
swallowed exactly like the existing method. Excluded (`exhausted`) sources are skipped.
```python
async def search_all_sources(self, query, source_chain, timeout=None,
progress_callback=None, exclude_sources=None)
-> Tuple[list, list] # (combined_tracks, combined_albums)
```
Quality ranking is **not** done here — the orchestrator/worker owns final ranking, same
division of labour as today (engine returns raw, orchestrator filters/ranks).
### Orchestrator wiring
`download_orchestrator.search(...)` gains awareness of `search_mode`:
- `priority` (default) → unchanged: `search_with_fallback`.
- `best_quality` **and** `mode == 'hybrid'``search_all_sources`.
- Single-source mode → unchanged regardless of `search_mode` (only one source exists;
its candidates are still quality-ranked downstream, so the toggle is a no-op there).
A thin helper `load_search_mode() -> str` reads the quality profile (see Config).
### Worker integration (the per-query loop stays intact)
In `download_track_worker`'s existing query loop (core/downloads/task_worker.py:398),
when best-quality mode is active:
1. The loop and the query generator are untouched.
2. `orchestrator.search(query, exclude_sources=_exclude_sources)` now returns
**pooled** results from all non-exhausted sources (via `search_all_sources`).
3. `candidates = get_valid_candidates(pooled, track, query)` — unchanged.
4. `ranked, _ = rank_for_profile(candidates)` — orders best→worst by profile quality.
5. `attempt_download_with_candidates(task_id, ranked, track, batch_id, quality_first=True)`.
Because the pool already came from every source, the **hybrid-fallback block**
(task_worker.py:529593, which re-tries `hybrid_order[1:]` individually) is
**redundant in best-quality mode** and is skipped — it would just re-search sources the
pool already covered. In priority mode it runs exactly as today.
The `exhausted_download_sources` set is already folded into `_exclude_sources`
(task_worker.py:446), so a budget-spent source is excluded from `search_all_sources`'s
pool automatically — satisfying constraint #2 with no new code. A belt-and-suspenders
filter drops any straggler exhausted-source candidates before step 5.
## Config / persistence
`search_mode` belongs to the quality profile (it *is* a quality-driven policy). Stored
in the v3 quality-profile JSON (`database.music_database.get_quality_profile`):
```json
{ "version": 3, "search_mode": "priority", ... }
```
- Default: `"priority"` (preserves today's behavior for every existing install).
- Accessor: extend `core/quality/selection.py` with `load_search_mode() -> str`
returning `'priority'` unless the profile says `'best_quality'`.
- No migration needed: a profile lacking the key reads as `'priority'`.
## UI
A single toggle in the existing **Quality Profile** tile (webui/index.html +
webui/static/settings.js), e.g. a labelled switch:
> **Search strategy:** ( ) Source priority — fastest, stops at first good source
> (•) Best quality — searches all sources, picks the highest quality
With a short note that best-quality searches **all** sources every track (slower / more
API calls). `collectQualityProfileFromUI` emits `search_mode`; `populateQualityProfileUI`
reads it. No change to the ranked-targets editor or the fallback toggle.
## Behaviour matrix (all four combinations must hold)
| search_mode | retry_exhaustive | Behaviour |
|---|---|---|
| priority | off | **Unchanged today.** First satisfying source wins; 5 global retries. |
| priority | on | **Unchanged today.** First source wins; per-source budgets. |
| best_quality | off | Pool all sources, best→worst; 5 **global** retries total. A source can't be "removed from the pool" because there's no per-source budget — the global cap fires first. (Documented: combine best-quality with exhaustive for per-source removal.) |
| best_quality | on | Pool all sources, best→worst; each source has `query_count × retries_per_query`; on spend → source removed from the whole pool via `exhausted_download_sources`. **This is the user's target configuration.** |
## Error handling
- `search_all_sources`: fail-open per source (swallow exceptions, keep going), identical
to `search_with_fallback`. If the whole pool is empty, the worker falls through to its
existing not-found handling.
- Quality-rank computation: any candidate that can't be ranked sorts last, never crashes
the walk (mirrors the engine's existing fail-open ranking).
## Testing (TDD)
New tests under `tests/quality/` and `tests/downloads/`:
1. **`load_search_mode`** — defaults to `'priority'`; returns `'best_quality'` when set;
unknown value falls back to `'priority'`.
2. **`search_all_sources`** — returns combined candidates from multiple fake sources;
skips excluded/exhausted sources; swallows a raising source; empty when all empty.
3. **quality-first ordering**`attempt_download_with_candidates(quality_first=True)`
tries a 24-bit candidate before a higher-confidence 16-bit one; `quality_first=False`
reproduces today's confidence-first order exactly (regression lock).
4. **budget-removal in pool** — given an exhausted source, its candidates never appear in
the walked list, while other sources' candidates remain (exhaustive mode).
5. **toggle independence** — orchestrator chooses `search_all_sources` only when
`search_mode == best_quality and mode == hybrid`; single-source and priority paths
call `search_with_fallback` (or single-client search) unchanged.
Each test written first, watched fail, then minimal code to green. No production code
before a failing test.
## Files touched
- `core/quality/selection.py` — add `load_search_mode()`; small quality-rank helper for the sort key (or reuse model).
- `core/download_engine/engine.py` — add `search_all_sources`.
- `core/download_orchestrator.py` — branch `search()` on `search_mode`.
- `core/downloads/candidates.py` — add `quality_first` param + alternate sort key.
- `core/downloads/task_worker.py` — pass pooled results + `quality_first=True`; skip the redundant hybrid-fallback block in best-quality mode. (Query generator block untouched.)
- `database/music_database.py` — include `search_mode` default in the v3 profile.
- `webui/index.html`, `webui/static/settings.js` — the toggle.
- `tests/quality/`, `tests/downloads/` — the tests above.
## Explicitly out of scope
- No change to the query generator.
- No change to the budget counters / `MAX_*` constants / `requeue_quarantined_task_for_retry`.
- No change to AcoustID / force_import behavior.
- No cross-query pooling: best-quality pools across **sources** within each query of the
existing loop. The primary query already covers all sources, so this captures the best
candidate without restructuring the loop. Later queries remain per-source fallbacks for
the empty-result case.