Files
news/tests/test_place_gate.py
connorandClaude Opus 5 80533f2824 Stop clusters drifting into topics; fix the local gate; document the algorithm
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
2026-09-17 10:40:41 -04:00

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")