Technical writing
Detecting Coordinated Inauthentic Behavior in Social Media at Scale
Coordinated inauthentic behavior — bot networks, astroturfing campaigns, state-sponsored influence operations — is not a content moderation problem. It is a behavioral pattern problem. The individual posts are often legal and may even be true. What is inauthentic is the coordination: the artificial amplification, the fake accounts manufacturing consensus, the synchronized timing designed to make a fringe view look mainstream.
This article describes the detection system we built between the election anomaly detection pipeline (which consumes coordination scores as one of its signals) and the NLP pipeline (which delivers normalized posts and entity extractions that feed the coordination detector). The gap between those two articles is exactly this: how coordinated campaigns are identified in the post stream before they reach the election-specific analysis.
We process 2.4 million posts per hour. The coordination detector runs in under 4ms per post and surfaces actionable alerts with a median detection latency of 47 minutes from the first coordinated post to analyst notification at the high-confidence threshold.
What coordinated inauthentic behavior looks like
Organic amplification happens when a post resonates and people share it. Coordinated amplification happens when accounts post the same content because they were instructed to, not because they independently found it compelling. The difference manifests in four observable patterns:
- Content synchrony: near-identical text posted by multiple accounts within tight time windows — a few minutes, not the hours or days organic sharing takes
- Account history gaps: new accounts with no organic posting history that suddenly post at volume, often created in batches around the same date
- Cross-platform synchrony: the same content appearing on Twitter/X, Telegram, and Facebook in synchronized bursts, despite no obvious cross-posting mechanism visible in the posts themselves
- Financial correlation: political ad spend spikes that precede or accompany organic posting bursts — the paid and organic components of the same campaign
The detection problem is that each individual signal is explainable by chance. Five accounts posting the same text within two minutes could be five friends who saw the same notification at the same time. New accounts could be people who just joined the platform during a high-profile news event. Cross-platform overlap could be journalists covering the same story. The combination of signals — across content similarity, timing, account age, network topology, and cross-platform reach — is what makes a campaign detectable with high confidence.
Content similarity detection with MinHash LSH
We use character 4-gram MinHash with 128 hash functions and 16 bands of 8 rows each. This configuration gives a Jaccard similarity threshold of approximately 0.45 at which the probability of a collision in at least one band reaches 50%, and a similarity threshold of 0.80 at which P(collision) exceeds 0.999. In practice we use the 0.80 threshold for flagging pairs — the lower 0.45 threshold governs band bucket candidacy.
The MinHash signature is computed over character 4-grams after stripping URLs, @-mentions, and the # prefix from hashtags. Keeping the hashtag word preserves the language and topic signal while removing the structural token that trivially differs between posts in the same campaign.
import numpy as np
import re
from typing import List
_NORMALIZE = re.compile(r"https?://\S+|@\w+|#")
_RNG_SEED = 42
# Pre-generate hash function parameters once at module load
_np_rng = np.random.default_rng(_RNG_SEED)
_HASH_A = _np_rng.integers(1, (1 << 31) - 1, size=128)
_HASH_B = _np_rng.integers(0, (1 << 31) - 1, size=128)
_MERSENNE_PRIME = (1 << 31) - 1
def compute_minhash(text: str, num_hashes: int = 128) -> np.ndarray:
"""
Compute MinHash signature over character 4-grams.
Returns array of shape (num_hashes,) — the MinHash signature.
"""
clean = _NORMALIZE.sub("", text.lower()).strip()
# Extract character 4-grams
shingles = set()
for i in range(max(0, len(clean) - 3)):
shingles.add(hash(clean[i:i+4]) & 0x7FFFFFFF)
if not shingles:
return np.full(num_hashes, _MERSENNE_PRIME, dtype=np.int64)
shingle_arr = np.array(list(shingles), dtype=np.int64)
# Universal hash family: h(x) = (a*x + b) mod p
# Shape: (num_hashes, num_shingles)
hashed = (
(_HASH_A[:, None] * shingle_arr[None, :] + _HASH_B[:, None])
% _MERSENNE_PRIME
)
# MinHash: take the minimum hash value per hash function
return hashed.min(axis=1) # shape (num_hashes,)
# LSH index: maps band_key -> list of post_ids
# 16 bands × 8 rows; band key = (band_index, tuple of 8 signature values)
_lsh_index: dict[tuple, list[str]] = {}
def lsh_query(signature: np.ndarray, bands: int = 16) -> List[str]:
"""
Query the LSH index for candidate near-duplicate posts.
Returns list of candidate post_ids sharing at least one band bucket.
"""
rows = len(signature) // bands # 8 rows per band
candidates: set[str] = set()
for b in range(bands):
band_sig = tuple(signature[b * rows:(b + 1) * rows].tolist())
band_key = (b, band_sig)
if band_key in _lsh_index:
candidates.update(_lsh_index[band_key])
return list(candidates)
def lsh_insert(post_id: str, signature: np.ndarray, bands: int = 16) -> None:
"""Insert a post's signature into all band buckets."""
rows = len(signature) // bands
for b in range(bands):
band_sig = tuple(signature[b * rows:(b + 1) * rows].tolist())
band_key = (b, band_sig)
_lsh_index.setdefault(band_key, []).append(post_id)
def jaccard_from_signatures(sig_a: np.ndarray, sig_b: np.ndarray) -> float:
"""Estimate Jaccard similarity from MinHash signatures."""
return float((sig_a == sig_b).mean())For each incoming post we compute its MinHash signature, query the LSH index for candidate matches (posts sharing at least one band bucket), and then compute the exact Jaccard similarity estimate from signatures for each candidate. Pairs where the estimated similarity exceeds 0.80 and the two posts were created within 30 minutes of each other by different accounts are added to the same content cluster.
At 2.4 million posts per hour, the in-memory LSH index is rotated every 60 minutes to prevent unbounded growth. At rotation, the current index is checkpointed to TimescaleDB and a fresh in-memory index is started. This bounds memory usage at approximately 2GB at peak volume (128 hash values × 8 bytes × 2.4M posts).
Temporal burst detection
Content similarity identifies clusters of near-duplicate posts. Temporal burst detection identifies when those clusters are growing unusually fast — a signal that amplification is coordinated rather than organic. A burst is defined as five or more distinct accounts posting content from the same MinHash cluster within a 15-minute window.
We use Redis sorted sets for efficient window queries. Each content cluster gets its own sorted set, keyed by cluster ID. Members are account IDs; scores are Unix timestamps. Querying the 15-minute window for a cluster is a single ZRANGEBYSCORE call.
import redis
import math
import time
from typing import Optional
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def record_cluster_post(
cluster_id: str,
account_id: str,
timestamp: Optional[float] = None,
) -> None:
"""Record that account_id posted content in cluster_id at timestamp."""
ts = timestamp or time.time()
# ZADD cluster:{cluster_id} {ts} {account_id}
# NX: only add, don't update existing member — prevents one account from
# inflating burst counts by posting repeatedly
r.zadd(f"cluster:{cluster_id}", {account_id: ts}, nx=True)
# Expire the key after 2 hours to avoid unbounded growth
r.expire(f"cluster:{cluster_id}", 7200)
def get_burst_accounts(
cluster_id: str,
window_seconds: int = 900, # 15 minutes
timestamp: Optional[float] = None,
) -> list[str]:
"""Return distinct account_ids that posted in the cluster within the window."""
t = timestamp or time.time()
# ZRANGEBYSCORE cluster:{cluster_id} {t-900} {t}
return r.zrangebyscore(f"cluster:{cluster_id}", t - window_seconds, t)
def compute_burst_score(
cluster_id: str,
account_ages_days: dict[str, float],
window_seconds: int = 900,
min_accounts: int = 5,
) -> float:
"""
Burst score = distinct_account_count × (1 / sqrt(avg_account_age_days + 1))
Newer accounts contribute more to the burst score.
Returns 0.0 if fewer than min_accounts in window.
"""
accounts = get_burst_accounts(cluster_id, window_seconds)
if len(accounts) < min_accounts:
return 0.0
ages = [account_ages_days.get(acct, 0.0) for acct in accounts]
avg_age = sum(ages) / len(ages)
# Inverse-sqrt weighting: account_age_days=0 → weight=1.0,
# age=3 → 0.5, age=99 → 0.1
age_weight = 1.0 / math.sqrt(avg_age + 1)
return len(accounts) * age_weightThe age discount is intentional. A burst of 10 two-year-old accounts posting the same content within 15 minutes is suspicious. A burst of 10 accounts created last week doing the same thing is much more suspicious — new account creation is a known precursor to coordinated campaign launches, and the burst score reflects this asymmetry.
At typical post volumes (2,000 distinct content clusters active at once) the Redis sorted set operations add approximately 0.3ms of latency per post, well within our 4ms per-post budget for the entire coordination detector.
Account age and behavior features
Seven per-account features feed a logistic regression coordination classifier trained on 40,000 labeled accounts from past campaigns. The features capture distinct axes of inauthentic behavior: account history depth, posting volume, interaction patterns, link diversity, social graph shape, profile completeness, and temporal regularity.
from dataclasses import dataclass
import numpy as np
import pandas as pd
from scipy.stats import entropy
@dataclass
class AccountFeatures:
account_age_days: float
"""Days since account creation. Bots often created in recent batches."""
posts_per_day: float
"""Average daily posting rate. Expected Pareto-distributed: mean ~3.2, median 0.8.
Values > 50/day are unusual for organic human accounts."""
reply_ratio: float
"""Fraction of posts that are replies (0.0–1.0).
Bots typically broadcast; humans converse. Organic mean ~0.42."""
unique_domains_per_day: float
"""Distinct link domains shared per day. Bot accounts post narrow link sets;
organic accounts share from varied sources."""
follower_following_ratio: float
"""follower_count / max(following_count, 1).
Accounts with 0 followers but many followings are common bot patterns."""
profile_completeness: float
"""0.0–1.0 composite: profile photo (0.4) + bio text (0.4) + custom header (0.2).
Most real accounts have at least a profile photo."""
posting_hour_entropy: float
"""Shannon entropy of the 24-bin hourly posting distribution (nats, max ln(24)≈3.18).
Organic humans: varies (high entropy). Bots: uniform or scripted peak (low entropy)."""
def compute_account_features(history: pd.DataFrame) -> AccountFeatures:
"""
Compute coordination features from a DataFrame of the account's post history.
Required columns:
created_at datetime: timestamp of each post
is_reply bool: True if the post is a reply
url_domain str | None: extracted domain from any link in the post
Additional context (passed as scalar, not in DataFrame):
account_created_at datetime
follower_count int
following_count int
profile_photo bool
bio_text str | None
custom_header bool
"""
# Degenerate case — brand new account with no posts
if len(history) == 0:
return AccountFeatures(
account_age_days=0.0,
posts_per_day=0.0,
reply_ratio=0.0,
unique_domains_per_day=0.0,
follower_following_ratio=0.0,
profile_completeness=0.0,
posting_hour_entropy=0.0,
)
history = history.copy()
history["created_at"] = pd.to_datetime(history["created_at"], utc=True)
span_days = max(
(history["created_at"].max() - history["created_at"].min()).total_seconds() / 86400,
1.0, # floor at 1 day to avoid division by zero
)
posts_per_day = len(history) / span_days
reply_ratio = float(history["is_reply"].mean())
domain_links = history["url_domain"].dropna()
unique_domains_per_day = domain_links.nunique() / span_days
# Hourly posting distribution — 24 bins
hours = history["created_at"].dt.hour
hour_counts = np.zeros(24)
for h, cnt in hours.value_counts().items():
hour_counts[int(h)] = cnt
hour_probs = hour_counts / max(hour_counts.sum(), 1)
posting_hour_entropy = float(entropy(hour_probs + 1e-9)) # nats
return AccountFeatures(
account_age_days=span_days,
posts_per_day=posts_per_day,
reply_ratio=reply_ratio,
unique_domains_per_day=unique_domains_per_day,
follower_following_ratio=0.0, # populated from profile metadata
profile_completeness=0.0, # populated from profile metadata
posting_hour_entropy=posting_hour_entropy,
)The posting hour entropy feature deserves emphasis. Organic human accounts vary their posting times across the day in response to news events, sleep schedules, and work patterns — entropy is high (2.5–3.1 nats). Scripted bot accounts that post on a fixed schedule show distinctive low-entropy patterns: spikes at a few programmatically scheduled hours and near-zero activity otherwise. Fully randomized bots trying to defeat this detection end up with a uniform distribution, which is itself anomalous (no human posts that evenly across the 24-hour cycle).
Network graph signals
Content similarity and temporal bursts can be fooled by campaigns that use many distinct accounts posting meaningfully varied content with deliberate time offsets. Network topology is harder to fake: coordinated accounts need to amplify each other, which leaves structural traces in the follow and retweet graph.
We use networkx to build a directed graph where nodes are account IDs and edges represent follower relationships, reply-to relationships, and retweet relationships. Three network signals feed the coordination score:
- Tight in-degree clusters: accounts that follow only each other and a small set of seed accounts, with minimal outward connections to the broader graph
- Synchronized follow events: 50 or more accounts following the same target within a 10-minute window — consistent with a coordinated follow-for-follow campaign to inflate follower counts
- Mutual amplification rings: account A retweets account B repeatedly, B retweets A repeatedly, forming closed cycles of 2–5 accounts that circulate the same content
import networkx as nx
from typing import List
def build_amplification_graph(retweets: list[dict]) -> nx.DiGraph:
"""
Build a directed graph from retweet events.
Each edge (A → B) means: A retweeted B's content.
Edge weight = number of times A retweeted B.
"""
G = nx.DiGraph()
for rt in retweets:
src, dst = rt["retweeter_id"], rt["original_author_id"]
if G.has_edge(src, dst):
G[src][dst]["weight"] += 1
else:
G.add_edge(src, dst, weight=1)
return G
def find_amplification_rings(
G: nx.DiGraph,
min_ring_size: int = 3,
max_ring_size: int = 5,
min_edge_weight: int = 2,
) -> List[List[str]]:
"""
Find mutual amplification rings using Johnson's algorithm for cycle detection.
Filters to cycles of length min_ring_size–max_ring_size where all edges
meet the minimum mutual-retweet threshold.
Johnson's algorithm finds all simple cycles in O((V+E)(C+1)) time,
where C is the number of cycles. On sparse retweet graphs (most accounts
have few retweet relationships) this is fast enough for hourly batch runs.
Returns list of cycles, each cycle a list of account_ids in order.
"""
# Prune weak edges before cycle detection to reduce search space
pruned = nx.DiGraph()
for u, v, data in G.edges(data=True):
if data.get("weight", 1) >= min_edge_weight:
pruned.add_edge(u, v, **data)
rings = []
for cycle in nx.simple_cycles(pruned):
cycle_len = len(cycle)
if min_ring_size <= cycle_len <= max_ring_size:
# Verify the cycle is mutual: every edge in the cycle has a
# reciprocal edge (i.e., A→B and B→A both exist)
is_mutual = all(
pruned.has_edge(cycle[(i + 1) % cycle_len], cycle[i])
for i in range(cycle_len)
)
if is_mutual:
rings.append(cycle)
return ringsLimiting cycle detection to length 2–5 is both a practical constraint and a conceptually correct one. Real amplification rings are small — an influence operation running 500 accounts does not have each account explicitly mutually retweeting every other account, which would be obvious. They use small closed loops (pairs, triangles, quads) as the coordination substrate.
We run ring detection on a sliding 24-hour retweet graph updated hourly. Accounts that appear in three or more detected rings within 24 hours are flagged as ring participants regardless of whether the rings share other members.
Cross-platform amplification
A content cluster that appears on a single platform can plausibly be organic. The same content appearing on Twitter/X, Telegram, and Facebook within a two-hour window — posted by accounts with no detectable prior cross-platform relationship — is a strong coordination signal. Organic users who post across platforms typically have an observable cross-platform presence (linked profiles, shared followers); coordinated campaigns frequently do not.
The pipeline joins post content hashes (produced by the content deduplication system in the social media ingestion layer — see the ingestion article for the hash schema) across platforms. A cross-platform amplification event is scored when the same content_hash appears on three or more distinct platforms within 120 minutes, and the posting accounts have no prior cross-platform overlap in their follower graphs.
-- Cross-platform amplification detection query
-- Run hourly against the canonical post table
-- Returns content_hash values with cross-platform coordination signals
WITH recent_posts AS (
SELECT
content_hash,
platform,
account_id,
created_at,
COUNT(DISTINCT platform) OVER (PARTITION BY content_hash) AS platform_count
FROM canonical_posts
WHERE created_at >= NOW() - INTERVAL '2 hours'
AND content_hash IS NOT NULL
),
multi_platform_hashes AS (
-- Step 1: content appearing on 3+ platforms in the window
SELECT content_hash
FROM recent_posts
GROUP BY content_hash
HAVING COUNT(DISTINCT platform) >= 3
),
poster_pairs AS (
-- Step 2: distinct (account_id, platform) pairs per content_hash
SELECT
rp.content_hash,
rp.account_id,
rp.platform,
rp.created_at
FROM recent_posts rp
INNER JOIN multi_platform_hashes mph ON rp.content_hash = mph.content_hash
),
cross_platform_overlap AS (
-- Step 3: check whether any poster pair has prior cross-platform relationship
-- account_cross_links maps (account_id, platform) -> known alt accounts
SELECT pp.content_hash, COUNT(*) AS linked_accounts
FROM poster_pairs pp
INNER JOIN account_cross_links acl
ON acl.account_id = pp.account_id
AND acl.linked_platform != pp.platform
GROUP BY pp.content_hash
)
SELECT
pp.content_hash,
ARRAY_AGG(DISTINCT pp.platform ORDER BY pp.platform) AS platforms,
COUNT(DISTINCT pp.account_id) AS distinct_accounts,
MIN(pp.created_at) AS first_seen,
MAX(pp.created_at) AS last_seen,
EXTRACT(EPOCH FROM (MAX(pp.created_at) - MIN(pp.created_at))) AS window_seconds,
COALESCE(cpo.linked_accounts, 0) AS known_cross_platform_accounts
FROM poster_pairs pp
LEFT JOIN cross_platform_overlap cpo ON cpo.content_hash = pp.content_hash
GROUP BY pp.content_hash, cpo.linked_accounts
HAVING COALESCE(cpo.linked_accounts, 0) = 0 -- No prior cross-platform relationships
ORDER BY distinct_accounts DESC;The account_cross_links table is maintained by the ingestion pipeline, which identifies linked accounts when platforms provide explicit cross-references (e.g., a Twitter profile that links to a Telegram channel) or when heuristic matching on username, display name, and profile image finds high-confidence matches across platforms. An empty result from the cross-platform overlap join means none of the accounts posting this content have a detectable prior cross-platform relationship — the coordination is not explained by legitimate multi-platform users sharing the same content.
The coordination score
The four signal streams — content similarity, temporal bursts, account features, and network graph — are combined into a 0–100 coordination score. The score is computed per content cluster, not per post: it answers the question "how coordinated is the campaign that produced this cluster of near-duplicate content?"
from dataclasses import dataclass
import math
@dataclass
class CoordinationSignals:
# Content similarity (0–30 points)
# Driven by cluster size and average within-cluster Jaccard similarity
cluster_size: int
avg_jaccard: float # average pairwise similarity within cluster
# Temporal burst (0–25 points)
burst_score: float # from compute_burst_score() above
max_burst_score: float = 50.0 # calibration constant for normalization
# Account features (0–25 points)
account_feature_logit: float # raw logit from logistic regression classifier
# trained on 40K labeled accounts
# Network graph (0–20 points)
ring_count: int # number of amplification rings detected
synchronized_follow_events: int # count of 50+accounts/10min follow bursts
def compute_coordination_score(signals: CoordinationSignals) -> int:
"""
Assemble per-signal scores into a 0–100 coordination score.
Score > 70 → analyst alert queue
Score > 90 → automatic flag for election anomaly detection pipeline
"""
# --- Content similarity score (0–30) ---
# Larger clusters with higher average similarity score higher
# log(cluster_size) caps contribution of very large organic clusters
size_component = min(math.log1p(signals.cluster_size) / math.log1p(100), 1.0)
similarity_component = max(0.0, (signals.avg_jaccard - 0.45) / (1.0 - 0.45))
content_score = 30 * (0.5 * size_component + 0.5 * similarity_component)
# --- Temporal burst score (0–25) ---
# Normalize burst_score against calibration constant
burst_normalized = min(signals.burst_score / signals.max_burst_score, 1.0)
temporal_score = 25 * burst_normalized
# --- Account feature score (0–25) ---
# Logistic regression output: sigmoid(logit) gives probability in [0,1]
account_prob = 1.0 / (1.0 + math.exp(-signals.account_feature_logit))
account_score = 25 * account_prob
# --- Network graph score (0–20) ---
# Rings are a strong signal: each ring contributes up to 8 points (cap at 3)
ring_contribution = min(signals.ring_count, 3) / 3 * 12
# Synchronized follow events contribute up to 8 points
follow_contribution = min(signals.synchronized_follow_events, 2) / 2 * 8
graph_score = ring_contribution + follow_contribution
total = content_score + temporal_score + account_score + graph_score
return min(100, round(total))
def classify_coordination_score(score: int) -> str:
if score >= 90:
return "AUTO_FLAG" # automatic flag for election anomaly pipeline
elif score >= 70:
return "ANALYST_ALERT" # enters human review queue
else:
return "MONITOR" # logged, no immediate actionThe logistic regression classifier for account features was trained on 40,000 labeled accounts: 20,000 confirmed bot/coordinated accounts from past campaigns (labeled by analysts after investigation) and 20,000 organic accounts drawn randomly from the same platforms and time periods. Features are standardized before training. The model achieves 91.3% accuracy on a held-out test set — adequate for a component of a multi-signal score, though not reliable enough to flag accounts independently.
Score thresholds were calibrated against the labeled campaign dataset: at the 70-point threshold, 94% of confirmed coordinated campaigns score above threshold; at the 90-point threshold, 78% score above it. The difference reflects the precision/recall tradeoff — higher thresholds reduce false positives at the cost of some missed campaigns.
False positive management
Organic amplification of breaking news looks almost identical to a coordinated campaign: many accounts posting the same clip within minutes, some of them new accounts that were activated by the news event, cross-platform spread as people share the clip to different audiences. Three mitigations reduce the false positive rate on organic viral events:
News event baseline: We maintain a trending topic feed from a set of major news aggregators (Reuters, AP, BBC, Al Jazeera, plus platform-native trending endpoints where available). When a content cluster's entity signature (extracted by the NLP pipeline's SpaCy NER stage) overlaps significantly with trending topics, the coordination score threshold rises by 20 points for that cluster. A cluster that would normally trigger an analyst alert at 70 now requires 90 before alerting. This suppression is time-limited to 90 minutes — if a campaign sustains amplification beyond the news cycle that gave it cover, the threshold resets.
Account age filter: Accounts older than three years with a consistent organic posting history (measured by posting hour entropy above 2.0 nats and reply ratio above 0.20) receive a 0.5× weight in the temporal burst score. A burst of established organic accounts sharing breaking news should not score the same as a burst of three-week-old accounts posting the same content.
Human review queue: Coordination scores in the 70–90 range go to a human review queue staffed by analysts who investigate the cluster — examining the specific accounts, the post content, the timing, and any available context about the narrative being amplified — before any action is taken. Fully automated flagging (which triggers a flag in the election anomaly detection pipeline) triggers only at scores above 90.
In practice the human review queue resolves 68% of 70–90 scores as non-coordinated after investigation — the false positive rate at the analyst alert threshold is high by design, because the cost of missing a real campaign during an election is higher than the cost of analyst time spent on false positives.
Detection results on known campaigns
We validated the system against three historically confirmed coordinated campaigns in the dataset. Specific countries and dates are omitted; the campaigns are anonymized as Campaign A (election-period domestic amplification campaign), Campaign B (cross-platform foreign influence operation), and Campaign C (domestic astroturfing operation around ballot measure).
Campaign TP Rate FP Rate FP Rate Detection Detection
(confirmed (@70 thr.) (@90 thr.) latency latency
posts) (70+ med.) (90+ med.)
──────────────────────────────────────────────────────────────────────────────────────────
Campaign A (domestic amp.) 97% 8% 3% 9 min 41 min
Campaign B (cross-platform) 89% 11% 4% 16 min 58 min
Campaign C (ballot measure) 93% 6% 2% 11 min 43 min
──────────────────────────────────────────────────────────────────────────────────────────
Overall (weighted by size) 93% 8% 3% 12 min 47 minCampaign B's lower true positive rate (89%) reflects the characteristic of cross-platform foreign influence operations: they deliberately vary content across platforms more than domestic campaigns do, which reduces content similarity scores and pushes some clusters below the detection threshold. Cross-platform amplification is the strongest signal for Campaign B; content similarity contributes less.
The 47-minute median detection latency at the 90+ threshold means that for a campaign that runs for four hours, we typically alert with more than three hours remaining — enough time for platform trust-and-safety teams to act if the alert is escalated to them. The 12-minute latency at the 70+ threshold supports faster investigative triage, though with higher false positive rates.
For the election anomaly detection pipeline that consumes coordination scores as one of its four detection signals: Detecting election anomalies using statistical methods →
For the NLP pipeline that feeds entity extraction into coordination detection: NLP pipeline for real-time sentiment analysis at scale →
For the social media ingestion architecture that delivers the posts: Social media ingestion at scale: collecting 58M posts per day from 47 platform schemas →
For how MinHash deduplication works at pipeline scale: How we process 2.4M social-media posts per hour →
Multilingual bot detection extends the coordination detection pipeline across 14 languages with language-stratified XGBoost training and per-language Platt calibration.
Election finance entity resolution covers the downstream FEC entity matching pipeline that uses coordination scores as one input signal.