Skip to content

S10.6 — Recently Played (frecency)#57

Merged
ramith merged 4 commits into
mainfrom
sprint/s10-6-recently-played
Jul 14, 2026
Merged

S10.6 — Recently Played (frecency)#57
ramith merged 4 commits into
mainfrom
sprint/s10-6-recently-played

Conversation

@ramith

@ramith ramith commented Jul 14, 2026

Copy link
Copy Markdown
Owner

S10.6 — Recently Played (frecency)

Reworks the S10.2 History tab into an all-time, per-track, frecency-ranked "Recently Played" view (US-PLAY-10). Each track appears once with a play count, ordered so recency outweighs raw count — the duplicate-row-on-replay bug is gone by construction.

Design + full expert/Fool review trail: recently-played-frecency-design.md (merged in #56; §8 carries the research-grounded resolutions).

What shipped (3 chunks)

  • Store (schema v4): frecency_score (decayed-play accumulator) + frecency_rank (indexed projected read key) + idx_tracks_frecency_rank. Write is a read-modify-write in one txn: score = prev·2^(−max(0,now−lp)/H) + 1; rank = now + (H/ln2)·ln(score); H = 7 days. Read: WHERE play_count>0 ORDER BY frecency_rank DESC, id DESCindex-driven, no now, no temp b-tree.
  • ≥60% play detection: pure PlaybackQueueKit.PlayThroughTracker — counts once per play-through at min(60%·duration, 240s) with a ≥30s floor. Accrues suspend-stopping monotonic playback time every tick (immune to seeks/stalls/sleep/clock-skew); the four completion sites are gated (no unconditional count).
  • UX: "Recently Played" tab, RecentlyPlayedRow (artwork + title + "N plays · «time ago»" cue + now-playing ▶), tap = play-now; refreshes on a post-commit playCountRevision. The in-memory session log is deleted.

Research-grounded design (Last.fm scrobble rule · Firefox/Mozilla-Places frecency · fre)

  • Firefox's projected-rank trick makes the sort indexable (no filesort, no read-time decay) — proven equivalent to current frecency 2^((rank−t)/H) analytically, numerically (0 mismatches / 200 now × 400 rows), and by EXPLAIN on 10k rows.
  • Last.fm's playback-time measure + >30s floor → the monotonic ≥60% rule.

Review & QA (the bar, not just the gate)

  • architect-reviewer + swift-expert + qa-expert + two Fool passes across the design; the pre-implementation Fool found 10 issues, all resolved or accepted.
  • qa-expert break-it on the real code found + fixed a HIGH race (deferred tracker reset counting the outgoing track against the newly-selected one on a manual switch) + a LOW display clamp; it confirmed the gapless-seam ordering, migration path, refresh race, and Swift-6 isolation safe.
  • Gate: make strict-gate PASSED — 0 lint, clang-tidy + periphery clean, 144 swift tests (incl. 12 PlayThroughTracker cases), VerifyLibraryStore 114/114 (+8 new FR checks: first-play, decay, burst-vs-spread, recency-outweighs-count, never-played-excluded, rank≡current-frecency equivalence, backward-clock-clamp, indexed-read), leak-check clean. Founder by-ear ✓.

Notes

  • Schema v4 resets play counts once (delete-rebuild posture, founder D9); the read is NULL-tolerant (NULLs sort last) so it's robust on a non-wiped store too.
  • H is baked into stored ranks (changing it later needs a rank recompute). S10.6 is an enhancement — not an R1 gate.

🤖 Generated with Claude Code

ramith added 4 commits July 15, 2026 00:57
…gate (chunk 1)

The Recently-Played frecency data layer + its proof. Schema v4 adds
tracks.frecency_score (decayed-play accumulator) + tracks.frecency_rank (indexed
projected read key) + idx_tracks_frecency_rank. Additive migration; counts reset
via delete-rebuild (founder D9); nullable rank sorts NULLs last (plain indexed
DESC — no COALESCE — keeps the filesort gone).

- LibraryStore.frecencyAfterPlay (pure, public): score = prev·2^(−max(0,now−lp)/H)+1
  (first play → 1; backward-clock clamp), rank = now + (H/ln2)·ln(score). H = 7 days.
- incrementPlayCount(id:/url:) → read-modify-write in one dbWriter.write txn
  (Swift-computed score/rank; silent no-op if absent).
- frecencyTracksDisplay(limit:offset:): WHERE play_count>0 ORDER BY frecency_rank
  DESC, id DESC — index-driven, no now, no temp b-tree.
- frecencyState(id:) + explainFrecencyTracksDisplayPlan verification hooks.

Gate: VerifyLibraryStore 114/114 (106 prior + FR1–FR8). FR6 verifies the projected-
rank order ≡ current frecency 2^((rank−t)/H) end-to-end; FR3 proves burst-vs-spread;
FR8 asserts index-used + no temp b-tree. Existing 106 pass at schema v4.

Design + expert/Fool review + fixes: recently-played-frecency-design.md §8.
…ring (chunk 2)

Replaces the natural-completion play-count rule with the founder's ≥60%-heard rule.

- PlaybackQueueKit.PlayThroughTracker (pure, Sendable): counts once per play-through
  when heard ≥ min(60%·duration, 240s), with a ≥30s track floor. accrue(delta,
  duration) fires on the crossing; naturalEnd(duration) is the same-gate fallback
  (FIX-1 — a scrub-to-end / sub-30s / unresolved-duration track does NOT count);
  reset() re-arms (repeat-one recounts). maxPlausibleDelta clamps a pathological
  single delta while still accruing real UI-tick stalls (FIX-3).
- AudioViewModel: accruePlayThrough() runs every transport tick — advances a
  suspend-stopping monotonic reference (DispatchTime.uptimeNanoseconds, excludes
  system sleep, FIX-2) unconditionally, accrues only while isPlaying (so pause/
  stall/seek never mis-count, FIX-3), and records the current play on the crossing.
  The four completion sites now route through countPlayIfNaturalEndQualifies()
  (gated) instead of an unconditional count. resetPlayTracking() at the two
  new-play-through points (fresh startPlayback; gapless/repeat-one transition).
- writePlayCount bumps playCountRevision on the main actor AFTER the store write
  commits (R4), for the Recently-Played refresh.

Tests: 12 PlayThroughTracker cases (PT-01..12) — below/at/past threshold, once-per-
play-through, reset/repeat, 30s floor + boundary, 240s cap, stall-accrues,
pathological-clamp, seek-back/no-motion, duration-0, scrub-to-end. Build clean.
The History tab becomes an all-time, per-track, frecency-ranked "Recently Played"
view backed by the persistent store read — the duplicate-row-on-replay bug is gone
by construction (each track appears once).

- LibraryBrowseModel.loadHistory() (+History extension): frecencyTracksDisplay read,
  loadSongs-style epoch/isStoreReady guard; history/historyState internal state.
- RecentlyPlayedRow: LibraryTrackDisplay-bound row — leading artwork (now-playing ▶
  cue), title + FormatBadge, and a "N plays · «relative last-played»" cue replacing
  the blank subtitle so the frecency order reads as intentional. No rank/duration.
  Design-system tokens throughout; a11y label = title · count · last-played.
- QueueHistoryList: rebinds to LibraryBrowseModel.history via RecentlyPlayedRow; tap
  → playTrackNextNow (non-destructive); .task loads on appear + reloads on
  playCountRevision (only while the tab is on screen → no background thrash). Empty
  state reworded ("Tracks you finish will appear here.").
- PlaylistView: tab renamed "Recently Played" (header) / "Recent" (segmented picker,
  avoids truncation); subtitle count from the browse model.
- DELETED the in-memory session log: AudioViewModel+History.swift, Models/HistoryItem
  .swift, the sessionHistory property, and the recordPlayStart call sites (replaced by
  resetPlayTracking in chunk 2).

make strict-gate PASSED: 0 lint, clang-tidy + periphery clean, 144 tests
(incl. PlayThroughTracker), VerifyLibraryStore 114/114, leak-check clean.
…-it)

qa-expert break-it pass on the real impl found 1 high + 1 low (seam ordering it
flagged as top-risk was confirmed CORRECT).

- HIGH: startPlayback reset the PlayThroughTracker INSIDE its async Task (after
  await engine.startAudio). On a manual switch (next/prev/playTrack/Pure reconfigure)
  selectedTrackIndex is already the new track, and the 20Hz tick fires while the Task
  is parked — so the OUTGOING track's accrual could cross the threshold and count
  against the NEWLY-selected track (and the real ≥60% track miss its count). Reset
  now runs SYNCHRONOUSLY before the Task (resumeFrom == nil), closing the window.
- LOW: RecentlyPlayedRow rendered a future/clock-skewed last_played as 'in N minutes'
  — clamp the display string to 'just now' when the stamp is not in the past.

Verified-safe by the pass (no change): migration legacy-row path, NULL-rank ordering,
double-load/refresh race, now-playing cue, repeat-one, idempotency, Swift-6 isolation,
pause/stall accrual. make strict-gate PASSED (144 tests, 114/114, periphery clean).
@ramith
ramith merged commit 5cec476 into main Jul 14, 2026
1 check passed
ramith added a commit that referenced this pull request Jul 15, 2026
Vetted design for S10.4 (last R1 gate): media keys + Now Playing/Control Center
(MediaPlayer) + Stop/Jump shortcuts, folding in the on-screen footer/mini-player
metadata fix (same resolver). architect + swift-expert (SDK-grounded) + Fool + founder
brainstorm. Locked: no entitlement/Info.plist change needed; explicit macOS
playbackState; event-driven pushes (not per-tick); NowPlayingController peer via the
onNowPlayingRefresh hook; pure NowPlayingSnapshot/RemoteCommandIntent in
PlaybackQueueKit for tests; fix the latent ⌘←/⌘→ text-focus bug in passing.

sprint-plan §Status + tables: S10.6 ✅ (shipped #57), S10.4 in progress → then S10.3.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant