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
}
/// 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 {