Files
news/tests/test_cluster.py
T
connor d57b9e092c Detect syndication from text, not just from configured ownership
Connor's observation: two outlets running the same story can be caught by
comparing the text, without anyone having to know who owns whom.

Corroboration is now origin_count(), a union-find where two items share an
origin if EITHER they are in the same bucket, OR their text is near-identical
(normalised title match, or cosine >= 0.97). The rules are complementary:
buckets are same-owner/different-text (NYT filing five distinct AfD pieces),
text matching is different-owner/same-text (a local station reprinting wire).

On real data it fires exactly where it should: the vote-by-mail cluster
collapses 2 buckets -> 1 origin, catching the States Newsroom syndication
automatically -- the case previously fixed by hand in config -- and a
Smithsonian story collapses 3 -> 2, catching WCPO reprinting wire, which no
static bucket can express since WCPO is genuinely independent on local stories.
The eight Renoir-heist reports, each independently written, correctly stay at 8.
Across 91 multi-item clusters: 174 buckets -> 163 true origins.

Note this is the one place a lexical comparison is right. Clustering fails on
lexical methods because paraphrases share no wording, which is why the SimHash
stage was measured and deleted -- but syndicated copy is literally the same
bytes.

First formulation was wrong and the data caught it: summing syndication groups
and solo buckets produced 3 origins for a 2-bucket cluster. Syndication may only
ever reduce corroboration, which the union-find guarantees and a test asserts.
2026-09-08 15:49:51 -04:00

156 lines
7.3 KiB
Python

"""Clustering behaviour, using synthetic vectors so no model download is needed."""
import numpy as np
import pytest
from news import cluster, store
from news.util import stable_id
@pytest.fixture
def con(tmp_path):
c = store.connect(tmp_path / "t.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)
store.upsert_source(
c, id="s2", name="B", url="http://b", kind="rss", bucket="b",
weight=1.0, prominence=0, enabled=1, poll_interval_secs=900)
return c
def add(con, item_id, source_id, when, chash):
con.execute(
"""INSERT INTO items (id,source_id,title,summary,url,canonical_url,
published_at,first_seen_at,content_hash)
VALUES (?,?,?,'',?,?,?,?,?)""",
(item_id, source_id, f"t-{item_id}", f"http://x/{item_id}",
f"http://x/{item_id}", when, when, chash))
return con.execute("SELECT * FROM items WHERE id=?", (item_id,)).fetchone()
def unit(*v):
a = np.array(v, dtype=np.float32)
return a / np.linalg.norm(a)
def test_similar_items_merge_and_distinct_ones_do_not(con):
near_a, near_b = unit(1, 0.02, 0), unit(1, 0.06, 0) # cosine ~0.999
far = unit(0, 1, 0) # orthogonal
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1"),
add(con, "i2", "s2", "2026-01-01T01:00:00+00:00", "h2"),
add(con, "i3", "s1", "2026-01-01T02:00:00+00:00", "h3")]
out = cluster.assign(con, rows, {"h1": near_a, "h2": near_b, "h3": far})
by_item = {a.item_id: a.cluster_id for a in out}
assert by_item["i1"] == by_item["i2"], "paraphrase-near items must merge"
assert by_item["i3"] != by_item["i1"], "unrelated item must not merge"
def test_items_outside_the_window_do_not_merge(con):
v = unit(1, 0, 0)
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1"),
add(con, "i2", "s2", "2026-01-05T00:00:00+00:00", "h2")] # 4 days later
out = cluster.assign(con, rows, {"h1": v, "h2": v})
assert out[0].cluster_id != out[1].cluster_id
def test_bucket_count_counts_newsrooms_not_feeds(con):
"""Two feeds from one newsroom are one independent confirmation, not two."""
v = unit(1, 0, 0)
store.upsert_source(
con, id="s3", name="A2", url="http://a2", kind="rss", bucket="a",
weight=1.0, prominence=0, enabled=1, poll_interval_secs=900)
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1"),
add(con, "i2", "s3", "2026-01-01T01:00:00+00:00", "h2")]
out = cluster.assign(con, rows, {"h1": v, "h2": v})
assert out[0].cluster_id == out[1].cluster_id
assert cluster.bucket_count(con, out[0].cluster_id) == 1
rows2 = [add(con, "i3", "s2", "2026-01-01T02:00:00+00:00", "h3")]
cluster.assign(con, rows2, {"h3": v})
assert cluster.bucket_count(con, out[0].cluster_id) == 2
def test_assignment_is_idempotent(con):
v = unit(1, 0, 0)
rows = [add(con, "i1", "s1", "2026-01-01T00:00:00+00:00", "h1")]
a = cluster.assign(con, rows, {"h1": v})
b = cluster.assign(con, rows, {"h1": v})
assert a[0].cluster_id == b[0].cluster_id
n = con.execute("SELECT COUNT(*) c FROM cluster_members").fetchone()["c"]
assert n == 1, "re-assigning an item must not duplicate membership"
def test_syndicated_partners_in_one_bucket_count_once(con):
"""Regression: Ohio Capital Journal and Kentucky Lantern are both States
Newsroom and carried 10 identical headlines in one poll. Configured as
separate buckets they would have doubled the corroboration signal."""
v = unit(1, 0, 0)
for sid, name in (("sn1", "Ohio Capital Journal"), ("sn2", "Kentucky Lantern")):
store.upsert_source(con, id=sid, name=name, url=f"http://{sid}", kind="rss",
bucket="states-newsroom", weight=1.0, prominence=0,
enabled=1, poll_interval_secs=900)
rows = [add(con, "s1i", "sn1", "2026-01-01T00:00:00+00:00", "hs1"),
add(con, "s2i", "sn2", "2026-01-01T00:30:00+00:00", "hs2")]
out = cluster.assign(con, rows, {"hs1": v, "hs2": v})
assert out[0].cluster_id == out[1].cluster_id
assert cluster.bucket_count(con, out[0].cluster_id) == 1, (
"syndicated partners must not read as independent confirmation")
def _cluster_of(con, items_spec, vec):
"""Put every item in one cluster and return its id."""
rows = [add(con, iid, sid, when, chash) for iid, sid, when, chash in items_spec]
out = cluster.assign(con, rows, {c: vec for *_, c in items_spec})
return out[0].cluster_id
def test_origin_count_collapses_syndicated_copy_across_newsrooms(con):
"""Different owners, identical text: a wire reprint is not corroboration.
This is the case no static bucket can express -- a local station is
independent on local stories and a pure reprinter on national ones.
"""
v = unit(1, 0, 0)
con.execute("INSERT OR REPLACE INTO embeddings (content_hash,dim,vec) VALUES (?,?,?)",
("hw", 3, store.to_blob(v)))
rows = [add(con, "w1", "s1", "2026-01-01T00:00:00+00:00", "hw"),
add(con, "w2", "s2", "2026-01-01T00:20:00+00:00", "hw")]
# identical headline text across two different buckets
con.execute("UPDATE items SET title='Wire dispatch: minister resigns' WHERE id IN ('w1','w2')")
rows = con.execute("SELECT * FROM items WHERE id IN ('w1','w2') ORDER BY published_at").fetchall()
cid = cluster.assign(con, list(rows), {"hw": v})[0].cluster_id
assert cluster.bucket_count(con, cid) == 2
assert cluster.origin_count(con, cid) == 1
def test_origin_count_keeps_independently_written_reports_separate(con):
"""Same event, different newsrooms, different wording: real corroboration."""
# cosine ~0.90: above the 0.82 clustering threshold so they land in one
# cluster, below the 0.97 syndication threshold so they stay distinct
# origins. The first draft of this test used ~0.999 vectors and correctly
# merged them -- the test data was wrong, not the rule.
a, b = unit(1, 0, 0), unit(0.90, 0.436, 0)
for h, v in (("ha", a), ("hb", b)):
con.execute("INSERT OR REPLACE INTO embeddings (content_hash,dim,vec) VALUES (?,?,?)",
(h, 3, store.to_blob(v)))
add(con, "r1", "s1", "2026-01-01T00:00:00+00:00", "ha")
add(con, "r2", "s2", "2026-01-01T00:30:00+00:00", "hb")
con.execute("UPDATE items SET title='Thieves steal two paintings' WHERE id='r1'")
con.execute("UPDATE items SET title='Renoir works taken in museum heist' WHERE id='r2'")
rows = con.execute("SELECT * FROM items WHERE id IN ('r1','r2') ORDER BY published_at").fetchall()
cid = cluster.assign(con, list(rows), {"ha": a, "hb": b})[0].cluster_id
assert cluster.origin_count(con, cid) == 2
def test_origin_count_never_exceeds_bucket_count(con):
"""Syndication detection may only ever reduce corroboration."""
v = unit(1, 0, 0)
con.execute("INSERT OR REPLACE INTO embeddings (content_hash,dim,vec) VALUES (?,?,?)",
("hx", 3, store.to_blob(v)))
specs = [("x1", "s1", "2026-01-01T00:00:00+00:00", "hx"),
("x2", "s2", "2026-01-01T00:10:00+00:00", "hx"),
("x3", "s1", "2026-01-01T00:20:00+00:00", "hx")]
cid = _cluster_of(con, specs, v)
assert cluster.origin_count(con, cid) <= cluster.bucket_count(con, cid)