Status, 6 October 2026. This page describes production, the bot that has posted hourly since August. Its successor, v0, runs beside it with its own database and its own test chat, and is the design going forward. Production retires once v0 fetches its own articles.

RingFacts · Pipeline Walkthrough

Raw headlines in. One message out.

Every gate an article passes through, roughly in the order the code runs them, with real items from the live archive. This page describes main as it is today — where it and the code disagree, the code is right.

All of this happens inside one function, huntSubject() in hunter.js, which runs once per tracked fighter per hour. The gates are not separate services. Fetching, the address check, reading and embedding are done for the whole batch first; then one loop over at most five unseen articles decides and records each one (§4–§8, §10); the message is sent last (§9). For the system around the funnel, see the architecture overview.

The shape

Of 1,114 articles ever fetched, 167 reached the group. Everything below is an account of what happened to the other 947.

285 too similar to something already sent  ·  209 a story already told  ·  189 not about this fighter  ·  169 the same fact, sighted again  ·  167 posted  ·  56 he was only background  ·  37 the same page twice  ·  1 a spam domain

Counted on 11 September 2026. The eight buckets add up to 1,113, one short of the total, and the extraction rungs in §3 to 898 of 904; a recount would settle both.

§1Where articles come from

In code hunter.js fetchFreshItems· lib/feeds.js publisher feeds· domain/mma.js the outlet list

Two independent sources, so one going down does not blind the bot.

Google News, searched once per name alias. Each fighter is searched in every language his press is written in — English, Ukrainian, Spanish — because a Ukrainian fighter's biggest coverage is often in Ukrainian and an English-only search never sees it.

Six publisher feeds — UFC, MMA Fighting, Bloody Elbow, Sherdog, Sport.ua, Marca — fetched once per run for everybody, then filtered by name. These matter for two reasons: they give real, fetchable article addresses rather than Google's wrappers, and they keep working when Google starts refusing our datacenter's requests.

Only articles published in the last 24 hours count, and at most five per fighter per run. Each feed logs how many items it matched and how many it threw away — a name filter that has quietly stopped matching looks exactly like a quiet news week, and these counts are what tell them apart.

§2Have we seen this address?

In code hunter.js collectCandidates· lib/db.js knownUrls

The cheapest question first, asked in one database round trip for the whole batch.

The feed's address is checked against both addresses stored for every earlier article, and a known one is dropped before anything expensive happens. The real address behind a Google wrapper is known only after decoding, in §3; it is checked then, one article at a time, and a match is recorded as a held duplicate, so the same article arriving from both rails is caught once.

§3Read the article

In code lib/googlenews.js unwrapping· lib/extract.js the text ladder

This happens before any decision about duplicates, because the thing that makes the decision needs to read the article.

Google News hands out redirect links — news.google.com/rss/articles/CBMi… — that work in a browser and resist a script. The token is where the real address hides, and most of them can be decoded locally with no network call at all. Failure is fine: the item continues with just its headline.

Once the real address is known, the page is fetched and the text pulled out by trying five things in order, best first. There is no readability library — the consumer is a 1,200-character excerpt, not a reader view, so the opening paragraphs are all that matter and news sites put them in predictable places.

423 json-ld  ·  215 article tag  ·  140 paragraphs  ·  74 the feed's own content  ·  46 a meta description only  ·  904 of 1,114 articles have readable text

Which rung produced the text is recorded on every row. Without it an outlet can "succeed" with a useless one-line blurb and look healthy in the totals.

§4Shortlist three stories

In code lib/embeddings.js· lib/db.js storyShortlist

Vectors no longer decide anything. They pick the candidates.

The headline and the first 1,500 characters of body go to Gemini, which returns a list of numbers representing the meaning. Articles about the same event land near each other even across languages. That closeness is used to pull up the three nearest stories from the last seven days — the menu the decider gets to choose from.

If the embedding service is down, the shortlist falls back to the most recently opened stories instead. The decider still gets a menu; an outage cannot trick it into calling everything new.

§5The decision

In code lib/matcher.js prompt, form, and validation· hunter.js decideStory

One Claude Haiku call. It sees the article and three one-line story summaries, and must fill in a form — its reasoning first, then one of four answers.

join — this is a story we have already told

Held. The group has seen this news; the article is recorded as another sighting of it.

Belal Muhammad: Ilia Topuria quit vs. Justin Gaethje, doesn't deserve rematch

Yahoo Sports held · join

Dana White confirmó que Ilia Topuria está listo para pelear y analiza dos cruces

Agencia NOVA held · join

The second one is in Spanish and joined an English story. That is the whole point of comparing meaning rather than words — and the reason the decision is worth paying a model for.

The biggest story in the archive holds 50 articles and the group saw one of them. “Dana White confirms Ilia Topuria is ready to fight again with the UFC considering two options” — forty-nine repeats, in three languages, held.

new — news none of the listed stories covers

Posts, and opens a story of its own. The model writes one plain sentence saying what the news is; that sentence becomes the story's description and the first line the next article is compared against.

reaction — someone answering a story we carry

Its own news, recorded as pointing at the story it answers. This is the distinction a similarity score cannot make: the reply and the thing replied to are about the same subject, in the same words, on the same day, and they are two different pieces of news.

Ilia Topuria vs. Paddy Pimblett has never made more sense

Yahoo Sports new story · answers

→ “Paddy Pimblett said he would love to fight Ilia Topuria in December”

wrong_subject — not about this fighter at all

Recorded, never posted, never linked to anything. Almost always an article about somebody else that happens to name him.

First man to beat Justin Gaethje believes he benefits from an Arman Tsarukyan win

Bloody Elbow wrong subject

Alex Pereira says he'll 'mess Paulo Costa's life up' in 205 return

Bloody Elbow wrong subject

The rule is narrower than it looks. An article mostly about another fighter that genuinely involves this one — as a rival, a comparison, context — is not the wrong subject. It is news with no claim, and readers still see it. Wrong subject means dropped.

The answer is never trusted as written. Every field is checked against the values it is allowed to take. An off-menu claim type is coerced rather than stored. A story id the model was never shown is discarded and the article opens its own story instead — unchecked, it throws a foreign-key error that kills the rest of that fighter's hour. A claim that does not name the fighter is dropped.

§6When the decider cannot answer

In code hunter.js checkDuplicateGate

The old similarity rule stands in: closer than 0.85 to something already posted, and the article is held.

This is the fallback, not the gate. It fires when the model errors or answers that it cannot tell. It is more cautious than the thing it replaces — it can hold an article the decider would have posted, and with no verdict there is also no claim. An unsure answer still reports how prominent the fighter is, so the §8 rules still apply; when the call fails outright, the counting rule alone decides and can fold the article away entirely. A decider outage costs coverage quietly rather than spilling repeats into the group.

285 articles in the archive were held this way. Most of them predate the decider, when this rule was the gate rather than the net under it.

§7Is the site itself spam?

In codelib/untrusted.js

Three conditions, all required: at least five articles seen from the domain, at least half of them already judged not about the fighter, and never once a readable body.

Some sites stuff fighter names into unrelated content to get indexed. All three tests are needed because any one alone catches innocents: a bad ratio alone would silence real outlets whose name-filtered feeds run 35–45% off-target, and a blocked fetcher alone would silence real outlets that refuse cloud servers. The check runs after the decider, so the domain's record keeps growing from its verdicts.

§8Is he actually in this story?

In code lib/tier.js· hunter.js postOutcome

Real news about somebody else, in which our fighter is background colour, does not earn a headline.

Two rules, joined by OR. The decider's judgement: it read the article and called him passing — background colour, a comparison, an opponent's cornerman. A counting rule measured against the archive: the headline does not name him, the body is long enough to judge, and it names him once or not at all.

Neither rule can rescue what the other rejects. One was measured; the other is a model's opinion. Giving either a veto over the other made the results worse.

Separately, the decider answers would a follower learn anything from this. A "no" folds the article away even when it technically contains a quote — a quote nobody learns from is not news. A booking, result, injury or negotiation is never folded this way: an event is news whatever a model thinks of the article describing it.

Gaethje On Possible Retirement After UFC Title Win

boxingnews.com passing · no

Throwback: Ilia Topuria Stressed On The Importance Of Carbs

MMA Sucka central · no

The second is the interesting one: the article really is about him — the model said central — and it was still folded, because a follower learns nothing from it. Being about someone is not the same as being news about them.

Folded articles are queued, not deleted. 56 of them are recorded waiting for a daily digest job. That job exists and nothing schedules it, so in practice these are recorded and never shown.

§9What gets sent

In code hunter.js assembleMessages → deliver

Three kinds of message, and only one of them ever gets a post to itself.

A ceremony — a confirmed fight announcement, standalone.

The digest — one message per fighter per hour carrying everything else: rumours about big things as marked lines at the top, then bullets with headline, source link, and how long ago it was published. Headlines in any language other than English or Ukrainian are translated (in practice mostly Spanish); those two are left alone because the group reads both. For articles from English or Ukrainian feeds the headline itself is checked offline, because Google's English edition carries Spanish articles too.

A confirmation — a reply threaded under the message where the rumour first appeared, so the update lands beneath the thing it updates instead of scrolling away from it.

🔎 Ilia Topuria

🕵️ Rumor: Dana White said a potential fight between Ilia Topuria and Paddy Pimblett has enough hype — MMA Fighting, 2h ago

• Ilia Topuria's manager dismisses Paddy Pimblett fight — Bloody Elbow, 5h ago

§10What gets written down

In code hunter.js recordOutcome· schema.sql

Every article is stored, including everything rejected. The record of what was thrown away is what makes the throwing-away checkable.

A posted article writes its row, opens a story, and — when it asserts a real fact about a fighter's career — mints a claim: one English sentence, with a type and a status. A claim is born confirmed when it comes from ufc.com, or when the decider reads the article as the promotion's own announcement; otherwise it is born rumor. Later articles saying the same thing are linked to it as evidence, and if a ufc.com article arrives asserting it, the claim flips to confirmed and the bot replies to its own original message.

Daniil Donchenko defeated Punahele Soriano by unanimous decision at UFC Fight Night 287

result confirmed · 28 sources

Ilia Topuria announced his return to the UFC after recovering from a double orbital fracture

other confirmed · 29 sources

Denials are linked as evidence and never acted on. Nothing in the code flips a claim because one outlet pushed back.