CLUSTERING. Merging on similarity to the running centroid alone let the centroid walk. Every merge pulls the mean toward what has accumulated, so a cluster that keeps growing converges on its SUBJECT rather than its event -- and a subject centroid is within 0.82 of nearly everything on that subject. Measured on the 110-item "Losing control of AI is the plan" cluster: 10 members within 0.82 of the founding story, 110 within 0.82 of the final centroid. Two new bars. `anchor_threshold` requires similarity to the founding item, not just the mean; it is clamped to `threshold` so lowering that in config is not silently overridden. `discussion_threshold` holds a merge to a near-duplicate bar when BOTH sides are discussion sources -- HN, Reddit, Lobsters, arXiv, GitHub -- which publish independent posts about a subject rather than reports of an event, and were being counted as corroboration for each other. Measured: discussion merges that are the same link cross-posted sit at 0.990 similarity, topical ones at 0.769; 0.90 keeps 89% of the former and rejects 98% of the latter, and the latter were 2,745 of 2,876. Re-clustering the full corpus through the real code: zero clusters of 50+, down from 16 holding 2,269 items; largest is 33, down from 667. The arXiv blob goes 665 -> 1, the AI one 110 -> 15, and a 108-item Houthi blob splits into two distinct events. Known-good events are intact (Space Force 33 -> 30, Turkey LGBTQ+ raids 25 -> 23). THE PLACE GATE was a union across a quota group where it should have been an intersection. `local` holds three blocks with three disjoint gazetteers, so an item had to name a term from all three to survive. "Flash flood relief: Cincinnati City Council advances new stormwater plan" scored 0.697 against the cincinnati probes -- the highest of any block on either axis, z 4.04 against urbanism's 2.10 -- and was zeroed for not saying "Kentucky". That, not the gazetteer, is why the block took 0 of 196 digest slots in a week. Cincinnati now wins 12 clusters and Kentucky 5, both reaching the digest. `local_sources` still earns its place for the second-order cause: only 16% of clusters led by a Cincinnati newsroom name any gazetteer term, and widening the list to the whole metro reached just 31%. Provenance settles what a headline does not say. DIGEST PROSE now receives each story's cleaned summary, not only its headline. With headlines alone an opinion piece arrived as nothing but its own provocation. Measured: 23 of 27 summaries carry 15+ words absent from the headline; 8 of 27 needed a trailer stripped, 3 a byline block, 2 HTML entities. SPACE. Three probes added for satellites, launch policy and agency contracts -- the block covered only civil and scientific spaceflight, so anything about orbital operations had nothing to match and lost to a war block. Deliberately no defence-space probe: that is the one that would win the Space Force story, and whether military space belongs here is Connor's call. The `war-escalation` probe that named "weapons of mass destruction" was matching the word "weapons"; it now describes the act of escalating. That story's importance falls 5.22 -> 4.40. Five space sources added, all verified live: European Spaceflight, Payload, SpacePolicyOnline, The Planetary Society, NASA Science. The old three returned 17 items in a week against 255 from one Cincinnati station, so even a well-scoring space story rarely had anything to score. Also: a profile with no [[veto]] blocks raised UnboundLocalError on the first item scored. ALGORITHM.md records the whole pipeline and why each decision is what it is, including the conclusions this project has reached and since corrected, and the defects that are known and still open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xPLgmcXCc9JjVwmQF63eM
173 lines
7.7 KiB
Python
173 lines
7.7 KiB
Python
"""Place gating.
|
|
|
|
Embeddings model meaning well and named entities poorly: "Italian politics" and
|
|
"Australian politics" sit almost on top of each other. Measured on live data,
|
|
66% of the winners of a place-based block named no place at all -- Glastonbury
|
|
and Anthony Albanese were scoring as `italy-mexico`.
|
|
|
|
Place is identity, not meaning, so it is matched lexically.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from news import store
|
|
from news.score import ProfileVectors, _mentions, score_clusters
|
|
|
|
|
|
def test_word_boundaries_not_substrings():
|
|
"""The lesson from "nfl" matching co-NFL-ict, which vetoed every Iran story."""
|
|
assert _mentions("meloni visits rome today", ["rome"])
|
|
assert not _mentions("open the chrome browser", ["rome"])
|
|
assert not _mentions("efforts to normalise ties", ["mali"])
|
|
assert _mentions("fighting spreads in mali", ["mali"])
|
|
|
|
|
|
class _FakeVectors:
|
|
"""Two blocks in different groups: one gated on a place, one ungated."""
|
|
|
|
def __init__(self):
|
|
near = np.array([[1.0, 0.0]], dtype=np.float32)
|
|
self.blocks = {
|
|
"interest": [
|
|
("italy-mexico", near, {"id": "italy-mexico", "weight": 1.0,
|
|
"requires_any": ["italy", "mexico"]}),
|
|
("urbanism", np.array([[0.0, 1.0]], dtype=np.float32),
|
|
{"id": "urbanism", "weight": 1.0}),
|
|
],
|
|
"veto": [], "demote": [], "importance": [
|
|
("x", np.array([[0.0, 0.0]], dtype=np.float32), {"id": "x", "weight": 1.0})],
|
|
}
|
|
|
|
def raw_matrix(self, kind, vecs):
|
|
return {bid: (vecs @ m.T).max(axis=1) for bid, m, _ in self.blocks[kind]}
|
|
|
|
def weight(self, kind, bid):
|
|
return 1.0
|
|
|
|
def group(self, kind, bid):
|
|
return bid
|
|
|
|
def block(self, kind, bid):
|
|
return next(b for i, _, b in self.blocks[kind] if i == bid)
|
|
|
|
|
|
@pytest.fixture
|
|
def con(tmp_path):
|
|
c = store.connect(tmp_path / "g.db")
|
|
store.upsert_source(c, id="s1", name="A", url="http://a", kind="rss", bucket="a",
|
|
weight=1.0, prominence=0, enabled=1, poll_interval_secs=900)
|
|
return c
|
|
|
|
|
|
def _add(con, cid, title):
|
|
when = "2026-01-01T00:00:00+00:00"
|
|
con.execute("""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
|
|
published_at,first_seen_at,content_hash) VALUES (?,?,?,'',?,?,?,?,?)""",
|
|
(cid, "s1", title, f"http://x/{cid}", f"http://x/{cid}", when, when, cid))
|
|
con.execute("""INSERT INTO clusters (id,centroid,size,first_seen_at,last_seen_at,lead_item_id)
|
|
VALUES (?,?,1,?,?,?)""",
|
|
(cid, store.to_blob(np.array([1.0, 0.0], dtype=np.float32)), when, when, cid))
|
|
con.execute("INSERT INTO cluster_members VALUES (?,?,?)", (cid, cid, when))
|
|
|
|
|
|
CFG = {"veto_absolute": 0.9, "veto_margin_over_interest": 9.0,
|
|
"corroboration_pickup_weight": 0.3, "corroboration_depth_weight": 0.2}
|
|
CLUST = {"syndication_threshold": 0.97}
|
|
|
|
|
|
def test_a_gated_block_cannot_win_without_a_mention(con):
|
|
"""Both items point at the italy-mexico probe; only one names a place."""
|
|
_add(con, "yes", "Meloni's government becomes Italy's longest-lasting")
|
|
_add(con, "no", "Anthony Albanese held an undeclared honorary membership")
|
|
scores = {s.cluster_id: s for s in score_clusters(con, _FakeVectors(), CFG, CLUST)}
|
|
assert scores["yes"].interest.block == "italy-mexico"
|
|
assert scores["no"].interest.block != "italy-mexico"
|
|
|
|
|
|
def test_gate_survives_normalisation(con):
|
|
"""Regression for a real bug.
|
|
|
|
The gate was first applied by writing a sentinel into the RAW score before
|
|
normalisation. When most items are gated that sentinel becomes the block's
|
|
own mean, so a gated item z-scored to roughly zero and could still win --
|
|
the normalisation erased the gate. With 30 gated items against 1 mention,
|
|
a pre-normalisation gate lets the gated ones through.
|
|
"""
|
|
_add(con, "mention", "Sheinbaum speaks in Mexico")
|
|
for i in range(30):
|
|
_add(con, f"other{i}", f"Unrelated story number {i}")
|
|
scores = score_clusters(con, _FakeVectors(), CFG, CLUST)
|
|
winners = [s for s in scores if s.interest.block == "italy-mexico"]
|
|
assert [s.cluster_id for s in winners] == ["mention"], (
|
|
"only the item naming a place may win the gated block")
|
|
|
|
|
|
def test_one_block_in_a_group_is_enough_to_clear_the_gate(tmp_path):
|
|
"""Regression: the gate was a union across a group, so a group of blocks
|
|
with disjoint gazetteers gated everything.
|
|
|
|
`local` holds cincinnati, kentucky-louisville and ohio-state-politics. A
|
|
Cincinnati story does not say "Kentucky", so kentucky-louisville gated it
|
|
and the whole group -- including the block that matched at 0.697, the
|
|
highest score on either axis -- was set to -inf. The block took 0 of 196
|
|
digest slots in a week because of this line.
|
|
"""
|
|
import numpy as np
|
|
from news import store
|
|
from news.score import score_clusters
|
|
|
|
con = store.connect(tmp_path / "g.db")
|
|
store.upsert_source(con, id="s1", name="WCPO Cincinnati", url="http://w",
|
|
kind="rss", bucket="wcpo", weight=1.0, prominence=0,
|
|
enabled=1, poll_interval_secs=900)
|
|
|
|
profile = {"interest": [
|
|
{"id": "cincinnati", "group": "local", "weight": 1.0,
|
|
"requires_any": ["cincinnati"],
|
|
"probes": ["Cincinnati city council votes on a local ordinance."]},
|
|
{"id": "kentucky", "group": "local", "weight": 1.0,
|
|
"requires_any": ["kentucky"],
|
|
"probes": ["Kentucky state politics and legislation in Frankfort."]},
|
|
], "veto": [], "demote": [], "importance": [
|
|
{"id": "x", "weight": 1.0, "probes": ["A major disaster kills many people."]}]}
|
|
|
|
titles = ["Cincinnati city council votes on a stormwater ordinance",
|
|
"Kentucky legislature debates a new budget in Frankfort",
|
|
"Sydney transport authority approves a new ferry timetable"]
|
|
for n, title in enumerate(titles):
|
|
when = f"2026-09-1{n + 1}T00:00:00+00:00"
|
|
con.execute("""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
|
|
published_at,first_seen_at,content_hash) VALUES (?,?,?,'',?,?,?,?,?)""",
|
|
(f"i{n}", "s1", title, f"http://x/{n}", f"http://x/{n}", when, when, f"h{n}"))
|
|
|
|
from news.embed import Embedder
|
|
from news.score import ProfileVectors
|
|
emb = Embedder.from_config(__import__("news.config", fromlist=["Config"]).Config())
|
|
vecs = emb.encode(titles)
|
|
for n in range(len(titles)):
|
|
con.execute("""INSERT INTO clusters (id,centroid,size,first_seen_at,last_seen_at,
|
|
lead_item_id) VALUES (?,?,1,?,?,?)""",
|
|
(f"c{n}", store.to_blob(vecs[n]),
|
|
f"2026-09-1{n + 1}T00:00:00+00:00", f"2026-09-1{n + 1}T00:00:00+00:00",
|
|
f"i{n}"))
|
|
con.execute("INSERT INTO cluster_members VALUES (?,?,?)",
|
|
(f"c{n}", f"i{n}", f"2026-09-1{n + 1}T00:00:00+00:00"))
|
|
|
|
pv = ProfileVectors(con, profile, emb)
|
|
scored = {s.cluster_id: s for s in score_clusters(
|
|
con, pv, {"normalize": "zscore-per-block", "veto_absolute": 0.6,
|
|
"veto_margin_over_interest": 0.3, "corroboration_pickup_weight": 0.3,
|
|
"corroboration_depth_weight": 0.2, "importance_weights": {}},
|
|
{"syndication_threshold": 0.97})}
|
|
|
|
import math
|
|
assert not math.isinf(scored["c0"].interest_score), (
|
|
"the Cincinnati story was gated even though it names Cincinnati")
|
|
assert not math.isinf(scored["c1"].interest_score), (
|
|
"the Kentucky story was gated even though it names Kentucky")
|
|
assert math.isinf(scored["c2"].interest_score) and scored["c2"].interest_score < 0, (
|
|
"the Sydney story names neither place and must still be gated")
|