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.
110 lines
4.7 KiB
TOML
110 lines
4.7 KiB
TOML
# Runtime configuration and tuning knobs.
|
|
#
|
|
# Three-file split, so a new user knows what to touch:
|
|
# config.toml this file -- machinery. Sane defaults; rarely edited.
|
|
# profile.toml who you are: interests, vetoes, importance, delivery.
|
|
# sources.toml what to read.
|
|
#
|
|
# Nothing here is specific to any person. Everything personal lives in the
|
|
# other two files.
|
|
|
|
[http]
|
|
# Sent on every outbound request. Feed operators use it to identify traffic and
|
|
# some will block a generic or absent agent, so put a real contact here.
|
|
# CHANGE THIS if you are not Connor -- it is the contact address feed operators
|
|
# see, and pointing them at someone else's domain is rude and unhelpful.
|
|
user_agent = "news/0.1 (+https://news.rcjohnstone.com; personal feed reader; one user)"
|
|
timeout_secs = 30
|
|
connect_timeout_secs = 10
|
|
concurrency = 8
|
|
max_body_bytes = 10485760
|
|
|
|
[embedding]
|
|
# Any model fastembed can serve. `dim` must match the model.
|
|
#
|
|
# The default is English-only. For native-language sources use a multilingual
|
|
# model -- "intfloat/multilingual-e5-small" (384 dim) is the usual choice.
|
|
# Be aware that changing the model INVALIDATES the calibrated constants below
|
|
# and in [scoring]: they are cosine thresholds, and a different model puts
|
|
# similarity on a different scale. Re-run the calibration before trusting them.
|
|
model = "BAAI/bge-small-en-v1.5"
|
|
dim = 384
|
|
|
|
[clustering]
|
|
# Calibrated by inspection on a 3,885-item corpus; see CALIBRATION.md.
|
|
# 0.70 merged "Solheim Cup capsules" with "FIBA Women's World Cup Glance";
|
|
# 0.86 under-merged. Over-merging is the worse failure because it inflates the
|
|
# corroboration signal that feeds importance.
|
|
#
|
|
# Re-tune if your sources differ substantially in style or language.
|
|
threshold = 0.82
|
|
window_hours = 48
|
|
|
|
# Above this cosine, two items from DIFFERENT newsrooms are treated as the same
|
|
# syndicated copy rather than independent corroboration. Deliberately high:
|
|
# eight outlets each writing their own Renoir-heist headline stayed separate,
|
|
# while a local station reprinting a wire dispatch collapsed correctly.
|
|
syndication_threshold = 0.97
|
|
|
|
[scoring]
|
|
# Compare blocks by z-score WITHIN each block, never by raw cosine: cosine
|
|
# magnitude is not comparable across blocks, and a plain max let one block win
|
|
# 78% of all items. See CALIBRATION.md.
|
|
normalize = "zscore-per-block"
|
|
|
|
# Veto is not a plain similarity threshold. A story can be genuinely adjacent to
|
|
# a vetoed topic and still be wanted, so an item is vetoed on unambiguous
|
|
# similarity OR when the veto signal beats the best interest signal by a margin.
|
|
veto_absolute = 0.60
|
|
veto_margin_over_interest = 0.3
|
|
|
|
# Corroboration = origins + pickup_weight*log1p(pickups) + depth_weight*log1p(depth).
|
|
# "Several outlets carried this" covers four different situations; see
|
|
# news/cluster.py:Corroboration. Origins (independent newsrooms doing their own
|
|
# reporting) dominate; the other two break ties among the many two- and
|
|
# three-origin clusters. Measured on a real corpus: 163 origins, 90 depth,
|
|
# 11 pickups -- depth matters far more here because these sources mostly write
|
|
# their own copy. Raise pickup_weight if you add local papers running wire.
|
|
corroboration_pickup_weight = 0.3
|
|
corroboration_depth_weight = 0.2
|
|
|
|
[routing]
|
|
# Daily budgets. Deliberately small: the point of the system is a handful of
|
|
# well-chosen interruptions, not a feed.
|
|
interest_per_day = 3
|
|
important_per_day = 2
|
|
# Hard cap on waking the reader during quiet hours, inside important_per_day.
|
|
important_per_night = 1
|
|
|
|
# Thresholds are in z-score units -- "how many standard deviations above normal
|
|
# for this block", not raw cosine. Calibrate with `news simulate`, which reports
|
|
# the notifications-per-day these values would actually have produced.
|
|
interest_threshold = 2.0
|
|
important_threshold = 2.0
|
|
digest_floor = 0.5
|
|
# A floor alone yielded 198 items a day. The digest is capped by count and fed
|
|
# strongest-first, so the cap is always spent on the best candidates.
|
|
digest_max_items = 25
|
|
# Serendipity slots are filled from items BELOW this interest score, so they are
|
|
# genuinely outside the profile rather than near-misses.
|
|
serendipity_max_interest = 0.5
|
|
|
|
# Aggregator maturation: an HN post at 3 points is noise, at 300 it is news.
|
|
hn_min_score = 150
|
|
|
|
# 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
|
|
|
|
[scoring.importance_weights]
|
|
# Importance is NOT corroboration count -- that was the old system's error.
|
|
# Velocity is weighted as heavily as corroboration: 0 to 6 independent origins
|
|
# in ninety minutes is what breaking news looks like, and a story sitting at 6
|
|
# origins for three days is not.
|
|
block = 1.0
|
|
corroboration = 0.4
|
|
velocity = 0.5
|
|
prominence = 0.3
|
|
aggregator = 0.3
|