SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
27.9 KB

# CountryAtlas API — reference

Base URL: https://www.countryatlas.co/api/v1 (proxied by the Next.js app to the FastAPI process on 127.0.0.1:8291). Interactive docs: /api/v1/docs (Swagger) · /api/v1/redoc · spec /api/v1/openapi.json.

  • All endpoints are GET (except POST /admin/refresh, POST /admin/cache/clear) and return JSON (gzip when accepted).
  • Identifiers: countries by ISO3 or slug (case-insensitive: CAN, can, canada); indicators, topics and groups by slug.
  • Every response carries meta: {built_at, run_id, generated_at}; the header X-CountryAtlas-Run repeats the snapshot run id and X-Cache: HIT|MISS tells whether the in-process cache served it (cache keys include the run id, so a new snapshot invalidates everything).
  • Errors are RFC 7807 application/problem+json: {"type","title","status","detail","instance",…}. 404 unknown country/indicator/topic/group (with a helpful detail), 400 bad combination, 422 invalid parameter (with errors[]), 429 rate limit (120 req/min/IP, Retry-After), 503 {"title":"Data not built yet"} while no snapshot exists.
  • Run locally: CA_DATA_DIR=~/countryatlas-data ca-api (or python -m uvicorn countryatlas.api.main:app --port 8291).

# The provenance object

Every value the API returns (headline metrics, series points, ranking rows, map values, curated lists, changes…) carries a provenance object built from observations → indicator_sources → sources:

json
{
  "value": 55697.66, "period": "2025-01-01", "year": 2025, "unit": "current US$", "is_estimate": false, "is_forecast": false,
  "status": "imported", "formatted": "55.7k",
  "provenance": {
    "source": "worldbank", "source_name": "World Bank", "dataset": "WDI", "series_code": "NY.GDP.PCAP.CD",
    "retrieved_at": "2026-09-11T06:49:30Z", "source_updated_at": "2026-07-13T00:00:00Z",
    "url": "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA", "transform": null, "licence": "CC BY 4.0"
  }
}
field meaning
source / source_name connector id (worldbank, imf, oecd, eurostat, who, fred, owid, bis, ilo) and display name
dataset, series_code dataset and series inside the source (WDI NY.GDP.PCAP.CD, WEO NGDPDPC, OWID co2_per_capita, FRED FEDFUNDS…)
retrieved_at when the pipeline fetched the raw payload (UTC)
source_updated_at last update advertised by the source (may be null when the source does not publish it)
url deep link: World Bank …/indicator/{code}?locations={iso2}, OWID grapher ourworldindata.org/grapher/{slug} or the owid/co2-data / owid/energy-data repos, Eurostat databrowser, WHO GHO indicator page, FRED series page, IMF / OECD / BIS / ILO data portals
transform expression applied at normalisation (e.g. x*1e9), null if none
licence licence of the source

Series responses also list all sources used (sources[], with n_values) and the dominant one as provenance.

# Endpoints

# Health

text
GET /health          → {status: "ok"|"empty", run_id, built_at, observations, countries, indicators, version, cache}

/health never returns 503; status: "empty" means the pipeline has not produced atlas.duckdb yet.

# Countries

text
GET /countries?region=&income=&q=&sort=name|population|gdp|gdp_per_capita|coverage&kind=&limit=&offset=
GET /countries/{id}                       overview: country header, groups, coverage, freshness, headline metrics, topics summary, neighbours
GET /countries/{id}/topics/{topic}        indicators of a topic grouped by subtopic (registry order); indicators without data → has_data:false
GET /countries/{id}/series/{indicator}?from=&to=&freq=A|Q|M&include_forecast=true&include_alt=false
GET /countries/{id}/changes?limit=&kind=  recent detected changes (by severity)
GET /countries/{id}/events?limit=&kind=&indicator=   whole-history timeline
GET /countries/{id}/similar?mode=overall|economic|demographic|energy|social&limit=
GET /countries/{id}/insights
GET /countries/{id}/dna
GET /countries/{id}/download.csv|json?include_forecast=&topic=

region accepts any group id or slug (ecs, europe-central-asia, oecd, g7…); income accepts HIC|UMC|LMC|LIC or the slug.

bash
curl -s https://www.countryatlas.co/api/v1/countries/canada | jq '.headline[] | {indicator, formatted, year, rank_world, n_world, source: .provenance.source}'
# {"indicator":"gdp-per-capita","formatted":"55.7k","year":2025,"rank_world":21,"n_world":188,"source":"worldbank"} …
curl -s "https://www.countryatlas.co/api/v1/countries/CAN/series/gdp-per-capita?from=2000" | jq '.stats, .provenance'

Headline metric object (MetricValue): value, formatted, period, year, unit, is_estimate, is_forecast, status, prev{period,value}, change{abs,pct,formatted}, change_10y{…}, rank_world/n_world, rank_region/n_region (World Bank region), rank_income/n_income, rank_year, rank_is_stale (rank computed on a year > 2 years older than the indicator's latest year), higher_is_better, sparkline [[year, value]…] (last 30 non-forecast points), provenance.

# Indicators

text
GET /indicators?topic=&q=&featured=&source=&with_data=
GET /indicators/{slug}                    definition, sources (priority order, deep links), coverage (+ by_year), world_latest, freshness, top5/bottom5, years, topics
GET /indicators/{slug}/map?year=&nearest=false&classes=6
GET /indicators/{slug}/trend?group=world&from=&to=&min_n=5
GET /indicators/{slug}/download.csv|json?from=&to=&include_forecast=
  • world_latest is computed across countries (World Bank aggregates such as WLD are not stored): kind is sum for additive indicators, weighted_mean (population-weighted) for per-capita / share indicators, median otherwise; median, mean, weighted_mean and n are always returned.
  • map: without year, the latest year with ≥ 50 countries is used (year_used). With nearest=true each country's latest value within 3 years of the reference year is used and years{ISO3: year} says which. legend.breaks are quantile breaks (5–7 classes).
  • trend: per year median, mean, weighted_mean (population) for per-capita/percent indicators, sum for additive ones, n; preferred says which to plot.
bash
curl -s "https://www.countryatlas.co/api/v1/indicators/life-expectancy/map?year=2023" | jq '{year_used, n, legend, CAN: .values.CAN}'
curl -s "https://www.countryatlas.co/api/v1/indicators/gdp/trend?group=oecd" | jq '.points[-1]'

# Series bundle

text
GET /series?country=CAN,FRA&indicator=gdp-per-capita,inflation&from=1990&to=2026&freq=A&include_forecast=true

Returns series[] (one per indicator × country), each with values[] (per-point provenance), sources[], provenance, stats{min,max,first,last,cagr}.

# Rankings

text
GET /rankings?topic=                      rankable indicators (featured first) with ranking_year / ranking_n
GET /rankings/{indicator}?year=&group=world&sort=asc|desc&limit=50&offset=0&sparkline=true
GET /rankings/{indicator}/history?countries=CAN,USA&from=&to=

Rows: rank (within the requested group), rank_world/n_world (from the snapshot), pct_rank, country{id,slug,name,flag,region,income}, value, formatted, year, change_1y, change_10y, sparkline, provenance. Default sort is asc when higher_is_better=false (e.g. infant mortality), otherwise desc. year_used falls back to the nearest available year; years_available lists them.

bash
curl -s "https://www.countryatlas.co/api/v1/rankings/gdp-per-capita?limit=3" | jq '.rows[] | [.rank, .country.name, .formatted, .change_1y.formatted]'
# [1,"Luxembourg","147.3k","+6.9 %"] [2,"Ireland","131.6k","+16.6 %"] [3,"Switzerland","114.8k","+6.6 %"]

# Compare

text
GET /compare?countries=CAN,USA,FRA&indicators=gdp,gdp-per-capita&from=&to=&mode=absolute|per-capita|index100|pct&include_forecast=
GET /compare/snapshot?countries=CAN,USA&topic=economy   (or &indicators=a,b; default = headline indicators)
GET /compare/download.csv|json?countries=&indicators=&from=&to=

Modes: index100 rebases each series to 100 at the first available year ≥ from; per-capita divides additive (aggregation=sum) indicators by the population series of the same year (already per-capita indicators are left unchanged, transform.applied=false); pct = % change vs previous period. snapshot returns one row per indicator with a values{ISO3: MetricValue} map and best (when higher_is_better is known).

# Regions / groups

text
GET /regions?kind=world|region|continent|income|org
GET /regions/{slug}?indicator=gdp-per-capita&sort=asc|desc

Group page: members with headline values (+ provenance), aggregates (sum population/GDP, population-weighted GDP per capita, median life expectancy…, each with kind), and a member ranking on the chosen indicator.

text
GET /search?q=&limit=10&type=country|indicator|topic|region|source

Typed hits {type, id, slug, name, hint, score, url} from search_index (exact → prefix → word → substring → Jaro-Winkler fuzzy). Hints: Country · North America, Indicator · Economy · annual %, Topic · 12 indicators, Region · Organisation · 38 members. Two-word combos such as housing canada or canada gdp also return country_topic / country_indicator hits with a ready URL (/countries/canada/housing).

# Home

text
GET /home

snapshot (world population / GDP sums, median life expectancy, counts, built_at), lists (largest economies; fastest GDP growth, fastest population growth, highest life expectancy and energy transition leaders among countries ≥ 1 M; highest GDP per capita PPP; lowest unemployment among countries ≥ 5 M — 8 rows each with provenance, filter_note), recent_changes (12 most severe), recently_updated, featured_indicators, trending.

# Changes, sources, methodology

text
GET /changes?limit=&offset=&kind=&indicator=&country=&topic=&min_severity=
GET /sources                              n_observations, n_indicators, licence, last_retrieved_at
GET /sources/{id}                         indicators mapped, datasets, import_runs, freshness
GET /methodology                          registry-derived: topics, units/formats, source priority rule + URL patterns, validation rules, derived computations, DNA dimensions

# Downloads

CSV is streamed with a leading # CountryAtlas export · run … · built … comment line and the columns country_id, country_name, indicator_id, indicator_name, period, year, frequency, value, unit, is_estimate, is_forecast, status, source, source_name, dataset, series_code, retrieved_at, source_updated_at, url, licence. JSON returns {meta, n, columns, rows}.

# Admin (header X-Admin-Token: $CA_ADMIN_TOKEN)

text
GET  /admin/overview          db, meta, table counts, connectors health (import_runs), sources freshness/stale counts, scheduler heartbeat (data_dir/scheduler.json + scheduler.pid), cache stats
GET  /admin/runs?limit=&connector=&status=
GET  /admin/issues?severity=&connector=&indicator=&code=&run_id=&limit=
GET  /admin/coverage          indicator × n_countries / last_year matrix + per-country coverage
GET  /admin/raw?run_id=       raw files stored for a run
POST /admin/refresh           sends SIGUSR1 to the scheduler pid (409 when no scheduler)
POST /admin/cache/clear

403 on a wrong/missing token, 503 {"title":"Admin disabled"} when CA_ADMIN_TOKEN is not set.

# Operations notes

  • Snapshot swap: the API opens ~/countryatlas-data/atlas.duckdb read-only and compares st_ino/st_mtime_ns on every request; when the pipeline os.replace()s a new file, the old connection is closed and the new one opened (DuckDB caches instances per path, so the close must happen first). Responses are cached in-process per (run_id, path, query); nothing survives a new run id.
  • Performance (real snapshot, 2.0 M observations): country overview ≈ 12 ms warm (≈ 170 ms on the very first request while static lookups load), rankings ≈ 20 ms, home ≈ 33 ms, map ≈ 4 ms, cached hits ≈ 1 ms.
  • Formatting: formatted strings use the indicator's format: currency compact with the registry unit_short prefix (US$53.4k, US$1.2T, intl $45.3B), number compact (41.7M), percent 3.4 %, years 82.1 yrs, tonnes 5.2 t, per-1000 3.2 per 1,000, per-100k 1.2 per 100k. formatted_short is the same value without prefix/unit (53.4k, 3.4) for tight cells.
  • Registry reloads: the headline list (topics.yaml), indicator metadata and groups are re-read from the YAML registry each time a new snapshot run id is opened (the loaders' lru_cache is cleared), so registry edits take effect with the next build.
  • Home lists: each curated list carries min_population and filter_note (e.g. "Countries above 1M inhabitants") when a population floor applies (fastest GDP growth, fastest population growth, highest life expectancy, energy transition leaders: ≥ 1M; lowest unemployment: ≥ 5M).

# Analytics endpoints (API 1.1 — contract, 2026-09-11)

All additive, read-only, GET, cached per snapshot like the rest. Paths stay under /api/v1. Country-only pools (kind='country'), annual canonical frequency, non-forecast, non-quarantined. Every value object still carries provenance where a single source applies; aggregates carry provenance[] (one per contributing source, with n_values). Neutral wording everywhere: a change is an increase / decrease; it is called an improvement / deterioration only when the indicator declares higher_is_better.

text
GET /pulse                                         World Pulse — what is changing globally (latest year vs previous)
GET /movers?window=1|5|10&category=&kind=&limit=&min_population=      Biggest movers by window / category / kind
GET /extremes?window=1|5|10|25|since1990&topic=&min_population=        Curated extremes facets (fastest ageing, urbanising…)
GET /scatter?x=&y=&size=&year=&group=&log_x=&log_y=                      Cross-section scatter + Pearson / Spearman / OLS
GET /trajectory?x=&y=&size=&from=&to=&group=                           Gapminder-style frames (compact arrays per country)
GET /finder?f=slug:op:value&f=…&mode=and|or&region=&income=&sort=&limit= Structured country finder over `latest`
GET /peers?y=&x=&year=&method=theil-sen|ols&log_x=                      Above / below expected (robust cross-sectional fit)
GET /indicators/{slug}/related?limit=&min_n=                           Statistically related indicators (descriptive)
GET /indicators/{slug}/distribution?year=&highlight=&bins=              Histogram + medians (world / region / income) + percentile
GET /indicators/{slug}/frames?from=&to=&step=&group=                    Multi-year map frames for the time machine
GET /indicators/{slug}/quality                                         Coverage / freshness / continuity summary
GET /rankings/{indicator}/race?from=&to=&top=&group=                    Rank race frames (top N per year)
GET /regions/compare?a=&b=&indicators=                                 Group vs group aggregates + history
GET /countries/{id}/story                                              "How X changed": long-run indicators, templated text
GET /countries/{id}/dna?reference=world|region|income|<ISO3>           DNA + reference profile
GET /countries/{id}/quality                                            Per-indicator data quality for a country
GET /updates                                                           Freshness dashboard (sources, runs, changed values)
GET /search?q=compare canada usa                                       Intent hits (type action) — see below

# Shapes

MoverItem — {country: CountryCard, indicator: IndicatorCard, kind, year, ref_year, value, ref_value, delta, delta_pct, formatted, formatted_ref, severity (0–1), direction: "up"|"down", interpretation: "improvement"|"deterioration"|null, headline, provenance}. kind ∈ yoy_jump | yoy_drop | record_high | record_low | n_year_high | n_year_low | sign_flip | accelerating | decelerating | structural_break | trend_reversal | volatility_spike | change_5y | change_10y. Categories: economic = economy+government+trade+income, demographic = population, health, energy, climate = climate+environment, digital = digital+innovation, housing, labor. Kinds filter: improvement | deterioration | increase | decrease | record | reversal | acceleration | structural | all.

/pulse → {meta, year_reference, summary: {n_indicators, n_countries_reporting, n_record_highs, n_record_lows, n_changes}, items: [{indicator, year, n, n_up, n_down, n_flat, share_up, share_down, median_change_abs, median_change_pct, direction_semantics: "higher_is_better"|"lower_is_better"|"neutral", record_highs, record_lows, headline, top_up: MoverLite, top_down: MoverLite, convergence: {direction, cv_start, cv_end, n, from_year} | null, provenance}]} where MoverLite = {country, value, ref_value, delta, delta_pct, formatted, year}. Only headline + featured indicators whose latest year is ≥ reference − 1. Headlines are templates, e.g. "Inflation fell in 73 % of 176 reporting countries" (share_down ≥ 60), "Population is shrinking in 31 countries", "Renewable electricity hit a record high in 42 countries".

/extremes → {meta, window, from_year, to_year, min_population, filter_note, facets: [{id, title, indicator, direction: "up"|"down", metric: "abs"|"pct"|"points", rows: [{country, value_start, value_end, year_start, year_end, delta, delta_pct, formatted_start, formatted_end}], n, provenance}]}. Facets (fixed, in this order, skipped when the indicator has < 30 countries in the window): aging median-age ↑ · urbanizing urban-population-share ↑ · fertility-decline fertility-rate ↓ · life-expectancy-gains ↑ · gdp-transformations gdp-per-capita-ppp ↑ (pct) · digital-adoption internet-users ↑ · renewable-transitions renewable-electricity-share ↑ · co2-reductions co2-per-capita ↓ (pct) · population-decline population ↓ (pct) · population-boom population ↑ (pct) · inflation-surges inflation ↑ (points) · debt-buildup general-government-gross-debt-pct-gdp ↑ (points) · unemployment-falls ↓ (points). topic filters facets by their indicator topic. window=since1990 → from_year 1990.

/scatter → {meta, x, y, size, year, year_used, nearest_years: 3, group, n, points: [{id, slug, name, flag, region, income, x, y, size, year_x, year_y}], stats: {n, pearson, spearman, ols: {slope, intercept, r2} | null, log_x, log_y}, note}. log_x/log_y accept true|false|auto (auto = true when the indicator's format is currency/number with bounds ≥ 0 and max/min > 50). size defaults to population; size=none disables. Each axis uses the country's value in year_used or the nearest within 3 years.

/trajectory → {meta, x, y, size, group, years: [int…], countries: [{id, slug, name, flag, region, income}], series: {ISO3: {x: [num|null…], y: [...], size: [...]}}, domains: {x: [min,max], y: [min,max], size: [min,max]}, log_x, log_y, provenance: [...]} — arrays aligned on years, no interpolation. Default from = first year with ≥ 50 countries on both axes, to = last year with ≥ 50. Countries with < 30 % of frames on both axes are dropped.

/finder → filters f=slug:op:value (repeatable; ops gt gte lt lte eq between — between uses a..b), mode=and|or (default and), region/income group slug, sort=slug:asc|desc (default: first filter, desc), limit ≤ 218. Response {meta, mode, filters: [{indicator, op, value, value2, year_used}], n_matching, n_evaluated, items: [{country: CountryCard, matched: [slug…], values: {slug: {value, year, formatted, provenance}}}]}. year_used = latest year of each country (mixed years, honest).

/peers → {meta, x, y, year_used, n, method, fit: {slope, intercept, r2, log_x, residual_scale}, points: [{id, slug, name, flag, region, income, x, y, expected, residual, residual_z}], above: [top 12 by residual_z], below: [bottom 12], pairs: [{x, y, label}], note, methodology}. residual_z = residual / (1.4826·MAD of residuals). Default pair y=life-expectancy, x=gdp-per-capita-ppp, log_x=true. Suggested pairs: (life-expectancy, gdp-per-capita-ppp), (expected-years-of-schooling, gdp-per-capita-ppp), (co2-per-capita, gdp-per-capita-ppp), (life-expectancy, health-expenditure-per-capita), (internet-users, gdp-per-capita-ppp), (infant-mortality-rate, gdp-per-capita-ppp). Wording must stay descriptive: "above the fitted line", never "outperforms because".

/indicators/{slug}/related → {meta, indicator, year_used, n_candidates, items: [{indicator, pearson, spearman, n, year, log_x, log_y, direction: "positive"|"negative"}], note: "Correlation does not imply causation."} — cross-section on latest (values within 3 years of the indicator's max year), pairs need n ≥ min_n (default 40), sorted by |Spearman| desc, self and per-capita twins of the same quantity excluded when both are present (e.g. gdp vs gdp-ppp is allowed; gdp-per-capita vs gdp-per-capita-ppp is allowed — no hand rules beyond self).

/indicators/{slug}/distribution → {meta, indicator, year, year_used, n, log, histogram: {edges, counts, log}, stats: {min, p10, p25, median, mean, p75, p90, max}, highlight: {country: CountryCard, value, percentile, rank, n, region: GroupCard|null, region_median, income: GroupCard|null, income_median} | null, by_region: [{group: GroupCard, median, n}], by_income: [{group, median, n}], provenance}.

/indicators/{slug}/frames → {meta, indicator, group, years: [...], values: {ISO3: [num|null…]}, legend: {min, max, breaks: [...], n_classes}, n_by_year: [...], provenance} — breaks are quantiles over the pooled values of all years (a stable legend while scrubbing). Years limited to those with ≥ 20 countries; ≤ 80 frames.

/indicators/{slug}/quality → {meta, indicator, n_countries, n_countries_total, coverage_pct, first_year, last_year, latest_common_year, n_years, years_with_50plus, median_points_per_country, sparse_countries, stale_countries, flagged_values, sources: [...], badges: [...]}. Badges vocabulary (shared with the country endpoint): fresh (latest year ≥ reference − 1), historical (first year ≤ 1970), sparse (median points per country < 10), limited-coverage (< 50 % of countries), stale (latest year ≤ reference − 3), flagged (> 5 % of values with status warning), forecast (chosen source publishes projections).

/rankings/{indicator}/race → {meta, indicator, group, top, years: [...], frames: [{year, rows: [{id, value, rank}]}], countries: {ISO3: CountryCard}, max_value, provenance}. Union of countries that appear in the top N in any frame; only years with ≥ 20 ranked countries.

/regions/compare → {meta, groups: [GroupCard, GroupCard], rows: [{indicator, kind: "sum"|"median"|"weighted_mean", label, values: {<group id>: {value, formatted, n, year}}}], shares: {<group id>: {population_share_pct, gdp_share_pct}}, history: {<indicator slug>: {years: [...], <group id>: [num|null…]}}} for indicators (default) population, gdp, gdp-per-capita, life-expectancy, co2-per-capita, internet-users, gdp-growth, inflation; history for the first four (sum/weighted/median per year, ≥ 60 % of members reporting).

/countries/{id}/story → {meta, country, since, items: [{indicator, first: {year, value, formatted}, last: {year, value, formatted}, change_abs, change_pct, cagr, peak: {year, value}, trough: {year, value}, rank_first: {rank, n, year} | null, rank_last: {...} | null, series: [[year, value]…], text, provenance}]} — indicators in this order when ≥ 10 annual points: population, gdp-per-capita-ppp (fallback gdp-per-capita), life-expectancy, fertility-rate, urban-population-share, co2-per-capita, internet-users, renewable-electricity-share, general-government-gross-debt-pct-gdp, median-age, energy-use-per-capita, unemployment-rate; max 10 items. text template: "{country}'s {indicator} {rose|fell} from {first} in {y0} to {last} in {y1} ({signed pct} / {signed points})."

/countries/{id}/dna (extended) → adds reference: {kind: "world"|"region"|"income"|"country", id, label, dims: {...}} | null (world → 50 on every dimension; region/income → median of the members' dims; country → that country's dims).

/countries/{id}/quality → {meta, country, summary: {n_indicators, n_with_data, coverage_pct, latest_year, n_fresh, n_stale, n_sparse, n_flagged}, items: [{indicator, latest_year, first_year, n_points, expected_points, missing_years, continuity_pct, status, source, source_updated_at, retrieved_at, badges: [...]}]}.

/updates → {meta, snapshot: {run_id, built_at, observations, indicators, countries, values_changed, values_changed_by_source: {...}}, sources: [{source: Source, status: "ok"|"partial"|"failed"|"stale"|"unknown", last_success_at, last_retrieved_at, source_updated_at, n_datasets, n_indicators, n_observations, latest_year, values_changed, countries_affected}], recent_runs: [{run_id, connector, dataset, started_at, finished_at, status, rows_valid, warnings, errors, message}], indicators_recently_updated: [IndicatorSummary…]}. No file paths, hosts or secrets in the payload.

/search intents — deterministic parsing before the index lookup; each intent yields a hit {type: "action", action, id, name, hint, url, score: 1.0}: compare <c1> <c2> [<c3>…] → /compare/<slugs>; rank[ing] <indicator> → /rankings/<slug>; <indicator> <country> (already country_indicator); <indicator> <group> → /rankings/<slug>?group=<group>; <indicator> map|explore → /explore?indicator=; <c1> vs <c2> → compare.

# Change detection 2.0 kinds (pipeline)

structural_break (single mean shift with gain ≥ 0.5 and shift ≥ 1.5 × series sd, segments ≥ 5 years, break within the last 10 years for changes), trend_reversal (three consecutive yearly moves of one sign after three of the opposite sign), volatility_spike (sd of the last 5 yearly differences ≥ 3 × the sd of the previous 15). Same row shape; detail documents the parameters.

# Implementation notes (as built, 2026-09-12 — deviations from the contract above)

  • /pulse takes min_population (default 1 000 000, 0 disables) that filters the top movers only (top_up/top_down); counts and shares use every reporting country. The response echoes min_population and filter_note. Pools need ≥ 30 reporting countries (not 20) so a thin indicator (Gini, 25 countries) never headlines; year_reference is the latest year of the annual pillars population / GDP / life expectancy (monthly/quarterly series already carry the current year). Convergence compares max-year vs max-year − 10.
  • /movers windows 5/10 report kinds change_5y / change_10y (direction in direction), keep moves with robust |z| ≥ 1.5 and rank by severity = min(1, |z|/4) × importance; kind=record|reversal|acceleration|structural are only meaningful for window=1 and return an empty list otherwise. Window 1 reads the changes table (any kind, including the pipeline's structural_break / trend_reversal / volatility_spike).
  • /extremes facets each carry their own from_year / to_year (the indicator's latest common year and its window start); the top-level pair is the min/max across facets. Start values use the nearest observation within ±2 years of the window start.
  • /indicators/{slug}/related: min_n accepts ≥ 5 (default 40). Pearson is computed on the association scale (log10 for level series, flagged log_x/log_y), Spearman on raw values.
  • /indicators/{slug}/distribution: highlight also returns year; group medians need ≥ 2 members with data.
  • /regions/compare history[slug] also carries kind; /updates sources carry runs {n, n_failed} and snapshot.reference_year.
  • /countries/{id}/story text template is "{Country}: {Indicator} rose|fell from {first} in {y0} to {last} in {y1} ({signed change}[, ±x % a year])." (colon form keeps acronyms such as GDP / PPP intact); /countries/{id}/dna adds note.
  • /search intents: countries are resolved by ISO2/ISO3 code first, then exact single word, then (near-)exact 2–3-word names, then fuzzy.