Three bugs from the first day of real use.
1. Quiet hours were evaluated in UTC. The router is handed a UTC `now` and
compared its hour against "22:00-07:00" directly, so notifications were
silenced from 18:00 local -- prime reading time -- and the whole day's budget
was released at 03:00 local. The reported avalanche landed at 03:39 and 03:55.
Quiet hours are now converted to the reader's wall clock first.
2. The timezone never resolved anyway. TZ was "America/Louisville", which exists
on this Arch host but NOT in a Debian container, which ships only the
canonical "America/Kentucky/Louisville". The container fell back to UTC and
`date` printed "America" as the zone abbreviation. The zone is now read from
config rather than the environment, so a bad TZ cannot silently change
behaviour, and the compose entry uses the canonical name. NOTE: ~20 other
services in compose.yml carry the same bad string; they were left alone since
it only affects their log timestamps.
3. Budgets are not a rate. Six notifications fired in a single minute because
the daily allowance could all be spent in one cycle. Each lane now has a
minimum gap (interest 75min, importance 30min) and sends at most one per
cycle, which turns the budget into a rate spread across the day.
Separately, the morning digest had silently fallen back to the templated list.
The real 28-story payload takes 70.3s to generate against a 60s timeout. Raised
to 180s -- and write_prose now LOGS why it failed instead of returning None in
silence, which is what made a one-line timeout cost a debugging session.
Scoring (news/score.py): every profile block is embedded and compared by
z-score WITHIN block, then weighted. Veto fires on absolute similarity OR a
margin over the interest signal; demotes apply a penalty instead of excluding,
because those categories sit adjacent to real interests.
Importance is deliberately NOT corroboration count -- the old system's error,
which measured the subscription list rather than the news. It combines block
similarity, corroboration, burst velocity, editorial prominence and aggregator
score, all weighted from config.
Routing (news/route.py): two lanes with separate budgets. Interest never
overrides quiet hours; importance may, within a hard nightly cap. Vetoes are
absolute on BOTH lanes -- the v0.2 bypass lane never consulted relevance, so a
blocklisted item could still fire at priority 5, and a test now asserts it
cannot. Budgets are spent strongest-first rather than first-come.
Measured on 1,318 real clusters: 2.5 notifications/day and a 14-item digest,
inside the 2-5/day target.
Three defects the real data exposed, none of which unit tests would have caught:
- velocity was computed as origins/hours with a 0.5h floor, giving every
SINGLE-source cluster a spurious velocity of 2.0. Growth rate is undefined
for a cluster that has not grown; it is now zero below two origins.
- deferred items (quiet hours, budget exhausted, immature) settled straight
into the digest and bypassed its cap, producing 198 items/day. Everything
that misses an alert lane now competes for one ranked, capped digest.
- feeds return large archives on first fetch -- 88 Strong Towns items going back
five months, and a BBC entry titled "BBC News app" dated 2025. Those belong in
the scoring population but must never notify, so routing now has a recency
requirement.
Reading the output also found two profile gaps: recurring columns and link
round-ups ("Catching Our Eye News Roundup", "Astronomy Picture of the Day")
scored as top-ranked interest items. They are on-topic but publish on a
schedule, so they are demoted rather than vetoed and still reach the digest.