From 326d5fe9838b3647557f4a18466aeb2278390686 Mon Sep 17 00:00:00 2001 From: Kutesir Date: Mon, 13 Jul 2026 02:56:34 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20stale-news=20control=20=E2=80=94=20repai?= =?UTF-8?q?r=20clientRank's=20age=20source,=20add=20isFossil=20backstop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clientRank already existed specifically to stop stale high-scorers from leading the feed, but computed age from updatedAt — the same timestamp that keeps resetting whenever the backend attaches any article to a cluster (BACKLOG #15). A fossil's age always read as ~0, silently defeating the whole mechanism. Fixed to use firstSeenAt. clientRank's decay is soft (max 20% penalty) and isn't always enough against a high signalScore alone, so added StorySummary.isFossil (>5 days old by firstSeenAt) as a hard backstop: mainStories and makeDigest now sink fossils below current stories, so one can never take the hero/lead slot regardless of score. Still shown in the feed, just not in the most prominent position. Co-Authored-By: Claude Sonnet 4.6 --- Jarvis/Views/Home/SignalFeedView.swift | 21 +++++++++++++++------ Jarvis/Views/Shared/Theme.swift | 20 +++++++++++++++++++- docs/BACKLOG.md | 25 +++++++++++++++++++------ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 8947529..ccef2ad 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -51,12 +51,16 @@ struct SignalFeedView: View { return matched } - /// The main feed: unread stories, with already-seen ones sunk below fresh ones. + /// The main feed: unread stories, with already-seen ones sunk below fresh + /// ones, and fossils (old by firstSeenAt, see StorySummary.isFossil) sunk + /// below everything — a high signalScore alone can't make old news lead. private var mainStories: [StorySummary] { let notRead = filteredStories.filter { !readStoryIds.contains($0.id) } - let fresh = notRead.filter { !seenSnapshot.contains($0.id) } - let seen = notRead.filter { seenSnapshot.contains($0.id) } - return fresh + seen + let current = notRead.filter { !$0.isFossil } + let fossils = notRead.filter { $0.isFossil } + let fresh = current.filter { !seenSnapshot.contains($0.id) } + let seen = current.filter { seenSnapshot.contains($0.id) } + return fresh + seen + fossils } /// Read stories, tucked into the collapsible "Read" shelf. @@ -72,8 +76,13 @@ struct SignalFeedView: View { /// per-section targeted fetches held in store.sectionSupplement. private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) { let unread = filteredStories.filter { !readStoryIds.contains($0.id) } - let top = Array(unread.prefix(5)) - var pool = Array(unread.dropFirst(5)) + // Fossils (old by firstSeenAt) sink below current stories so the lead + // slot is never a high-scoring but stale story — see StorySummary.isFossil. + let current = unread.filter { !$0.isFossil } + let fossils = unread.filter { $0.isFossil } + let reordered = current + fossils + let top = Array(reordered.prefix(5)) + var pool = Array(reordered.dropFirst(5)) var usedIds = Set(top.map(\.id)) var out: [(NewsSection, [StorySummary])] = [] for section in sections { diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index 5f85902..46cfca3 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -269,14 +269,32 @@ enum AppearanceMode: String, CaseIterable { // // Effect: a score-80 story from 1 hour ago (rank ≈ 79.8) beats a score-82 story // from 48 hours ago (rank ≈ 71.4). Pure score still dominates for same-age stories. +// +// Age is measured from `firstSeenAt`, not `updatedAt` — the backend bumps +// `updatedAt` every time it attaches another article to a cluster, however +// loosely related (see BACKLOG #15), so a story can read as "just now" while +// being weeks old by `firstSeenAt`. Using `updatedAt` here previously +// neutralized the whole point of this decay: a fossil story's age always +// computed near zero. extension StorySummary { var clientRank: Double { - let ageHours = max(0.0, -updatedAt.timeIntervalSinceNow) / 3600.0 + let ageHours = max(0.0, -(firstSeenAt ?? updatedAt).timeIntervalSinceNow) / 3600.0 let decay = 1.0 / (1.0 + ageHours / 24.0) return Double(signalScore) * (0.80 + 0.20 * decay) } + /// Even with the fix above, this decay is soft (max 20% penalty) — a + /// high enough signalScore can still outrank fresher, lower-scored + /// stories. `isFossil` is a hard backstop views use to keep genuinely + /// old stories out of the lead/hero slot regardless of score. + static let fossilThresholdDays: Double = 5 + + var isFossil: Bool { + let ageSeconds = -(firstSeenAt ?? updatedAt).timeIntervalSinceNow + return ageSeconds > StorySummary.fossilThresholdDays * 86400 + } + static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool { let diff = a.clientRank - b.clientRank // Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index aeb723d..54173d5 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -45,12 +45,25 @@ Status: 🔴 open · 🟡 mitigated client-side (real fix still pending) · 🟢 unrelated articles keep a stale cluster's clock alive. Also confirmed the API's own `isStale` field is unusable as a stopgap — it reads `false` on every story checked regardless of actual age, so it isn't computed from a real staleness threshold. - *Client mitigation shipped:* age display (row meta line, hero card) now reads from - `firstSeenAt` instead of `updatedAt`, so the UI no longer shows "1 hr ago" on stories - that are actually weeks old. *Ranking is still wrong* — old stories still surface at - the top of the feed, because `signalScore`/`freshnessScore` (used for ordering) are - computed server-side and the client must not re-rank (architecture rule). The - real fix is still backend-side. + *Client mitigations shipped:* + 1. Age display (row meta line, hero card) now reads from `firstSeenAt` instead of + `updatedAt`, so the UI no longer shows "1 hr ago" on stories that are actually + weeks old. + 2. Found that `StorySummary.clientRank` — an *existing* time-decay mechanism, already + in the codebase specifically to stop stale high-scorers from leading the feed — was + itself silently neutralized by this same bug: it computed age from `updatedAt`, so a + fossil's age always read as ~0. Fixed to use `firstSeenAt`. This isn't new + client-side ranking logic; it's un-breaking a pre-existing, intentional one. + 3. Added `StorySummary.isFossil` (`firstSeenAt` > 5 days old) as a hard backstop — + `clientRank`'s decay is soft (max 20% score penalty) and isn't always enough to + overcome a high `signalScore` alone. Fossils are sunk below current stories in + `mainStories` and `makeDigest`, so a stale story can never occupy the hero/lead slot + even if the server still ranks it #1. They still appear in the feed, just not in + the most prominent, most misleading position. + *Still wrong at the source* — the server's own `signalScore`/`freshnessScore` ordering + (what page 2+, pagination cutoffs, and `min_signal` filtering all rely on) is + unaffected; these are display/ordering patches downstream of it. The real fix is + still backend-side. ## Backend — features