From news bursts to story graphs, with local LLMs

This post traces how tweets or news stories spread through the media and whether we can visualise the spread of news or items visually. Typically, items start with small bursts and then become full-blown stories as more people connect and discuss it online.

The plan was simple to state:

posts → people, places, organisations and claims → a graph that changes over time → “what’s happening?”

Originally, I wanted to use X f(ormerly Twitter) and listen to tweets live, but the cost of the X stream is prohibitive, so I switched to Bluesky whose whole public stream is free to read.

The the idea was to identify news/event bursts and then see how the event or news item actually becomes a full-blown story. The interesting part is what happens over the next day. So the project was created in two halves 1) finding bursts, and 2) following each burst into a story.

The setup

  • Data: Bluesky’s public stream through its free Jetstream feed, English posts only. This was done for four days from 18 to 22 October 2026, and then frozen so every number below comes from the same recording: about 8 million original posts and 7 million reposts.
  • Everything runs offline on the recording. Recording happens once; every experiment replays the saved files. That made it possible to change a threshold and compare results on identical data.
Flowchart illustrating data capture and replay processes for Bluesky Jetstream, detailing steps for polling news feeds, saving JSONL files, freezing data collection, and replaying files for analysis.
JobModel (all local)Why this one
Find names in every postGLiNER, a small entity modelfast enough for every post, about 140 posts a second
Judge whether posts say the same thingMiniLM sentence embeddingsabout 7,000 posts a second
Extract facts and claims for a storyGLM-4.7-Flash, via Ollamathe fastest model
Tamil news, merging names, captionsGemma 4 31B, via Ollamabest quality , and it reads Tamil
Store the graphNeo4j Community editionfree and local

How it works

  1. Record the Bluesky stream to hourly files.
  2. Find the names in each post: people, organisations, places, facilities, products and events.
  3. Detect bursts: a set of names that suddenly many more people are writing about than usual.
  4. Follow the story: collect its posts from 3 hours before the burst to 24 hours after.
  5. Extract facts and claims at 12 points in time with a local model, each tied to the posts that support it.
  6. Merge duplicate names, store everything in Neo4j, and draw the story as an interactive page that plays the 12 steps in order.
Flowchart illustrating data sources, capture and replay methods, and entity processing in a system. It includes information about Bluesky Jetstream, news RSS feeds, data recording, and entity cache operations.
Flowchart illustrating a process for tracking and analyzing story bursts, including steps for identifying co-mention graphs, tracking event writers and reposts, managing facts and claims, and generating knowledge graphs with outputs.

Part 1: Bursts, or finding the moment a story breaks

1. Counting hashtags finds bots

The obvious first step is to count hashtags and words and flag whatever grows fastest. In the first morning of data, the top trends were:

TrendPostsAuthors
roadworks (a UK traffic bot)5641
#kickban1971
#greatwhitemike1581
nebraska4332

Three of the top four were single accounts. Everything from here on counts distinct people, not posts, and nearly identical texts count once, because spam networks post the same template from dozens of accounts (one sent the same advert from 82).

2. Names, not hashtags

Only 11% of Bluesky posts carry a hashtag, names were needed inside the text. Gliner and Spacy entity extractors were compared on 200 posts, using Gemma 4 31B as the reference. Gemma is too slow for every post, at 0.3 posts a second, but it is accurate.

ExtractorPrecisionRecallTypes rightSpeed
spaCy (small)0.770.670.59about 460 posts a second
GLiNER0.480.810.72about 160 posts a second
GLiNER with a junk filter0.730.720.75about 160 posts a second

Unfiltered, GLiNER labelled “I’m 49”, “Dinner” and “He” as entities. A small filter (no pronouns, no dates, no common phrases in lower case) fixed most of it. Over a full hour, 53% of posts named nothing, 24% named one thing, and only 23% named two or more. Short posts mostly mention one part of a story.

3. One story, seven events

The initial detector linked names that people mention together. On the morning of 18 October, Ukrainian drones struck targets in St Petersburg: an oil terminal, a VK data centre, an Ozon warehouse and a bridge. The detector reported seven separate events. One post said “Ozon”, another “VK data center”, a third “oil terminal”, and almost nobody named two of them in one post.

4. Need for embeddings

Names alone could not tell which posts belonged to the same story, because most posts name only one part of it. I needed a way to compare what posts say, not just which names they share.

An embedding turns a post into a list of numbers, so that posts with similar meaning get similar numbers even when they share no words. “Drones hit an oil terminal in St Petersburg” and “Ozon warehouse on fire after the attack” come out close together; a post about a football score does not. A small local model (MiniLM) does this at about 7,000 posts a second, so it can be applied freely. Reading every post with a language model would understand meaning better, but Gemma manages 0.3 posts a second while 16 to 44 arrive every second.

I ended up using embeddings in four places:

WhereWhat went wrong with names aloneWhat embeddings did
Grouping a burst into one eventThe strike came out as 7 events, because “Ozon”, “VK data center” and “oil terminal” were rarely named togetherNames whose posts read alike were linked, and the strike became one event with 8 names
Following an event from one window to the nextWhen later posts used new names (“Baltic oil terminal”, “Saint Petersburg”), the strike restarted as a new eventThe new group was matched to the existing event by meaning, and the strike stayed one event for 105 minutes
Choosing the posts to read for an eventA post that only said “Nintendo” or “Korea” was pulled into unrelated stories: a 22 year old game, a Korean sports teamA post sharing only one name with the event must also read like the event
Following a story for a dayEvery passing mention of a name would have joined the storyPosts naming one of the story’s names must also be close in meaning to the story’s own posts

Two limits showed up. The threshold matters: at a similarity of 0.45, unrelated political stories merged, because political posts all read alike to a small model; at 0.65 the strike split again; 0.55 worked. And embeddings cannot rescue a story whose names are too general, which comes up in Part 2.

Each change was tested on the same three hours of data:

RunChangeEvents foundWhat happened
1names mentioned together173the strike came out as 7 events, a college football game as 7
2linking posts that read alike35the strike disappeared altogether (see the next section)
4writers counted per event60the strike became one event with 8 names
7thresholds tuned61the strike lasted 105 minutes as one event, and unrelated politics stopped merging

5. The burst formula

Putting the pieces together, this is how a burst is scored. Every 5 minutes, for every name, the detector looks at the last 15 minutes:

  • authors: the distinct people who wrote or reposted a post naming it
  • total: the distinct people active at all in those 15 minutes
  • share: authors divided by total

Each name has its own baseline: its usual share, and how much that share normally varies. Both are running averages that remember roughly the last 6 hours (a half life of 72 steps of 5 minutes):

Each part has a job:

  • Share, not count. Activity on Bluesky roughly triples between the US night and morning. Counts would make every name look like a burst each morning; shares only move when the conversation does.
  • The expected term allows for ordinary chance: a name expected 100 times can easily turn up 110 times.
  • The (total × √var)² term asks more of names that are always busy and always vary, such as Trump.
  • The + 1 stops a name never seen before from scoring infinity with two people, while a new name with 20 people still scores about 20.
  • The baseline is updated after scoring, so a burst is never compared with itself.

A name is bursting when at least 8 people named it in the window. Bursting names are grouped into events (by co-mention and by embeddings), and an event must then pass two more tests from the previous section: at least 3 people wrote original posts about it, and for at least one of its names the writers alone score at least 2 on a second baseline that counts writers only. The first 2 hours of any run only build the baselines.

Two examples. At 06:45 IST on 18 October, Ozon was named by 21 people in 15 minutes where about 0.8 would be normal, giving z = 7.6, and VK data center by 11 people where almost none was expected, giving z = 9.1: both bursts. For a constantly busy name, take an illustration: a usual share of 3% of 15,000 active people gives an expected 450; if its share normally varies by 0.4 points, the spread term adds 60, so the denominator is about 64. Then 520 people give z = 1.1, which is ordinary, and it would take about 770 to reach z = 5.

7. The burst is short

Every tech event the detector found on Monday 19 October lasted between 10 and 105 minutes. That is when people suddenly pile in. But the stories themselves ran for a day:

Bar chart showing social media post activity over time for three stories: 'St Petersburg drone strike', 'Prop 40 and Sergey Brin', and 'Nvidia and warehoused GPUs', with indicated burst detection periods.

The shaded band is all the detector saw. For the strike, the biggest wave came six hours later, in the Indian evening, as casualty figures and claims arrived. Prop 40 had a second wave the next evening. A graph built from the burst alone had 2 to 5 checkpoints: a snapshot, not a story.

Part 2: From a burst to a story

1. Following a story for a day

So the burst became a seed. Each detected event gives its main names and the average embedding of its posts. One pass over the recording, from 3 hours before the burst to 24 hours after, collects every post that names two of the story’s names, or names one and reads like the story, along with its repost count. All stories are followed in the same pass:

StoryPosts followedReposts of them
Steam Next Fest2,3127,763
Nvidia and warehoused GPUs7943,398
St Petersburg drone strike4143,784
Prop 40 and Sergey Brin3424,044
Amazon GPUs2862,965

2. Twelve checkpoints, facts and claims

The followed posts are cut into 12 slices with similar numbers of posts, so busy periods get finer steps. For each slice, GLM-4.7-Flash reads the most reposted posts (and the first one) and returns:

  • Facts as subject → verb → object (“Ukrainian drones → attacked → Ozon warehouse”), each citing the posts that state it. A fact citing no real post is dropped.
  • Claims, with who makes them: “135 drones were shot down” (the regional authorities), “our services are working normally” (VK), “the warehouse was used by the Russian defence ministry” (Ukraine; Ozon denies it).

Separating facts from attributed claims turned out to be one of the most useful parts. A story is mostly claims: casualty figures from three different sources, accusations, denials.

Infographic showing the impact of a drone attack in St. Petersburg, highlighting targeted infrastructure such as oil terminals, data centers, and warehouses.

3. Many names for one thing

Extraction runs slice by slice, and the same thing comes back under different names: “Vijay”, “CM Vijay”, “C Joseph Vijay” and “TN CM”; “IMD”, “India Meteorological Department” and “weather department”. Three layers fixed most of it:

  1. Prevention: each slice is given the names already used for this story, with an instruction to reuse them, plus a short list of preferred verbs (“attacked”, “located in”, “deployed to” and so on).
  2. Clean up in code: “X says Y” becomes a claim by X, not a fact; a fact linking a thing to itself is dropped; “VK data centre was hit by a drone” is turned round to “drone hit VK data centre”.
  3. One Gemma call per story groups the names that mean the same thing and picks one, while being told that a district is not its city (North Chennai is not Chennai).

On the Chennai rain story (below), this cut 127 different names to 70, and 42 different verbs to 11. Gemma also knew that “Kovai” is Coimbatore and that “the Tamil Nadu Weatherman” is the blogger Pradeep John. It made mistakes too: it merged “weather centre”, which in Tamil reports usually means Chennai’s regional centre, into the national IMD.

4. Captions that say only what’s new

My first pages showed each slice’s summary, and every summary began by restating the whole story. So Gemma now writes one fixed sentence for the story, and one sentence per step with only what is new. For Prop 40, California’s proposed tax on billionaires:

Story: Users debate California’s proposed billionaire wealth tax (Prop 40) and criticise tech billionaires’ ties to Donald Trump and Mar-a-Lago.

  1. Users suggest renaming Mar-a-Lago and discuss Props 41 and 42.
  2. Sergey Brin reportedly spent over $100 million against Prop 40 and dined with Trump at Mar-a-Lago.
  3. Reports that Mark Zuckerberg moved to Florida and Gavin Newsom vacationed with Brin.
  4. Musk, Thiel and Zuckerberg reportedly oppose Prop 40; Bernie Sanders supports it.
  5. A poll suggests Prop 40 faces a “spectacular collapse”.
  6. Brin reportedly moved to Nevada and bought a Miami home; the BBC reportedly demanded Mar-a-Lago’s membership list.
  7. Economist Gabriel Zucman explains Prop 40 as a wealth tax; Mark Ruffalo warns of health insurance losses.

And for the strike, the account firms up over the day: targets first, then the radar site and bridge, then Ozon’s denial, then casualty figures from three different sources (Zelensky, the Russian defence ministry and the city’s mayor, each with different numbers), then “over 1,000 drones, the largest attack on the city”. These are what posts claimed, not checked facts, which is why every claim keeps its source.

A visual map illustrating connections related to California's proposed billionaire wealth tax, featuring Sergey Brin's move to Nevada and a Miami purchase, alongside claims related to Mar-a-Lago and political figures like Donald Trump and Peter Thiel.

The pages play the 12 steps in order, with a speed control (2 to 10 seconds per step) and each step’s caption over the graph. They work on a phone.

5. When following goes wrong

The Nvidia story shows the weakness. Its seed names were “Nvidia”, “GPUs”, “RAM” and “Jensen Huang”, all of which people discuss constantly. So the day’s story became everything about Nvidia: warehoused GPUs, “channel stuffing” accusations, a German AI cloud deal, a new CPU. Every Nvidia post read like the story, so embeddings could not hold it together either. You can see it in the figure above: a flat, steady line rather than a wave. A story can only be followed if its names are specific to it. Steam Next Fest, a festival of hundreds of game demos, had the opposite problem: a few prolific developers filled the evidence with their own game.

A detour to Chennai: a story told in two languages

Heavy northeast monsoon rain hit Chennai on 17 and 18 October while I was recording. Bluesky had almost nothing about it (128 posts in 21 hours, mostly automatic copies of Tamil TV headlines), so I followed 1,621 news articles instead, in English and Tamil, alongside Open-Meteo’s hourly rainfall.

  • GLM cannot read Tamil. It returned nothing for seven Tamil headlines. Gemma 4 31B read them and wrote every name in English (சென்னை → Chennai, தாம்பரம் → Tambaram), so Tamil and English articles about the same place joined up in the graph.
  • News arrived the morning after. The heaviest rain fell around 00:30 IST; articles peaked six to nine hours later, and Tamil outlets were out first, from 06:30, with English coverage peaking around midday.
Bar graph showing rainfall (in mm/h) in central Chennai over time with a peak of 16 mm/h. Below, line graphs show the number of Tamil news articles, English news articles, and Bluesky posts about Chennai weather over the same period.
  • The figures firmed up through the day: 175 mm at Parrys, then “30 cm in Chennai”, then 36.34 cm overnight. The official and critical accounts sat side by side: the Corporation’s “no fatalities” and “drained by dawn, barring Perambur and Velachery”, a minister’s “Chennai cannot withstand 24 hours of continuous rain”, and a resident’s “36 cm fell in 3 hours and the weather centre gave no warning”.

One caveat: the rainfall model under reads badly. It peaked at 16 mm an hour, while the reported totals were ten times higher. It gets the timing right, not the amount.

Play the stories

Each story is an interactive page: press Play to watch it build up step by step, with a caption saying what changed, and use the slider to jump to any step. They work on a phone too. All eight are listed at https://tvganesh.github.io/bluesky-stories/.

The pages show what the models extracted, not the posts themselves: account names, links to posts and quoted posts have been removed, and posts their authors deleted after I recorded them were dropped.

This was an interesting exercise to see how the stories develop. What would be more interesting is so what cause tweets or stories to go viral. That is definitely food for thought.

Also see

To see all posts click Index of posts

Leave a Reply