Files
news/profile/config.toml
T
connor b852840729 Normalise per group, and make the notification quota subject-based
Following Connor's point that corpus prevalence reflects how much an outlet
publishes rather than what deserves a notification: allocation should track
stated weights, not corpus composition in EITHER direction. Penalising a topic
for being prevalent is a bug; rewarding it would just amplify whoever publishes
most. This rules out the "reserve slots proportional to corpus share" model that
the previous commit's findings were pointing toward.

Two fixes:

1. Normalisation moves from per-block to per-GROUP. Taking the max across blocks
   gives a subject one draw per block, so foreign affairs -- six of 23 blocks --
   beat everything else by having more chances. It held 70% of the digest while
   software-systems, a quarter of the corpus, held none. Collapsing to the group
   before normalising prices that in. Digest conflict share 13 -> 9, space 2 ->
   4, and us-national appears at all.

2. The per-group quota now keys on the INTEREST group for both lanes. Importance
   blocks describe a kind of importance, not a subject, so two stories on one
   subject could each take a slot via different importance blocks -- which is
   what happened: both importance alerts were Middle East, one via
   war-escalation and one via economic-break.

Interest alerts went from five-of-five Middle East to Ohio redistricting,
political theory and tech policy.

Also demoted prerelease/nightly build entries. The first alert software-systems
ever won was "Nvim development (prerelease) build", which matches the block
correctly and is worthless as a notification.

Documented that switching normalizer requires re-tuning [routing] thresholds:
they are in z-score units, and leaving them at 2.0 under "top-anchored"
silences the interest lane entirely.
2026-09-08 16:19:24 -04:00

122 lines
5.4 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.
# "zscore-per-block" or "top-anchored". Both normalise per GROUP (see `group`
# in profile.toml), not per block: taking the max across blocks gives a subject
# one draw per block, so a topic split into five blocks beats one covered by a
# single block purely by having more chances.
#
# NOTE the thresholds in [routing] are in z-score units. "top-anchored" puts
# scores on a different scale (1.0 = that group's 99th percentile), so switching
# normalizer REQUIRES re-tuning them -- verified: leaving them at 2.0 silences
# the interest lane completely.
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
# Max alerts per quota group per day (see `group` in profile.toml). Stops one
# subject taking every slot.
max_alerts_per_group_per_day = 1
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