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

@@ -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