Skip to content
Joel Mercer
← Writing
6 min read

I caught a coordinated influence campaign without spending a single token

Originally published on Medium.

(Spoiler: This is a tech experiment, not an OSINT one)

In early summer 2026, a drone came down on Romanian soil, NATO territory, near the Black Sea. Within hours, two dozen accounts posted the same sentence about it:

Russia's embassy in Bucharest had called the incident a pretext Romania was using to escalate.

Not a paraphrase. The identical sentence, across 24 accounts, in the same second.

No newsroom with two dozen people posts in the same second. That is a scheduler, not a team. You do not catch this by reading the content, which is plausible enough in three languages. You catch it by looking at the timing.

I have spent years in complex systems in this space. Right now, I am ignoring the AI apocalypse and the endless 'I built Palantir in a weekend' noise. My focus is rebuilding parts of the OSINT stack with three goals from the start:

  • Any CSV, zero setup. Point it at a folder or storage bucket with any exports, one file or a hundred, any schema. Get back the core who, what, and when.
  • Deterministic, trustworthy, and offline. No LLM, no tokens spent, nothing leaves the machine. The same input always gives byte-identical output.
  • Fast. A million rows in minutes on a laptop.

First, the groundwork: the data layer and the first analysis, coordination.

The bet

The obvious approach is to count. Loudest account, fastest message. It does not work. In this dataset, the top 1% of authors made 36% of the posts. They were Associated Press, Reuters, and PA. Rank by volume and you just find the wire services again. Source credibility is not the answer here. That breaks the rules.

The bet is that coordination lives in the structure: who said what, when. You do not need to parse the content. That is good, because content is where language and edge cases kill systems. A timestamp does not care about language.

The real work is the unglamorous part

The process is simple: combine all the CSVs into one Parquet dataset, each row with who, what, and when, plus a chain-of-custody receipt. Before you can align timestamps, you have to define what a timestamp is across files that agree on nothing.

I fed a dozen very different exports to the same detector, unchanged: X(Twitter) dumps, YouTube and Facebook comments, Telegram, and a multilingual news firehose. None shared a schema. Each had different columns, delimiters, encodings, languages, and misleading headers. The news file had 46 columns, tab-separated, UTF-16, three languages in one file. A column named Date was the scrape time, while the real event time was unlabeled three columns over.

No per-file config. No template library. The rule: read schema from values, not names. Names change with every vendor. Value-shapes are universal: is the column unique, does it parse as a date, is it shaped like a handle? Each column gets profiled into a feature vector, and roles are assigned jointly, so one column cannot be both 'id' and 'author.'

URL is almost as unique as the id column, a perfect id by that measure, but excluded for being a URL. Author Handle wins the author role even if it is 90% empty, because a handle is a more stable identity than a display name. Author Name is the fallback. The tool refuses to guess: if there is no id, it reports no id instead of inventing one.

Why not use an LLM? Most of this is not judgment, it is arithmetic: is this column unique, does it parse as a date. Code counts exactly where a model guesses. The output might be evidence, and 'the model decided' is not a chain of custody. An LLM will always give you an id column, even when nothing qualifies. This is not anti-ML dogma. A pinned classifier has its place when structure runs out, but the core stays deterministic and byte-identical every run.

What it found

The core idea is simple. Every account is tied to the traits it leaves: shared phrases, shared links, hashtags. Accounts that keep using the same ones get pulled into a network. The key is weighting: two accounts sharing a rare template is signal; two using a trending hashtag is noise. Weight for rarity, cap celebrity traits, and groups surface on their own.

Most of this is not mine to claim. The method comes from established co-action research. The moving parts are fast open-source libraries: MinHash/LSH so lightly-reworded copies still collide, a disparity filter to keep only statistically-significant links, and Leiden to cut the network into communities. Assembling beats building. The compiled cores do the work, so I can focus on the problem, not the plumbing.

What I did own was making it fast. A naive timing self-join took about 100 seconds. Bounded properly, it is about seven. The analysis is almost free. Vectorised passes clear about a million rows a second. The wall-clock time goes to the real work: decode, normalise, hash for custody, write immutable Parquet. That is about three minutes per million rows on a laptop. Polars does the crunching. DuckDB over Parquet serves the live queries.

Timing is what turns a community into a cell. 634 communities came out. 455 were just wire syndication: one AP story republished across many sites, hours apart. The tool recognises and pushes these down. 33 survived as tightly synchronised, non-syndication cells.

Following the edges from one surfaced a ring I did not expect: a family of Russian regional 'city-news' handles and a cluster with a tsargrad_ prefix, all posting identical framing in the same second. The system had no names, no labels, no seed list. It rebuilt a coherent, branded network from posting structure alone. What that network is remains for a human to decide. The point is that its shape came straight from the data.

The deeper bet

What interests me more than detection is the deeper bet. The tools analysts use feel like 2012 enterprise software with an AI agent slapped on the side: heavy, slow, drag-a-graph-around. Developer tooling, meanwhile, spent a decade perfecting the primitives this problem needs: navigating huge graphs of named entities, searching massive indexes instantly, reconstructing history, provenance you can trust. Same problem shape, different craft.

So I build it like a dev tool. The analogy keeps paying off. Chain-of-custody is git blame for every row. A re-run is a hermetic build. The schema fingerprint is its lockfile. Coordination findings arrive like linter warnings: a ranked, triageable list, not a wall of text. The engine speaks a protocol modeled on LSP, the thing behind go-to-definition in your editor. Here, the symbols are actors: go to an account's definition (its profile), find all references (its co-actions), walk its call hierarchy (its co-posting neighborhood).

Decouple the engine behind that contract and one backend serves a CLI, a web app, or a headless integration. The intelligence is not tied to a UI. I have not seen anyone in this space build it this way. That is why the gap is worth walking into.

What I left out

A few days of work in a few hundred words skips the real detail: how joint role-assignment breaks ties, where a pinned classical model earns its place and why phrase-lists never do, how syndication gets separated from genuine coordination, what 'LSP for actors' looks like in practice. Also, I am still working on writing.

If you are building, buying, or trying to break this kind of system, I want to talk. Pick a thread and pull.