Skip to content

Extract shared RecommendationScoringCoordinator across recommendation-scoring view models #306

@jubishop

Description

@jubishop

Problem

Three view models each hand-rolled a similar recommendation-scoring orchestration:

  • EpisodesListViewModelScoredInputsKey, a $scoringRevision observation, a cancel-and-restart scoring task.
  • EpisodeDetailViewModelRecommendationScoringSnapshot, a $scoringRevision observation, a 400ms debounce, a cancel-and-restart fetch task.
  • PodcastRecommendationScorerRecommendationScoringSnapshot (with entries), a $scoringRevision observation, a cancel-and-restart scoring task.

Each re-implemented a similar shape:

  1. Observe recommendationEngine.$scoringRevision.stream().dropFirst().
  2. Build an Equatable snapshot/key that embeds scoringRevision.
  3. Skip — or post-hoc drop — a redundant pass using that key.
  4. 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.

  1. 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.
  2. 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.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions