Narrow the window to 24 hours everywhere a reader sees it

Notifications, the digest and the triage queue now share one 24h window. The
app is built on a daily cadence -- the recent news of today -- and 48h meant
the digest could carry a story purely because the window was wider than the
rhythm the app runs on. Measured on a live day: 7 of 16 carried-over digest
stories age out.

The triage dropdown keeps 48h / 7 days / everything for catching up after a
few days away; only the default moves.

[clustering] window_hours stays at 48 deliberately. That governs how far back a
new item looks for a cluster to join, not what reaches the reader, and cutting
it would split a two-day story into two clusters instead of keeping it as one.

Note what this does NOT fix: staleness is measured on the cluster's
last_seen_at, refreshed whenever a new report joins, so a story reported on
again this morning stays inside the window however long it has been running.
That is correct -- an ongoing story is today's news -- but it means 9 of those
16 carry-overs survive, and repeat suppression is still a separate question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xPLgmcXCc9JjVwmQF63eM
This commit is contained in:
connorandClaude Opus 5 committed 2026-09-10 09:48:54 -04:00
1 parent f1242065ec
commit d78eec0b92
4 files changed
+42 -20

No files matched your search

+6 -5
View File
@@ -119,11 +119,12 @@ class Config:
"important_threshold": r.get("important_threshold", 2.0),
"digest_floor": r.get("digest_floor", 0.5),
"hn_min_score": r.get("hn_min_score", 150),
"max_age_hours": r.get("max_age_hours", 48),
# How far back the triage queue reaches. Distinct from
# max_age_hours: that one governs what may INTERRUPT you, this one
# governs what is still worth labelling. Defaults to the same 48h.
"triage_window_hours": r.get("triage_window_hours", 48),
# The reader-facing window: notifications, digest and triage all
# use it. 24h, because the app is built on a daily cadence.
"max_age_hours": r.get("max_age_hours", 24),
# Kept separate so the labelling surface can be widened without
# widening delivery, but normally the same value.
"triage_window_hours": r.get("triage_window_hours", 24),
"digest_max_items": r.get("digest_max_items", 25),
"digest_max_per_group": r.get("digest_max_per_group", 4),
"digest_importance_slots": r.get("digest_importance_slots", 6),
+1 -1
View File
@@ -170,7 +170,7 @@ def route(
def defer(s: ClusterScore, reason: str) -> None:
deferred[s.cluster_id] = reason
max_age = timedelta(hours=routing.get("max_age_hours", 48))
max_age = timedelta(hours=routing.get("max_age_hours", 24))
live: list[ClusterScore] = []
for s in scores:
# Staleness first. Feeds hand back large archives on first fetch -- one
+26 -9
View File
@@ -120,22 +120,39 @@ serendipity_max_interest = 0.5
# would reject most of what the crowd has actually promoted.
hn_min_score = 100
# The window. Every reader-facing surface uses it: notifications, the digest,
# and the triage queue. This app is built on a daily cadence -- "the recent news
# of today" -- so 24, not 48.
#
# Nothing older than this may generate a notification. Feeds return large
# archives on first fetch -- one run pulled 88 items going back five months --
# and those belong in the scoring corpus but not in your pocket.
max_age_hours = 48
#
# NOTE this is measured on the cluster's last_seen_at, which is refreshed every
# time a new report joins it, so a story with fresh coverage today stays inside
# the window however long it has been running. That is deliberate -- an ongoing
# story IS today's news -- but it means this setting alone does not stop the
# digest repeating a long-running story. Measured on a real day: cutting 48 -> 24
# aged out 7 of 16 carried-over digest stories; the other 9 had been reported on
# again that morning and stayed, correctly.
#
# Distinct from [clustering] window_hours, which stays at 48 on purpose: that is
# how far back a NEW item looks for a cluster to join, and shortening it would
# split a two-day story into two clusters rather than keeping it as one.
max_age_hours = 24
# How far back the TRIAGE queue reaches. Separate from max_age_hours, which
# governs what may interrupt you; this governs what is still worth labelling.
# How far back the TRIAGE queue reaches. Normally the same as max_age_hours --
# nothing older can reach you, so nothing older needs labelling -- but kept
# separate so you can widen the labelling surface without widening delivery.
#
# Not optional in practice. Scoring covers every cluster ever stored, on
# purpose -- the z-score population should describe what news looks like, not
# only the last two days -- but with no window the page rendered that whole
# archive ranked by score. Nothing aged out, so a strong story from day one
# still led the queue on day three, and 11 of the first 60 items were feed
# backfill published before the system existed. The dropdown on the page
# overrides this per visit.
triage_window_hours = 48
# only the last day -- but with no window the page rendered that whole archive
# ranked by score. Nothing aged out, so a strong story from day one still led
# the queue on day three, and 11 of the first 60 items were feed backfill
# published before the system existed. The dropdown on the page overrides this
# per visit, for catching up after a few days away.
triage_window_hours = 24
[scoring.importance_weights]
# Importance is NOT corroboration count -- that was the old system's error.
+9 -5
View File
@@ -335,7 +335,9 @@ def _triage_db(tmp_path, monkeypatch):
def test_newest_first_orders_by_recency(tmp_path, monkeypatch):
html = _triage_db(tmp_path, monkeypatch).get("/?sort=new&show=all").text
# window=48h, not the default: c_old is 30 hours back and the default
# window is 24, by design.
html = _triage_db(tmp_path, monkeypatch).get("/?sort=new&show=all&window=48h").text
order = [t for t in ("c_new", "c_mid", "c_old") if f"Story {t}" in html]
positions = [html.index(f"Story {t}") for t in order]
assert positions == sorted(positions), "newest-first must be strictly by recency"
@@ -478,15 +480,17 @@ def test_the_triage_queue_ages_stories_out(tmp_path, monkeypatch):
web._VECTORS = None
c = TestClient(web.app)
# Default window is 48h, matching the routing staleness rule: nothing older
# can be notified, so nothing older needs triaging either.
# The default window matches the routing staleness rule -- nothing older can
# be notified, so nothing older needs triaging -- and both are 24h, the
# daily cadence the app is built on.
html = c.get("/?show=all").text
assert "Story fresh" in html
assert "Story yesterday" in html
assert "Story yesterday" not in html, "a 30-hour-old story is outside a day"
assert "Story lastweek" not in html, "a five-day-old story is still in the queue"
assert "Story backfill" not in html, "feed backfill is still in the queue"
assert "Story yesterday" not in c.get("/?show=all&window=24h").text
# Widening is one click, for catching up after a few days away.
assert "Story yesterday" in c.get("/?show=all&window=48h").text
assert "Story lastweek" in c.get("/?show=all&window=7d").text
assert "Story backfill" in c.get("/?show=all&window=all").text
# A bad value must fall back, not 500 and not silently widen to everything.