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 · 349 lines markdown
Rendered Raw Blame History
1# CountryAtlas API — reference23Base URL: `https://www.countryatlas.co/api/v1` (proxied by the Next.js app to the FastAPI process on `127.0.0.1:8291`).4Interactive docs: [`/api/v1/docs`](https://www.countryatlas.co/api/v1/docs) (Swagger) · [`/api/v1/redoc`](https://www.countryatlas.co/api/v1/redoc) · spec `/api/v1/openapi.json`.56* All endpoints are `GET` (except `POST /admin/refresh`, `POST /admin/cache/clear`) and return JSON (gzip when accepted).7* Identifiers: countries by **ISO3 or slug** (case-insensitive: `CAN`, `can`, `canada`); indicators, topics and groups by **slug**.8* Every response carries `meta: {built_at, run_id, generated_at}`; the header `X-CountryAtlas-Run` repeats the snapshot run id and9  `X-Cache: HIT|MISS` tells whether the in-process cache served it (cache keys include the run id, so a new snapshot invalidates everything).10* Errors are RFC 7807 `application/problem+json`: `{"type","title","status","detail","instance",…}`.11  `404` unknown country/indicator/topic/group (with a helpful `detail`), `400` bad combination, `422` invalid parameter (with `errors[]`),12  `429` rate limit (120 req/min/IP, `Retry-After`), `503 {"title":"Data not built yet"}` while no snapshot exists.13* Run locally: `CA_DATA_DIR=~/countryatlas-data ca-api` (or `python -m uvicorn countryatlas.api.main:app --port 8291`).1415## The provenance object1617Every value the API returns (headline metrics, series points, ranking rows, map values, curated lists, changes…) carries a `provenance`18object built from `observations` → `indicator_sources` → `sources`:1920```json21{22  "value": 55697.66, "period": "2025-01-01", "year": 2025, "unit": "current US$", "is_estimate": false, "is_forecast": false,23  "status": "imported", "formatted": "55.7k",24  "provenance": {25    "source": "worldbank", "source_name": "World Bank", "dataset": "WDI", "series_code": "NY.GDP.PCAP.CD",26    "retrieved_at": "2026-09-11T06:49:30Z", "source_updated_at": "2026-07-13T00:00:00Z",27    "url": "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA", "transform": null, "licence": "CC BY 4.0"28  }29}30```3132| field | meaning |33|---|---|34| `source` / `source_name` | connector id (`worldbank`, `imf`, `oecd`, `eurostat`, `who`, `fred`, `owid`, `bis`, `ilo`) and display name |35| `dataset`, `series_code` | dataset and series inside the source (WDI `NY.GDP.PCAP.CD`, WEO `NGDPDPC`, OWID `co2_per_capita`, FRED `FEDFUNDS`…) |36| `retrieved_at` | when the pipeline fetched the raw payload (UTC) |37| `source_updated_at` | last update advertised by the source (may be `null` when the source does not publish it) |38| `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 |39| `transform` | expression applied at normalisation (e.g. `x*1e9`), `null` if none |40| `licence` | licence of the source |4142Series responses also list **all** sources used (`sources[]`, with `n_values`) and the dominant one as `provenance`.4344## Endpoints4546### Health47```48GET /health          → {status: "ok"|"empty", run_id, built_at, observations, countries, indicators, version, cache}49```50`/health` never returns 503; `status: "empty"` means the pipeline has not produced `atlas.duckdb` yet.5152### Countries53```54GET /countries?region=&income=&q=&sort=name|population|gdp|gdp_per_capita|coverage&kind=&limit=&offset=55GET /countries/{id}                       overview: country header, groups, coverage, freshness, headline metrics, topics summary, neighbours56GET /countries/{id}/topics/{topic}        indicators of a topic grouped by subtopic (registry order); indicators without data → has_data:false57GET /countries/{id}/series/{indicator}?from=&to=&freq=A|Q|M&include_forecast=true&include_alt=false58GET /countries/{id}/changes?limit=&kind=  recent detected changes (by severity)59GET /countries/{id}/events?limit=&kind=&indicator=   whole-history timeline60GET /countries/{id}/similar?mode=overall|economic|demographic|energy|social&limit=61GET /countries/{id}/insights62GET /countries/{id}/dna63GET /countries/{id}/download.csv|json?include_forecast=&topic=64```65`region` accepts any group id or slug (`ecs`, `europe-central-asia`, `oecd`, `g7`…); `income` accepts `HIC|UMC|LMC|LIC` or the slug.6667```bash68curl -s https://www.countryatlas.co/api/v1/countries/canada | jq '.headline[] | {indicator, formatted, year, rank_world, n_world, source: .provenance.source}'69# {"indicator":"gdp-per-capita","formatted":"55.7k","year":2025,"rank_world":21,"n_world":188,"source":"worldbank"} …70curl -s "https://www.countryatlas.co/api/v1/countries/CAN/series/gdp-per-capita?from=2000" | jq '.stats, .provenance'71```7273Headline metric object (`MetricValue`): `value, formatted, period, year, unit, is_estimate, is_forecast, status, prev{period,value},74change{abs,pct,formatted}, change_10y{…}, rank_world/n_world, rank_region/n_region (World Bank region), rank_income/n_income, rank_year,75rank_is_stale (rank computed on a year > 2 years older than the indicator's latest year), higher_is_better, sparkline [[year, value]…]76(last 30 non-forecast points), provenance`.7778### Indicators79```80GET /indicators?topic=&q=&featured=&source=&with_data=81GET /indicators/{slug}                    definition, sources (priority order, deep links), coverage (+ by_year), world_latest, freshness, top5/bottom5, years, topics82GET /indicators/{slug}/map?year=&nearest=false&classes=683GET /indicators/{slug}/trend?group=world&from=&to=&min_n=584GET /indicators/{slug}/download.csv|json?from=&to=&include_forecast=85```86* `world_latest` is **computed across countries** (World Bank aggregates such as WLD are not stored): `kind` is `sum` for additive indicators,87  `weighted_mean` (population-weighted) for per-capita / share indicators, `median` otherwise; `median`, `mean`, `weighted_mean` and `n` are always returned.88* `map`: without `year`, the latest year with ≥ 50 countries is used (`year_used`). With `nearest=true` each country's latest value within89  3 years of the reference year is used and `years{ISO3: year}` says which. `legend.breaks` are quantile breaks (5–7 classes).90* `trend`: per year `median`, `mean`, `weighted_mean` (population) for per-capita/percent indicators, `sum` for additive ones, `n`; `preferred` says which to plot.9192```bash93curl -s "https://www.countryatlas.co/api/v1/indicators/life-expectancy/map?year=2023" | jq '{year_used, n, legend, CAN: .values.CAN}'94curl -s "https://www.countryatlas.co/api/v1/indicators/gdp/trend?group=oecd" | jq '.points[-1]'95```9697### Series bundle98```99GET /series?country=CAN,FRA&indicator=gdp-per-capita,inflation&from=1990&to=2026&freq=A&include_forecast=true100```101Returns `series[]` (one per indicator × country), each with `values[]` (per-point provenance), `sources[]`, `provenance`, `stats{min,max,first,last,cagr}`.102103### Rankings104```105GET /rankings?topic=                      rankable indicators (featured first) with ranking_year / ranking_n106GET /rankings/{indicator}?year=&group=world&sort=asc|desc&limit=50&offset=0&sparkline=true107GET /rankings/{indicator}/history?countries=CAN,USA&from=&to=108```109Rows: `rank` (within the requested group), `rank_world/n_world` (from the snapshot), `pct_rank`, `country{id,slug,name,flag,region,income}`,110`value`, `formatted`, `year`, `change_1y`, `change_10y`, `sparkline`, `provenance`. Default `sort` is `asc` when `higher_is_better=false`111(e.g. infant mortality), otherwise `desc`. `year_used` falls back to the nearest available year; `years_available` lists them.112113```bash114curl -s "https://www.countryatlas.co/api/v1/rankings/gdp-per-capita?limit=3" | jq '.rows[] | [.rank, .country.name, .formatted, .change_1y.formatted]'115# [1,"Luxembourg","147.3k","+6.9 %"] [2,"Ireland","131.6k","+16.6 %"] [3,"Switzerland","114.8k","+6.6 %"]116```117118### Compare119```120GET /compare?countries=CAN,USA,FRA&indicators=gdp,gdp-per-capita&from=&to=&mode=absolute|per-capita|index100|pct&include_forecast=121GET /compare/snapshot?countries=CAN,USA&topic=economy   (or &indicators=a,b; default = headline indicators)122GET /compare/download.csv|json?countries=&indicators=&from=&to=123```124Modes: `index100` rebases each series to 100 at the first available year ≥ `from`; `per-capita` divides additive (`aggregation=sum`)125indicators by the `population` series of the same year (already per-capita indicators are left unchanged, `transform.applied=false`);126`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).127128### Regions / groups129```130GET /regions?kind=world|region|continent|income|org131GET /regions/{slug}?indicator=gdp-per-capita&sort=asc|desc132```133Group page: members with headline values (+ provenance), `aggregates` (sum population/GDP, population-weighted GDP per capita, median life134expectancy…, each with `kind`), and a member ranking on the chosen indicator.135136### Search137```138GET /search?q=&limit=10&type=country|indicator|topic|region|source139```140Typed hits `{type, id, slug, name, hint, score, url}` from `search_index` (exact → prefix → word → substring → Jaro-Winkler fuzzy).141Hints: `Country · North America`, `Indicator · Economy · annual %`, `Topic · 12 indicators`, `Region · Organisation · 38 members`.142Two-word combos such as `housing canada` or `canada gdp` also return `country_topic` / `country_indicator` hits with a ready URL143(`/countries/canada/housing`).144145### Home146```147GET /home148```149`snapshot` (world population / GDP sums, median life expectancy, counts, built_at), `lists` (largest economies; fastest GDP growth,150fastest population growth, highest life expectancy and energy transition leaders among countries ≥ 1 M; highest GDP per capita PPP;151lowest unemployment among countries ≥ 5 M — 8 rows each with provenance, `filter_note`), `recent_changes` (12 most severe),152`recently_updated`, `featured_indicators`, `trending`.153154### Changes, sources, methodology155```156GET /changes?limit=&offset=&kind=&indicator=&country=&topic=&min_severity=157GET /sources                              n_observations, n_indicators, licence, last_retrieved_at158GET /sources/{id}                         indicators mapped, datasets, import_runs, freshness159GET /methodology                          registry-derived: topics, units/formats, source priority rule + URL patterns, validation rules, derived computations, DNA dimensions160```161162### Downloads163CSV is streamed with a leading `# CountryAtlas export · run … · built …` comment line and the columns164`country_id, country_name, indicator_id, indicator_name, period, year, frequency, value, unit, is_estimate, is_forecast, status, source,165source_name, dataset, series_code, retrieved_at, source_updated_at, url, licence`. JSON returns `{meta, n, columns, rows}`.166167### Admin (header `X-Admin-Token: $CA_ADMIN_TOKEN`)168```169GET  /admin/overview          db, meta, table counts, connectors health (import_runs), sources freshness/stale counts, scheduler heartbeat (data_dir/scheduler.json + scheduler.pid), cache stats170GET  /admin/runs?limit=&connector=&status=171GET  /admin/issues?severity=&connector=&indicator=&code=&run_id=&limit=172GET  /admin/coverage          indicator × n_countries / last_year matrix + per-country coverage173GET  /admin/raw?run_id=       raw files stored for a run174POST /admin/refresh           sends SIGUSR1 to the scheduler pid (409 when no scheduler)175POST /admin/cache/clear176```177`403` on a wrong/missing token, `503 {"title":"Admin disabled"}` when `CA_ADMIN_TOKEN` is not set.178179## Operations notes180181* **Snapshot swap**: the API opens `~/countryatlas-data/atlas.duckdb` read-only and compares `st_ino`/`st_mtime_ns` on every request; when182  the pipeline `os.replace()`s a new file, the old connection is closed and the new one opened (DuckDB caches instances per path, so the183  close must happen first). Responses are cached in-process per `(run_id, path, query)`; nothing survives a new run id.184* **Performance** (real snapshot, 2.0 M observations): country overview ≈ 12 ms warm (≈ 170 ms on the very first request while static185  lookups load), rankings ≈ 20 ms, home ≈ 33 ms, map ≈ 4 ms, cached hits ≈ 1 ms.186* **Formatting**: `formatted` strings use the indicator's `format`: currency compact with the registry `unit_short` prefix187  (`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-1000188  `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.189* **Registry reloads**: the headline list (`topics.yaml`), indicator metadata and groups are re-read from the YAML registry each time a190  new snapshot run id is opened (the loaders' `lru_cache` is cleared), so registry edits take effect with the next build.191* **Home lists**: each curated list carries `min_population` and `filter_note` (e.g. `"Countries above 1M inhabitants"`) when a192  population floor applies (fastest GDP growth, fastest population growth, highest life expectancy, energy transition leaders: ≥ 1M;193  lowest unemployment: ≥ 5M).194195## Analytics endpoints (API 1.1 — contract, 2026-09-11)196197All additive, read-only, GET, cached per snapshot like the rest. Paths stay under `/api/v1`. Country-only pools (`kind='country'`),198annual canonical frequency, non-forecast, non-quarantined. Every value object still carries `provenance` where a single source199applies; aggregates carry `provenance[]` (one per contributing source, with `n_values`). Neutral wording everywhere: a change is an200*increase* / *decrease*; it is called an *improvement* / *deterioration* only when the indicator declares `higher_is_better`.201202```203GET /pulse                                         World Pulse — what is changing globally (latest year vs previous)204GET /movers?window=1|5|10&category=&kind=&limit=&min_population=      Biggest movers by window / category / kind205GET /extremes?window=1|5|10|25|since1990&topic=&min_population=        Curated extremes facets (fastest ageing, urbanising…)206GET /scatter?x=&y=&size=&year=&group=&log_x=&log_y=                      Cross-section scatter + Pearson / Spearman / OLS207GET /trajectory?x=&y=&size=&from=&to=&group=                           Gapminder-style frames (compact arrays per country)208GET /finder?f=slug:op:value&f=…&mode=and|or&region=&income=&sort=&limit= Structured country finder over `latest`209GET /peers?y=&x=&year=&method=theil-sen|ols&log_x=                      Above / below expected (robust cross-sectional fit)210GET /indicators/{slug}/related?limit=&min_n=                           Statistically related indicators (descriptive)211GET /indicators/{slug}/distribution?year=&highlight=&bins=              Histogram + medians (world / region / income) + percentile212GET /indicators/{slug}/frames?from=&to=&step=&group=                    Multi-year map frames for the time machine213GET /indicators/{slug}/quality                                         Coverage / freshness / continuity summary214GET /rankings/{indicator}/race?from=&to=&top=&group=                    Rank race frames (top N per year)215GET /regions/compare?a=&b=&indicators=                                 Group vs group aggregates + history216GET /countries/{id}/story                                              "How X changed": long-run indicators, templated text217GET /countries/{id}/dna?reference=world|region|income|<ISO3>           DNA + reference profile218GET /countries/{id}/quality                                            Per-indicator data quality for a country219GET /updates                                                           Freshness dashboard (sources, runs, changed values)220GET /search?q=compare canada usa                                       Intent hits (type action) — see below221```222223### Shapes224225`MoverItem` — `{country: CountryCard, indicator: IndicatorCard, kind, year, ref_year, value, ref_value, delta, delta_pct, formatted,226formatted_ref, severity (0–1), direction: "up"|"down", interpretation: "improvement"|"deterioration"|null, headline, provenance}`.227`kind` ∈ `yoy_jump | yoy_drop | record_high | record_low | n_year_high | n_year_low | sign_flip | accelerating | decelerating |228structural_break | trend_reversal | volatility_spike | change_5y | change_10y`. Categories: `economic` = economy+government+trade+income,229`demographic` = population, `health`, `energy`, `climate` = climate+environment, `digital` = digital+innovation, `housing`, `labor`.230Kinds filter: `improvement | deterioration | increase | decrease | record | reversal | acceleration | structural | all`.231232`/pulse` → `{meta, year_reference, summary: {n_indicators, n_countries_reporting, n_record_highs, n_record_lows, n_changes},233items: [{indicator, year, n, n_up, n_down, n_flat, share_up, share_down, median_change_abs, median_change_pct, direction_semantics:234"higher_is_better"|"lower_is_better"|"neutral", record_highs, record_lows, headline, top_up: MoverLite, top_down: MoverLite,235convergence: {direction, cv_start, cv_end, n, from_year} | null, provenance}]}` where `MoverLite = {country, value, ref_value, delta,236delta_pct, formatted, year}`. Only headline + featured indicators whose latest year is ≥ reference − 1. Headlines are templates, e.g.237"Inflation fell in 73 % of 176 reporting countries" (share_down ≥ 60), "Population is shrinking in 31 countries", "Renewable238electricity hit a record high in 42 countries".239240`/extremes` → `{meta, window, from_year, to_year, min_population, filter_note, facets: [{id, title, indicator, direction: "up"|"down",241metric: "abs"|"pct"|"points", rows: [{country, value_start, value_end, year_start, year_end, delta, delta_pct, formatted_start,242formatted_end}], n, provenance}]}`. Facets (fixed, in this order, skipped when the indicator has < 30 countries in the window):243`aging` median-age ↑ · `urbanizing` urban-population-share ↑ · `fertility-decline` fertility-rate ↓ · `life-expectancy-gains` ↑ ·244`gdp-transformations` gdp-per-capita-ppp ↑ (pct) · `digital-adoption` internet-users ↑ · `renewable-transitions` renewable-electricity-share ↑ ·245`co2-reductions` co2-per-capita ↓ (pct) · `population-decline` population ↓ (pct) · `population-boom` population ↑ (pct) ·246`inflation-surges` inflation ↑ (points) · `debt-buildup` general-government-gross-debt-pct-gdp ↑ (points) · `unemployment-falls` ↓ (points).247`topic` filters facets by their indicator topic. `window=since1990` → from_year 1990.248249`/scatter` → `{meta, x, y, size, year, year_used, nearest_years: 3, group, n, points: [{id, slug, name, flag, region, income, x, y,250size, year_x, year_y}], stats: {n, pearson, spearman, ols: {slope, intercept, r2} | null, log_x, log_y}, note}`. `log_x/log_y` accept251`true|false|auto` (auto = true when the indicator's format is currency/number with bounds ≥ 0 and max/min > 50). `size` defaults to252`population`; `size=none` disables. Each axis uses the country's value in `year_used` or the nearest within 3 years.253254`/trajectory` → `{meta, x, y, size, group, years: [int…], countries: [{id, slug, name, flag, region, income}], series: {ISO3: {x: [num|null…],255y: [...], size: [...]}}, domains: {x: [min,max], y: [min,max], size: [min,max]}, log_x, log_y, provenance: [...]}` — arrays aligned on256`years`, no interpolation. Default `from` = first year with ≥ 50 countries on both axes, `to` = last year with ≥ 50. Countries with257< 30 % of frames on both axes are dropped.258259`/finder` → filters `f=slug:op:value` (repeatable; ops `gt gte lt lte eq between` — between uses `a..b`), `mode=and|or` (default and),260`region`/`income` group slug, `sort=slug:asc|desc` (default: first filter, desc), `limit` ≤ 218. Response `{meta, mode, filters: [{indicator,261op, value, value2, year_used}], n_matching, n_evaluated, items: [{country: CountryCard, matched: [slug…], values: {slug: {value, year,262formatted, provenance}}}]}`. `year_used` = latest year of each country (mixed years, honest).263264`/peers` → `{meta, x, y, year_used, n, method, fit: {slope, intercept, r2, log_x, residual_scale}, points: [{id, slug, name, flag, region,265income, x, y, expected, residual, residual_z}], above: [top 12 by residual_z], below: [bottom 12], pairs: [{x, y, label}], note, methodology}`.266`residual_z` = residual / (1.4826·MAD of residuals). Default pair y=life-expectancy, x=gdp-per-capita-ppp, log_x=true. Suggested pairs:267(life-expectancy, gdp-per-capita-ppp), (expected-years-of-schooling, gdp-per-capita-ppp), (co2-per-capita, gdp-per-capita-ppp),268(life-expectancy, health-expenditure-per-capita), (internet-users, gdp-per-capita-ppp), (infant-mortality-rate, gdp-per-capita-ppp).269Wording must stay descriptive: "above the fitted line", never "outperforms because".270271`/indicators/{slug}/related` → `{meta, indicator, year_used, n_candidates, items: [{indicator, pearson, spearman, n, year, log_x, log_y,272direction: "positive"|"negative"}], note: "Correlation does not imply causation."}` — cross-section on `latest` (values within 3 years of273the indicator's max year), pairs need `n ≥ min_n` (default 40), sorted by |Spearman| desc, self and per-capita twins of the same274quantity excluded when both are present (e.g. gdp vs gdp-ppp is allowed; gdp-per-capita vs gdp-per-capita-ppp is allowed — no275hand rules beyond self).276277`/indicators/{slug}/distribution` → `{meta, indicator, year, year_used, n, log, histogram: {edges, counts, log}, stats: {min, p10, p25,278median, mean, p75, p90, max}, highlight: {country: CountryCard, value, percentile, rank, n, region: GroupCard|null, region_median,279income: GroupCard|null, income_median} | null, by_region: [{group: GroupCard, median, n}], by_income: [{group, median, n}], provenance}`.280281`/indicators/{slug}/frames` → `{meta, indicator, group, years: [...], values: {ISO3: [num|null…]}, legend: {min, max, breaks: [...],282n_classes}, n_by_year: [...], provenance}` — breaks are quantiles over the pooled values of all years (a stable legend while scrubbing).283Years limited to those with ≥ 20 countries; ≤ 80 frames.284285`/indicators/{slug}/quality` → `{meta, indicator, n_countries, n_countries_total, coverage_pct, first_year, last_year, latest_common_year,286n_years, years_with_50plus, median_points_per_country, sparse_countries, stale_countries, flagged_values, sources: [...], badges: [...]}`.287Badges vocabulary (shared with the country endpoint): `fresh` (latest year ≥ reference − 1), `historical` (first year ≤ 1970),288`sparse` (median points per country < 10), `limited-coverage` (< 50 % of countries), `stale` (latest year ≤ reference − 3), `flagged`289(> 5 % of values with status warning), `forecast` (chosen source publishes projections).290291`/rankings/{indicator}/race` → `{meta, indicator, group, top, years: [...], frames: [{year, rows: [{id, value, rank}]}], countries: {ISO3:292CountryCard}, max_value, provenance}`. Union of countries that appear in the top N in any frame; only years with ≥ 20 ranked countries.293294`/regions/compare` → `{meta, groups: [GroupCard, GroupCard], rows: [{indicator, kind: "sum"|"median"|"weighted_mean", label, values: {<group295id>: {value, formatted, n, year}}}], shares: {<group id>: {population_share_pct, gdp_share_pct}}, history: {<indicator slug>: {years: [...],296<group id>: [num|null…]}}}` for indicators (default) population, gdp, gdp-per-capita, life-expectancy, co2-per-capita, internet-users,297gdp-growth, inflation; history for the first four (sum/weighted/median per year, ≥ 60 % of members reporting).298299`/countries/{id}/story` → `{meta, country, since, items: [{indicator, first: {year, value, formatted}, last: {year, value, formatted},300change_abs, change_pct, cagr, peak: {year, value}, trough: {year, value}, rank_first: {rank, n, year} | null, rank_last: {...} | null,301series: [[year, value]…], text, provenance}]}` — indicators in this order when ≥ 10 annual points: population, gdp-per-capita-ppp302(fallback gdp-per-capita), life-expectancy, fertility-rate, urban-population-share, co2-per-capita, internet-users,303renewable-electricity-share, general-government-gross-debt-pct-gdp, median-age, energy-use-per-capita, unemployment-rate; max 10 items.304`text` template: "{country}'s {indicator} {rose|fell} from {first} in {y0} to {last} in {y1} ({signed pct} / {signed points})."305306`/countries/{id}/dna` (extended) → adds `reference: {kind: "world"|"region"|"income"|"country", id, label, dims: {...}} | null`307(`world` → 50 on every dimension; region/income → median of the members' dims; country → that country's dims).308309`/countries/{id}/quality` → `{meta, country, summary: {n_indicators, n_with_data, coverage_pct, latest_year, n_fresh, n_stale, n_sparse,310n_flagged}, items: [{indicator, latest_year, first_year, n_points, expected_points, missing_years, continuity_pct, status, source,311source_updated_at, retrieved_at, badges: [...]}]}`.312313`/updates` → `{meta, snapshot: {run_id, built_at, observations, indicators, countries, values_changed, values_changed_by_source: {...}},314sources: [{source: Source, status: "ok"|"partial"|"failed"|"stale"|"unknown", last_success_at, last_retrieved_at, source_updated_at,315n_datasets, n_indicators, n_observations, latest_year, values_changed, countries_affected}], recent_runs: [{run_id, connector, dataset,316started_at, finished_at, status, rows_valid, warnings, errors, message}], indicators_recently_updated: [IndicatorSummary…]}`.317No file paths, hosts or secrets in the payload.318319`/search` intents — deterministic parsing before the index lookup; each intent yields a hit `{type: "action", action, id, name, hint,320url, score: 1.0}`: `compare <c1> <c2> [<c3>…]` → `/compare/<slugs>`; `rank[ing] <indicator>` → `/rankings/<slug>`; `<indicator> <country>`321(already `country_indicator`); `<indicator> <group>` → `/rankings/<slug>?group=<group>`; `<indicator> map|explore` → `/explore?indicator=`;322`<c1> vs <c2>` → compare.323324### Change detection 2.0 kinds (pipeline)325326`structural_break` (single mean shift with gain ≥ 0.5 and shift ≥ 1.5 × series sd, segments ≥ 5 years, break within the last 10 years for327`changes`), `trend_reversal` (three consecutive yearly moves of one sign after three of the opposite sign), `volatility_spike` (sd of the328last 5 yearly differences ≥ 3 × the sd of the previous 15). Same row shape; `detail` documents the parameters.329330### Implementation notes (as built, 2026-09-12 — deviations from the contract above)331332* `/pulse` takes `min_population` (default 1 000 000, `0` disables) that filters the **top movers only** (`top_up`/`top_down`); counts and333  shares use every reporting country. The response echoes `min_population` and `filter_note`. Pools need ≥ 30 reporting countries (not 20)334  so a thin indicator (Gini, 25 countries) never headlines; `year_reference` is the latest year of the annual pillars population / GDP /335  life expectancy (monthly/quarterly series already carry the current year). Convergence compares max-year vs max-year − 10.336* `/movers` windows 5/10 report kinds `change_5y` / `change_10y` (direction in `direction`), keep moves with robust |z| ≥ 1.5 and rank by337  severity = min(1, |z|/4) × importance; `kind=record|reversal|acceleration|structural` are only meaningful for `window=1` and return an338  empty list otherwise. Window 1 reads the `changes` table (any kind, including the pipeline's structural_break / trend_reversal /339  volatility_spike).340* `/extremes` facets each carry their own `from_year` / `to_year` (the indicator's latest common year and its window start); the top-level341  pair is the min/max across facets. Start values use the nearest observation within ±2 years of the window start.342* `/indicators/{slug}/related`: `min_n` accepts ≥ 5 (default 40). Pearson is computed on the association scale (log10 for level series,343  flagged `log_x`/`log_y`), Spearman on raw values.344* `/indicators/{slug}/distribution`: `highlight` also returns `year`; group medians need ≥ 2 members with data.345* `/regions/compare` `history[slug]` also carries `kind`; `/updates` sources carry `runs {n, n_failed}` and `snapshot.reference_year`.346* `/countries/{id}/story` text template is `"{Country}: {Indicator} rose|fell from {first} in {y0} to {last} in {y1} ({signed change}[, ±x % a year])."`347  (colon form keeps acronyms such as GDP / PPP intact); `/countries/{id}/dna` adds `note`.348* `/search` intents: countries are resolved by ISO2/ISO3 code first, then exact single word, then (near-)exact 2–3-word names, then fuzzy.349