From 1029d3768e126e787312764bfce34ad9d96df286 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:57:36 +0530 Subject: [PATCH 1/4] =?UTF-8?q?feat(s10.6):=20frecency=20store=20=E2=80=94?= =?UTF-8?q?=20schema=20v4,=20write=20DAO,=20indexed=20read,=20FR=20gate=20?= =?UTF-8?q?(chunk=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../LibraryStore+BrowseReads.swift | 28 +++ Sources/LibraryStore/LibraryStore.swift | 91 +++++++- Sources/LibraryStore/Schema.swift | 32 ++- Sources/VerifyLibraryStore/Checks.swift | 3 + .../VerifyLibraryStore/ChecksFrecency.swift | 196 ++++++++++++++++++ Sources/VerifyLibraryStore/main.swift | 9 + .../recently-played-frecency-design.md | 56 ++++- 7 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 Sources/VerifyLibraryStore/ChecksFrecency.swift diff --git a/Sources/LibraryStore/LibraryStore+BrowseReads.swift b/Sources/LibraryStore/LibraryStore+BrowseReads.swift index 08c2a5e..dceff15 100644 --- a/Sources/LibraryStore/LibraryStore+BrowseReads.swift +++ b/Sources/LibraryStore/LibraryStore+BrowseReads.swift @@ -190,6 +190,23 @@ public extension LibraryStore { } } + /// Recently-Played display rows (S10.6): tracks played at least once, ordered by frecency + /// (recency-weighted play frequency). `WHERE t.play_count > 0` excludes never-played; + /// `ORDER BY t.frecency_rank DESC, t.id DESC` is **index-driven** (the `idx_tracks_frecency_rank` + /// index carries the rowid as its trailing key → no temp b-tree) and needs no `now` — the stored + /// projected rank is order-equivalent to current frecency at any read time. A legacy row with + /// `play_count > 0` but a NULL rank sorts LAST (NULLs last in DESC), never mis-ordering real + /// rows. `limit` caps the shelf (default 200). Reuses the shared display projection + the + /// `AudioFile(_:)` adapter — no new row decoding. + func frecencyTracksDisplay(limit: Int? = 200, offset: Int = 0) async throws -> [LibraryTrackDisplay] { + let sql = LibraryStore.displayTracksSQL( + whereClause: "WHERE t.play_count > 0", + order: "t.frecency_rank DESC, t.id DESC", limited: limit != nil + ) + let args = StatementArguments(LibraryStore.paginationArgs(limit: limit, offset: offset)) + return try await dbWriter.read { db in try LibraryTrackDisplay.fetchAll(db, sql: sql, arguments: args) } + } + // MARK: - Artwork cache-path map (batched, chunked IN-list) /// Resolve artwork `content_hash` → `cache_path` for `keys` in one batched map. The IN-list @@ -366,6 +383,17 @@ public extension LibraryStore { return try await dbWriter.read { db in try Self.collectQueryPlan(db, sql) } } + /// EXPLAIN QUERY PLAN for the Recently-Played frecency read (S10.6 FR7): must be INDEX-driven + /// on `idx_tracks_frecency_rank` with NO `USE TEMP B-TREE FOR ORDER BY` (the projected-rank + + /// rowid-carrying index removes the filesort). + func explainFrecencyTracksDisplayPlan() async throws -> [String] { + let sql = LibraryStore.displayTracksSQL( + whereClause: "WHERE t.play_count > 0", + order: "t.frecency_rank DESC, t.id DESC", limited: false + ) + return try await dbWriter.read { db in try Self.collectQueryPlan(db, sql) } + } + /// Run `EXPLAIN QUERY PLAN ` and collect the `detail` column (index 3) of each plan /// row. The `?` placeholders are bound to NULL (the plan is value-independent). Internal so /// `LibraryStore+Search`'s `explainSearchMatchingIDsPlan` reuses the SAME collector. diff --git a/Sources/LibraryStore/LibraryStore.swift b/Sources/LibraryStore/LibraryStore.swift index 2d6c0e7..028b86f 100644 --- a/Sources/LibraryStore/LibraryStore.swift +++ b/Sources/LibraryStore/LibraryStore.swift @@ -57,12 +57,20 @@ public final class LibraryStore: Sendable { "UPDATE tracks SET play_count = ?, loved = ?, rating = ? WHERE id = ?;" /// Read a track's reserved user-state columns (verification hook). private static let selectUserStateSQL = "SELECT play_count, loved, rating FROM tracks WHERE id = ?;" - /// Count one natural-completion play — a SINGLE atomic URL-keyed accumulate (§12.3). - private static let incrementPlayCountSQL = - "UPDATE tracks SET play_count = play_count + 1, last_played = ? WHERE url = ?;" - /// The durable-id-keyed accumulate (S10.2 — closes the S9.5 url→id play-count seam). - private static let incrementPlayCountByIDSQL = - "UPDATE tracks SET play_count = play_count + 1, last_played = ? WHERE id = ?;" + /// Recently-Played frecency (S10.6): read the prior accumulator + last-play, then write the + /// updated play_count / last_played / decayed score / projected rank in ONE write transaction + /// (a read-modify-write — the score/rank are Swift-computed, so this can't be a bare UPDATE; + /// the single serialized `DatabaseWriter` makes the in-closure read+write atomic, no TOCTOU). + private static let selectFrecencyStateByIDSQL = + "SELECT frecency_score, last_played FROM tracks WHERE id = ?;" + private static let selectFrecencyStateByURLSQL = + "SELECT frecency_score, last_played FROM tracks WHERE url = ?;" + private static let recordPlayByIDSQL = + "UPDATE tracks SET play_count = play_count + 1, last_played = ?, " + + "frecency_score = ?, frecency_rank = ? WHERE id = ?;" + private static let recordPlayByURLSQL = + "UPDATE tracks SET play_count = play_count + 1, last_played = ?, " + + "frecency_score = ?, frecency_rank = ? WHERE url = ?;" /// Read the schema version back from `schema_info` (0 on a fresh, unwritten store). private static let selectSchemaVersionSQL = "SELECT version FROM schema_info WHERE id = 1;" @@ -207,10 +215,14 @@ public final class LibraryStore: Sendable { public func incrementPlayCount(url: URL, playedAt: Int64) async throws { let key = PathNormalizer.normalizedString(for: url) try await dbWriter.write { db in - try db.execute( - sql: Self.incrementPlayCountSQL, - arguments: [playedAt, key] - ) + guard let row = try Row.fetchOne(db, sql: Self.selectFrecencyStateByURLSQL, arguments: [key]) else { + return // no matching row — silent no-op (never throw into the audio path) + } + let prevScore: Double = row["frecency_score"] ?? 0 + let lastPlayed: Int64? = row["last_played"] + let updated = Self.frecencyAfterPlay(prevScore: prevScore, lastPlayed: lastPlayed, now: playedAt) + try db.execute(sql: Self.recordPlayByURLSQL, + arguments: [playedAt, updated.score, updated.rank, key]) } } @@ -219,7 +231,37 @@ public final class LibraryStore: Sendable { /// the audio path). public func incrementPlayCount(id trackID: Int64, playedAt: Int64) async throws { try await dbWriter.write { db in - try db.execute(sql: Self.incrementPlayCountByIDSQL, arguments: [playedAt, trackID]) + guard let row = try Row.fetchOne(db, sql: Self.selectFrecencyStateByIDSQL, arguments: [trackID]) else { + return // absent id — silent no-op (never throw into the audio path) + } + let prevScore: Double = row["frecency_score"] ?? 0 + let lastPlayed: Int64? = row["last_played"] + let updated = Self.frecencyAfterPlay(prevScore: prevScore, lastPlayed: lastPlayed, now: playedAt) + try db.execute(sql: Self.recordPlayByIDSQL, + arguments: [playedAt, updated.score, updated.rank, trackID]) + } + } + + /// The raw frecency state of a track (S10.6 verification hook). A struct (not a tuple) to stay + /// within the large-tuple lint bound. + public struct FrecencyState: Sendable { + public let playCount: Int64 + public let score: Double + public let rank: Double? + public let lastPlayed: Int64? + } + + /// Verification hook (S10.6): the raw frecency state for a track id, for `VerifyLibraryStore` + /// to assert the accumulator/rank against `frecencyAfterPlay`. `nil` if the id is absent. + public func frecencyState(id trackID: Int64) async throws -> FrecencyState? { + try await dbWriter.read { db in + guard let row = try Row.fetchOne( + db, + sql: "SELECT play_count, frecency_score, frecency_rank, last_played FROM tracks WHERE id = ?;", + arguments: [trackID] + ) else { return nil } + return FrecencyState(playCount: row["play_count"], score: row["frecency_score"] ?? 0, + rank: row["frecency_rank"], lastPlayed: row["last_played"]) } } @@ -325,6 +367,9 @@ public final class LibraryStore: Sendable { migrator.registerMigration(Schema.MigrationID.v3) { db in try Schema.migrateV2toV3(db, appBuild: appBuild, timestamp: nowSeconds()) } + migrator.registerMigration(Schema.MigrationID.v4) { db in + try Schema.migrateV3toV4(db, appBuild: appBuild, timestamp: nowSeconds()) + } return migrator } @@ -333,4 +378,28 @@ public final class LibraryStore: Sendable { static func nowSeconds() -> Int64 { Int64(Date().timeIntervalSince1970) } + + /// Half-life for the Recently-Played frecency decay (design D5): 7 days, in seconds. + public static let frecencyHalfLifeSeconds: Double = 7 * 24 * 60 * 60 + + /// Pure frecency update (S10.6 R2/R7): the new `(score, rank)` after a play at `now`, given the + /// prior decayed `score` + `lastPlayed`. + /// - `score = prevScore·2^(−max(0, now−lp)/H) + 1` — first play (`lp == nil`) → `1`; the + /// `max(0,…)` clamp blocks a backward clock jump from inflating the decay factor. + /// - `rank = now + (H/ln2)·ln(score)` — the projected instant at which the score decays to 1; + /// `score ≥ 1` always ⇒ `ln ≥ 0` (no domain error), and ordering by `rank` equals ordering by + /// current frecency `score·2^(−(t−lp)/H)` at ANY read time `t` (Mozilla-Places projected-rank). + /// Pure + `internal` so `VerifyLibraryStore` / `swift test` can prove the algorithm directly. + public static func frecencyAfterPlay(prevScore: Double, lastPlayed: Int64?, now: Int64, + halfLife: Double = frecencyHalfLifeSeconds) -> (score: Double, rank: Double) { + let score: Double + if let lastPlayed { + let age = Double(max(0, now - lastPlayed)) + score = prevScore * pow(2.0, -age / halfLife) + 1.0 + } else { + score = 1.0 + } + let rank = Double(now) + (halfLife / log(2.0)) * log(score) + return (score, rank) + } } diff --git a/Sources/LibraryStore/Schema.swift b/Sources/LibraryStore/Schema.swift index 7f80bed..f175696 100644 --- a/Sources/LibraryStore/Schema.swift +++ b/Sources/LibraryStore/Schema.swift @@ -32,7 +32,7 @@ import GRDB /// means: register a new migration in `LibraryStore.makeMigrator` (with a new `Schema.MigrationID`) /// whose body writes `schema_info` at the new version, AND bump this constant — the migration /// creates/backfills the schema; this constant is the value the harness expects to read back. -public let currentSchemaVersion = 3 +public let currentSchemaVersion = 4 /// The reserved "unknown artist" sentinel rowid seeded at v1 for the M1 album key. public let unknownArtistID: Int64 = 0 @@ -54,6 +54,12 @@ public enum Schema { /// "current" queue playlist (S10.1). Durability across schema change is DEFERRED /// (design §0.1): `eraseDatabaseOnSchemaChange` stays true pre-R1. public static let v3 = "v3-playlists" + /// v3 → v4: `tracks.frecency_score` + `tracks.frecency_rank` (+ index) for the + /// Recently-Played frecency ordering (S10.6). ADDITIVE (ALTER ADD COLUMN); play counts + /// reset in practice via the delete-rebuild posture (founder D9). `frecency_rank` is + /// nullable so a legacy/never-scored played row sorts last (NULLs last in DESC) rather + /// than corrupting the order. + public static let v4 = "v4-frecency" } /// The complete set of `CREATE` statements for schema v1, ordered so a @@ -345,6 +351,30 @@ public enum Schema { createdAt: timestamp, migratedAt: timestamp) } + /// The `ALTER`/`CREATE` statements for schema v4 (S10.6): the frecency ordering columns on + /// `tracks`. `frecency_score` is the decayed-play accumulator; `frecency_rank` is the derived, + /// INDEXED read key (`last_played + (H/ln2)·ln(score)` — the Mozilla-Places projected-rank + /// trick: current frecency is monotonic in it, so `ORDER BY frecency_rank DESC` is the exact + /// order with no read-time decay math + no `now`). `frecency_rank` is nullable → a never-played + /// / legacy-unscored row sorts LAST (NULLs last in DESC), never mis-ordering real rows. The + /// plain index carries the rowid as its trailing key, so `ORDER BY frecency_rank DESC, id DESC` + /// is index-driven (no temp b-tree). + public static let createV4Statements: [String] = [ + "ALTER TABLE tracks ADD COLUMN frecency_score REAL NOT NULL DEFAULT 0;", + "ALTER TABLE tracks ADD COLUMN frecency_rank REAL;", + "CREATE INDEX idx_tracks_frecency_rank ON tracks(frecency_rank);", + ] + + /// Migrate v3 → v4: add the frecency columns + index. Additive — no `tracks` backfill (counts + /// reset via the delete-rebuild posture; the nullable rank keeps the read correct either way). + public static func migrateV3toV4(_ db: Database, appBuild: String?, timestamp: Int64) throws { + for statement in createV4Statements { + try db.execute(sql: statement) + } + try writeSchemaInfo(db, version: 4, appBuild: appBuild, + createdAt: timestamp, migratedAt: timestamp) + } + /// Seed the reserved `artists(id=0)` sentinel (`INSERT OR IGNORE` keeps it idempotent). private static let seedSentinelArtistSQL = "INSERT OR IGNORE INTO artists(id, name, sort_name) VALUES (?, ?, ?);" diff --git a/Sources/VerifyLibraryStore/Checks.swift b/Sources/VerifyLibraryStore/Checks.swift index 546e46e..d1a2e69 100644 --- a/Sources/VerifyLibraryStore/Checks.swift +++ b/Sources/VerifyLibraryStore/Checks.swift @@ -62,6 +62,9 @@ func fullMigrator() -> DatabaseMigrator { migrator.registerMigration(Schema.MigrationID.v3) { db in try Schema.migrateV2toV3(db, appBuild: "verify", timestamp: testTimestamp) } + migrator.registerMigration(Schema.MigrationID.v4) { db in + try Schema.migrateV3toV4(db, appBuild: "verify", timestamp: testTimestamp) + } return migrator } diff --git a/Sources/VerifyLibraryStore/ChecksFrecency.swift b/Sources/VerifyLibraryStore/ChecksFrecency.swift new file mode 100644 index 0000000..ed02ba8 --- /dev/null +++ b/Sources/VerifyLibraryStore/ChecksFrecency.swift @@ -0,0 +1,196 @@ +// ChecksFrecency — the S10.6 Recently-Played frecency algorithm checks (design §6, FR1–FR8). +// +// Drives the REAL LibraryStore: the write DAO (`incrementPlayCount(id:playedAt:)`), the read +// (`frecencyTracksDisplay`), the verification hook (`frecencyState(id:)`), and the EXPLAIN hook — +// asserting against expectations DERIVED from the pure `LibraryStore.frecencyAfterPlay` (never +// magic numbers). Proves the accumulator/rank math, recency-outweighs-count ordering, the +// rank≡current-frecency order-equivalence, never-played exclusion, the backward-clock clamp, and +// the index-driven (no-filesort) read. The ≥60% "heard" rule is proven separately by the pure +// `PlayThroughTracker` under `swift test`. + +import Foundation +import GRDB +import LibraryScan +import LibraryStore + +private let frecencyEps = 1e-6 +private let halfLife = LibraryStore.frecencyHalfLifeSeconds +private let baseTime: Int64 = 1_700_000_000 + +/// FR1 — first play: play_count 1, score 1.0, rank == last_played (ln 1 = 0). +func checkFrecencyFirstPlay(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/Fr", paths: ["/Fr/a.flac"]) + let id = seeded.trackIDs[0] + try await seeded.store.incrementPlayCount(id: id, playedAt: baseTime) + guard let state = try await seeded.store.frecencyState(id: id) else { + printFail(number, "FR1: no frecency state after first play"); return false + } + guard state.playCount == 1, abs(state.score - 1.0) < frecencyEps, state.lastPlayed == baseTime, + let rank = state.rank, abs(rank - Double(baseTime)) < 1e-3 else { + printFail(number, "FR1: first play state wrong " + + "(count=\(state.playCount) score=\(state.score) rank=\(String(describing: state.rank)))") + return false + } + printPass(number, "FR1: first play → play_count 1, score 1.0, rank == last_played (ln 1 = 0)") + return true + } catch { printFail(number, "FR1 threw: \(error)"); return false } +} + +/// FR2 — decay accumulation: two plays a half-life apart → score == 1·2⁻¹ + 1 == 1.5, matching the +/// pure helper (formula-derived, not magic). +func checkFrecencyDecayAccumulation(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/Fr", paths: ["/Fr/a.flac"]) + let id = seeded.trackIDs[0] + try await seeded.store.incrementPlayCount(id: id, playedAt: baseTime) + let second = baseTime + Int64(halfLife) + try await seeded.store.incrementPlayCount(id: id, playedAt: second) + let expected = LibraryStore.frecencyAfterPlay(prevScore: 1.0, lastPlayed: baseTime, now: second) + guard let state = try await seeded.store.frecencyState(id: id), state.playCount == 2, + abs(state.score - expected.score) < frecencyEps, abs(expected.score - 1.5) < 1e-9 else { + printFail(number, "FR2: Δt=half-life accumulation != 1.5"); return false + } + printPass(number, "FR2: two plays a half-life apart → score 1.5 (decay accumulate, formula-derived)") + return true + } catch { printFail(number, "FR2 threw: \(error)"); return false } +} + +/// FR3 — burst vs spread (the correctness property the naive `count×decay` model fails): 3 plays at +/// one instant (score 3) rank ABOVE 3 plays spaced a half-life (score 1.75). +func checkFrecencyBurstVsSpread(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/Fr", paths: ["/Fr/burst.flac", "/Fr/spread.flac"]) + let burst = seeded.trackIDs[0], spread = seeded.trackIDs[1] + for _ in 0 ..< 3 { + try await seeded.store.incrementPlayCount(id: burst, playedAt: baseTime) + } + for step in 0 ..< 3 { + try await seeded.store.incrementPlayCount(id: spread, playedAt: baseTime + Int64(step) * Int64(halfLife)) + } + guard let burstState = try await seeded.store.frecencyState(id: burst), + let spreadState = try await seeded.store.frecencyState(id: spread), + abs(burstState.score - 3.0) < frecencyEps, abs(spreadState.score - 1.75) < frecencyEps, + burstState.score > spreadState.score else { + printFail(number, "FR3: burst not > spread"); return false + } + printPass(number, "FR3: burst (3× at once, score 3) > spread (3× a half-life apart, score 1.75) " + + "— recency-weighting proven (the naive count×decay model fails this)") + return true + } catch { printFail(number, "FR3 threw: \(error)"); return false } +} + +/// FR4 — recency outweighs count (via the read): 3 recent plays rank ABOVE 30 plays 30 days stale +/// (H=7d). Order derived from the ranks, then confirmed by `frecencyTracksDisplay`. +func checkFrecencyRecencyOutweighsCount(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/Fr", paths: ["/Fr/recent.flac", "/Fr/stale.flac"]) + let recent = seeded.trackIDs[0], stale = seeded.trackIDs[1] + let staleTime = baseTime - 30 * 86400 + for _ in 0 ..< 3 { + try await seeded.store.incrementPlayCount(id: recent, playedAt: baseTime) + } + for _ in 0 ..< 30 { + try await seeded.store.incrementPlayCount(id: stale, playedAt: staleTime) + } + guard let recentState = try await seeded.store.frecencyState(id: recent), + let staleState = try await seeded.store.frecencyState(id: stale), + let recentRank = recentState.rank, let staleRank = staleState.rank else { + printFail(number, "FR4: missing state"); return false + } + // Derived: 3-recent must out-rank 30-stale despite the 10× count gap. + guard recentRank > staleRank else { + printFail(number, "FR4: recent rank (\(recentRank)) !> stale rank (\(staleRank))"); return false + } + let order = try await seeded.store.frecencyTracksDisplay().map(\.id) + guard order.first == recent, order.contains(stale), order.count == 2 else { + printFail(number, "FR4: read order \(order) did not put recent first"); return false + } + printPass(number, "FR4: 3 recent plays out-rank 30 plays 30 days stale (H=7d) — read confirms recency > count") + return true + } catch { printFail(number, "FR4 threw: \(error)"); return false } +} + +/// FR5 — never-played rows are excluded (`WHERE play_count > 0`). +func checkFrecencyNeverPlayedExcluded(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/Fr", paths: ["/Fr/a.flac", "/Fr/b.flac", "/Fr/c.flac"]) + let played = seeded.trackIDs[1] + try await seeded.store.incrementPlayCount(id: played, playedAt: baseTime) + let ids = try await seeded.store.frecencyTracksDisplay().map(\.id) + guard ids == [played] else { + printFail(number, "FR5: expected only the played id, got \(ids)"); return false + } + printPass(number, "FR5: never-played tracks absent from the frecency read (only play_count > 0)") + return true + } catch { printFail(number, "FR5 threw: \(error)"); return false } +} + +/// FR6 — rank order ≡ current-frecency order (the R2 core proof, empirically): several tracks with +/// varied (count, recency); the `frecencyTracksDisplay` order equals the order by independently +/// computed current frecency `score·2^(−(t−lp)/H)` at an arbitrary read time `t`. +func checkFrecencyOrderEquivalence(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks( + url, root: "/Fr", paths: ["/Fr/0.flac", "/Fr/1.flac", "/Fr/2.flac", "/Fr/3.flac"] + ) + // Distinct histories: (plays, secondsAgo-from-baseTime). + let plans: [(count: Int, ago: Int64)] = [(1, 0), (5, 20 * 86400), (2, 3 * 86400), (12, 40 * 86400)] + for (index, plan) in plans.enumerated() { + for _ in 0 ..< plan.count { + try await seeded.store.incrementPlayCount(id: seeded.trackIDs[index], playedAt: baseTime - plan.ago) + } + } + let readOrder = try await seeded.store.frecencyTracksDisplay().map(\.id) + // Independent current-frecency at a read time comfortably after the newest play. + let readNow = Double(baseTime + 86400) + var scored: [(id: Int64, freq: Double)] = [] + for id in seeded.trackIDs { + guard let state = try await seeded.store.frecencyState(id: id), let rank = state.rank else { continue } + // current frecency = 2^((rank − t)/H) — the proven identity. + scored.append((id, pow(2.0, (rank - readNow) / halfLife))) + } + let expected = scored.sorted { $0.freq > $1.freq }.map(\.id) + guard readOrder == expected else { + printFail(number, "FR6: read order \(readOrder) != current-frecency order \(expected)"); return false + } + printPass(number, "FR6: frecencyTracksDisplay order ≡ order by current frecency 2^((rank−t)/H) " + + "(the R2 projected-rank equivalence, verified end-to-end)") + return true + } catch { printFail(number, "FR6 threw: \(error)"); return false } +} + +/// FR7 — backward clock jump does NOT inflate the score (age clamped to ≥ 0): a second play stamped +/// EARLIER than the first accumulates as `prev·1 + 1`, matching the clamped pure helper. +func checkFrecencyBackwardClockClamp(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/Fr", paths: ["/Fr/a.flac"]) + let id = seeded.trackIDs[0] + try await seeded.store.incrementPlayCount(id: id, playedAt: baseTime) + let backward = baseTime - 100 // clock jumped backward + try await seeded.store.incrementPlayCount(id: id, playedAt: backward) + let expected = LibraryStore.frecencyAfterPlay(prevScore: 1.0, lastPlayed: baseTime, now: backward) + guard let state = try await seeded.store.frecencyState(id: id), + abs(state.score - expected.score) < frecencyEps, abs(expected.score - 2.0) < 1e-9 else { + printFail(number, "FR7: backward clock jump inflated the score (expected clamped 2.0)"); return false + } + printPass(number, "FR7: backward clock jump clamps age≥0 → score 2.0 (no decay-factor inflation)") + return true + } catch { printFail(number, "FR7 threw: \(error)"); return false } +} + +/// FR8 — the read is INDEX-driven: `EXPLAIN QUERY PLAN` uses `idx_tracks_frecency_rank` and has NO +/// `USE TEMP B-TREE FOR ORDER BY` (the projected-rank + rowid-carrying index removed the filesort). +func checkFrecencyReadIndexed(number: Int, url: URL) async -> Bool { + do { + let seeded = try await seedTracks(url, root: "/Fr", paths: ["/Fr/a.flac"]) + try await seeded.store.incrementPlayCount(id: seeded.trackIDs[0], playedAt: baseTime) + let plan = try await seeded.store.explainFrecencyTracksDisplayPlan() + let joined = plan.joined(separator: " | ").uppercased() + guard joined.contains("IDX_TRACKS_FRECENCY_RANK"), !joined.contains("TEMP B-TREE") else { + printFail(number, "FR8: frecency read not index-driven / has a temp b-tree: \(plan)"); return false + } + printPass(number, "FR8: frecency read uses idx_tracks_frecency_rank with NO temp b-tree (filesort gone)") + return true + } catch { printFail(number, "FR8 threw: \(error)"); return false } +} diff --git a/Sources/VerifyLibraryStore/main.swift b/Sources/VerifyLibraryStore/main.swift index 601218f..717faba 100644 --- a/Sources/VerifyLibraryStore/main.swift +++ b/Sources/VerifyLibraryStore/main.swift @@ -268,6 +268,15 @@ func browseReadsCheckCases() -> [CheckCase] { CheckCase(label: "br7-artist-count", run: checkBrowseArtistCount), CheckCase(label: "br8-empty-genre", run: checkBrowseEmptyGenre), CheckCase(label: "br9-tracksdisplay-by-ids", run: checkBrowseTracksDisplayByIDs), + // S10.6 Recently-Played frecency (FR1–FR8): the algorithm gate. + CheckCase(label: "fr1-first-play", run: checkFrecencyFirstPlay), + CheckCase(label: "fr2-decay-accumulation", run: checkFrecencyDecayAccumulation), + CheckCase(label: "fr3-burst-vs-spread", run: checkFrecencyBurstVsSpread), + CheckCase(label: "fr4-recency-outweighs-count", run: checkFrecencyRecencyOutweighsCount), + CheckCase(label: "fr5-never-played-excluded", run: checkFrecencyNeverPlayedExcluded), + CheckCase(label: "fr6-order-equivalence", run: checkFrecencyOrderEquivalence), + CheckCase(label: "fr7-backward-clock-clamp", run: checkFrecencyBackwardClockClamp), + CheckCase(label: "fr8-read-indexed", run: checkFrecencyReadIndexed), ] } diff --git a/docs/sprints/recently-played-frecency-design.md b/docs/sprints/recently-played-frecency-design.md index cc67a54..3ee5347 100644 --- a/docs/sprints/recently-played-frecency-design.md +++ b/docs/sprints/recently-played-frecency-design.md @@ -133,4 +133,58 @@ Covers the founder's three targets — **counters, sort order, ≥60% accounting - **"Reset Play Count" affordance** — flagged in UX (§4); build only if the founder wants a "forget this" action (needs confirm/undo; the only write from this view). ### Files -`AudioViewModel+SpectrumTimer.swift` (tick detector), `+PlayTracking.swift` (counting entry), `+Playback.swift`/`+AutoAdvance.swift` (reset sites), `AudioViewModel.swift` (state), new `PlayThroughTracker` (pure); delete `+History.swift`/`Models/HistoryItem.swift`; `UI/Playlist/QueueHistoryList.swift` + new `RecentlyPlayedRow`, `PlaylistView.swift` (label/subtitle); `LibraryBrowseModel.swift` (`loadHistory`); `LibraryStore/Schema.swift` (v4), `LibraryStore.swift` (write DAO + `prepareDatabase` decay fn), `LibraryStore+BrowseReads.swift` (`frecencyTracksDisplay`); `VerifyLibraryStore/` (FR checks) + `swift test` (`PlayThroughTracker`). +`AudioViewModel+SpectrumTimer.swift` (tick detector), `+PlayTracking.swift` (counting entry), `+Playback.swift`/`+AutoAdvance.swift` (reset sites), `AudioViewModel.swift` (state), new `PlayThroughTracker` (pure); delete `+History.swift`/`Models/HistoryItem.swift`; `UI/Playlist/QueueHistoryList.swift` + new `RecentlyPlayedRow`, `PlaylistView.swift` (label/subtitle); `LibraryBrowseModel.swift` (`loadHistory`); `LibraryStore/Schema.swift` (v4), `LibraryStore.swift` (write DAO), `LibraryStore+BrowseReads.swift` (`frecencyTracksDisplay`); `VerifyLibraryStore/` (FR checks) + `swift test` (`PlayThroughTracker`). + +--- + +## 8. Fool-gate resolutions — research-grounded (2026-07-15) + +The pre-implementation Fool pass raised 10 concerns. Researched against **Last.fm scrobbling** ([spec](https://www.last.fm/api/scrobbling)), **Mozilla Places frecency** ([Firefox ranking docs](https://firefox-source-docs.mozilla.org/browser/urlbar/ranking.html)), and the **`fre`** frecency tool ([repo](https://github.com/camdencheek/fre)). Two findings *improve* the design (R1, R2), not just patch it. These resolutions **supersede** the corresponding mechanics in §1–§3. + +### R1 — Play detection: monotonic elapsed-while-playing, not position-delta (fixes Fool #2 + #9) +Replace the per-tick **position-delta** accrual (which had to reject seeks *and* wrongly discarded real playback during UI-tick stalls) with the industry-standard scrobble measure — **cumulative playback time**. Accrue `heardSeconds` from a **monotonic clock** delta (`ContinuousClock` / `DispatchTime.uptimeNanoseconds`) between ticks **while `isPlaying`**: +- immune to UI-tick stalls (a stalled tick's monotonic delta = the real elapsed playtime → correctly accrued, never rejected); +- immune to seeks (a seek adds ~0 wall-time between ticks — no position term to reject); +- immune to wall-clock skew (a monotonic clock never jumps). + +Matches Last.fm ("played for at least half its duration" = playback time). Threshold `= min(0.60·duration, 240 s)`, plus Last.fm's **≥30 s minimum track duration** (sub-30 s clips / gapless fragments never count). Pure `PlayThroughTracker(monotonicDelta, isPlaying, duration)` → fully unit-testable (stall / seek / pause / short / long / ≥30 s floor). The natural-end fallback + `didCount` idempotency stay. + +### R2 — Read model: Firefox's stored projected-rank, not decay-at-read (fixes Fool #4, removes the registered-function concern) +Mozilla Places stores per item `frecency_rank = last_played + (H/ln2)·ln(score)` — the epoch instant at which the score decays to 1. Current frecency `= score·2^(−(now−lp)/H)` is **monotonic in that stored value** (all rows share `now` at read), so **`ORDER BY frecency_rank DESC` gives exact frecency order with no `now`, no per-row decay, no custom SQL function — and it is INDEXABLE** (plain REAL column + index → **no filesort**). All decay math moves to the **write**. +- **Schema v4** adds `frecency_score REAL` (write-time accumulator) + `frecency_rank REAL` (indexed read key) + an index on `frecency_rank`. `play_count` / `last_played` unchanged. **Drops** the planned `prepareDatabase` custom decay function entirely. +- **Write** (one atomic UPDATE per counted play): `score = score·2^(−max(0, now−last_played)/H) + 1` (first play → `1`); `last_played = now`; `frecency_rank = now + (H/ln2)·ln(score)`; `play_count += 1`. +- **Read**: `WHERE play_count > 0 ORDER BY frecency_rank DESC, id DESC LIMIT 200` — indexed, deterministic, `now`-free. (`frecencyTracksDisplay` no longer takes `now`.) + +Supersedes §2 (the accumulator + registered function) and §3 (the filesort). `fre` validates the single-stored-number accumulator; Firefox validates the projected-rank sort. + +### R3 — Clock skew (fixes Fool #1) +Time now enters only the **write**: clamp `age = max(0, now − last_played)` in the accumulate. A backward wall-clock jump can't inflate a score, and the read is time-free so ordering can't be inflated. (Cross-session recency needs wall-clock `last_played` — monotonic clocks reset per boot — so the clamp is the correct guard.) + +### R4 — Refresh (Fool #3/#4) +Bump `playCountRevision` **after** the detached store write commits, and **debounce** the History reload (coalesce a burst of counted plays during album playback into one refetch). The read is now a cheap indexed sort, so even a thrashed refresh is inexpensive. + +### R5 — Repeat-one (Fool #7) +Accept each qualifying play as a count — exactly Last.fm's behavior (repeats scrobble). No per-day cap (deferred; revisit only if it reads as broken in use). + +### R6 — QA seam (Fool #5) +The monotonic `PlayThroughTracker` is pure + time-injectable → ≥60% / cap / stall / seek / pause / short-track / ≥30 s-floor are all headlessly unit-tested. Add a VM-level `countCurrentPlay()` idempotency test (once-per-play across the four fold-in sites). The store write (accumulate + rank + `play_count`) and the indexed read are `VerifyLibraryStore`-gated (FR1–FR8; FR2/FR3 expectations **computed from the formula** in-test, epsilon only for float tolerance). The one seam that stays manual/by-ear: the tracker firing → the detached store write (engine-coupled). Stated, not hand-waved. + +### Accepted / deferred +- **Fool #6** — verify the move-match path is an UPDATE (not delete+reinsert) so `frecency_score`/`frecency_rank`/`last_played` survive a move; FR-move gates it. +- **Fool #8** — keep "Recently Played"; it lists ≥60 %-heard tracks (narrower than "everything started"). Accepted. +- **Fool #10** — the v3→v4 wipe also zeroes the Songs "Play Count" column + `.playCountDesc` sort (added to D8). +- **Half-life** — Firefox uses 30 d; we keep **7 d** (D5), deliberately more recency-aggressive for "what I've been into lately" than browser history. + +### R7 — Final decisions + fixes locked after the expert+Fool review (2026-07-15) + +Reviewed by **architect-reviewer** (R2 monotonicity proven analytically + numerically; no-filesort confirmed empirically via `EXPLAIN` on 10k rows with the real joins; move-preserve traced) + **qa-expert** (both proofs; full edge matrix) + a **Fool** frame pass. Founder decisions + the resulting fixes — these are the build spec: + +- **Founder D9 — v4 rollout:** RESET counts on the v4 bump (delete-rebuild posture, [[feedback-delete-rebuild-dev-db]]) **AND** make the read NULL-tolerant: `ORDER BY COALESCE(frecency_rank, last_played) DESC, id DESC` — so any legacy/edge row with `play_count>0` but a NULL `frecency_rank` still sorts sanely (as score≈1 at its `last_played`). Cheap insurance independent of when the wipe fires (GRDB `eraseDatabaseOnSchemaChange` triggers on a CHANGED migration body, not on merely ADDING one — so don't rely on the additive v4 erasing). +- **Founder D10 — empty state:** when nothing has been played ≥60% yet, show the empty state ("Tracks you finish will appear here."). No recent-starts fallback. +- **Founder D11 — proceed now, production-grade, no corners.** +- **FIX-1 (natural-end gate):** the four fold-in completion sites must NOT count unconditionally. Route natural-end through the SAME gate — count only if `duration ≥ 30 ∧ heardSeconds ≥ min(0.60·duration, 240)` — else a scrub-to-end or a sub-30 s full-play would count (violates D2/D3). Its real purpose (a genuine full-listen whose threshold tick was missed at a gapless seam, where `heardSeconds ≈ duration`) still works. +- **FIX-2 (clock):** use a **suspend-stopping monotonic clock** (`SuspendingClock` / `DispatchTime.uptimeNanoseconds`), NOT `ContinuousClock` — audio isn't heard during system sleep. +- **FIX-3 (monotonic reference):** advance `lastMono` on EVERY tick (the tick is always-on), accrue only while `isPlaying`. This makes during-pause ticks move the reference without accruing, cleanly resolving pause-vs-stall; clamp the per-tick delta to a max-plausible bound as belt-and-braces. +- **FIX-4 (write):** a read-modify-write inside ONE `dbWriter.write { }` transaction (read old `score`+`last_played` → compute in Swift → UPDATE), first play → `score=1, rank=now` (no NULL arithmetic); clamp `age=max(0, now−last_played)` (backward skew). Forward clock jump = accept + log (rare, self-heals). H is baked into stored ranks → changing H later needs a full rank recompute. +- **FIX-5 (QA plan):** FR7 asserts **index-driven / no `USE TEMP B-TREE FOR ORDER BY`** (not "accepted filesort"); FR-schema checks BOTH columns + the `frecency_rank` index; `frecencyTracksDisplay` is `now`-free (drop the param in §3/§5); the ≥60% test table is in monotonic-clock vocabulary + adds the stall / <30 s-floor / 30–31 s boundary / duration-0-then-resolves / pause-edge cases; add explicit checks for R2 rank≡decay-at-read equivalence (multiple `now`), accumulator≡events identity, first-play NULL guard, and backward/forward clock jumps. +- **FIX-6 (index):** a plain index on `frecency_rank` suffices (rowid is carried as the trailing key → the `id DESC` tiebreak needs no extra sort). Optional partial `WHERE play_count>0` micro-opt. From 49596350b4dc8c30576460dfcdf44da66a019a71 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:08:17 +0530 Subject: [PATCH 2/4] =?UTF-8?q?feat(s10.6):=20=E2=89=A560%-heard=20play=20?= =?UTF-8?q?detection=20=E2=80=94=20PlayThroughTracker=20+=20tick=20wiring?= =?UTF-8?q?=20(chunk=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Package.swift | 1 + .../AudioViewModel+AutoAdvance.swift | 5 +- .../AudioViewModel+PlayTracking.swift | 89 ++++++----- .../AudioViewModel+Playback.swift | 9 +- .../AudioViewModel+SpectrumTimer.swift | 7 +- Sources/AdaptiveSound/AudioViewModel.swift | 13 ++ .../PlaybackQueueKit/PlayThroughTracker.swift | 64 ++++++++ .../PlayThroughTrackerTests.swift | 144 ++++++++++++++++++ 8 files changed, 292 insertions(+), 40 deletions(-) create mode 100644 Sources/PlaybackQueueKit/PlayThroughTracker.swift create mode 100644 Tests/AudioViewModelTests/PlayThroughTrackerTests.swift diff --git a/Package.swift b/Package.swift index 82557e5..305686c 100644 --- a/Package.swift +++ b/Package.swift @@ -179,6 +179,7 @@ let package = Package( "QueueAdvanceTests.swift", "QueueOpsTests.swift", "QueueInsertTests.swift", + "PlayThroughTrackerTests.swift", ] // swift-testing is provided natively by the toolchain under swift-tools 6.2; // no manual Testing.framework linkage. (The former -F/-framework hack pointed diff --git a/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift b/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift index 67df574..b01e4bf 100644 --- a/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift +++ b/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift @@ -24,7 +24,7 @@ extension AudioViewModel { // completed naturally — count it before returning (a playlist-shrank-after- // arming edge; the normal end-of-queue path below is what usually counts the // final track). - countOutgoingTrackCompletion() + countPlayIfNaturalEndQualifies() pendingNextIndex = nil isPlaying = false playbackPosition = 0 @@ -36,10 +36,11 @@ extension AudioViewModel { // §12.3: count the OUTGOING track (pre-advance selectedTrackIndex) before it // reassigns below — this IS the gapless-seam natural-completion site. - countOutgoingTrackCompletion() + countPlayIfNaturalEndQualifies() selectedTrackIndex = nextIdx recordPlayStart(advancedTrack) // the incoming track begins playing — log it (S10.2 3a) + resetPlayTracking() // the gapless seam / repeat-one begins a NEW ≥60% play-through (S10.6) playbackPosition = 0 duration = 0 // zeroed now; async Task below refreshes from AVAudioFile diff --git a/Sources/AdaptiveSound/AudioViewModel+PlayTracking.swift b/Sources/AdaptiveSound/AudioViewModel+PlayTracking.swift index ce35229..1eebc67 100644 --- a/Sources/AdaptiveSound/AudioViewModel+PlayTracking.swift +++ b/Sources/AdaptiveSound/AudioViewModel+PlayTracking.swift @@ -1,59 +1,80 @@ import Foundation import LibraryStore -// MARK: - AudioViewModel play-tracking (S9.5 §12.3 — pulled forward from S10) +// MARK: - AudioViewModel play-tracking (S9.5 §12.3 → S10.6 ≥60%-heard rule) @MainActor extension AudioViewModel { - /// Count the OUTGOING track — the one at `selectedTrackIndex` right now, BEFORE any - /// reassignment at the call site — as a natural-completion play. A no-op if - /// `selectedTrackIndex` is nil or stale (out of range, e.g. the playlist shrank). - /// - /// Shared by all FOUR completion sites (`AudioViewModel+AutoAdvance`'s - /// `handleTrackTransition` normal path + its out-of-range guard, and - /// `AudioViewModel+SpectrumTimer`'s `tickTransport` reconfigure + end-of-queue - /// branches) so none of their call sites' own guard adds to that function's - /// cyclomatic complexity. - func countOutgoingTrackCompletion() { - guard let completedIdx = selectedTrackIndex, completedIdx < queue.count else { return } - countPlayCompletion(file: queue[completedIdx].file) + /// Accrue play-through time for the current track (S10.6 R1/FIX-3). Called EVERY transport tick: + /// it advances the monotonic reference unconditionally (so a pause/stall doesn't later read as + /// one huge delta), but feeds a delta to the tracker only while actually playing. When the + /// accrual first crosses the qualifying threshold (≥ min(60%·duration, 240s), ≥30s track), the + /// current track's play is recorded — once per play-through. Seeks add ~0 wall-time between + /// ticks, so they don't accrue; a UI-tick stall's delta IS real playtime and accrues. + func accruePlayThrough() { + let now = DispatchTime.now().uptimeNanoseconds // suspend-stopping (excludes system sleep) + defer { lastPlayThroughMonoNanos = now } + guard let last = lastPlayThroughMonoNanos else { return } // first tick: just seed + guard isPlaying, duration > 0 else { return } // accrue only genuine playback + let delta = Double(now &- last) / 1_000_000_000 + if playThroughTracker.accrue(delta, duration: duration) { + countCurrentPlay() + } } - /// Count a natural-completion play for the track at `url`: fires a detached, - /// fire-and-forget write to the persistent store's `play_count`/`last_played` - /// columns (`LibraryStore.incrementPlayCount`). - /// - /// Invoked (via `countOutgoingTrackCompletion()` above) at all FOUR natural-completion - /// sites (the gapless seam in `handleTrackTransition`, its out-of-range guard, the Pure - /// reconfigure advance, and the true end-of-queue branch in `tickTransport`) — NEVER from - /// a manual skip/jump (`nextTrack`/`previousTrack`/`playTrack`/`playTrackNextNow`/ - /// `playNext`/`startPlayback` are all deliberately excluded, per the pre-change review: - /// "a play = heard through"). - /// - /// Errors (including "store not ready yet" and a write failure) are swallowed via - /// `logUX`, never thrown — a play-count write must never stall or fail the audio path. + /// Begin a new play-through: clear the accrued heard-time + the once-per-play-through guard and + /// reseed the monotonic reference. Called at exactly the two "new play begins" points — a fresh + /// `startPlayback` (not a pause-resume) and the gapless `handleTrackTransition` (incl. a + /// repeat-one re-arm, so each repeat can count again). + func resetPlayTracking() { + playThroughTracker.reset() + lastPlayThroughMonoNanos = nil + } + + /// Count the CURRENT track (`selectedTrackIndex`) as a play. Invoked from the tick accrual (on + /// threshold crossing) and from the natural-end gate below. A no-op if the selection is nil or + /// stale (out of range). + func countCurrentPlay() { + guard let index = selectedTrackIndex, index < queue.count else { return } + writePlayCount(file: queue[index].file) + } + + /// A track reached its natural end (the four completion sites): count it ONLY if it qualifies by + /// the SAME ≥60%/≥30s gate as the tick path (FIX-1) — so a scrub-to-end or a short/unresolved- + /// duration track does NOT count. Idempotent: if the tick path already counted this play-through + /// (`didCount`), this is a no-op. `selectedTrackIndex`/`duration` still refer to the just- + /// finished track at every call site (they reassign AFTER). + func countPlayIfNaturalEndQualifies() { + guard playThroughTracker.naturalEnd(duration: duration) else { return } + countCurrentPlay() + } + + /// Fire the detached, fire-and-forget play-count write (`play_count`/`last_played` + the S10.6 + /// frecency `score`/`rank` — `LibraryStore.incrementPlayCount`), then bump `playCountRevision` + /// on the main actor AFTER the write commits (R4 — the Recently-Played view refreshes on it, so + /// the bump must not race the write). Errors are swallowed via `logUX`, never thrown — a + /// play-count write must never stall or fail the audio path. /// - /// S3 F5: this is the ONE audio→library edge — it reaches the store through the injected - /// `library` peer (`library?.store`) rather than owning the store itself. - private func countPlayCompletion(file: AudioFile) { + /// S3 F5: the ONE audio→library edge — reaches the store through the injected `library` peer. + private func writePlayCount(file: AudioFile) { guard let store = library?.store else { - logUX("countPlayCompletion: store not ready; skipping play-count for \(file.absoluteURL.lastPathComponent)") + logUX("writePlayCount: store not ready; skipping play-count for \(file.absoluteURL.lastPathComponent)") return } let playedAt = Int64(Date().timeIntervalSince1970) let trackID = file.trackID let url = file.absoluteURL - Task.detached(priority: .utility) { + Task.detached(priority: .utility) { [weak self] in do { - // Prefer the durable id (S10.2 — the queue carries it, no url→id lookup); - // fall back to url for a slot with no library row. + // Prefer the durable id (the queue carries it, no url→id lookup); fall back to url. if let trackID { try await store.incrementPlayCount(id: trackID, playedAt: playedAt) } else { try await store.incrementPlayCount(url: url, playedAt: playedAt) } + await MainActor.run { self?.playCountRevision += 1 } } catch { - logUX("countPlayCompletion: write failed for \(url.lastPathComponent) — \(error)") + logUX("writePlayCount: write failed for \(url.lastPathComponent) — \(error)") } } } diff --git a/Sources/AdaptiveSound/AudioViewModel+Playback.swift b/Sources/AdaptiveSound/AudioViewModel+Playback.swift index ba4b612..5653033 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Playback.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Playback.swift @@ -52,8 +52,13 @@ extension AudioViewModel { await primeGaplessPipeline(startIndex: startIndex, pureMode: pureModeSnapshot) isPlaying = true errorMessage = nil - // A fresh start (not a pause/resume seek) logs a session-history play (S10.2 3a). - if resumeFrom == nil { recordPlayStart(startFile) } + // A fresh start (not a pause/resume seek) logs a session-history play (S10.2 3a) + // and begins a NEW ≥60% play-through (S10.6). A pause-resume (resumeFrom != nil) + // continues the same play-through — no reset. + if resumeFrom == nil { + recordPlayStart(startFile) + resetPlayTracking() + } } catch { errorMessage = "Playback failed: \(error.localizedDescription)" isPlaying = false diff --git a/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift b/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift index 438a3e7..a2e99be 100644 --- a/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift +++ b/Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift @@ -60,6 +60,9 @@ extension AudioViewModel { } else if pausedResumePosition == nil { playbackPosition = 0 } + // S10.6: accrue ≥60%-heard play-through time (advances the monotonic reference every tick; + // accrues only while playing → a pause/stall/seek never mis-counts). + accruePlayThrough() loudness = engine.currentLoudness() var freshPath = engine.currentSignalPath() // F4: copy enhancement overlay fields so the badge is a pure function of the snapshot. @@ -103,7 +106,7 @@ extension AudioViewModel { logUX("playbackEnded — advancing to queued track[\(nextIdx)] (reconfigure gap)") // §12.3: count the OUTGOING track (pre-advance selectedTrackIndex) before it // reassigns below — the Pure cross-rate reconfigure natural-completion site. - countOutgoingTrackCompletion() + countPlayIfNaturalEndQualifies() selectedTrackIndex = nextIdx // Clear the trigger SYNCHRONOUSLY before the async startPlayback: `playbackEnded()` // stays true until startPlayback's Task runs `pureModeEngineStart` (≤ a few ticks @@ -116,7 +119,7 @@ extension AudioViewModel { logUX("playbackEnded — no next track, stopping") // §12.3: true end-of-queue / single-track completion — count the track that // just finished before clearing isPlaying below. - countOutgoingTrackCompletion() + countPlayIfNaturalEndQualifies() isPlaying = false playbackPosition = 0 } diff --git a/Sources/AdaptiveSound/AudioViewModel.swift b/Sources/AdaptiveSound/AudioViewModel.swift index fda703c..9021e7c 100644 --- a/Sources/AdaptiveSound/AudioViewModel.swift +++ b/Sources/AdaptiveSound/AudioViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import PlaybackQueueKit // MARK: - AudioViewModel @@ -194,6 +195,18 @@ final class AudioViewModel { /// One-shot guard so launch hydration (`+QueueHydration`) runs at most once. Not UI-bound. var queueHydrated = false + /// The ≥60%-heard play-through detector (S10.6). Pure state (PlaybackQueueKit); the transport + /// tick feeds it monotonic playback-time deltas. When it crosses the threshold the current + /// track's play is recorded (once per play-through). Not UI-bound. + var playThroughTracker = PlayThroughTracker() + /// Monotonic reference (suspend-stopping uptime nanos) for the play-through accrual — advanced + /// EVERY tick, consumed only while playing (FIX-3), so a pause/stall/seek never mis-accrues. + /// `nil` reseeds on the next tick. Not UI-bound. + var lastPlayThroughMonoNanos: UInt64? + /// Bumped (on the main actor) AFTER a play-count write commits (S10.6 R4) so the Recently-Played + /// view can reload without racing the detached store write. UI-bound. + var playCountRevision = 0 + /// The session play-history (`+History`), newest LAST — the History view shows it reversed. /// A track is appended when it BEGINS a genuine new play (manual start / gapless auto-advance); /// dups allowed. Session-scoped + in-memory: NOT persisted across relaunch, and Clear Queue diff --git a/Sources/PlaybackQueueKit/PlayThroughTracker.swift b/Sources/PlaybackQueueKit/PlayThroughTracker.swift new file mode 100644 index 0000000..c9d877b --- /dev/null +++ b/Sources/PlaybackQueueKit/PlayThroughTracker.swift @@ -0,0 +1,64 @@ +// PlayThroughTracker — the pure ≥60%-heard play-detection decision (S10.6 R1/FIX-1..3). +// +// Fed monotonic-time deltas (seconds) of ACTUAL playback and counts a play ONCE per play-through +// when the heard time reaches `min(60%·duration, 240s)`, subject to a ≥30s minimum-track floor +// (Last.fm-style). The caller (the AudioViewModel transport tick) advances its monotonic reference +// every tick but feeds a delta only while `isPlaying`, so pauses / stalls / seeks never mis-accrue. +// Pure + `Sendable` so it is unit-tested without a clock or an audio engine. + +public struct PlayThroughTracker: Equatable, Sendable { + /// Fraction of the track that must be heard to count (design D2). + public static let thresholdFraction = 0.60 + /// Absolute cap so a very long track counts after a substantial listen, not 60% of an hour (D2). + public static let capSeconds = 240.0 + /// Minimum track duration to be eligible at all — a Last.fm-style floor (FIX-1/R1): sub-30s + /// clips / gapless fragments never count. + public static let minTrackSeconds = 30.0 + /// Belt-and-braces clamp on a single accrual (FIX-3). A real UI-tick stall of a few seconds is + /// genuine playtime and IS accrued in full; only a pathological single-tick jump beyond this is + /// bounded (the suspend-stopping monotonic clock shouldn't produce one, but this caps the blast). + public static let maxPlausibleDelta = 10.0 + + public private(set) var heardSeconds: Double = 0 + public private(set) var didCount: Bool = false + + public init() {} + + /// Begin a NEW play-through (a fresh manual start, a gapless advance, or a repeat-one re-arm). + public mutating func reset() { + heardSeconds = 0 + didCount = false + } + + /// The heard-time threshold for `duration`, or `nil` if the track is too short to ever count. + public static func threshold(forDuration duration: Double) -> Double? { + guard duration >= minTrackSeconds else { return nil } + return min(thresholdFraction * duration, capSeconds) + } + + /// Accrue one playback-time delta (seconds). Returns `true` EXACTLY on the accrual that first + /// reaches the qualifying threshold, so the caller records the play then (and only then). + public mutating func accrue(_ delta: Double, duration: Double) -> Bool { + guard !didCount, delta > 0 else { return false } + heardSeconds += min(delta, Self.maxPlausibleDelta) + return fireIfQualified(duration: duration) + } + + /// The track reached its natural end. Counts (once) only if it QUALIFIES by the same gate as + /// the tick path — `duration ≥ 30s` AND `heardSeconds ≥ threshold` — so a scrub-to-end or a + /// short / unresolved-duration (`duration == 0`) track does NOT count (FIX-1). Its real job is a + /// genuine full listen whose threshold-crossing tick was missed at a gapless seam (there + /// `heardSeconds ≈ duration`, so it qualifies). + public mutating func naturalEnd(duration: Double) -> Bool { + guard !didCount else { return false } + return fireIfQualified(duration: duration) + } + + private mutating func fireIfQualified(duration: Double) -> Bool { + guard let threshold = Self.threshold(forDuration: duration), heardSeconds >= threshold else { + return false + } + didCount = true + return true + } +} diff --git a/Tests/AudioViewModelTests/PlayThroughTrackerTests.swift b/Tests/AudioViewModelTests/PlayThroughTrackerTests.swift new file mode 100644 index 0000000..6a2e16a --- /dev/null +++ b/Tests/AudioViewModelTests/PlayThroughTrackerTests.swift @@ -0,0 +1,144 @@ +import PlaybackQueueKit +import Testing + +// MARK: - PlayThroughTracker — the ≥60%-heard play-detection decision core (S10.6 R1/FIX-1..3) + +// +// Imports the shipped `PlaybackQueueKit.PlayThroughTracker` (not a mirror). Feeds monotonic +// playback-time deltas directly (the tracker is pure — the clock/seek/pause handling is the VM's +// job and is the one acknowledged manual seam), and asserts the full ≥60% edge matrix headlessly. + +@Suite("PlaybackQueueKit — PlayThroughTracker ≥60%-heard rule") +struct PlayThroughTrackerTests { + /// A 180s track: threshold = min(0.6·180, 240) = 108s. + private let longTrack = 180.0 + + @Test("PT-01: below threshold does not count") + func belowThreshold() { + var tracker = PlayThroughTracker() + var fired = false + for _ in 0 ..< 100 { + fired = tracker.accrue(1.0, duration: longTrack) || fired + } // 100s < 108 + #expect(!fired) + #expect(!tracker.didCount) + } + + @Test("PT-02: crossing the threshold counts exactly once, on the crossing accrual") + func crossingCountsOnce() { + var tracker = PlayThroughTracker() + var fireCount = 0 + for _ in 0 ..< 120 where tracker.accrue(1.0, duration: longTrack) { + fireCount += 1 + } // crosses at 108 + #expect(fireCount == 1) + #expect(tracker.didCount) + } + + @Test("PT-03: accruing past the threshold (to natural end) never double-counts") + func pastThresholdNoDoubleCount() { + var tracker = PlayThroughTracker() + _ = tracker.accrue(108, duration: longTrack) // cross in one delta (clamped, see PT-09) … + // … so accrue to threshold in small deltas instead, to isolate the double-count guard: + var t2 = PlayThroughTracker() + var fires = 0 + for _ in 0 ..< 180 where t2.accrue(1.0, duration: longTrack) { + fires += 1 + } + #expect(fires == 1) // fired at 108, never again through 180 + #expect(t2.naturalEnd(duration: longTrack) == false) // already counted → no-op + } + + @Test("PT-04: reset re-arms a new play-through (repeat-one counts again)") + func resetReArms() { + var tracker = PlayThroughTracker() + for _ in 0 ..< 120 { + _ = tracker.accrue(1.0, duration: longTrack) + } + #expect(tracker.didCount) + tracker.reset() + #expect(!tracker.didCount && tracker.heardSeconds == 0) + var refired = false + for _ in 0 ..< 120 { + refired = tracker.accrue(1.0, duration: longTrack) || refired + } + #expect(refired) + } + + @Test("PT-05: a track under the 30s floor never counts, even played in full") + func shortTrackFloor() { + var tracker = PlayThroughTracker() + var fired = false + for _ in 0 ..< 25 { + fired = tracker.accrue(1.0, duration: 25.0) || fired + } // 25s track, fully heard + #expect(!fired) + #expect(tracker.naturalEnd(duration: 25.0) == false) + #expect(PlayThroughTracker.threshold(forDuration: 25.0) == nil) + } + + @Test("PT-06: the 30s boundary counts at 18s (0.6·30)") + func thirtySecondBoundary() { + #expect(PlayThroughTracker.threshold(forDuration: 30.0) == 18.0) + var tracker = PlayThroughTracker() + var fires = 0 + for _ in 0 ..< 30 where tracker.accrue(1.0, duration: 30.0) { + fires += 1 + } + #expect(fires == 1) + } + + @Test("PT-07: a very long track counts at the 240s cap, not 60% of an hour") + func longTrackCap() { + let show = 104.0 * 60.0 // 6240s; 60% would be 3744s + #expect(PlayThroughTracker.threshold(forDuration: show) == 240.0) + var tracker = PlayThroughTracker() + var fires = 0 + for _ in 0 ..< 240 where tracker.accrue(1.0, duration: show) { + fires += 1 + } // crosses at 240 + #expect(fires == 1) + #expect(tracker.heardSeconds >= 240) + } + + @Test("PT-08: a stall's large real delta accrues (not rejected) up to the plausibility clamp") + func stallAccrues() { + var tracker = PlayThroughTracker() + _ = tracker.accrue(100, duration: longTrack) // one big (stall-like) delta + #expect(tracker.heardSeconds == PlayThroughTracker.maxPlausibleDelta) // clamped to 10s, not dropped + } + + @Test("PT-09: a pathological single delta is clamped, not dropped") + func pathologicalDeltaClamped() { + var tracker = PlayThroughTracker() + _ = tracker.accrue(10000, duration: longTrack) + #expect(tracker.heardSeconds == PlayThroughTracker.maxPlausibleDelta) + #expect(!tracker.didCount) // 10s << 108s threshold + } + + @Test("PT-10: non-positive deltas (seek-back / no motion) never accrue") + func nonPositiveDeltaIgnored() { + var tracker = PlayThroughTracker() + #expect(tracker.accrue(0, duration: longTrack) == false) + #expect(tracker.accrue(-5, duration: longTrack) == false) + #expect(tracker.heardSeconds == 0) + } + + @Test("PT-11: duration 0 (unresolved) never accrues a count; naturalEnd also refuses") + func unresolvedDuration() { + var tracker = PlayThroughTracker() + for _ in 0 ..< 300 { + _ = tracker.accrue(1.0, duration: 0) + } // duration not yet known + #expect(!tracker.didCount) + #expect(tracker.naturalEnd(duration: 0) == false) + } + + @Test("PT-12: naturalEnd on a below-threshold play-through does not count (scrub-to-end)") + func naturalEndBelowThreshold() { + var tracker = PlayThroughTracker() + _ = tracker.accrue(5, duration: longTrack) // heard ~5s (e.g. scrubbed to the end) + #expect(tracker.naturalEnd(duration: longTrack) == false) + #expect(!tracker.didCount) + } +} From 05eba66a5ae990e6b5955ecf590b76fa92ba1b48 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:25:40 +0530 Subject: [PATCH 3/4] feat(s10.6): Recently Played UX + retire session log (chunk 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AudioViewModel+AutoAdvance.swift | 1 - .../AudioViewModel+History.swift | 29 ------- .../AudioViewModel+Playback.swift | 10 +-- Sources/AdaptiveSound/AudioViewModel.swift | 6 -- .../LibraryBrowseModel+History.swift | 27 +++++++ .../AdaptiveSound/LibraryBrowseModel.swift | 9 +++ .../AdaptiveSound/Models/HistoryItem.swift | 16 ---- .../UI/Playlist/PlaylistView.swift | 11 +-- .../UI/Playlist/QueueHistoryList.swift | 73 ++++++++++------- .../UI/Playlist/RecentlyPlayedRow.swift | 78 +++++++++++++++++++ .../VerifyLibraryStore/ChecksFrecency.swift | 1 - 11 files changed, 167 insertions(+), 94 deletions(-) delete mode 100644 Sources/AdaptiveSound/AudioViewModel+History.swift create mode 100644 Sources/AdaptiveSound/LibraryBrowseModel+History.swift delete mode 100644 Sources/AdaptiveSound/Models/HistoryItem.swift create mode 100644 Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift diff --git a/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift b/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift index b01e4bf..0fb34e6 100644 --- a/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift +++ b/Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift @@ -39,7 +39,6 @@ extension AudioViewModel { countPlayIfNaturalEndQualifies() selectedTrackIndex = nextIdx - recordPlayStart(advancedTrack) // the incoming track begins playing — log it (S10.2 3a) resetPlayTracking() // the gapless seam / repeat-one begins a NEW ≥60% play-through (S10.6) playbackPosition = 0 duration = 0 // zeroed now; async Task below refreshes from AVAudioFile diff --git a/Sources/AdaptiveSound/AudioViewModel+History.swift b/Sources/AdaptiveSound/AudioViewModel+History.swift deleted file mode 100644 index 4d39e26..0000000 --- a/Sources/AdaptiveSound/AudioViewModel+History.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -// MARK: - AudioViewModel session play-history (S10.2 3a) - -// -// The queue panel's "History" tab: an append-only log of tracks played THIS session. Distinct -// from the queue (Up Next) and from the persistent "current" playlist — it is a session record, -// never mirrored to the store and never persisted across relaunch (founder §3a). Clear Queue does -// NOT touch it. - -@MainActor -extension AudioViewModel { - /// Append a freshly-started track to the session play-history. Called at the two points where - /// a track BEGINS a genuine new play: a manual start (`startPlayback`, only when `resumeFrom` - /// is nil, so a pause→resume of the same track never spams the log) and the gapless - /// auto-advance seam (`handleTrackTransition`). Dups allowed (re-playing logs again). - func recordPlayStart(_ file: AudioFile) { - sessionHistory.append(HistoryItem(file: file)) - } - - /// Play a History entry NOW (founder: History tap = "play it now"). Routes through - /// `playTrackNextNow`, so the track plays immediately while the rest of the queue is preserved - /// (and, if it's already queued, its first occurrence is moved rather than duplicated) — the - /// same non-destructive jump-play the Songs list uses. - func playFromHistory(_ item: HistoryItem) { - logUX("playFromHistory: '\(item.file.name)'") - playTrackNextNow(item.file) - } -} diff --git a/Sources/AdaptiveSound/AudioViewModel+Playback.swift b/Sources/AdaptiveSound/AudioViewModel+Playback.swift index 5653033..fcc973f 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Playback.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Playback.swift @@ -52,13 +52,9 @@ extension AudioViewModel { await primeGaplessPipeline(startIndex: startIndex, pureMode: pureModeSnapshot) isPlaying = true errorMessage = nil - // A fresh start (not a pause/resume seek) logs a session-history play (S10.2 3a) - // and begins a NEW ≥60% play-through (S10.6). A pause-resume (resumeFrom != nil) - // continues the same play-through — no reset. - if resumeFrom == nil { - recordPlayStart(startFile) - resetPlayTracking() - } + // A fresh start (not a pause/resume seek) begins a NEW ≥60% play-through (S10.6). + // A pause-resume (resumeFrom != nil) continues the same play-through — no reset. + if resumeFrom == nil { resetPlayTracking() } } catch { errorMessage = "Playback failed: \(error.localizedDescription)" isPlaying = false diff --git a/Sources/AdaptiveSound/AudioViewModel.swift b/Sources/AdaptiveSound/AudioViewModel.swift index 9021e7c..0904bf7 100644 --- a/Sources/AdaptiveSound/AudioViewModel.swift +++ b/Sources/AdaptiveSound/AudioViewModel.swift @@ -207,12 +207,6 @@ final class AudioViewModel { /// view can reload without racing the detached store write. UI-bound. var playCountRevision = 0 - /// The session play-history (`+History`), newest LAST — the History view shows it reversed. - /// A track is appended when it BEGINS a genuine new play (manual start / gapless auto-advance); - /// dups allowed. Session-scoped + in-memory: NOT persisted across relaunch, and Clear Queue - /// leaves it untouched (founder §3a). UI-bound (the History list observes it). - var sessionHistory: [HistoryItem] = [] - /// Track selection (does NOT auto-play). Selection and playback are separate. /// Use playTrack() or startPlayback() to actually play the selected track. var selectedTrackIndex: Int? { diff --git a/Sources/AdaptiveSound/LibraryBrowseModel+History.swift b/Sources/AdaptiveSound/LibraryBrowseModel+History.swift new file mode 100644 index 0000000..2cf23b8 --- /dev/null +++ b/Sources/AdaptiveSound/LibraryBrowseModel+History.swift @@ -0,0 +1,27 @@ +import Foundation +import LibraryStore + +// MARK: - LibraryBrowseModel Recently-Played (frecency) loader (S10.6) + +extension LibraryBrowseModel { + /// Load the Recently-Played (frecency) rows. Mirrors `loadSongs`' epoch / `isStoreReady` guard; + /// the DAO returns tracks played at least once, already frecency-ordered. Empty ⇒ the tab shows + /// its empty state ("nothing finished yet") — no firstRun/roots distinction is needed here. + func loadHistory() async { + guard let store else { + historyState = .loading // store still building; the tab reloads on `isStoreReady` + return + } + historyLoadEpoch &+= 1 + let epoch = historyLoadEpoch + do { + let loaded = try await store.frecencyTracksDisplay() + guard epoch == historyLoadEpoch else { return } // a newer load superseded this one + history = loaded + historyState = loaded.isEmpty ? .empty : .loaded + } catch { + guard epoch == historyLoadEpoch else { return } + historyState = .failed(error.localizedDescription) + } + } +} diff --git a/Sources/AdaptiveSound/LibraryBrowseModel.swift b/Sources/AdaptiveSound/LibraryBrowseModel.swift index 3669676..cfbc4f1 100644 --- a/Sources/AdaptiveSound/LibraryBrowseModel.swift +++ b/Sources/AdaptiveSound/LibraryBrowseModel.swift @@ -75,6 +75,15 @@ final class LibraryBrowseModel { ] private(set) var songsState: LoadState = .idle + /// Recently-Played (frecency) rows (S10.6) — the DAO returns them already frecency-ordered + /// (recency-weighted play frequency). Loaded when the Recently-Played tab appears and refreshed + /// when a play crosses the ≥60% threshold (`AudioViewModel.playCountRevision`). Declared + /// `internal` (not `private(set)`) because the loader lives in a same-type extension + /// (`LibraryBrowseModel+History`). + var history: [LibraryTrackDisplay] = [] + var historyState: LoadState = .idle + var historyLoadEpoch = 0 + // Songs filter (S9.5 chunk 2b — "filter-preserves-sort", design §3.3/§5). The filter NARROWS // the already-`songSort`-ordered `songs` in place (A2): it never touches `songSort`/`sortOrder`, // so the sort triangle is preserved and headers keep re-sorting the filtered subset. diff --git a/Sources/AdaptiveSound/Models/HistoryItem.swift b/Sources/AdaptiveSound/Models/HistoryItem.swift deleted file mode 100644 index 370f079..0000000 --- a/Sources/AdaptiveSound/Models/HistoryItem.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// One entry in the session play-history — a stable-identity wrapper around a played file, so the -/// History list has dups-safe `ForEach` identity (the same track can be played repeatedly in a -/// session). Session-scoped + in-memory ONLY: NOT persisted across relaunch, and NOT cleared by -/// Clear Queue (founder §3a: "Session; Clear keeps it"). Mirrors `QueueItem`'s shape but is a -/// distinct type — a history entry is a past play, not a queue slot. -struct HistoryItem: Identifiable { - let id: UUID - let file: AudioFile - - init(file: AudioFile, id: UUID = UUID()) { - self.id = id - self.file = file - } -} diff --git a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift index 20265c1..a3aeda0 100644 --- a/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift +++ b/Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift @@ -20,7 +20,7 @@ struct PlaylistView: View { PlaylistHeaderView(jumpToCurrentRequestID: $jumpToCurrentRequestID, panelMode: $panelMode) Picker("View", selection: $panelMode) { ForEach(QueuePanelMode.allCases) { mode in - Text(mode.title).tag(mode) + Text(mode.pickerLabel).tag(mode) } } .pickerStyle(.segmented) @@ -59,25 +59,26 @@ struct PlaylistView: View { private struct PlaylistHeaderView: View { @Environment(AudioViewModel.self) var viewModel + @Environment(LibraryBrowseModel.self) var library @Binding var jumpToCurrentRequestID: Int @Binding var panelMode: QueuePanelMode - /// Mode-aware subtitle: the queue's track count, or the number of session plays. + /// Mode-aware subtitle: the queue's track count, or the number of recently-played tracks. private var subtitle: String { switch panelMode { case .upNext: let count = viewModel.queue.count return "\(count) \(count == 1 ? "track" : "tracks")" case .history: - let count = viewModel.sessionHistory.count - return "\(count) played" + let count = library.history.count + return "\(count) \(count == 1 ? "track" : "tracks")" } } var body: some View { HStack { VStack(alignment: .leading, spacing: 2) { - Text(panelMode == .history ? "History" : "Queue") + Text(panelMode == .history ? "Recently Played" : "Queue") .font(.caption.weight(.semibold)) .tracking(0.5) .textCase(.uppercase) diff --git a/Sources/AdaptiveSound/UI/Playlist/QueueHistoryList.swift b/Sources/AdaptiveSound/UI/Playlist/QueueHistoryList.swift index 07759d4..02e2a4e 100644 --- a/Sources/AdaptiveSound/UI/Playlist/QueueHistoryList.swift +++ b/Sources/AdaptiveSound/UI/Playlist/QueueHistoryList.swift @@ -1,61 +1,76 @@ +import Foundation import SwiftUI -// MARK: - Queue panel mode + session-History list (S10.2 3a) +// MARK: - Queue panel mode + Recently Played list (S10.6) -/// Which list the queue panel shows: the live queue (Up Next) or this session's plays (History). +/// Which list the queue panel shows: the live queue (Up Next) or the Recently-Played digest. enum QueuePanelMode: String, CaseIterable, Identifiable { case upNext - case history + case history // internal name kept stable; the tab is LABELED "Recently Played" (S10.6) var id: String { rawValue } - var title: String { + /// Compact label for the segmented picker (so "Up Next | Recently Played" doesn't truncate). + /// The full header title is set by `PlaylistHeaderView` ("Queue" / "Recently Played"). + var pickerLabel: String { switch self { case .upNext: "Up Next" - case .history: "History" + case .history: "Recent" } } } -/// The session play-History list, newest first. Tapping a row plays that track NOW -/// (`playFromHistory` → `playTrackNextNow`), keeping the rest of the queue intact. History is -/// session-scoped + in-memory: never persisted, and Clear Queue leaves it untouched (founder §3a). +/// The Recently-Played (frecency) list: each track ONCE, ranked by recency-weighted play frequency +/// (S10.6). Bound to `LibraryBrowseModel.history` — a persistent frecency store read — NOT the old +/// in-memory session log, so re-playing a track never adds a duplicate row. Tapping plays it now +/// (`playTrackNextNow`, non-destructive). Loads on appear and refreshes when a play crosses the +/// ≥60% threshold; empty until something has been heard through. struct QueueHistoryList: View { - @Environment(AudioViewModel.self) var viewModel + @Environment(AudioViewModel.self) private var viewModel + @Environment(LibraryBrowseModel.self) private var library var body: some View { - if viewModel.sessionHistory.isEmpty { - emptyHistory - } else { - // ScrollView/LazyVStack (not List) so it shares the queue's self-styled - // `PlaylistItemRow` rendering exactly (the queue left List for drag-reorder; keeping - // History on List would double-style the row via `.listRow*`). No reorder here. - ScrollView { - LazyVStack(spacing: 0) { - // Newest first: reverse the append-ordered log. `rank` is the recency position - // (0 → "1" = most recent) shown in the row's number column. Keyed on the stable - // `HistoryItem.id` so replaying a track (a duplicate entry) stays distinct. - ForEach(Array(viewModel.sessionHistory.reversed().enumerated()), id: \.element.id) { rank, item in - PlaylistItemRow(file: item.file, index: rank, isSelected: false, isNowPlaying: false) - .accessibilityAddTraits(.isButton) - .accessibilityAction { viewModel.playFromHistory(item) } - .simultaneousGesture( - TapGesture().onEnded { viewModel.playFromHistory(item) } - ) + Group { + if library.history.isEmpty { + emptyHistory + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(library.history) { track in + RecentlyPlayedRow(track: track, isNowPlaying: track.id == currentTrackID) + .accessibilityAction { library.playTrackNextNow(track) } + .simultaneousGesture( + TapGesture().onEnded { library.playTrackNextNow(track) } + ) + } } } + .frame(maxHeight: .infinity) } - .frame(maxHeight: .infinity) } + .task { await library.loadHistory() } + // A play crossing ≥60% bumps `playCountRevision` AFTER the store write commits, so the list + // reorders without a manual revisit. This view exists only while the tab is on screen, so + // the reload can't thrash during background album playback. + .onChange(of: viewModel.playCountRevision) { _, _ in + Task { await library.loadHistory() } + } + } + + /// The durable id of the track currently playing (drives the ▶ / accent cue), or nil. + private var currentTrackID: Int64? { + guard viewModel.isPlaying, let index = viewModel.selectedTrackIndex, + index < viewModel.queue.count else { return nil } + return viewModel.queue[index].file.trackID } private var emptyHistory: some View { ContentUnavailableView { Label("Nothing Played Yet", systemImage: "clock.arrow.circlepath") } description: { - Text("Tracks you play this session will appear here.") + Text("Tracks you finish will appear here.") } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift b/Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift new file mode 100644 index 0000000..2606018 --- /dev/null +++ b/Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift @@ -0,0 +1,78 @@ +import Foundation +import LibraryStore +import SwiftUI + +// MARK: - Recently Played row (S10.6) + +/// One row of the Recently-Played (frecency) tab, bound to a `LibraryTrackDisplay` (durable id, +/// artwork, play stats) — deliberately NOT the `AudioFile`-based `PlaylistItemRow` (wrong input + +/// different anatomy). Leading artwork, title + format badge, and the **"N plays · «relative +/// last-played»"** cue in place of the (blank) subtitle so the frecency ordering reads as +/// intentional. No rank number, no duration. Tapping plays it now (wired by the list). +struct RecentlyPlayedRow: View { + @Environment(LibraryBrowseModel.self) private var model + let track: LibraryTrackDisplay + /// The currently-playing track matches this row (shows the ▶ cue + accent tint). + let isNowPlaying: Bool + + var body: some View { + HStack(spacing: DesignSystem.Spacing.small) { + AlbumArtworkView(key: track.artworkKey, side: DesignSystem.SongsList.artwork, model: model) + .overlay { + if isNowPlaying { + Image(systemName: "play.fill") + .font(.system(size: 10)) + .foregroundStyle(DesignSystem.Color.onAccent) + .padding(3) + .background(DesignSystem.Color.accent, in: Circle()) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(track.title) + .font(DesignSystem.Font.body.weight(isNowPlaying ? .semibold : .regular)) + .foregroundStyle(isNowPlaying ? Color.asAccent : Color.asLabel) + .lineLimit(1) + Text(statsCue) + .font(DesignSystem.Font.monoSmall) + .foregroundStyle(Color.asLabelTertiary) + .lineLimit(1) + } + + Spacer() + + FormatBadgeView(format: track.format, isSelected: isNowPlaying) + } + .padding(.vertical, DesignSystem.Spacing.xSmall) + .padding(.horizontal, DesignSystem.Spacing.small) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isNowPlaying ? DesignSystem.Color.rowNowPlaying : Color.clear) + .contentShape(Rectangle()) + // One VoiceOver element per row: title · play count · relative last-played, now-playing as a + // value + button trait (activation wired by the list's `.accessibilityAction`). + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(isNowPlaying ? "Now playing" : "") + .accessibilityAddTraits(.isButton) + } + + /// "12 plays · 2 hours ago" (or "1 play" / just the count if never-stamped, which can't happen + /// for a played row). Relative time is localized via `Date.RelativeFormatStyle`. + private var statsCue: String { + let plays = "\(track.playCount) play\(track.playCount == 1 ? "" : "s")" + guard let lastPlayed = track.lastPlayed else { return plays } + return "\(plays) · \(relativeString(fromEpochSeconds: lastPlayed))" + } + + private var accessibilityLabel: String { + var parts = [track.title, "\(track.playCount) play\(track.playCount == 1 ? "" : "s")"] + if let lastPlayed = track.lastPlayed { + parts.append("last played " + relativeString(fromEpochSeconds: lastPlayed)) + } + return parts.joined(separator: ", ") + } + + private func relativeString(fromEpochSeconds seconds: Int64) -> String { + Date(timeIntervalSince1970: TimeInterval(seconds)).formatted(.relative(presentation: .named)) + } +} diff --git a/Sources/VerifyLibraryStore/ChecksFrecency.swift b/Sources/VerifyLibraryStore/ChecksFrecency.swift index ed02ba8..3d6dad8 100644 --- a/Sources/VerifyLibraryStore/ChecksFrecency.swift +++ b/Sources/VerifyLibraryStore/ChecksFrecency.swift @@ -10,7 +10,6 @@ import Foundation import GRDB -import LibraryScan import LibraryStore private let frecencyEps = 1e-6 From 0dee3da65f4b740da028ef0d1313e88118aeffc1 Mon Sep 17 00:00:00 2001 From: Ramith Jayasinghe <3402882+ramith@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:44:30 +0530 Subject: [PATCH 4/4] fix(s10.6): reset play-tracker synchronously on fresh start (QA break-it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Sources/AdaptiveSound/AudioViewModel+Playback.swift | 12 +++++++++--- .../UI/Playlist/RecentlyPlayedRow.swift | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/AdaptiveSound/AudioViewModel+Playback.swift b/Sources/AdaptiveSound/AudioViewModel+Playback.swift index fcc973f..68010d5 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Playback.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Playback.swift @@ -36,6 +36,15 @@ extension AudioViewModel { refreshDuration(for: fileURL) + // A fresh start (not a pause/resume seek) begins a NEW ≥60% play-through (S10.6). Reset + // SYNCHRONOUSLY here, BEFORE the async Task below: on a manual switch `selectedTrackIndex` + // is already the new track, and the 20 Hz tick fires while the Task is parked at + // `await engine.startAudio`. If the reset were deferred into the Task, those ticks would + // keep accruing the OUTGOING track's `heardSeconds` and could cross the threshold — counting + // the play against the newly-selected track (QA break-it #1). A pause-resume (resumeFrom != + // nil) continues the same play-through — no reset. + if resumeFrom == nil { resetPlayTracking() } + // Snapshot index and mode for use inside the Task (avoids capturing `self` for // values that could change between now and when the Task body runs). let startIndex = selectedTrackIndex @@ -52,9 +61,6 @@ extension AudioViewModel { await primeGaplessPipeline(startIndex: startIndex, pureMode: pureModeSnapshot) isPlaying = true errorMessage = nil - // A fresh start (not a pause/resume seek) begins a NEW ≥60% play-through (S10.6). - // A pause-resume (resumeFrom != nil) continues the same play-through — no reset. - if resumeFrom == nil { resetPlayTracking() } } catch { errorMessage = "Playback failed: \(error.localizedDescription)" isPlaying = false diff --git a/Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift b/Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift index 2606018..6364e22 100644 --- a/Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift +++ b/Sources/AdaptiveSound/UI/Playlist/RecentlyPlayedRow.swift @@ -73,6 +73,9 @@ struct RecentlyPlayedRow: View { } private func relativeString(fromEpochSeconds seconds: Int64) -> String { - Date(timeIntervalSince1970: TimeInterval(seconds)).formatted(.relative(presentation: .named)) + // Clamp a future / clock-skewed stamp so it never renders "in 3 minutes" in a + // *recently-played* list (display-only; the score's backward-clamp is separate). + guard seconds < Int64(Date().timeIntervalSince1970) else { return "just now" } + return Date(timeIntervalSince1970: TimeInterval(seconds)).formatted(.relative(presentation: .named)) } }