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.
156 lines
7.3 KiB
Python
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)
|