Problem
Three view models each hand-rolled a similar recommendation-scoring orchestration:
EpisodesListViewModel — ScoredInputsKey, a $scoringRevision observation, a cancel-and-restart scoring task.
EpisodeDetailViewModel — RecommendationScoringSnapshot, a $scoringRevision observation, a 400ms debounce, a cancel-and-restart fetch task.
PodcastRecommendationScorer — RecommendationScoringSnapshot (with entries), a $scoringRevision observation, a cancel-and-restart scoring task.
Each re-implemented a similar shape:
- Observe
recommendationEngine.$scoringRevision.stream().dropFirst().
- Build an
Equatable snapshot/key that embeds scoringRevision.
- Skip — or post-hoc drop — a redundant pass using that key.
- Run the pass in a
.utility Task, cancel-and-restart.
Why it matters
Because each view model builds its own $scoringRevision observation and snapshot key, each can independently get the set of scoring inputs wrong. A future scoring input added the wrong way would silently break in three places.
Resolution (PR #326)
Extracted a shared, generic RecommendationScoringCoordinator<Snapshot, Result> that owns:
- the
$scoringRevision observation,
- the snapshot-keyed skip,
- the cancel-and-restart task machinery,
- score-cache retention across teardown.
Each view model supplies only its snapshot shape, a "score these" closure, and an "apply" closure. This single-sources the "what triggers a re-score" contract (engine scoringRevision ↔ coordinator) and makes it structurally unable to drift per view model.
Scope — amended from the original proposal
The original proposal called for converging the three surfaces' debounce + in-flight coalescing + generation-guard machinery. That became obsolete before this work started (see comments below): PRs #315 and #321 removed EpisodesListViewModel's debounce/generation machinery and PodcastRecommendationScorer's runningDirty tristate, leaving both on plain cancel-and-restart. So the extraction is reduced-scope:
- Uniform cancel-and-restart. No per-trigger
.coalesce/.restart policy parameter — no surface debounces anymore, so there is nothing to flatten.
- The scoring backend is still not uniform (
recommendations(for:) vs. local embed + similarityScore(forEmbedding:)) and result shape is still per-surface — handled by the generic Result and each surface's own score closure.
Behavior changes
All deliberate; none is a correctness regression — every existing per-view-model scoring suite stays green.
EpisodeDetailViewModel's 400ms scoringRevision debounce is removed, converging it onto cancel-and-restart. $scoringRevision is already a low-frequency, upstream-coalesced signal (engine cacheDebounce + settings changes), so a per-surface debounce bought nothing.
- A transient candidate-observation failure in
EpisodesListViewModel no longer forces a recompute on the next reappear. The old skip-check was gated on recommendationScoresState == .loaded, so a .failed fell through to a re-score. The coordinator's skip is keyed purely on the snapshot, and the retained score is still valid for unchanged inputs — so reappear recovers by re-applying it.
EpisodeDetailViewModel gains the snapshot-keyed skip it previously lacked — it used to re-fetch on every trigger and drop stale writes post-hoc. The skip only elides redundant identical-input fetches.
New direct RecommendationScoringCoordinatorTests cover the shared machinery; three existing per-view-model cases were updated to assert the post-change observable behavior.
SearchRecommendationCollector (#284) was evaluated and is not a client of this coordinator — it is a structurally different pattern (external-ranking fan-out with a per-source debounce; it does not observe $scoringRevision).
Problem
Three view models each hand-rolled a similar recommendation-scoring orchestration:
EpisodesListViewModel—ScoredInputsKey, a$scoringRevisionobservation, a cancel-and-restart scoring task.EpisodeDetailViewModel—RecommendationScoringSnapshot, a$scoringRevisionobservation, a 400ms debounce, a cancel-and-restart fetch task.PodcastRecommendationScorer—RecommendationScoringSnapshot(withentries), a$scoringRevisionobservation, a cancel-and-restart scoring task.Each re-implemented a similar shape:
recommendationEngine.$scoringRevision.stream().dropFirst().Equatablesnapshot/key that embedsscoringRevision..utilityTask, cancel-and-restart.Why it matters
Because each view model builds its own
$scoringRevisionobservation and snapshot key, each can independently get the set of scoring inputs wrong. A future scoring input added the wrong way would silently break in three places.Resolution (PR #326)
Extracted a shared, generic
RecommendationScoringCoordinator<Snapshot, Result>that owns:$scoringRevisionobservation,Each view model supplies only its snapshot shape, a "score these" closure, and an "apply" closure. This single-sources the "what triggers a re-score" contract (engine
scoringRevision↔ coordinator) and makes it structurally unable to drift per view model.Scope — amended from the original proposal
The original proposal called for converging the three surfaces' debounce + in-flight coalescing + generation-guard machinery. That became obsolete before this work started (see comments below): PRs #315 and #321 removed
EpisodesListViewModel's debounce/generation machinery andPodcastRecommendationScorer'srunningDirtytristate, leaving both on plain cancel-and-restart. So the extraction is reduced-scope:.coalesce/.restartpolicy parameter — no surface debounces anymore, so there is nothing to flatten.recommendations(for:)vs. local embed +similarityScore(forEmbedding:)) and result shape is still per-surface — handled by the genericResultand each surface's ownscoreclosure.Behavior changes
All deliberate; none is a correctness regression — every existing per-view-model scoring suite stays green.
EpisodeDetailViewModel's 400msscoringRevisiondebounce is removed, converging it onto cancel-and-restart.$scoringRevisionis already a low-frequency, upstream-coalesced signal (enginecacheDebounce+ settings changes), so a per-surface debounce bought nothing.EpisodesListViewModelno longer forces a recompute on the next reappear. The old skip-check was gated onrecommendationScoresState == .loaded, so a.failedfell through to a re-score. The coordinator's skip is keyed purely on the snapshot, and the retained score is still valid for unchanged inputs — so reappear recovers by re-applying it.EpisodeDetailViewModelgains the snapshot-keyed skip it previously lacked — it used to re-fetch on every trigger and drop stale writes post-hoc. The skip only elides redundant identical-input fetches.New direct
RecommendationScoringCoordinatorTestscover the shared machinery; three existing per-view-model cases were updated to assert the post-change observable behavior.SearchRecommendationCollector(#284) was evaluated and is not a client of this coordinator — it is a structurally different pattern (external-ranking fan-out with a per-source debounce; it does not observe$scoringRevision).