Ripple turns GDELT's noisy, multilingual, heavily syndicated news stream into real-world stories, then shows how media attention to each story spreads across publisher countries over time on an interactive 3D globe.
![]() |
![]() |
![]() |
![]() |
Blue beacon = event location (GDELT lat/lon). Red markers = publisher
countries (where the outlet is based), lit in observation order. Arcs show
attention order, not transmission. Full write-up: docs/project-report.md.
Built at HackMIT 2026 for the Education track, and submitted to the Voloridge ("Signal in the Noise"), Cognition and Ramp sponsor challenges. The educational goal: let anyone, a student, a journalist, a policy class pick a real-world event and see how the world's media noticed it: which countries reported first, which lagged, how attention grew hour by hour, and, for any single article, why the system believes it belongs to that event. The technical goal is the Voloridge brief: find real structure in a large, noisy, redundant dataset and make it legible.
Our dataset is GDELT, which monitors news in 65+ languages every 15 minutes on our benchmark window ~300k documents a day, most of them syndicated copies, translations or near-duplicates of far fewer actual events.
Ripple treats this as a research problem in event resolution under
uncertainty. Each article is a noisy observation of an unknown real-world
event; four independent evidence channels (calibrated multilingual title
similarity, IDF-weighted GlobalEventID overlap, rare URL-slug tokens, rare
GKG persons/organisations) vote on whether two articles observe the same event,
an evidence gate refuses to link on a single weak signal, and Constant Potts
Model Leiden clustering resolves the surviving graph into incidents and story
families. Articles without sufficient evidence are left unassigned by design
abstention over false certainty. Every assignment is explainable per article,
every run is deterministic, and every quality number is measured against
labelled data.
Stack: Python 3.12 · Polars · sentence-transformers · FAISS · igraph/Leiden ·
FastAPI · React + TypeScript · Three.js / React Three Fiber. Full technical
write-up: docs/project-report.md.
Ripple is designed for GDELT's multi-terabyte archive; every number below comes from the one fully processed three-day window (Feb 6–8 2023: Turkey–Syria earthquake, Chinese balloon, State of the Union, Grammys, Ohio derailment).
raw 15-min zips preprocess─▶ typed Parquet ─atomic─▶ documents / atomic events
─embed─▶ multilingual title vectors ─cluster─▶ incidents → story families
─materialize─▶ attention store ─FastAPI─▶ /api/v2/attention/* ─▶ R3F globe
| Measured on Feb 6–8 2023 | |
|---|---|
| raw compressed GDELT (Events, Mentions, GKG, English + translated) | 4.33 GB |
| typed analytical Parquet | 348 MB (12.4× smaller) |
| GKG documents / with page title / languages / domains | 914,199 / 909,599 / 63 / 14,635 |
| documents with a resolved publisher country | 98.9 % |
| candidate pairs → evidence-gated edges | 47.5 M → 11.4 M |
| incidents / story families | 120,026 / 103,076 |
| documents deliberately left unassigned | 11.5 % |
| Turkey–Syria earthquake family | 17,204 docs, 291 incidents, 143 publisher countries, 55 languages (15,834 docs / 195 incidents served as macro-events ≥ 30 docs) |
Design principle: Ripple prefers abstention over false certainty. A document without sufficient evidence stays unassigned rather than contaminating a story; a false merge is visible on the globe and in the evidence panel, a split costs recall.
curl -LsSf https://astral.sh/uv/0.12.13/install.sh | sh && export PATH="$HOME/.local/bin:$PATH"
uv sync --locked
# GDELT's outlet→country lookup (tab-separated domain, FIPS code, country name); see
# https://blog.gdeltproject.org/mapping-the-media-a-geographic-lookup-of-gdelts-sources/
export GDELT_DOMAIN_LOOKUP=/path/to/MASTER-GDELTDOMAINSBYCOUNTRY-MAY2018.TXT
scripts/run_window.sh 20230206 20230206 20230208 # fetch → … → data/store/20230206
GDELT_ATTENTION_DATA=data/store/20230206 uv run uvicorn api.app:app --port 8000
cd web && npm install && npm run dev # http://localhost:5173/spread.htmlscripts/run_window.sh chains the six stages below (plus
attention.analytics, which materialises the country-response observations the
Country Stats tab reads; the API derives them on first use if the file is
missing); each stage is a module with --help and can be rerun on its own. The raw window is ~1.4–1.8 GB zipped per
day. On a CPU-only box: preprocess ≈ 10 min, embedding ≈ 38 min per 0.9 M titles
(the one step a GPU collapses to minutes; embeddings are cached in 20k-title
shards and resume after interruption), full clustering ≈ 61 min, re-gating from
cached pair features ≈ 8.5 min, materialize ≈ 4 min.
| Stage | Module | Output |
|---|---|---|
| fetch | scripts/fetch_window.py |
15-minute Events/Mentions/GKG zips, English and translation.* |
| preprocess | attention/preprocess.py |
typed Parquet (61/16/27+ columns, UTC), canonical URLs, PAGE_TITLE from GKG Extras, per-file audit.json (sha256, rows, malformed rows, missing slots), sources table with the publisher-country ladder |
| atomic | attention/atomic.py |
one row per canonical URL (GKG ⟗ web Mentions), document↔GlobalEventID links, atomic events, wire groups (normalised title) |
| embed | attention/embed.py |
paraphrase-multilingual-MiniLM-L12-v2 title vectors (384-d), distinct titles encoded once |
| cluster | attention/cluster.py |
candidate_pairs, pair_features, graph_edges, incident_memberships (assignment_score, is_primary, secondary memberships), incidents, run.json |
| materialize | attention/materialize.py |
macro_events, event_families, macro_event_documents, country_{event,family}_{attention,summary}, country_baseline, document_evidence, sources, meta.json |
Publisher country is resolved by a confidence ladder (exact GDELT domain lookup 0.9 → parent domain 0.7 → ccTLD 0.5 → unresolved 0.0) and is always where the outlet is based, never where the event happened. Attention measures are reported raw (documents), unique (distinct outlets) and effective (wire groups, so 200 copies of one agency story count once).
Every document is a graph node. Four channels each yield a bounded candidate set
(FAISS top-30 calibrated title cosine, IDF-weighted shared GlobalEventID, rare
URL-slug tokens, rare GKG persons/organisations), pairs within 48 h are scored on
all channels, and an evidence gate decides which become edges: two channels must
agree or one must clear its floor; a lone URL/entity/event channel needs ≥2 shared
features and is vetoed when both titles exist and disagree; a lone title edge
across languages needs ≥ 0.60. CPM-Leiden (resolution 0.05, seed 2026) gives
incidents; documents whose edge mass does not clear the gate get
incident_id = -1.
Incidents are precise but fragmented, so a second stage links them into story
families by comparing aggregates only centroid title similarity, corroborated
by a shared top person/organisation, a shared GlobalEventID, or (optional
geo channel) a shared sub-country place, within a family_max_hours gap — and
runs CPM-Leiden again (resolution 0.5). The document→incident gate is never
loosened to recover family recall. Title scores are calibrated per domain and per
language pair (TitleChannel), because the encoder's background similarity for
random Korean or Arabic pairs (≈0.3) is far above English (≈0.1).
Given the same input and seed the run is deterministic: entity/place top-k ties,
incident edge order and igraph vertex mapping are all totally ordered
(tests/test_attention.py covers this; see Reproducibility below).
Labels: eval/pairs_20230206.jsonl (300 hard pairs) and
eval/neighborhoods_20230206.jsonl (300 docs across five stories), single
first-pass labeller; the 150 hardest pairs are exported in
eval/review_20230206.csv for review. Metrics via attention.evaluate.
| run | level | pair P | pair R | pair F1 | B³ F1 | CEAF-e F1 | unassigned |
|---|---|---|---|---|---|---|---|
_fam (first demo) |
incident | 0.320 | 0.463 | 0.378 | 0.394 | 0.265 | 8.1 % |
| family | 0.497 | 0.796 | 0.612 | 0.647 | 0.208 | ||
_v3 (+ single-channel min features / title veto) |
incident | 0.449 | 0.522 | 0.483 | 0.397 | 0.270 | 9.2 % |
| family | 0.579 | 0.714 | 0.639 | 0.590 | 0.202 | ||
_v5 (+ cross-language lone-title floor 0.60, served) |
incident | 0.528 | 0.418 | 0.467 | 0.402 | 0.273 | 11.5 % |
| family | 0.681 | 0.653 | 0.667 | 0.574 | 0.226 |
_v5 numbers are from the deterministic rerun (eval/metrics_20230206_v5.json);
the pre-fix run reported family P/R/B³ 0.667/0.673/0.585 from the same settings,
which is the run-to-run drift the determinism fix removed.
Selection is constrained optimisation, not max F1:
max 0.45·P_fam + 0.25·F1_fam + 0.15·B³_fam + 0.15·F1_inc subject to
unassigned ≤ 12 % and a manual flagship audit with no catastrophic merge
(scripts/eval_sweep.py, scripts/rank_sweep.py, scripts/inspect_flagship.py
→ eval/flagship_20230206_*.md). _v5 is the provisional demo model, not a
frozen result: the gate sweep was run adaptively and stopped at the constraint
boundary (floor 0.65 → 12.8 % unassigned).
Each gate change was motivated by a concrete failure found through the evidence
panel: a Thai K-pop article held in the earthquake family by URL-only edges to
boilerplate pages (_fam → _v3: quake family URL-only/entity-only
cross-incident edges 52/151 → 3/0), then Indian Railways and SEPTA stories in the
Ohio-derailment family via 0.55–0.60 cross-language title edges (_v3 → _v5:
both gone; the control family is 938 coherent docs in 8 incidents with no
URL-only or entity-only cross-incident edge). Candidate retrieval
independently reaches 98.9 % of labelled documents, with 99.6 % of gold documents
in one connected candidate component (eval/candidate_recall_20230206.json).
Notation: document attention/cluster.py and
attention/materialize.py.
1. Calibrated title similarity. Titles are encoded once per distinct string
with paraphrase-multilingual-MiniLM-L12-v2. Raw cosine is not comparable
across languages (random Korean pairs sit at ≈0.4, English at ≈0.03) or within
one site (boilerplate suffixes), so vectors are centered per domain and per
language ("all-but-the-top", Mu & Viswanath 2018) and re-normalised,
(
Candidates are the 30 nearest neighbours (FAISS HNSW, inner product) with raw cosine ≥ 0.35; titles repeated ≥ 10 times by one domain are excluded as boilerplate.
2. Sparse evidence channels. For GlobalEventIDs; URL-slug tokens after host-boilerplate suppression; GKG
persons/organisations appearing in ≤ 10 % of documents) is IDF-weighted and
row-normalised, so the channel score is a cosine over rare shared features:
Each channel also proposes its own candidates (top-$k$ by
3. Evidence gate (apply_gate). A pair becomes an edge only if the evidence
is corroborated or unambiguous. With evidence count
where _v5
cross-language floor) and 0.55 otherwise, and "titles agree" is
with titleless_min_channels. Documents with no surviving edge get
incident_id = -1 — the 11.5 % abstention is this rule, not a post-hoc filter.
4. Incidents: CPM-Leiden. On the sparse weighted graph, Leiden (Traag, Waltman & van Eck 2019) maximises the Constant Potts Model
i.e. a community survives only while its mean intra-pair weight exceeds
reported as assignment_score (primary) and as a secondary membership for
every other incident with
5. Families: aggregate-only linking. Incidents are compared as units so the
document gate is never loosened. Centroid
where gap is the distance between the incidents' first_seen ranges, then
CPM-Leiden again with
6. Attention metrics (materialize.py). With
Onset is deliberately conservative the later of the hour in which the third
distinct outlet appears and the hour in which cumulative documents reach 10 % of
the group's total;
6b. Country response analytics (analytics.py). Built on the materialised
store, without touching the clustering. Each story family event_country carrying the
most effective reports across its incidents is crossed with every publisher
country
falling back to world_fallback) when the origin never reached onset. Negative values are kept:
the destination's press reached onset first. Per country, pair or matrix cell:
coverage
7. Evaluation. Pairwise same_event at incident level, same_event ∪ related at family level);
B³ over labelled neighbourhood documents,
| Route | Returns |
|---|---|
GET /search?q= |
story families first, with their incidents |
GET /events, /events/{id} |
macro-events (incidents ≥30 docs, ≥5 effective reports) |
GET /events/{id}/spread, /timeline, /countries |
chronological publisher-country onsets, lag vs world onset, attention ratio (include_family, min_country_confidence) |
GET /families/{id}, /spread, /timeline, /countries |
the same at story-family level |
GET /event-types, /event-types/{t}/countries |
type-level roll-ups |
GET /documents/{id}/evidence |
why this article is here: assignment score, supporting same-incident edges with per-channel scores, best competing edge |
GET /countries |
country baseline and centroids |
GET /analytics/countries, /analytics/countries/{c} |
observed response of every publisher country to foreign vs domestic events; per-country breakdown by event type, origin and magnitude |
GET /analytics/pairs?origin=FR&destination=GM |
France → Germany and the reverse direction, with the contributing events |
GET /analytics/matrix?origin=GM&origin=FR&destination=… |
origin × destination cells (diagonal = domestic response) plus per-destination rows |
GET /analytics/origins/{c}, /destinations/{c}, /events |
how the world responds to events in c, who c responds to, and the raw observations behind any number |
Analytics routes accept level (family/incident), event_type, start/end,
min_event_effective_reports, reference (origin_preferred/origin_only/world)
and the support minimums; all filters AND together, and every response adds
filters, support and caveats.
Every response carries meta (denominators, resolution model, semantics).
GET /docs is the OpenAPI UI. The older GDELT-shaped explorer API
(/api/v2/doc/doc, /geo/geo, /ext/*) is described in docs/prototypes.md.
React + TypeScript + Three.js via React Three Fiber/drei: textured Earth with
normal-map relief, ocean specular, atmosphere, optional clouds, starfield with
sun/moon, and day/night lighting driven by the playhead's UTC time. A blue beacon
marks the event location; red markers, expanding rings and great-circle arcs
appear at publisher-country centroids in observed-time order with
play/pause/scrub/speed controls, hover tooltips (onset, lag, articles, effective
reports, attention ratio) and a side panel with the story's stats and top
countries. Arcs show attention order, not transmission — GDELT does not
establish causal flow. Clicking an article opens the evidence panel. Quality,
terrain exaggeration, atmosphere, marker size, arcs, speed, camera and light/dark
stage are props. Texture provenance is in web/README.md.
The Country Stats tab (CountryStats.tsx) makes countries queryable the
way stories already are: a country overview (foreign vs observed domestic
response, breakdowns), a Country ↔ Country view with the reverse direction
measured separately and a one-click swap, and a multi-country comparison with a
sortable table and an origin × destination heatmap switchable between median,
mean, coverage and event count. Every cell and row drills down to the story
families behind it, and each family opens on the globe. In this mode the globe
highlights the selected publisher country and the event-origin countries with
supported estimates; arcs are observed media-attention relationships, not
transmission.
uv run pytest -q tests # synthetic end-to-end pipeline, gate rules, determinism, API shapes
uv run ruff check . && uv run ruff format --check .
uv run ty check attention api scripts
cd web && npx tsc -b && npm run lint && npm run buildReproducibility: two independent _v5 runs from the same cached pair features
(data/clusters/20230206_v5_run3, _run4) were verified byte-identical on all
six cluster outputs (candidate_pairs, pair_features, graph_edges,
title_background, incident_memberships, incidents), with identical audit
counts (120,026 incidents, 103,076 families, 11.459 % unassigned); only
runtime/path metadata in run.json differs. tests/test_backend_pipeline.py
asserts the same byte identity on the synthetic window.
Globe: a recorded browser pass against the served _v5 store (PR #5) verified
search → auto-select → fly-to, beacon vs publisher markers, chronological
activation with rings/arcs, pause/scrub/speed/replay, tooltips, drag/zoom
clamps, dark stage with stars/sun and UTC-driven lighting, the evidence panel on
two stories, a one-country spread, the empty state and API-outage recovery. The
screenshots above come from that run. Known cosmetics: a THREE.Clock
deprecation warning in the console and missing CJK/Devanagari glyphs on a
font-less test box.
| Window | State |
|---|---|
| Feb 6–8 2023 | complete, served (_v5); _fam/_v3 kept for comparison |
| Aug 4–6 2020 (Beirut explosion) | preprocess + atomic + embedding done (5.33 GB → 471 MB, 11.3×; 1.335 M docs, 64 languages, 23,595 domains); clustering/materialisation run on a 48-core CPU EC2 box — not servable until its API smoke checks pass |
| Apr 15 2019 (Notre-Dame) | early metadata-only slice; GKG has no page titles before ~2020 |
| Feb 24–26 2022, Sep 8–10 2022 | not fetched |
- That the whole GDELT archive has been processed — one three-day window is.
- That
_v5is optimal or final; the labels are single-pass and the sweeps were adaptive, not exhaustive. - That recall is good: family pair recall 0.65, the earthquake is split into hundreds of incidents and several families.
- That
assignment_scoreis a probability (it is normalised edge mass), or that arcs on the globe show information flow. - Anything about pre-2020 windows beyond the metadata fallback.
attention/ preprocess · atomic · embed · cluster · materialize · analytics · evaluate
api/ FastAPI app (attention + country-analytics routers, legacy explorer routes)
web/ Vite: spread.html (Ripple globe) and index.html (legacy explorer)
scripts/ run_window.sh, fetch_window.py, sweeps, audits, label sampling/export
eval/ labelled pairs/neighbourhoods, metrics, flagship audits, review CSV
tests/ pytest gate (105 tests)
docs/ project-report.md (full guide), assets/ (screenshots), architecture-plan.md, article-graph-results.md, prototypes.md
Earlier prototypes (event co-mention communities, article-graph experiments,
the April 2019 slice, v0) are documented in docs/prototypes.md.




