fix: stale-news control — repair clientRank's age source, add isFossil backstop
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user