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%
31.4 KB · 363 lines markdown
Rendered Raw Blame History
1# CountryAtlas — Architecture (contract for all contributors)23> **www.countryatlas.co — Understand the world, one country at a time.**4> The country is the primary unit of navigation. Every visible number is traceable to its source.56This document is the binding contract between the data platform (Python), the API (FastAPI) and the7web app (Next.js). Change it first, then the code.89## 1. Deployment topology (MacLustr cluster)1011```12Internet ─ DNS A www.countryatlas.co → 51.161.112.61 (BHS64, OVH Beauharnois)13        └─ Caddy (TLS auto) ─ WireGuard wg1 ─→ M2M32 (Mac Studio M2, 12c/32GB)  10.67.0.x14                                                 ├─ countryatlas-web        Next.js 16   :8290  (public upstream)15                                                 ├─ countryatlas-api        FastAPI      :8291  (loopback only; reached via Next rewrites /api/v1/*)16                                                 └─ countryatlas-scheduler  Python loop  (daily refresh, writes snapshots)17                                                 ~/countryatlas-data/  (raw/, staging/, build/, atlas.duckdb, exports/, logs/)18```1920* Orchestrated by `mld` from the gateway M1M32 (manifest `deploy/countryatlas.mld.json`, PM2 processes).21* No database port is ever public. The API listens on 127.0.0.1:8291 only.22* The web app listens on 0.0.0.0:8290 (reached through the WireGuard tunnel only; the Mac has no public IP).23* Apex `countryatlas.co` → 301 to `www` (Caddy redirect on BHS64; requires an A record for the apex).2425## 2. Storage: DuckDB snapshots (no Postgres/ClickHouse/Redis for the MVP)2627Volumes: ~220 countries × ~250 indicators × ≤65 years ≈ 3–4 M observations. DuckDB handles this in28milliseconds, exports Parquet/CSV natively and needs zero operations. The design keeps the door open to29Postgres/ClickHouse later (same logical schema).3031**Snapshot-swap rule (critical):**32331. The pipeline builds a brand-new file `~/countryatlas-data/build/atlas-<run_id>.duckdb`.342. Derived tables are computed inside it (latest, rankings, changes, events, similarity, insights, coverage).353. Integrity checks pass → `os.replace()` it onto `~/countryatlas-data/atlas.duckdb` (atomic rename) and copy36   to `snapshots/atlas-<run_id>.duckdb` (keep last 7).374. The API opens the DB **read-only**; on every request it compares the file's `st_ino`/`st_mtime` with the open38   one and reopens when they differ. Readers never block the writer and vice-versa.3940Raw source payloads are kept forever (gzip JSON/CSV) under `raw/<connector>/<dataset>/<YYYY-MM-DD>/…`.41Nothing is ever fetched without also being stored raw.4243### 2.1 Logical schema (DuckDB)4445```sql46countries(id TEXT PK /* = iso3 */, iso2, iso3, iso_numeric, slug, short_name, official_name, capital,47          continent, region_wb /* WB region id */, region_wb_name, subregion, income_group /* HIC/UMC/LMC/LIC */,48          currency_code, currency_name, area_km2 DOUBLE, latitude, longitude, flag_emoji,49          un_member BOOLEAN, status TEXT /* country|territory|historical */, independent BOOLEAN,50          landlocked BOOLEAN, borders TEXT[] , languages TEXT[], demonym, kind TEXT /* country|aggregate */)51groups(id TEXT PK /* world|oecd|g7|g20|eu|brics|nac|ecs|hic|... */, slug, name, kind /* world|region|income|org|custom */,52       description, wb_code /* WLD, OED, EUU, HIC ... when the source has an aggregate */)53group_members(group_id, country_id)54sources(id TEXT PK /* worldbank|imf|oecd|eurostat|who|fred|owid|bis|ilo */, name, organization, url, licence,55        attribution, api_base, last_success_at, notes)56indicators(id TEXT PK /* = slug */, slug, name, short_name, description, topic, subtopic, unit, unit_short,57           frequency /* A|Q|M */, precision INT, aggregation /* sum|mean|weighted_mean|none */,58           higher_is_better BOOLEAN NULL, ranking_eligible BOOLEAN, per_capita_of TEXT NULL,59           format TEXT /* number|percent|currency|index|years|per_1000|... */, scale TEXT /* raw|thousands|millions|billions */,60           source_priority TEXT[] , methodology TEXT, tags TEXT[], featured BOOLEAN)61indicator_sources(indicator_id, source_id, dataset, series_code, params JSON, priority INT, transform TEXT NULL, notes)62observations(country_id, indicator_id, period DATE /* first day of period */, year INT, frequency,63             value DOUBLE, unit, source_id, source_dataset, source_series_code,64             is_estimate BOOL, is_forecast BOOL, revision INT, retrieved_at TIMESTAMP, source_updated_at TIMESTAMP,65             status TEXT /* verified|imported|warning|stale|quarantined */, metadata JSON)66             -- PK (country_id, indicator_id, period, frequency). Exactly ONE source per WHOLE SERIES (country, indicator,67             -- frequency): the highest-priority source having non-forecast data for that country, unless a lower-priority68             -- source is > 3 years fresher (metadata.merge_reason = "priority" | "fresher"). Sources are never spliced69             -- inside a series. Every other source's complete series is kept in observations_alt.70observations_alt(same columns)      -- complete alternative series (other sources), for provenance / alternative views71observation_revisions(country_id, indicator_id, period, frequency, old_value, new_value, old_source_id, new_source_id,72             changed_at TIMESTAMP, run_id)   -- never silently overwrite: carry forward from the previous snapshot73latest(country_id, indicator_id, period, year, value, prev_period, prev_value, change_abs, change_pct,74       rank_world INT, n_world INT, rank_region INT, n_region INT, rank_income INT, n_income INT,75       source_id, is_forecast, is_estimate, status)       -- latest NON-forecast observation per country×indicator76rankings(indicator_id, year, country_id, value, rank INT, n INT, pct_rank DOUBLE)   -- countries only (kind='country'), non-forecast77changes(id, country_id, indicator_id, kind /* yoy_drop|yoy_jump|record_high|record_low|n_year_high|n_year_low|sign_flip|accelerating|decelerating */,78        period, year, value, ref_value, delta, delta_pct, window_years INT, severity DOUBLE /* 0-1 */, headline TEXT, detail JSON, detected_at)79        -- "What changed" = recent (latest period) ; events = whole history80events(id, country_id, indicator_id, kind, period, year, value, ref_value, delta, delta_pct, severity, headline, detail JSON)81similarity(country_id, mode /* overall|economic|demographic|energy|social */, peer_id, score DOUBLE /* 0-100 */, rank INT,82           contributions JSON /* {indicator: {z_a, z_b, weight, contribution}} */)83insights(id, country_id, template_id, text, values JSON, indicators TEXT[], computed_at)84country_dna(country_id, dims JSON /* {income:0-100, demographics:..., urbanization, trade, energy, emissions, innovation, education, public_spending} */, year_ref)85coverage(country_id, n_indicators INT, n_observations INT, latest_year INT, coverage_pct DOUBLE, updated_at)86import_runs(run_id, connector, dataset, started_at, finished_at, status /* ok|failed|partial */, rows_raw, rows_norm, rows_valid,87            warnings INT, errors INT, message, raw_path)88validation_issues(run_id, connector, indicator_id, country_id, period, severity /* info|warning|error */, code, message)89meta(key, value)      -- build_run_id, built_at, schema_version, indicator_count, observation_count, ...90```9192## 3. Registries (YAML, versioned in git, loaded by both pipeline and API)9394* `registry/countries.yaml` — canonical registry generated by `scripts/build_country_registry.py` from the World Bank95  country list (region, income group, capital, lat/long) merged with the mledoze/countries dataset (ISO numeric, official96  name, subregion, currency, area, UN membership, flag, borders, languages). Hand-edited fields survive regeneration97  (`overrides:` block). Aggregates (WLD, OED, EUU, HIC …) are **not** countries; they live in `groups.yaml`.98* `registry/groups.yaml` — World, WB regions (7), income groups (4), organisations (OECD, EU, G7, G20, BRICS, ASEAN,99  African Union, Eurozone, Nordic, NAFTA/USMCA, Commonwealth…) with explicit member lists (ISO3).100* `registry/indicators.yaml` — the canonical indicator registry (see §4). Every indicator maps to one or more101  external series (connector + code + params + priority).102* `registry/topics.yaml` — topics (economy, government, population, …) with order, blurb, icon, and the ordered list of103  indicator slugs to show on the country topic page, plus `headline` indicators for the country overview.104105**Rule:** the code never identifies a country by its name. Always `iso3` (id) or `slug`.106URLs use slugs (`/countries/canada`); the API accepts both iso3 and slug.107108## 4. Indicator conventions109110* `slug` is kebab-case, stable, and is the URL: `/indicators/gdp-per-capita`.111* `unit` is human (`current US$`, `% of GDP`, `years`, `people`, `% of population`), `unit_short` for axes (`US$`, `%`, `yrs`).112* `format` drives display: `currency` (scale automatically: 1.2T / 45.3B), `percent` (1 decimal), `number` (thousand separators,113  scale for large), `years`, `index`, `per_1000`, `per_100k`, `ratio`, `celsius`, `tonnes`.114* `higher_is_better` is only set when it is unambiguous (life expectancy = true, infant mortality = false). Otherwise null115  (inflation, population, exchange rate) — rankings then sort descending by value and say "highest".116* `ranking_eligible` false for series that make no sense to rank (exchange rate, policy rate in local terms, indexes).117* Period → `period` is the first day: annual `YYYY-01-01`, quarterly `YYYY-{01,04,07,10}-01`, monthly `YYYY-MM-01`.118* Forecast observations (IMF WEO projections) are stored with `is_forecast=true` and never enter `latest`, `rankings`,119  `changes` or `events`; they are shown dashed on charts.120* A "topic" belongs to the fixed set: `economy, government, population, labor, income, housing, health, education, trade,121  energy, climate, environment, infrastructure, digital, innovation, agriculture, tourism, security, quality-of-life`.122123## 5. Pipeline (`ca` CLI, package `countryatlas`)124125```126ca registry validate                # YAML sanity (unique slugs, known connectors, topics)127ca fetch [--connector X] [--indicator Y]   # raw payloads → raw/…  (retries, backoff, rate limit, ETag/If-Modified-Since when available)128ca normalize [--connector X]        # raw → staging/<connector>/<dataset>.parquet in the NormalizedObservation schema129ca validate                         # rules (§6) → statuses + validation_issues130ca build                            # merge staging by source priority → new DuckDB → derived tables → integrity → atomic swap131ca refresh [--connector X]          # fetch + normalize + validate + build (idempotent; safe to run any time)132ca schedule                         # long-running loop: refresh daily at 03:15 America/Toronto; also refresh on SIGUSR1133ca status                           # last runs, row counts, freshness, stale sources134ca export indicator <slug> | country <iso3>   # CSV/JSON/Parquet into exports/ (also served by the API on demand)135```136137Connector interface (`countryatlas/connectors/base.py`):138139```python140class Connector(Protocol):141    id: str                                   # "worldbank"142    def discover(self) -> list[DatasetDescriptor]143    def fetch(self, spec: IndicatorSourceSpec, ctx: FetchContext) -> RawPayload      # one HTTP "unit" per spec; stored raw144    def normalize(self, raw: RawPayload, spec: IndicatorSourceSpec) -> list[NormalizedObservation]145    def validate(self, rows: list[NormalizedObservation]) -> ValidationReport          # source-specific checks146```147148Error isolation: each (connector, dataset) runs in its own try/except and its own `import_runs` row. A failed fetch149**never** removes previously good data: `build` merges the newest successful staging file per dataset. A connector150that fails validation with `error` severity is quarantined (its staging file is ignored and the previous one kept).151152## 6. Validation rules (deterministic)153154* duplicates (same key twice in one dataset) → error (dataset quarantined)155* impossible values: negative for `nonnegative` indicators, percentages outside [-5, 105] for `percent_share`156  indicators, life expectancy outside [20, 100], etc. (bounds in registry `bounds: [min, max]`) → row `quarantined`157* unit change vs registry unit → dataset quarantined158* extreme jump: |Δ| > `jump_threshold` × robust std of the country series (MAD) → row `warning` (kept, flagged)159* partial download: rows < 30 % of the previous run for the same dataset → dataset quarantined (previous kept)160* stale: `source_updated_at` / latest period older than `stale_after_days` (annual: 800 d, quarterly: 200 d, monthly: 75 d) → `stale`161* mapping errors: unknown country code → logged, row dropped (aggregates are dropped unless mapped to a group)162163Unusual values are never deleted, only flagged.164165## 7. Derived computations166167* **latest**: last non-forecast observation per country×indicator; previous = previous period of the same frequency; ranks168  among `kind='country'` for the same *year* (if a country's latest year is older than the global max year by > 2,169  its rank is still computed within that year but flagged `rank_year != max_year` in the API response).170* **rankings**: for every `ranking_eligible` indicator and every year with ≥ 20 countries.171* **changes / events**: per country×indicator series (annual or higher), deterministic detectors:172  YoY change beyond ±(2 × MAD of yearly diffs) and beyond an indicator-specific absolute floor (e.g. inflation ±2 pts,173  unemployment ±1 pt, GDP growth ±3 pts, population growth ±0.5 pt), record high/low over the whole series, N-year high/low174  (N ∈ {10, 20, 30}), sign flip (growth → contraction), acceleration (3 consecutive increases of the diff).175  `severity` = min(1, |z| / 4) blended with the floor ratio. Headlines are template strings with computed numbers —176  **no LLM**.177* **similarity**: features per mode (see `registry/similarity.yaml`): log-transform heavy-tailed features (GDP pc, population,178  area), z-score across countries (latest values, only countries with ≥ 70 % of the mode's features), weighted Euclidean179  distance → score = 100 × exp(−d / d₀); store the top 12 peers with per-feature contributions (explainable).180* **insights**: templates in `registry/insights.yaml` (e.g. "{country}'s population grew {pct}% since {y0}."),181  each fully computed from data; numbers are never generated by a model.182* **country_dna**: 9 dimensions in [0, 100] = percentile rank of the country for a representative indicator (or the mean of183  2-3 indicators) among all countries: income (GDP pc PPP), demographics (median age ↔ fertility), urbanization,184  trade (trade % GDP), energy (energy use pc), emissions (CO₂ pc), innovation (R&D % GDP, patents pc), education185  (tertiary enrolment, expected years), public spending (gov. expenditure % GDP). Descriptive, not a score.186187### 7.1 Pipeline implementation notes and deviations (as built, 2026-09-11 — see docs/PIPELINE.md)188189No table or column of `schema.sql` was changed. The following precisions/deviations from §2, §5–§7 are binding for the API:190191* **Staging granularity** is one parquet per *source spec* `(connector, dataset, code, indicator)` —192  `staging/<connector>/<dataset>__<code>__<indicator>.parquet` — not one per dataset. Error isolation, quarantine and the193  "keep the previous file" rule apply per spec. `import_runs` holds the **latest attempt per spec** (not the full history);194  `import_runs.dataset` is `"<dataset>:<code>→<indicator>"`; `rows_raw` is the raw payload size in bytes.195* **Series-level source selection** (`build.py::_merge_observations`): for each `(country_id, indicator_id, frequency)`196  the whole series comes from one source — the highest-priority source with any non-forecast data for that country; if a197  lower-priority source's latest non-forecast year is more than 3 years more recent, the freshest source wins instead.198  The decision is stored per row in `metadata.merge_reason` (`"priority"` | `"fresher"`); `meta`/build counts report199  `series_fresher_source`. Consequence: forecast rows appear in `observations` only when the chosen source itself publishes200  them (IMF-only indicators, or IMF chosen as fresher); the complete IMF series (history + forecasts) is always available in201  `observations_alt` for alternative views. Series with forecast-only data fall back to plain priority.202* **Canonical frequency only in derived tables**: `latest`, `rankings`, `changes`, `events`, `similarity`, `insights`,203  `country_dna` and `coverage` use only observations whose `frequency` equals the indicator's registry `frequency`204  (inflation A, policy-rate M, real-house-price-index Q…). Higher-frequency series (e.g. FRED monthly CPI for the USA) stay in205  `observations` for the series/chart endpoints only. Integrity emits warnings when a `latest` row has a non-canonical206  frequency or a headline indicator is ranked in a pool of fewer than 50 countries.207* **Quarantined rows stay in `observations`** (never deleted) but are excluded from every derived table (`latest`,208  `rankings`, `changes`, `events`, `similarity`, `insights`, `country_dna`). A lower-priority source is *not* promoted when209  a row is quarantined (the value is flagged, not replaced). `observations_alt` = every row of every non-chosen source,210  whatever its status.211* **Stale** is evaluated on the *end* of the period (annual 2024 → 2024-12-31) of each country's latest observation, not on212  `source_updated_at` (the WDI vintage date says nothing about a country whose series stops in 2019); only that latest row is213  flagged `stale`. With 800 days, an annual series ending in 2023 is stale in September 2026, one ending in 2024 is not.214* **Extreme jump**: positive level series (formats currency/number/tonnes/kwh/per_*/km/ha with lower bound ≥ 0) are215  compared on log-differences; others on absolute differences. Threshold = `jump_threshold` × 1.4826·MAD with a floor216  (10 % relative, or 2 % of the country's series range) to avoid flagging smooth series; ≥ 5 points required. ~5 % of WDI217  rows carry `warning`.218* **`latest` ranks** are computed among *all* `kind='country'` values of the same indicator within one **year**: the most219  recent year ≤ the country's latest year in which the country has a value AND at least 20 countries are ranked220  (`MIN_COUNTRIES_FOR_RANKING`). `latest.rank_year` = that year — it may be older than `latest.year` when only a handful of221  countries already report the newest year (e.g. CHN internet-users: value 2025, ranks from 2024); ranks are NULL when no222  such year exists. Q/M series use the last period of each year. `rank_income` is NULL when the country has no income group.223  `change_10y_*` compares with the observation exactly 10 years earlier (same frequency).224* **Ranking direction**: rank 1 = lowest value when `higher_is_better = false`, otherwise the highest value — i.e. "best"225  when `higher_is_better` is set, "highest" when it is null. `rankings.pct_rank = 1 − (rank − 1)/(n − 1)` (1.0 = rank 1).226  `rankings` includes every year with ≥ 20 countries for `ranking_eligible` indicators.227* **Changes / events**: working scale = points for percent-like indicators, log-differences (reported as % change) for228  positive level series, absolute otherwise. z = (Δ − median)/max(1.4826·MAD, 0.25 × floor); a move must also clear the229  floor (`change_floor` in the indicator's unit; default 5 % relative, or 2 % of the series range with a 0.5-point minimum230  for shares/rates). YoY severity = `0.3·min(1,|z|/4) + 0.7·min(1,|Δ|/(3·floor))` when the registry defines `change_floor`231  (real-world magnitude dominates), else `0.6·z-part + 0.4·min(1,|Δ|/(2·floor))`; records `0.5 + 0.25·min(1,n/100) +232  0.25·(exceedance in floor units)`; N-year highs/lows 0.4–0.6; sign flips 0.7; acceleration 0.4. The stored severity is233  multiplied by an **importance weight** (headline indicators ×1.0, `featured` ×0.9, others ×0.7; `detail.weight`,234  `detail.raw_severity`). **`changes` are recent by construction**: a detection is kept only if the series' latest year is235  within 2 years of the indicator's max year in the snapshot AND within 3 years of today; older detections exist only in236  `events`. Record/N-year detectors are skipped for series that are monotone over their whole history, and indicators tagged237  `cumulative` (e.g. `cumulative-co2`) run no detector at all. `events` (whole history) contain YoY jumps/drops, records238  reached after a ≥ 5-year gap since the previous record and sign flips, at most 30 per series; `changes` add N-year239  highs/lows (N ∈ {10, 20, 30}) and acceleration/deceleration (3 consecutive increases/decreases of the difference) at the240  latest period only. `id = sha1("changes|"+country+indicator+kind+period)[:16]` (`"events|"` for events). Headlines are241  English templates. **Noise rules** (changes and events): Q/M series are annualised before detection (last value of the242  calendar year for indices/shares/rates/ratios, calendar-year mean otherwise; `period` = Jan 1 of that year) so an indicator243  yields at most one detection per year; indicators with `format: index` or `ranking_eligible: false` only report YoY moves244  with |Δ%| ≥ 10 %; `events` drop rows with severity < 0.35 and keep at most the 3 most severe per (country, indicator, year).245* **OWID freshness**: raw.githubusercontent.com sends no `Last-Modified`; `source_updated_at` for the co2/energy files is the246  date of the last GitHub commit touching the file (`api.github.com/repos/owid/<repo>/commits?path=…`), grapher charts use247  `lastUpdated` from their metadata.248* **Similarity**: features/weights/transforms in `registry/similarity.yaml`; z-scores from `latest` (mixed years allowed);249  a pair needs ≥ 50 % of the mode's weight in common (distance rescaled to full weight); `d0` = median pairwise distance of250  the mode; `contributions[feature].contribution` = share of the squared distance. `country_dna` dimensions are defined in251  the `dna:` section of the same file (percentile rank 0–100, `invert` for fertility); `year_ref` = max year of the inputs.252* **Insights**: `registry/insights.yaml` (16 templates, kinds `change_since` / `rank_in_group` / `vs_median` / `avg_growth`),253  English text, `values` JSON keeps the raw numbers. `insights.id = sha1(country|template_id)[:16]`.254* **Forecasts**: OWID grapher charts with a `*projected*` column (UN WPP) contribute `is_forecast=true` rows for years255  without an estimate, capped at current year + 6.256* **World Bank**: `source=<id>` is added automatically when the indicator metadata says the series lives outside WDI257  (e.g. WGI = source 3, codes `GOV_WGI_*`); codes moved to "WDI Database Archives" (source 57) fail with a RETIRED hint and258  must be remapped in the registry. `lastupdated` (WDI vintage) → `source_updated_at`; `obs_status` E/F → estimate/forecast.259* **Integrity**: the headline-coverage check (≥ 100 countries in `latest`) applies only to headline indicators that have260  staging data; `--no-strict` turns integrity failures into warnings (stored in `meta.integrity_warnings`).261* **`meta` keys**: `schema_version`, `build_run_id`, `built_at`, `observation_count`, `observations_alt_count`,262  `indicator_count`, `country_count`, `latest_count`, `rankings_count`, `changes_count`, `events_count`, `insights_count`,263  `similarity_count`, `staging_files`, `connectors`, `build_duration_s`, `integrity_warnings`.264* **`validation_issues`** are capped at 2 000 rows per rule per spec (5 000 per spec file) to stay small; counts are exact in265  `import_runs.warnings/errors`.266* `ca sql "<query>"` was added to the CLI (read-only DuckDB); `ca normalize` re-reads the newest raw files per spec.267* **Change detection 2.0 (2026-09-12)**: three more `kind` values in `changes`/`events` — `structural_break`, `trend_reversal`,268  `volatility_spike` — computed with `countryatlas.stats` (docs/PIPELINE.md §Build step 6). Same row shape; `detail` records the269  parameters (`gain`, `shift_ratio`, `run`, `ratio`…). Similarity `contributions[feature]` gain `value_a` / `value_b` (raw values).270* **Reliability (2026-09-12)**: validation adds `impossible_year` (row quarantined), `schema_change` (dataset quarantined),271  `null_spike` and `vintage_shift` (warnings vs the previous staging file); the build refuses to publish a snapshot with < 90 % of272  the previous snapshot's observations, records `meta.previous_observation_count` and `meta.source_health`, and writes273  `logs/build-<run_id>.json`. HTTP retries are deterministic (2/4/8/16 s, 5 attempts, `Retry-After` honoured).274275## 8. API (FastAPI, `/api/v1`, OpenAPI at `/api/v1/openapi.json`, docs at `/api/v1/docs`)276277Every response carries `meta: {built_at, run_id, generated_at}`; every value object carries provenance:278279```json280{"value": 53372.1, "period": "2024-01-01", "year": 2024, "unit": "current US$", "is_estimate": false, "is_forecast": false,281 "status": "verified",282 "provenance": {"source": "worldbank", "source_name": "World Bank", "dataset": "WDI", "series_code": "NY.GDP.PCAP.CD",283               "retrieved_at": "2026-09-11T03:20:11Z", "source_updated_at": "2026-07-01", "url": "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA",284               "transform": null, "licence": "CC BY 4.0"}}285```286287Endpoints (all GET, JSON, cached in-process with the snapshot run_id as cache key):288289```290/health                                   {status, run_id, built_at, observations}291/countries?region=&income=&q=             list (id, slug, name, flag, region, income, population_latest, gdp_latest, coverage_pct)292/countries/{id}                           header + headline metrics (topics.yaml `headline`) with latest+rank+change293/countries/{id}/topics/{topic}            all indicators of the topic: latest + sparkline (last 30 pts) + rank294/countries/{id}/series/{indicator}?from=&to=&freq=     full history (values[], with per-observation provenance summary)295/countries/{id}/changes                   recent changes (sorted by severity)296/countries/{id}/events?limit=             timeline297/countries/{id}/similar?mode=overall      peers with contributions298/countries/{id}/insights299/countries/{id}/dna300/countries/{id}/download.{csv|json}       full country dataset301/indicators?topic=&q=                     list302/indicators/{slug}                        definition + sources + coverage + world/regions latest + freshness303/indicators/{slug}/map?year=              {year, values: {ISO3: value}, legend: {min,max,breaks[]}, n}304/indicators/{slug}/trend?group=world      median/aggregate trend (WB aggregate when available)305/indicators/{slug}/download.{csv|json}306/series?country=CAN,FRA&indicator=gdp-per-capita&from=1990&to=2026&freq=A307/rankings                                 list of rankable indicators (featured first)308/rankings/{indicator}?year=&group=&sort=asc|desc&limit=&offset=     rows: rank, country, value, change_1y, change_10y, sparkline309/rankings/{indicator}/history?countries=CAN,USA    rank by year310/compare?countries=CAN,USA,FRA&indicators=…&from=&to=&mode=absolute|per-capita|index100|pct      series bundle311/compare/snapshot?countries=…&topic=      table of latest values for the topic's indicators312/regions | /regions/{slug}                group page data (members, aggregate metrics, member rankings)313/search?q=&limit=                         [{type: country|indicator|topic|region|source, id, slug, name, hint, score}]314/home                                     global snapshot + curated lists (largest economies, fastest growth, …) + recent changes + recently updated315/changes?limit=&kind=                     global recent changes feed316/sources | /sources/{id}                  source metadata + datasets + import runs + freshness317/methodology                              static from registry (units, priorities, validation rules) — for the page318/admin/*   (header X-Admin-Token)         connectors health, import_runs, validation_issues, coverage matrix, trigger refresh (SIGUSR1)319```320321Errors: RFC 7807 problem+json. Unknown country/indicator → 404. Rate limit 120 req/min/IP (in-process token bucket; requests that322carry `X-CountryAtlas-Internal: <CA_ADMIN_TOKEN>` or arrive from loopback without any forwarding header — server-side renders, the323build prerender, the pipeline — are exempt). Public GET responses carry a weak `ETag` derived from the snapshot run id (304 on324`If-None-Match`) and `Cache-Control: public, max-age=300, stale-while-revalidate=3600`. API 1.1 analytics endpoints: see docs/API.md.325326## 9. Web app (Next.js 16, React 19, TypeScript, Tailwind v4, App Router)327328* Server components fetch the API at `process.env.API_URL` (`http://127.0.0.1:8291`). Browser-side fetches go to the329  same origin `/api/v1/*` (Next `rewrites`).330* Routes (2.0, 2026-09-12): `/` (hero map + time machine, world pulse, movers), `/explore` (World Explorer, full viewport),331  `/trajectories`, `/scatter`, `/finder`, `/extremes`, `/peers`, `/countries`, `/countries/[slug]` (story, timeline 2.0, DNA reference,332  similar 2.0), `/countries/[slug]/[topic]`, `/compare`, `/compare/[...slugs]` (head-to-head for two countries, percentile / change modes),333  `/rankings`, `/rankings/[indicator]` (filters, table/bars/map, rank race), `/indicators`, `/indicators/[slug]` (frames map, distribution,334  related), `/regions`, `/regions/[slug]`, `/regions/compare`, `/changes`, `/stories`, `/stories/[slug]`, `/download` (`/data` → 308),335  `/updates`, `/sources`, `/sources/[id]`, `/methodology`, `/api` (interactive explorer), `/admin`, `/sitemap/<shard>.xml`, `/robots.txt`,336  OG images per country / indicator / ranking / compare. Full-bleed routes (`/explore`, `/trajectories`) are declared in337  `components/layout/main-frame.tsx`.338* Charts: our own SVG chart kit in `apps/web/src/components/charts/` (server-renderable, touch, accessible summaries):339  LineChart, AreaChart, StackedArea, RankedBars, Scatter, BubbleChart (animated, region colours, fit line, trails), Histogram340  (markers), RankRace, Sparkline, SlopeChart, SmallMultiples, PopulationPyramid, Choropleth (d3-geo + world-atlas 110m, SVG;341  zoom/pan canvas in `components/explorer/map-canvas.tsx`), DNA radial (reference polygon). Shared controls in342  `components/controls/` (YearSlider with play, IndicatorSelect, Segmented); URL state via `lib/url-state.ts` (client) /343  `lib/url-params.ts` (server-safe).344* i18n: all UI strings through `apps/web/src/i18n/en.ts` (`t('key')`), no hard-coded English in components.345* Design tokens in `apps/web/src/app/globals.css` (@theme). Editorial, calm, data-dense. No card soup.346* Navigation: Explore · Countries · Compare · Rankings · Indicators · Changes · More ▾ (Regions, Trajectories, Scatter, Finder,347  Extremes, Above/below expected, Stories, Sources, API, Downloads, Data updates, Methodology). Mobile tab bar: Explore · Countries ·348  Compare · Rank · Search; bottom sheets for filters and map details; no horizontal overflow at 320–1920 px (`qa/final-sweep.mjs`).349* Direction semantics: `--inc`/`--dec` (teal/sienna) and the diverging ramp `--div-1..7` colour *increase/decrease*; `--up`/`--down`350  (green/red) are used only when an indicator declares `higher_is_better` (ChangeChip). Quality badges: fresh · historical · sparse ·351  limited-coverage · stale · flagged · forecast (`components/data/quality-badge.tsx`, vocabulary shared with the API).352* Provenance drawer: any value component (`<Metric>`, chart point) opens `<ProvenanceSheet>` with the provenance object.353354## 10. Ports & env355356| Process | Port | Env |357|---|---|---|358| countryatlas-web | 8290 | `API_URL=http://127.0.0.1:8291`, `NEXT_PUBLIC_SITE_URL=https://www.countryatlas.co` |359| countryatlas-api | 8291 (127.0.0.1) | `CA_DATA_DIR=~/countryatlas-data`, `CA_ADMIN_TOKEN`, `FRED_API_KEY` |360| countryatlas-scheduler | — | same as API |361362Secrets (FRED key, admin token) live only in the mld manifest on M1M32 and in local `.env` (git-ignored).363