📖 Methodology

How every score on this site is calculated.

Each number below is documented exactly as the pipeline implements it, not simplified for the reader. The weights and thresholds are pulled live from analysis/scoring.py and analysis/nlp_extraction.py when this page renders, so it cannot quietly drift out of sync with what the code actually computes.

None of this is a fact-check or a truth score. Every figure is an observable proxy: a correction history, a count of hedging words, an LLM rating of the writing. A high score means fewer of those red flags were observed, and nothing more than that.
01 Claim certainty 02 Hedged claims 03 Publisher trust score 04 Correction detection 05 Article leaning 06 Source leaning consistency 07 Narrative framing 08 Framing intensity 09 Content type 10 Story clustering 11 Content handling
01

Claim certainty

Every claim is labelled asserted or uncertain by looking for one specific list of hedging words.

A claim is one sentence containing a subject and a verb. Each one is tagged asserted or uncertain by a single check: does the sentence contain any of these words or phrases, matched whole-word and case-insensitively?

Worked example
“The minister met the delegation on Tuesday.” no hedging word present asserted
“The minister reportedly met the delegation on Tuesday.” matched reportedly, stored as this claim's certainty_reason uncertain

The full list, live from the code:

maymightcouldpossiblyreportedlyallegedlyaccording toclaimsclaimsuggestssuggestedunclearunconfirmedunverifiednot independently verifiedremains unclear
The match is a whole-word regex rather than a substring check, so "could" does not match inside "couldn't". Whichever word triggered the label is stored alongside the claim as its certainty_reason, which means every label traces back to the exact phrase that caused it instead of being a black-box judgment.
02

Hedged claims

One formula produces every "X% uncertain" figure on the site. Only the set of claims being counted changes.

Worked example
One article with 35 claims, 8 of them carrying a hedging word:
8 ÷ 35 22.9% of claims hedged
density = (uncertain claims) / (total claims)

Count the claims in one article and you get that article's figure. Count them across a topic, a story or a publisher and you get that one instead. It appears as marker colour on the maps, as density-over-time on topic pages, and as one of the three inputs to the publisher trust score below.

03

Publisher trust score

Three observable red-flag rates, each normalised to 0 to 1, combined by weight and subtracted from 100.

This is not a truth or fact-check score. It measures how often a publisher shows three specific warning signs, nothing more.

Only reporting sources are scored. Each of the three signals below is a claim about a newsroom's record with its own reporting, so they are not computed for sources that publish commentary. A newsletter or a signed column is meant to argue a position, and counting how often it hedges a claim measures the form of writing rather than the care taken over a fact. Every source is marked as reporting or commentary in the registry, by hand, and the publisher pages for commentary sources say so in place of a score.

Correction rate Hedging density Assertive mismatch
SignalWeightWhat it measures
correction_rate0.4 How often this publisher self-corrects, as correction_count over article_count. A 15% rate counts as fully maxed out.
uncertainty_density0.25 The hedged-claim rate above, across all of this publisher's claims. A 50% rate counts as fully maxed out.
assertive_mismatch_rate0.35 Of the articles that did get corrected, how many had been framed assertively, meaning stated with confidence and then walked back.
Worked example
A publisher with 3 corrections in 100 articles, 12% of its claims hedged, and half of those corrected articles originally framed assertively:
SignalRate÷ maxes out at Normalised× weightPenalty
correction_rate 0.03 0.15 0.2 0.4 0.08
uncertainty_density 0.12 0.5 0.24 0.25 0.06
assertive_mismatch_rate 0.5 1.0 0.5 0.35 0.175
total penalty 0.315
100 × (1 − 0.315) trust score 68.5
penalty = 0.4·correction + 0.25·density + 0.35·mismatch trust_score = round(100 × (1 − penalty), 1) # 0-100, higher = fewer red flags
Read it carefully in both directions. A publisher that never runs corrections might be careful, or might simply be one nobody is checking. The score is recomputed for every publisher on each pipeline pass; python scoring.py trust <publisher_id> checks a single one directly.
04

Correction detection

An article counts as a correction if its opening contains one of these keywords.

Worked example
Correction: an earlier version of this story gave the wrong date for the vote.” matched in the title counts towards this publisher's correction_count
A story that merely mentions a correction further down is not caught: only the title and the first 500 characters are searched.

The title plus the first 500 characters are searched for any of the following, matched case-insensitively as substrings:

correctioncorrectedretractionretractedupdate:editor's noteclarificationerratum
05

Article leaning

One local LLM call per article, rating how the piece is written rather than what it is about.

The model scores word choice, framing and story selection. It is explicitly not scoring the political leaning of the topic or of the people the article covers.

Most articles get no score at all. The first thing the model is asked is whether the article takes a side on a contested political question. A match report, a transfer, a rescue, an obituary or a product launch does not, however warmly or critically it is written, so it is recorded as not politically coded and no number is produced. Approval of a person is not right-wing and sympathy for a victim is not left-wing.

The model also has to name the contested question — the actual political argument the article comes down on one side of. If it cannot finish the sentence "this article takes the side of ___ in the argument about ___", the verdict is thrown out and the article is recorded as not politically coded. A subject being serious, sad, or merely involving the government is not a political question.

Where there is a score, the model must first quote the article's own words that carry the lean, say which way each one points, and only then give the number. Every quote is checked against the article text before it is stored, and any that cannot be found there is discarded. Words spoken by someone the article quotes are that person's words, not the article's, and do not count as evidence on their own.

A score that cannot survive its own evidence is not published. If none of the quotes can be found in the article, or if every quote that can be found points the opposite way from the number, the article is left without a leaning rather than shown one that its own working contradicts. What the model said is kept in the audit trail either way, so the rate is something you can go and look at.

Leans strongly left Leans left Centre Leans right Leans strongly right
-0.82Leans strongly left -0.35Leans left +0.05Centre +0.44Leans right +0.90Leans strongly right
−1.0 left0.0 centre+1.0 right
The bands are cut wide at the centre on purpose: anything within ±0.2 is inside the noise of a single model judgment, so it is called Centre rather than dressed up as a lean.
Confidence is recorded separately from the score, so a faint signal can be reported as faint without pulling the number toward the centre. It is stored on the article and shown beside the leaning, so a verdict the model was unsure of does not read like one it was sure of.
The article page shows the band, not the number. The model emits a value between −1 and +1, and that scale is finer than the judgment behind it: across the corpus it used 34 distinct values, and two of them accounted for nearly half of every score. Printing "+0.35" would invite a comparison against "+0.40" that the measurement cannot support, so what a reader gets is one of the five bands above. The underlying number still drives the position of the marker and the colour of a dot on the article map.
The same article gets the same answer every time. The model runs at zero temperature for this score, so re-judging an article does not produce a different verdict. Before that was pinned it did: on a sample re-scored three times, the published band changed on eight articles out of fifteen and the left/right sign flipped on four.
This is a single pass. Some LLM-backed scores in this app get an adversarial second opinion, but that pattern suits a keep or discard decision rather than a scalar rating, so it is not used here. Every score's evidence goes to the reasoning audit trail at /admin/reasoning, and the article's own page shows it. If Ollama cannot be reached the score is left unset rather than guessed, so it fails open. That is a different state from not politically coded, and the two are stored apart.
This scoring was rebuilt on 21 August 2026 and repaired on 27 August 2026. The version before the rebuild had no way to answer "not political", so every article received a political score whether or not it had political content; measured across the corpus it produced, Sports articles averaged further right than Politics articles, because warm coverage of a team was being read as a right-wing lean. The rebuild fixed what was being measured and left it unable to measure the same thing twice — the sampling was never pinned — and it published scores that no quote from the article supported. The repair pinned the sampling, added the named-question test, and stopped publishing verdicts that contradict their own evidence. Scores from any two of these three are not comparable with each other.
06

Source leaning consistency

How much one source's leaning swings from article to article, across its whole output.

Shown on the Story Map sidebar's second chart. It is computed over every article the source has published, not only the ones belonging to the story you clicked.

Worked example
A steady source, five scored articles: +0.30 +0.34 +0.28 +0.32 +0.31
mean +0.31 (Leans right) · spread 0.02 consistency 0.98
A swinging source, five scored articles: -0.55 +0.62 +0.10 -0.30 +0.75
mean +0.12 (Centre) · spread 0.50 consistency 0.66
Both sources lean about the same amount on average. Consistency is the part that says whether that average describes anything.
mean_leaning = mean(leaning_score across every scored article from this source) consistency = 1 / (1 + stdev(leaning_score across those same articles))
Consistency is bounded to (0, 1], and higher means the source's leaning moves less between articles. Note the edge case: a source with exactly one scored article gets a consistency of 1.0, because the standard deviation of a single point is 0. That is a real limitation of a small corpus rather than a special case written into the code.
07

Narrative framing

An article is labelled speculative, mixed or assertive purely by what fraction of its claims are uncertain.

0% of claims hedged50%100%
Worked example
5% of an article's claims hedged assertive
35% of an article's claims hedged mixed
70% of an article's claims hedged speculative
uncertainty_ratio = uncertain_claims / total_claims ratio > 0.5 → "speculative" ratio > 0.2 → "mixed" otherwise → "assertive"

The inputs are the certainty labels from section 01, so this adds no new judgment of its own. It only summarises the ones already made.

08

Framing intensity

A separate measure of loaded or persuasive language, independent of whether the underlying claim is true.

Every sentence is scanned for terms belonging to five weighted categories:

CategoryWeightExample terms
certainty_booster 1.0 clearly, obviously, undeniably, undoubtedly, …
absolutist 0.6 always, never, every, everyone, …
emotional_charge 1.4 shocking, outrageous, alarming, devastating, …
credibility_loaded_verb 1.2 claims, claimed, insists, insisted, …
attribution_verb 0.15 alleges, alleged, denies, denied, …
Worked example
A 100-word report containing one certainty_booster term (clearly, weight 1.0) and one absolutist term (always, weight 0.6):
(1.0 + 0.6) ÷ (100 ÷ 100) 1.6 per 100 words ÷ 8 intensity 0.2
Eight full-weight hits in 100 words is where the scale maxes out, so two of them is a low score rather than a middling one. Loaded language is supposed to be unusual.
density_per_100w = (sum of category weights hit) / (word_count / 100) intensity_score = clip01(density_per_100w / 8) # 8 hits per 100 words ≈ maxed out
It is computed twice. Once over the whole article, and once counting only the hits that fall outside direct quotes, which is stored as reporter_intensity_score. The reason is that loaded language inside an accurately quoted subject's own words reflects what that person said, not how the reporter framed it. Standard attribution verbs such as "alleged", "denied" and "accused" carry a near-zero weight, because they are required vocabulary for reporting contested claims responsibly rather than signs of editorialising.
09

Content type

A coarse structural check that only catches the genres a source marks itself.

"first_person" if the text contains an explicit first-person-column marker "analysis" if the headline starts "Name Name: ..." (BBC's signed-column convention) "news" otherwise (the default)
This is not a writing-style model, and it is built to under-flag rather than over-flag. The certainty and framing scores are built to judge how a news article reports a claim, which does not quite fit an opinion column. Labelling the genre lets anything downstream treat the two differently without changing how the underlying signals are computed.
11

Content handling

Whether an article needs a content note, its picture held back, or an age confirmation. Judged on what the article describes, not on whether its subject is upsetting.

"general" ordinary news reporting, whatever the subject. Almost everything. "sensitive" describes distressing material in detail. Content note, and the lead image is held back on pages the reader did not choose. "restricted" explicit material. Needs a signed-in, age-confirmed account.
Reporting that something terrible happened is "general". A murder, a war, a death, a sexual offence, a court result: reporting those is what a news service does, and putting a warning on all of it would make every warning worthless. What moves an article up is describing the thing in detail, for example a victim's own account of an assault, rather than reporting that it took place.
A warning has to point at what it is warning about. The classifier is a language model, and to return anything other than "general" it has to quote the passage that makes it so. The quote is checked against the article text, and a verdict whose quote cannot be found, or whose quote turns out to contain nothing distressing, is recorded as "general" instead. An earlier version without that check marked one article in eight as sensitive, and most of those were wrong.
Support resources are separate from the level. An article that simply reports a death by suicide is "general" and still carries crisis-line details, because that is the article a reader is most likely to need them on. The level decides whether a picture is held back; the subject decides whether help is offered.
What an age confirmation is. It is a statement made once on an account, and a date recorded against it. No date of birth is collected, nothing is verified, and this page does not claim otherwise. It can be withdrawn at any time from the account page. The alternative, a tickbox for a signed-out visitor, would record nothing and confirm nothing.
It can be wrong in both directions, and it is a judgment about one piece of text rather than about a publisher. Tell us if an article is handled wrongly.
10

Story clustering

Articles are grouped only when an LLM judges them to describe the same specific incident, not merely a related one.

In A Quake hits coast B Coastal quake: 12 hurt C Quake aid arrives D Budget vote passes
Pre-filter A·BA·CB·C D shares no wording with the rest, so it never reaches a model. Similarity ≥ 0.55 and within 5 days.
LLM judge A·B same event · 92 A·C related, not same · 40 B·C related, not same · 35
Union-find Story 1 A + B Story 2 C Story 3 D Only pairs at confidence ≥ 60 join. C is about the same quake and still gets its own story, because the aid effort is a different incident.
1. Pre-filter embedding cosine similarity ≥ 0.55 AND published within 5 days a cheap recall net: a false positive here costs one extra LLM call, while a false negative is a story that never groups 2. LLM judge judge_same_event(A, B) → {same_event: bool, confidence: 0-100} defaults to false on any tie, uncertainty or failed call 3. Union-find over every pair with same_event AND confidence ≥ 60
Verdicts are cached in same_event_links, so re-running the pipeline only pays the LLM cost for genuinely new pairs. Every article ends up in exactly one story: an article with no confirmed match becomes its own single-article story, so nothing is silently dropped.
💬 Give feedback
🕘 History 🎫 Support