RingFacts · Pipeline Walkthrough

Raw headlines in. Verified claims out.

Every gate the hourly hunt runs an article through, in order, illustrated with real items pulled from the live archive — not invented examples. Most examples below follow one tracked subject, Ilia Topuria (Subject C, the shorthand label used throughout docs/architecture-overview.html) — named in full here, as everywhere else in the repo: the watchlist is committed, so there is nothing for a label to hide. Everyone else named below is real.

This page covers the funnel only — discovery through claims. For the whole system (the Telegram service, operations, cost, the autonomous improvement loop) see docs/architecture-overview.html in the repo; for how it's tested, see docs/test-suite-overview.html.

Each stage below opens with a grey In code strip naming the files that implement it. One thing to hold onto while reading them: §1 through §7 all happen inside a single function, huntSubject() in hunter.js, which runs once per tracked subject per hour. The gates are not separate services or separate passes — they are consecutive if statements in one loop over at most five articles. Line numbers drift with edits; the file names don't.

Two discovery rails, every hour (§1) Google News (en·uk·es) + 6 direct outlet feeds Gate 1 — drop anything with a known URL, silently (§2) 1,248 URL checks → 90 new, 1,158 already known + 5 more via one untracked manual run — not counted here 95 items reach the remaining gates 40 held — near-duplicate (§3) cosine ≥ 0.80 — inherits the neighbour's claim, free 55 reach the matcher body fetched (§4) · Haiku reads it (§5) 9 dropped — wrong subject (§5) 3 held — same fact (MATCH) (§5) 41 posted NO_CLAIM · UNSURE · rumor · ceremony +2 held back: 1 suppressed, 1 legacy (§6) 10 claims · 46 corroborating links (§7)
Fig. 1 — The whole shape, discovery to claims, refreshed to this snapshot. Gate 1's numbers come from Cloud Logging, not the database — dropped items are never written to a row, so this is the one gate the archive itself can't measure. Even Cloud Logging misses one slice: 5 of the 95 items below arrived through a single manual run that predates its view entirely, which is why "1,248 checked → 90 new" and "95 items reach the remaining gates" aren't quite the same number. One further row pre-dates the held_reason/digest_tier columns and isn't broken out below either — a rounding error in an otherwise exact set of counts.

§1Discovery — two rails, one window

In code hunter.js:669–689 fetches the six outlet feeds once per run· hunter.js:109 fetchFreshItems() adds the Google queries per subject· lib/feeds.js parse + name filter· domain/mma.js the outlet list· lib/subjects.js + watchlist.js who is tracked

Every hour, for every tracked subject: Google News RSS, queried once per language edition the subject gets covered in (English always; Ukrainian and Spanish where it applies), plus six direct publisher feeds — UFC.com, MMA Fighting, Bloody Elbow, Sherdog, Sport.ua, Marca — fetched once per run and filtered per subject by matching surname stems in both scripts. Both rails only look at the last 24 hours.

One real run: 6 outlets fetched, 180 items pooled across all three subjects — of which 5 were unseen for Topuria. — docs/checkin-log.md, 2026-08-09

The 24-hour window overlapping the hourly cadence ~24× is deliberate: a single failed run loses nothing, because the next run re-covers the same ground.

§2Gate 1 — Have we already seen this URL?

In code hunter.js:281–287 — four lines· lib/db.js:19 knownUrls() the one query· the 5-per-run cap is hunter.js:45

Exact match on url or its decoded resolved_url against everything ever stored → dropped silently, no database write at all. This is the only gate that costs nothing — no embedding, no fetch, no LLM call.

The database has no record of this gate — dropped items are never written. Cloud Logging does: across every scheduled run since 2026-08-07, 1,248 URL checks → 90 new (93% already known, dropped — 1,158 of 1,248). See Fig. 1.

Survivors are capped at 5 unseen items per subject per run, newest first, applied after this gate — so on a busy day the newest 5 unseen items go through now, and whatever's left over is simply picked up next run. Nothing is lost, only delayed.

§3Gate 2 — Same story, different link?

In code hunter.js:305–315 embeds the batch· :330–352 compares and holds· :237 holdAsDup() writes the row and inherits the neighbour's claim· lib/embeddings.js the Gemini call· lib/db.js:47 nearestRecent() the pgvector query· the 0.80 threshold is hunter.js:49

The survivor's headline is embedded and compared against the subject's recent items. Cosine similarity ≥ 0.80 → held back, never posted — but not discarded: it inherits whatever claim its nearest neighbour already supports, recorded as corroboration for free, no LLM spent. The threshold is measured, not guessed: a real translated pair of the same story scored 0.841; unrelated same-subject pairs never rose above 0.702.

40 of 95 items were held here — the single biggest gate in the funnel.

"Manager believes Usman Nurmagomedov walks through UFC lightweight contenders, with one exception"

Sherdogheld · sim 1.00

"Topuria's manager claims he'd beat every Ali Abdelaziz fighter: 'If we fought Gaethje again…'"

Bloody Elbowheld · sim 0.99

Official sources (UFC.com) are exempt from this gate specifically — a confirmation headline is, by construction, near-identical to the rumor it confirms, so holding it here would swallow the exact transition the claims layer exists to catch. And inheritance itself is checked: if an item actually reads closer to some other claim than the one it's about to be linked to, it stays held but unlinked rather than becoming a wrong piece of evidence. Both mechanisms are detailed in docs/architecture-overview.html §4.

§4The body step — reading past the headline

In code hunter.js:356–391 drives it· lib/googlenews.js unwraps Google's URL· lib/extract.js fetch, strip HTML, the four-rung ladder, the 10,000-char stored cap (:20)

Only for items that survived both free gates. Google's wrapped URL is decoded to the real article address, then the body is extracted through a ladder — cheapest and most reliable first — and every attempt is logged, success or failure:

RungSourceTimes recorded
0 — feed contentthe outlet's own RSS already contains the article4
1 — JSON-LDa machine-readable copy embedded for search engines3
2 — <article> tagthe page's own article paragraphs2
3 — all paragraphsevery <p> on the page, cruder2
4 — og:descriptiona one-sentence meta summary, last resort0
— failed (403 / no extract)every attempt logged even on failure16

Those 27 do not add up to the 55 items that reach this step, and the gap is honest rather than missing data: the body_via column that records which rung won only shipped partway through the archive's life. Items processed before it exist with a body and no rung — 49 of them got their text later from a one-off backfill script, which is also why the mention counts in §6 could be recomputed for articles nobody re-fetched. Telemetry starts when you add it, not when the behaviour starts.

The fix this walkthrough exists to explain (2026-08-10) Sports Illustrated's page renders its own lede paragraphs twice inside <article> — measured live: 24 paragraphs, only 17 unique. Rung 2 joined both copies without noticing, so a single passing mention of Topuria got counted twice. That doubled count is what breaks §6 below.
Before the fix
body:
5,799 chars
Topuria mentions:
2
rung:
article-tag
After the fix
body:
4,286 chars
Topuria mentions:
1
rung:
article-tag

Fixed by dropping a paragraph identical to one already kept, before anything downstream counts mentions — both at rung 2 and at rung 0, where a publisher's own feed ships the whole article and would otherwise be flattened to one line before any duplicate could be spotted.

§5Gate 3 — What is this article actually about?

In code lib/matcher.js is the whole model call — the forced tool schema (:15), buildPrompt() (:72), and normalizeVerdict() (:124), which is what makes a verdict safe to branch on· hunter.js:392–457 branches on it: WRONG_SUBJECT at :416, MATCH at :424, the official re-check at :445· domain/mma.js supplies the claim types and the prompt's domain words

One Haiku call per survivor: the headline, source, a body excerpt when there is one, and every currently active claim for the subject. Forced tool use means the answer is always one of five verdicts — never free text to parse. The same call answers a second, independent question in the same breath: how prominent is the subject in this articlecentral, supporting, or passing. The verdict says what the article asserts; the role says who it is about. §6 is where the role earns its keep.

Verdict — all fiveMeaningWhat happens to the article
NEWa claim-worthy fact not on fileopens a claim · never folded by §6
MATCHa fact already on file, sighted againlinked as corroboration · not posted
WRONG_SUBJECTnot about this subject at alldropped · never posted
NO_CLAIMtheir world, but asserts nothing recordablegoes to §6, which decides how it posts
UNSUREcan't tell — abstains rather than guessgoes to §6, which decides how it posts
subject_role — all threeMeaningEffect on the §6 decision
centralthe article is about themnone — falls through to the count rule
supportingthey act in it: quoted, involvednone — falls through to the count rule
passingnamed as background colour in someone else's storyfolds into "Also mentioning"

Two things the tables are easy to misread. "Goes to §6" is not "posts with its headline intact" — §6 decides that, and may drop the headline entirely. And central is not a promotion: only passing changes any outcome. The role is nonetheless recorded on every article the matcher sees, including the ones it drops and holds, so its agreement with the count rule can be measured on real data later.

The same real thread — Ali Abdelaziz predicting Usman Nurmagomedov beats Topuria — supplies most of the examples below: it produced a NEW, two MATCHes, and the corroboration stack §7 ends on, across five days and four outlets in two languages.

NEW — a claim-worthy fact not seen before

"'Head Kick KO': Ali Abdelaziz Predicts Usman Nurmagomedov Finishes Topuria and Other UFC Elites"

MMA Sucka · Aug 6NEW → claim #13

MATCH — the same fact, sighted again

"Abdelaziz Calls Out Topuria For Nurmagomedov's UFC Debut"

boxingnews.com · Aug 9MATCH #13 → echo

"Daniel Cormier rejects Ali Abdelaziz's 'absolutely crazy' take about Usman Nurmagomedov"

Bloody Elbow · Aug 10MATCH #13 → echo

Both are linked as evidence for claim #13 and neither is posted — the group never sees a repeat of a fact it already has. Worth noticing what is not in this pair: two further articles in the same thread never reached the matcher at all, because Gate 2 recognised them as near-duplicates one step earlier and they inherited the claim for free (§3). Identical corroboration, one gate sooner, one Haiku call cheaper — the funnel doing exactly what it is shaped to do. §7 shows the finished stack, with each source labelled by the gate that caught it.

WRONG_SUBJECT — not about this subject at all

"Salkilld sigue imparable: Somete a Gamrot y se dispara en la división de Topuria" (the Spanish twin of the §4 story — same fight, Topuria named only as the division marker)

Eurosport (es)dropped

"Morning Report | Dakota Ditcheva broke both hands at PFL New York"

MMA Fightingdropped

Never posted, never a claim. The Eurosport item is the instructive one: the matcher read this correctly on the first pass — the exact case §6 is about is the English version of the same underlying Salkilld story slipping past on a technicality one gate later.

NO_CLAIM & UNSURE — posts, but no fact recorded

"Matchmaking the lightweight top 10 while Justin Gaethje sits out until 2027"

Bloody ElbowNO_CLAIM · posted

"Ilia Topuria Taken to Task over Meeting with Ferran Torres"

MMA SuckaUNSURE · posted

NO_CLAIM: about the subject's world, but asserts nothing claim-worthy — division context, reactions, lifestyle. UNSURE: the matcher genuinely can't tell, and abstains rather than guess. The Ferran Torres item is the archive's only UNSURE so far, and it earns the label — a viral nightclub clip is lifestyle noise the system ignores by design, except that Topuria's own manager posted a mocking video reply, which is a quote of a sort. The matcher declined to decide whether that counted. Both verdicts post through the normal digest exactly as if no claims layer existed at all — which is where §6 picks up, with the role judgment riding along.

§6Digest tier — does this earn its own headline?

In code lib/tier.js the rule itself — digestTierFor() at :95 is the three lines that decide· hunter.js:472 the single call, and the isRealClaim exemption in front of it· hunter.js:175 alsoMentioningLine() renders the demoted ones· measured by audit-digest-tier.js and corpus/measure-tier.js

An article that opened a claim keeps its own headline, full stop — whatever fed a claim has earned a line. Everything else that posts — NO_CLAIM, UNSURE, and the NEW verdicts whose claim type the digest ignores — goes down a three-rung ladder. The rungs run in order and the first one that answers wins; nothing below it is consulted.

There are only two answers. The article keeps a bullet of its own, or it is folded — the code's word is tangential — meaning it is merged with every other demoted article from that run into one shared line naming nothing but their outlets. Folding is a demotion, not a rejection: the article still posts, and the reader can still reach it. It just stops taking up a headline.

In orderThe questionIf it answers
1Does the headline name the subject? keeps its own headline — stop
2Did §5 report the role as passing? folded — stop
3Is the body long enough to judge (≥300 characters) and does it name the subject at most once? yes → folded · no → keeps its own headline

Reading that ladder answers the question people ask first: the two rules are not weighed against each other. A central or supporting role does not answer rung 2, so the article simply drops to rung 3 and the counting rule decides it exactly as it did before the role existed. The role can only ever add a demotion, never cancel one. That asymmetry is deliberate: the count threshold was read off real archived articles, while the role is one model's opinion and has not been measured yet, so it is allowed to accuse but not to acquit. When the matcher is off or its answer is missing, the role is simply absent, rung 2 stays silent, and the ladder is byte-for-byte the rule that shipped a day earlier.

Both outcomes post. The difference is how much of the article the group is shown. Here is the real Kutateladze item from rung 2, rendered each way inside the same digest message:

Keeps its own headline
🔎 Ilia Topuria
⋮ the run's other bullets
• Khamzat Chimaev's teammate returns as clash of ex-UFC stars headlines brand new MMA org's first card — Bloody Elbow, 0h ago
Folded
🔎 Ilia Topuria
⋮ the run's other bullets
↘ Also mentioning: Bloody Elbow

A bullet is the whole article as the reader meets it: the publisher's headline verbatim — translated into English first if it isn't — the outlet as a link, and how long ago it ran. Folding keeps only the link. The headline is thrown away, the timestamp with it, and what remains is the outlet's name on one shared line at the bottom of the message, which every folded article that run shares. Two folded articles become Bloody Elbow · MMA Junkie on that same line. So the reader can still get to the story in one tap; what they no longer get is a headline competing for attention with news that is actually about the subject.

One outlet with two folded stories in the same run gets both links, numbered newest-first: Bloody Elbow (1) · Bloody Elbow (2). Until 2026-08-10 the line showed only the newest, which read cleanly and quietly cost something — the message is the only place a folded article is ever offered, and Gate 1 guarantees no later run offers it again, so the older story became unreachable. A bare repeated outlet name reads as a bug; the index is what earns the second link its place. The numbers appear only where an outlet actually has more than one, since a lone Sherdog (1) would imply a sibling that isn't there. Nothing was ever lost from the record either way: every folded article had its own archived row, with its own URL, before the message was built — and a folded article can never be a claim source, because anything that opened a claim skips the ladder entirely.

There is a third outcome the ladder doesn't decide. If everything a run found for a subject folded, there is no bullet for that shared line to sit under, and a message of nothing but a header and a row of outlet names is precisely the noise this stage exists to remove — so nothing is sent for that subject at all.

One clarification the two cards above invite. The ladder's answer is stored as digest_tier, and it has exactly two values: tangential, which always means folded, and main, which means only not folded — not "shows its headline." Anything that opened a claim is set to main without consulting the ladder, and may then leave by a route that discards the headline anyway, because once the system has extracted a fact it trusts its own sentence more than the publisher's wording. Five shapes reach the group in all:

ShapeWhere it appearsWhat the reader sees
🚨 Ceremonyits own standalone message the claim sentence — headline not shown
🕵️ Rumora line in the digest the claim sentence — headline not shown
• Bulleta line in the digest publisher's headline, outlet, hours ago
↘ Also mentioningone shared line, last in the digest outlet names only, deduped
✅ Confirmeda threaded reply to the original rumor the claim sentence and the official source

And the ways an article appears nowhere: MATCH held as evidence (§5), WRONG_SUBJECT dropped (§5), a Gate 2 near-duplicate (§3), and the all-folded run above — which also writes the suppression back to the row it had already marked as posted, so a later measurement doesn't count as broadcast something the group never saw.

The Topuria / Salkilld story, end to end The Sports Illustrated item from §4 is a NO_CLAIM: about Salkilld, not Topuria, who appears in exactly one sentence. It should have folded into "also mentioning." Because that sentence was doubled by the extraction bug, the mention count read 2 instead of 1 — one over the threshold — and it kept a full headline in Topuria's digest it hadn't earned. The tier rule was never wrong; the body handed to it was.
Before
mentions:
2
result:
main — own headline
After
mentions:
1
result:
tangential — folded in

Both of this stage's real cases were decided at rung 3, which is the only rung that existed until yesterday. The tier is new enough (2026-08-09) that it has fired just 6 times in the live archive: 5 kept their headline (including the Salkilld item above, pre-fix) and one folded — a Mark Zuckerberg viral-video story naming Topuria once in passing, which was the whole of that run's Topuria news and so triggered the third outcome above: nothing was sent for him at all. Rung 2 is newer still (2026-08-10), and it exists because of the one case rung 3 could never have caught: an article about Guram Kutateladze that named Topuria twice — once as a teammate, once as a man in someone else's corner, pure background color both times — and so cleared the ≤1 threshold honestly. That was the first item the tier got wrong on its own terms rather than on a body handed to it broken.

§7Claims — the distilled output

In code hunter.js:477–500 births the claim and routes it (ceremony, rumor, or digest)· hunter.js:508–651 assembles and sends every message· lib/db.js insertClaim, linkClaimSource, confirmClaim, setClaimMessageId· lib/telegram.js the send

Everything the funnel does exists to feed this table. A claim is one row per real-world fact: a canonical English sentence, a type, a status that starts as rumor and moves to confirmed only when an official source asserts it. Every corroborating sighting above — however it was caught, by the matcher, by Gate 2's inheritance, or by a retroactive backfill — is recorded against exactly one such row.

Two of those statuses are all the system has ever written. The schema (schema.sql, the claims.status column) reserves five, and it is worth being plain about which three are vocabulary rather than behaviour:

status — all fiveStateWhat it would mean
rumorlivethe starting state of every claim · stays in the matcher's candidate list
confirmedlivean official source asserted it · stays in the candidate list
deniedreserved — never writtena source of record contradicted the claim
stalereserved — never writtenthe claim aged out without ever resolving
supersededreserved — never writtena later claim replaced it (the supersedes column exists for this)

No code path writes the last three: the matcher writes only rumor, and the one promotion that exists moves rumorconfirmed. Their only live consequence is already wired, though — the query that assembles a subject's active claims selects status IN ('rumor','confirmed'), so writing any of the other three would quietly drop that claim out of the candidate list the matcher is shown, and the fact would stop being matchable. That is the retirement mechanism, built but never fired. Nothing yet decides when a claim has been denied, gone stale, or been replaced — that judgment is the unbuilt part.

CLAIM #13 · prediction · Topuria
Ali Abdelaziz predicts Usman Nurmagomedov will finish Ilia Topuria.

Read the right-hand column and the whole funnel is visible in one stack. One article opened the claim. Two were caught by Gate 2 as near-duplicates and inherited it for free, no LLM spent. Two reached Gate 3 and the matcher recognised the fact it already had. One — the Spanish article — predates the claims layer and was linked when the archive was replayed through the matcher after the fact. Six sightings, one row.

6 sources · 4 outlets · 2 languages · 5 days · still rumor — no official source has weighed in on a prediction, and structurally none likely will.

Across the whole archive: 10 claims, 46 corroborating links, zero confirmed yet. The rumor → confirmed transition is real and wired — one threaded "✅ Confirmed" reply the moment UFC.com covers a pending announcement — it just hasn't fired live. That state machine, the ceremony posts, and everything downstream of a claim (§8 onward) is the other document's territory: docs/architecture-overview.html §6–§8.