fix: stale-news control — repair clientRank's age source, add isFossil backstop
Some checks failed
CI / Build · Debug (push) Has been cancelled
CI / Build · Release (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Kutesir
2026-07-13 02:56:34 +03:00
parent 676119b328
commit 326d5fe983
3 changed files with 53 additions and 13 deletions

View File

@@ -51,12 +51,16 @@ struct SignalFeedView: View {
return matched 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] { private var mainStories: [StorySummary] {
let notRead = filteredStories.filter { !readStoryIds.contains($0.id) } let notRead = filteredStories.filter { !readStoryIds.contains($0.id) }
let fresh = notRead.filter { !seenSnapshot.contains($0.id) } let current = notRead.filter { !$0.isFossil }
let seen = notRead.filter { seenSnapshot.contains($0.id) } let fossils = notRead.filter { $0.isFossil }
return fresh + seen 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. /// Read stories, tucked into the collapsible "Read" shelf.
@@ -72,8 +76,13 @@ struct SignalFeedView: View {
/// per-section targeted fetches held in store.sectionSupplement. /// per-section targeted fetches held in store.sectionSupplement.
private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) { private func makeDigest(sections: [NewsSection]) -> (top: [StorySummary], sections: [(NewsSection, [StorySummary])]) {
let unread = filteredStories.filter { !readStoryIds.contains($0.id) } let unread = filteredStories.filter { !readStoryIds.contains($0.id) }
let top = Array(unread.prefix(5)) // Fossils (old by firstSeenAt) sink below current stories so the lead
var pool = Array(unread.dropFirst(5)) // 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 usedIds = Set(top.map(\.id))
var out: [(NewsSection, [StorySummary])] = [] var out: [(NewsSection, [StorySummary])] = []
for section in sections { for section in sections {

View File

@@ -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 // 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. // 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 { extension StorySummary {
var clientRank: Double { 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) let decay = 1.0 / (1.0 + ageHours / 24.0)
return Double(signalScore) * (0.80 + 0.20 * decay) 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 { static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
let diff = a.clientRank - b.clientRank let diff = a.clientRank - b.clientRank
// Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are // Stable tiebreak for nearly-equal ranks: newer ID wins (IDs are

View File

@@ -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 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 `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. 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 *Client mitigations shipped:*
`firstSeenAt` instead of `updatedAt`, so the UI no longer shows "1 hr ago" on stories 1. Age display (row meta line, hero card) now reads from `firstSeenAt` instead of
that are actually weeks old. *Ranking is still wrong* — old stories still surface at `updatedAt`, so the UI no longer shows "1 hr ago" on stories that are actually
the top of the feed, because `signalScore`/`freshnessScore` (used for ordering) are weeks old.
computed server-side and the client must not re-rank (architecture rule). The 2. Found that `StorySummary.clientRank` — an *existing* time-decay mechanism, already
real fix is still backend-side. 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 ## Backend — features