search for standard sites pub-search.waow.tech
search zig blog atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

pub-search / scripts / classify-bulk-mirror
12 kB 338 lines
1#!/usr/bin/env -S uv run --script --quiet 2# /// script 3# requires-python = ">=3.12" 4# dependencies = ["httpx", "pydantic-settings"] 5# /// 6"""Offline bulk-mirror classifier (Phase 1 of docs/spam-detection-plan.md). 7 8Scores every publisher (DID) on whether it looks like a machine-generated 9registry/feed mirror vs human long-form writing, and validates the score 10against a hand-labeled known set. This is the *emission brain* for the planned 11pub-search labeler: it decides which DIDs should carry a `bulk-mirror` account 12label. It does NOT ban anything and does NOT write — read-only. 13 14WHAT THIS IS: the *decision function* — "given a DID's records, is this a 15bulk-mirror?" — plus an offline harness to prove it. A single record can't be 16judged ("S07E14: The Cadillac" is innocent alone); only the pattern across a 17DID's records reveals the mirror, so the decision needs a per-DID aggregate. 18 19This script feeds that aggregate from turso for two reasons: (1) validate the 20scoring against the known set offline, and (2) the one-time BACKFILL of ~40k 21docs indexed before the labeler exists. At steady state there is no batch job 22and no turso — the live labeler is a firehose listener that maintains the same 23rolling per-DID aggregate in its own store and runs this same decision function. 24 25WHY THESE SIGNALS (the data forced them — see the plan): 26- volume & burst ALONE are useless: the corpus's #1 author by doc count 27 (coryd.dev, 941) is a real human, and real blogs backfill hundreds of posts 28 in a day. so volume only *corroborates*, never decides. 29- absence of curation proves nothing (the recommend graph is too sparse — 30 coryd.dev has 0 recommends). so curation is a VETO only: if an account HAS 31 curation, never emit. 32- the real discriminator is authorship / content SHAPE: templated titles 33 ("Train #N Delayed", "I felt X"), empty/templated content, thin length. 34 35Usage: 36 scripts/classify-bulk-mirror # rank + validate (min 100 docs) 37 scripts/classify-bulk-mirror --min-docs 50 # widen the candidate floor 38 scripts/classify-bulk-mirror --samples # print sample titles per row 39""" 40 41from __future__ import annotations 42 43import argparse 44import asyncio 45import math 46import os 47import re 48import sys 49import time 50from dataclasses import dataclass 51 52import httpx 53from pydantic_settings import BaseSettings, SettingsConfigDict 54 55# ── known set (hand-labeled, 2026-06-29) ──────────────────────────────────── 56# the 3 historically-banned DIDs are purged from turso (no live rows to score), 57# so the live validation set is the 4 surfaced vs the 4 real humans. 58FLAG = { 59 "did:plc:jgg4dtdflzzemyvnybucnzdw": "chicagotransitalerts.app", 60 "did:plc:5swhfspkrynnbidlkrkch3lh": "prideraiser.org", 61 "did:plc:esvvys4mvoclui34shb23l5w": "eligundry.com", 62 "did:plc:gvudfu6dhl5cbpinhofa325s": "alekslessmann.de", 63} 64KEEP = { 65 "did:plc:sttgf52vkk46f6yuknvqxvgh": "coryd.dev", 66 "did:plc:77mn3ult3b72tpvtqqva6tat": "frankhecker.com", 67 "did:plc:rkjxbatkiros6f7pwtgsir54": "den.dev", 68 "did:plc:icpcpp5txyow3prnfgi533lj": "mikebifulco.com", 69} 70 71EMIT_THRESHOLD = 0.35 # score at/above which the labeler would emit bulk-mirror 72 73STOPWORDS = { 74 "the", "a", "an", "and", "or", "but", "of", "to", "in", "on", "for", "with", 75 "at", "by", "from", "is", "are", "was", "were", "be", "as", "it", "this", 76 "that", "i", "you", "we", "my", "your", "our", "me", "no", "not", "do", 77} 78 79CANARY_SLOW_SECS = 1.5 80 81 82def log(msg: str = "") -> None: 83 print(msg, flush=True) 84 85 86# ── turso plumbing (same shape as scripts/purge-banned-turso) ──────────────── 87class Settings(BaseSettings): 88 model_config = SettingsConfigDict( 89 env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 90 ) 91 turso_url: str 92 turso_token: str 93 94 @property 95 def turso_host(self) -> str: 96 url = self.turso_url 97 return url[len("libsql://"):] if url.startswith("libsql://") else url 98 99 100def _stmt(sql: str, args: list | None = None) -> dict: 101 s: dict = {"sql": sql} 102 if args: 103 s["args"] = [{"type": "text", "value": str(a)} for a in args] 104 return s 105 106 107async def _execute(client: httpx.AsyncClient, st: Settings, sql: str, args=None) -> dict: 108 r = await client.post( 109 f"https://{st.turso_host}/v2/pipeline", 110 headers={"Authorization": f"Bearer {st.turso_token}", "Content-Type": "application/json"}, 111 json={"requests": [{"type": "execute", "stmt": _stmt(sql, args)}, {"type": "close"}]}, 112 timeout=120, 113 ) 114 r.raise_for_status() 115 res = r.json()["results"][0]["response"] 116 if res.get("type") == "error": 117 raise RuntimeError(res["error"]) 118 return res["result"] 119 120 121async def rows(client, st, sql, args=None) -> list[list]: 122 res = await _execute(client, st, sql, args) 123 return [[c.get("value") for c in row] for row in res.get("rows", [])] 124 125 126async def canary(client, st) -> None: 127 t0 = time.monotonic() 128 await _execute(client, st, "SELECT 1") 129 dt = time.monotonic() - t0 130 if dt > CANARY_SLOW_SECS: 131 log(f"ABORT: turso canary {dt:.2f}s — prod shares this db; not piling on.") 132 sys.exit(2) 133 134 135# ── feature extraction ─────────────────────────────────────────────────────── 136def _norm(title: str) -> str: 137 t = title.lower() 138 t = re.sub(r"\d+", "#", t) 139 t = re.sub(r"[^\w\s#]", " ", t) 140 return re.sub(r"\s+", " ", t).strip() 141 142 143def _toks(title: str) -> set[str]: 144 return {w for w in _norm(title).split() if len(w) > 1 and w not in STOPWORDS} 145 146 147@dataclass 148class Pub: 149 did: str 150 docs: int 151 pubs: int 152 avg_len: float 153 titles: list[str] 154 recos: int 155 subs: int 156 157 @property 158 def empty_title_frac(self) -> float: 159 return sum(1 for t in self.titles if not t.strip()) / max(1, len(self.titles)) 160 161 @property 162 def digit_title_frac(self) -> float: 163 return sum(1 for t in self.titles if re.search(r"\d", t)) / max(1, len(self.titles)) 164 165 @property 166 def distinct_norm_ratio(self) -> float: 167 return len({_norm(t) for t in self.titles}) / max(1, len(self.titles)) 168 169 @property 170 def scaffold_coverage(self) -> float: 171 """How much of each title is shared scaffolding. Tokens appearing in 172 >=50% of titles form the scaffold; coverage = mean fraction of a 173 title's content tokens that are scaffold. High = templated.""" 174 toksets = [_toks(t) for t in self.titles] 175 n = len(toksets) 176 if n == 0: 177 return 0.0 178 freq: dict[str, int] = {} 179 for ts in toksets: 180 for w in ts: 181 freq[w] = freq.get(w, 0) + 1 182 scaffold = {w for w, c in freq.items() if c >= 0.5 * n} 183 cov = [] 184 for ts in toksets: 185 if not ts: 186 cov.append(1.0) # empty/punctuation-only title is maximally templated 187 else: 188 cov.append(len(ts & scaffold) / len(ts)) 189 return sum(cov) / n 190 191 @property 192 def template(self) -> float: 193 return max(1.0 - self.distinct_norm_ratio, self.scaffold_coverage) 194 195 @property 196 def thinness(self) -> float: 197 return min(max(1.0 - self.avg_len / 800.0, 0.0), 1.0) 198 199 @property 200 def structural(self) -> float: 201 return max(self.digit_title_frac, self.empty_title_frac) 202 203 @property 204 def volume(self) -> float: 205 # 100 docs → 0, 10k docs → 1 (log-scaled corroboration only) 206 return min(max((math.log10(max(self.docs, 1)) - 2.0) / 2.0, 0.0), 1.0) 207 208 @property 209 def vetoed(self) -> bool: 210 return self.recos > 0 or self.subs > 0 211 212 @property 213 def score(self) -> float: 214 raw = ( 215 0.45 * self.template 216 + 0.20 * self.structural 217 + 0.20 * self.thinness 218 + 0.15 * self.volume 219 ) 220 return 0.0 if self.vetoed else raw 221 222 223async def build_pubs(client, st, min_docs: int) -> list[Pub]: 224 # Q1: per-DID aggregates over the whole corpus (one GROUP BY scan). 225 agg = await rows(client, st, f""" 226 SELECT did, COUNT(*) docs, COUNT(DISTINCT publication_uri) pubs, 227 AVG(LENGTH(content)) avg_len 228 FROM documents 229 WHERE is_bridgyfed = 0 230 GROUP BY did 231 HAVING docs >= {int(min_docs)} 232 ORDER BY docs DESC 233 """) 234 cand = {r[0]: (int(r[1]), int(r[2]), float(r[3] or 0)) for r in agg} 235 if not cand: 236 return [] 237 238 await canary(client, st) 239 240 # Q2: titles for candidates only (bounded by candidate count). 241 dids = list(cand) 242 ph = ",".join("?" for _ in dids) 243 title_rows = await rows( 244 client, st, 245 f"SELECT did, title FROM documents WHERE is_bridgyfed = 0 AND did IN ({ph})", 246 dids, 247 ) 248 titles: dict[str, list[str]] = {d: [] for d in dids} 249 for did, title in title_rows: 250 titles[did].append(title or "") 251 252 await canary(client, st) 253 254 # Q3: curation signal (the veto) per candidate. 255 recos = { 256 r[0]: int(r[1]) for r in await rows(client, st, f""" 257 SELECT d.did, COUNT(*) FROM recommends r 258 JOIN documents d ON r.document_uri = d.uri 259 WHERE d.did IN ({ph}) GROUP BY d.did 260 """, dids) 261 } 262 subs = { 263 r[0]: int(r[1]) for r in await rows(client, st, f""" 264 SELECT p.did, COUNT(*) FROM subscriptions s 265 JOIN publications p ON s.publication_uri = p.uri 266 WHERE p.did IN ({ph}) GROUP BY p.did 267 """, dids) 268 } 269 270 out = [] 271 for did, (docs, pubs, avg_len) in cand.items(): 272 out.append(Pub(did, docs, pubs, avg_len, titles[did], recos.get(did, 0), subs.get(did, 0))) 273 out.sort(key=lambda p: p.score, reverse=True) 274 return out 275 276 277def validate(pubs: list[Pub]) -> int: 278 by_did = {p.did: p for p in pubs} 279 log("\nvalidation against the known set (FLAG should be ≥ %.2f, KEEP below):" % EMIT_THRESHOLD) 280 ok = True 281 worst_flag, best_keep = 1.0, 0.0 282 for did, name in FLAG.items(): 283 p = by_did.get(did) 284 if not p: 285 log(f" ?? FLAG {name:<26} no live rows (purged or below floor)") 286 continue 287 worst_flag = min(worst_flag, p.score) 288 hit = p.score >= EMIT_THRESHOLD 289 ok &= hit 290 log(f" {'' if hit else ''} FLAG {name:<26} score={p.score:.3f}") 291 for did, name in KEEP.items(): 292 p = by_did.get(did) 293 if not p: 294 log(f" ?? KEEP {name:<26} no live rows") 295 continue 296 best_keep = max(best_keep, p.score) 297 hit = p.score < EMIT_THRESHOLD 298 ok &= hit 299 log(f" {'' if hit else ''} KEEP {name:<26} score={p.score:.3f}") 300 margin = worst_flag - best_keep 301 log(f"\nseparation margin (worst FLAG − best KEEP): {margin:+.3f}") 302 log("RESULT: " + ("PASS — clean separation" if ok else "FAIL — overlap, retune weights")) 303 return 0 if ok else 1 304 305 306async def main() -> int: 307 ap = argparse.ArgumentParser(description="offline bulk-mirror classifier") 308 ap.add_argument("--min-docs", type=int, default=100, help="candidate doc floor") 309 ap.add_argument("--top", type=int, default=25, help="rows to print") 310 ap.add_argument("--samples", action="store_true", help="print sample titles") 311 args = ap.parse_args() 312 313 st = Settings() # type: ignore 314 async with httpx.AsyncClient() as client: 315 pubs = await build_pubs(client, st, args.min_docs) 316 317 if not pubs: 318 log(f"no publishers with ≥{args.min_docs} docs") 319 return 0 320 321 known = {**FLAG, **KEEP} 322 log(f"{'score':>6} {'emit':>4} {'veto':>4} {'tmpl':>5} {'struc':>5} {'thin':>5} " 323 f"{'vol':>4} {'docs':>6} publisher") 324 for p in pubs[:args.top]: 325 tag = known.get(p.did, "") 326 mark = "·" + tag if tag else p.did[:18] 327 log(f"{p.score:6.3f} {'YES' if p.score >= EMIT_THRESHOLD and not p.vetoed else '-':>4} " 328 f"{'V' if p.vetoed else '-':>4} {p.template:5.2f} {p.structural:5.2f} " 329 f"{p.thinness:5.2f} {p.volume:4.2f} {p.docs:6d} {mark}") 330 if args.samples: 331 for t in p.titles[:4]: 332 log(f" · {t[:80]}") 333 334 return validate(pubs) 335 336 337if __name__ == "__main__": 338 raise SystemExit(asyncio.run(main()))