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%

Analytics API 1.1 + pipeline reliability and change detection 2.0

API: /pulse, /movers, /extremes, /scatter, /trajectory, /finder, /peers, /indicators/{slug}/{related,distribution,frames,quality}, /rankings/{id}/race, /regions/compare, /countries/{id}/{story,quality}, dna?reference=, /updates, search intents (type action); shared per-snapshot cross-section cache; 13 API tests.
Pipeline: structural_break / trend_reversal / volatility_spike detectors, similarity contributions carry value_a/value_b, deterministic retry/backoff honouring Retry-After, validation rules schema_change / impossible_year / null_spike / vintage_shift, ≥90 % observation-count integrity guard, meta.source_health + previous_observation_count, build logs; group descriptions in the registry; docs updated. 138 tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent d588bee

25 changed files +2,856 −52

modified docs/API.md +20 −0
@@ -326,3 +326,23 @@ url, score: 1.0}`: `compare <c1> <c2> [<c3>…]` → `/compare/<slugs>`; `rank[i
326 326 `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
327 327 `changes`), `trend_reversal` (three consecutive yearly moves of one sign after three of the opposite sign), `volatility_spike` (sd of the
328 328 last 5 yearly differences ≥ 3 × the sd of the previous 15). Same row shape; `detail` documents the parameters.
329 +
330 +### Implementation notes (as built, 2026-09-12 — deviations from the contract above)
331 +
332 +* `/pulse` takes `min_population` (default 1 000 000, `0` disables) that filters the **top movers only** (`top_up`/`top_down`); counts and
333 + 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 by
337 + severity = min(1, |z|/4) × importance; `kind=record|reversal|acceleration|structural` are only meaningful for `window=1` and return an
338 + 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-level
341 + 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.
modified docs/ARCHITECTURE.md +7 −0
@@ -264,6 +264,13 @@ No table or column of `schema.sql` was changed. The following precisions/deviati
264 264 * **`validation_issues`** are capped at 2 000 rows per rule per spec (5 000 per spec file) to stay small; counts are exact in
265 265 `import_runs.warnings/errors`.
266 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 the
269 + 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 % of
272 + the previous snapshot's observations, records `meta.previous_observation_count` and `meta.source_health`, and writes
273 + `logs/build-<run_id>.json`. HTTP retries are deterministic (2/4/8/16 s, 5 attempts, `Retry-After` honoured).
267 274
268 275 ## 8. API (FastAPI, `/api/v1`, OpenAPI at `/api/v1/openapi.json`, docs at `/api/v1/docs`)
269 276
modified docs/PIPELINE.md +29 −7
@@ -74,9 +74,21 @@ latest period ended more than `stale_after_days` ago; only that latest row) · `
74 74 or non-finite). Nothing is deleted. Quarantined rows stay in `observations` for inspection but are **excluded from every
75 75 derived table** (`latest`, `rankings`, `changes`, `events`, `similarity`, `insights`, `country_dna`).
76 76
77 Dataset-level quarantine (`quarantine_dataset=True`): duplicates, unit ≠ registry unit, or fewer than 30 % of the rows
78 of the previous staging file for the same spec. The new file is **not written**, the previous one is kept, and the
79 `run.json` says `quarantined` with the reason. `ca status` lists these.
77 +Row statuses also cover `impossible_year` (year < 1750 or > current year + 10 → `quarantined`).
78 +
79 +Dataset-level quarantine (`quarantine_dataset=True`): `schema_change` (a required staging column is missing or has an
80 +incompatible dtype), duplicates, unit ≠ registry unit, or fewer than 30 % of the rows of the previous staging file for the
81 +same spec (`partial_download`). The new file is **not written**, the previous one is kept, and the `run.json` says
82 +`quarantined` with the reason. `ca status` lists these.
83 +
84 +Dataset-level **warnings** compared with the previous staging file of the same spec (`PreviousStats`, read by
85 +`fetch.py` before validation): `null_spike` (share of null values > 3 × the previous share and > 5 %) and `vintage_shift`
86 +(median |relative change| over ≥ 20 overlapping keys > 25 % — a source revision or a changed definition). Both are logged,
87 +stored in `validation_issues`, and the file is still written: suspicious values are flagged, never deleted.
88 +
89 +HTTP reliability (`connectors/base.py::Connector.get`): every request goes through one method with 5 attempts and a
90 +deterministic exponential backoff (2, 4, 8, 16 s, capped at 60 s, no jitter) on 408/425/429/5xx and transport errors
91 +(timeouts, resets); a `Retry-After` header (seconds or HTTP-date) is honoured before the backoff, capped at 120 s.
80 92
81 93 Extreme jumps: positive level series (`format` in currency/number/tonnes/kwh/per_*/km/ha with a lower bound ≥ 0) are
82 94 compared on log-differences, others on absolute differences; threshold = `jump_threshold` × 1.4826 × MAD of the
@@ -110,12 +122,22 @@ country's differences, with a floor of 10 % (relative) or 2 % of the series rang
110 122 monotone series and `cumulative`-tagged indicators are silent; severity = detector score × importance weight
111 123 (headline 1.0 / featured 0.9 / other 0.7). Q/M series are annualised first (one detection per year at most); index /
112 124 non-rankable series need a ≥ 10 % move; `events` keep severity ≥ 0.35 and ≤ 3 rows per (country, indicator, year).
125 + **Change detection 2.0** (`countryatlas.stats`): `structural_break` — single mean shift (binary segmentation) with
126 + gain ≥ 0.5 of the total sum of squares, |shift| ≥ 1.5 × sd, ≥ 5 years on each side, non-monotone series; a `change` when
127 + the break is within the last 10 years, always an `event` (one per series); `trend_reversal` — three yearly moves of one
128 + sign after three of the opposite sign, cumulative move ≥ floor (severity 0.55); `volatility_spike` — sd of the last 5
129 + yearly differences ≥ 3 × sd of the previous 15 and ≥ floor (severity 0.45–0.7). Real snapshot (2026-09-12): 3 355 breaks,
130 + 1 799 volatility spikes (mostly life expectancy 2019–2024), 546 reversals in `changes`; 19 593 break events.
113 131 7. `similarity` (5 modes, `registry/similarity.yaml`) and `country_dna` (9 percentile dimensions, `dna:` section of the
114 same file). 8. `insights` (`registry/insights.yaml`, 16 templates, all numbers computed).
115 9. `meta` (schema_version, build_run_id, built_at, counts, connectors, duration) → `CHECKPOINT`.
132 + same file). Each contribution carries `z_a`, `z_b`, `weight`, `contribution` **and the raw latest values `value_a` /
133 + `value_b`** (after an optional `per` ratio, before the log transform) so the UI can quote real numbers. 8. `insights` (`registry/insights.yaml`, 16 templates, all numbers computed).
134 +9. `meta` (schema_version, build_run_id, built_at, counts, `previous_observation_count`, `source_health` JSON = per
135 + connector ok / partial / failed / quarantined spec counts from `import_runs`, connectors, duration) → `CHECKPOINT`.
116 136 10. Integrity: ≥ 100 000 observations when World Bank staging exists; every headline indicator that has staging data
117 has ≥ 100 countries in `latest`. Failure (or any exception) deletes the build file and leaves the live DB untouched.
118 11. `os.replace``atlas.duckdb`, copy to `snapshots/`, prune to `CA_KEEP_SNAPSHOTS`.
137 + has ≥ 100 countries in `latest`; **the new snapshot has ≥ 90 % of the previous snapshot's observations** (a shrunken
138 + build is never published). Failure (or any exception) deletes the build file and leaves the live DB untouched.
139 +11. `os.replace``atlas.duckdb`, copy to `snapshots/`, prune to `CA_KEEP_SNAPSHOTS`. Every build (published or failed)
140 + writes `logs/build-<run_id>.json` (counts, warnings, error, staging files used per connector).
119 141
120 142 ## Adding a connector
121 143
modified registry/groups.yaml +12 −0
@@ -71,21 +71,25 @@ groups:
71 71 slug: asia
72 72 name: Asia
73 73 kind: continent
74 + description: Countries and territories on the Asian continent (registry `continent` field; transcontinental states follow their registry assignment).
74 75 members: CONTINENT:Asia
75 76 - id: africa
76 77 slug: africa
77 78 name: Africa
78 79 kind: continent
80 + description: Countries and territories on the African continent.
79 81 members: CONTINENT:Africa
80 82 - id: americas
81 83 slug: americas
82 84 name: Americas
83 85 kind: continent
86 + description: North, Central and South America and the Caribbean.
84 87 members: CONTINENT:Americas
85 88 - id: oceania
86 89 slug: oceania
87 90 name: Oceania
88 91 kind: continent
92 + description: Australia, New Zealand and the Pacific island countries and territories.
89 93 members: CONTINENT:Oceania
90 94
91 95 # ---- Income groups (World Bank FY2026 classification) -------------------------------------------
@@ -101,18 +105,21 @@ groups:
101 105 name: Upper middle income
102 106 kind: income
103 107 wb_code: UMC
108 + description: World Bank income classification (GNI per capita, Atlas method).
104 109 members: WB_INCOME:UMC
105 110 - id: lmc
106 111 slug: lower-middle-income
107 112 name: Lower middle income
108 113 kind: income
109 114 wb_code: LMC
115 + description: World Bank income classification (GNI per capita, Atlas method).
110 116 members: WB_INCOME:LMC
111 117 - id: lic
112 118 slug: low-income
113 119 name: Low income
114 120 kind: income
115 121 wb_code: LIC
122 + description: World Bank income classification (GNI per capita, Atlas method).
116 123 members: WB_INCOME:LIC
117 124
118 125 # ---- Organisations ------------------------------------------------------------------------------
@@ -179,6 +186,7 @@ groups:
179 186 slug: african-union
180 187 name: African Union
181 188 kind: org
189 + description: African Union — the 54 African states in the registry (all UN member states of the continent; the Sahrawi Republic is an AU member but is not a registry country).
182 190 members: CONTINENT:Africa
183 191 - id: opec
184 192 slug: opec
@@ -190,6 +198,7 @@ groups:
190 198 slug: commonwealth
191 199 name: Commonwealth of Nations
192 200 kind: org
201 + description: Commonwealth of Nations — 56 member states (Gabon and Togo joined in 2022).
193 202 members: [ATG, AUS, BHS, BGD, BRB, BLZ, BWA, BRN, CMR, CAN, CYP, DMA, SWZ, FJI, GAB, GMB, GHA, GRD, GUY, IND, JAM, KEN, KIR, LSO,
194 203 MWI, MYS, MDV, MLT, MUS, MOZ, NAM, NRU, NZL, NGA, PAK, PNG, RWA, KNA, LCA, VCT, WSM, SYC, SLE, SGP, SLB, ZAF, LKA,
195 204 TZA, TGO, TON, TTO, TUV, UGA, GBR, VUT, ZMB]
@@ -197,16 +206,19 @@ groups:
197 206 slug: mercosur
198 207 name: Mercosur
199 208 kind: org
209 + description: Southern Common Market — full members Argentina, Brazil, Paraguay, Uruguay and Bolivia (full member since 2024); Venezuela is suspended since 2016 and excluded.
200 210 members: [ARG, BRA, PRY, URY, BOL]
201 211 - id: gcc
202 212 slug: gulf-cooperation-council
203 213 name: Gulf Cooperation Council
204 214 kind: org
215 + description: Gulf Cooperation Council — Bahrain, Kuwait, Oman, Qatar, Saudi Arabia and the United Arab Emirates.
205 216 members: [BHR, KWT, OMN, QAT, SAU, ARE]
206 217 - id: small-island-developing-states
207 218 slug: small-island-developing-states
208 219 name: Small island developing states
209 220 kind: org
210 221 wb_code: SST
222 + description: UN list of Small Island Developing States present in the registry (World Bank aggregate code SST).
211 223 members: [ATG, BHS, BRB, BLZ, CPV, COM, CUB, DMA, DOM, FJI, GRD, GNB, GUY, HTI, JAM, KIR, MDV, MHL, MUS, FSM, NRU, PLW, PNG,
212 224 WSM, STP, SYC, SGP, SLB, KNA, LCA, VCT, SUR, TLS, TON, TTO, TUV, VUT]
modified src/countryatlas/api/main.py +8 −3
@@ -26,6 +26,7 @@ from countryatlas.api.errors import Problem, problem_body
26 26 from countryatlas.api.ratelimit import TokenBucketLimiter, client_ip
27 27 from countryatlas.api.routers import (
28 28 admin,
29 + analytics,
29 30 changes,
30 31 compare,
31 32 countries,
@@ -34,6 +35,7 @@ from countryatlas.api.routers import (
34 35 home,
35 36 indicators,
36 37 methodology,
38 + quality,
37 39 rankings,
38 40 regions,
39 41 search,
@@ -79,7 +81,7 @@ def create_app(db_path: str | Path | None = None, *, rate_limit_per_minute: int
79 81 app = FastAPI(
80 82 lifespan=lifespan,
81 83 title="CountryAtlas API",
82 version="1.0.0",
84 + version="1.1.0",
83 85 summary="Country statistics with provenance on every value — www.countryatlas.co",
84 86 description=(
85 87 "Public data API of CountryAtlas. Countries (ISO3 or slug), indicators (slug), rankings, comparisons, groups, "
@@ -176,8 +178,11 @@ def create_app(db_path: str | Path | None = None, *, rate_limit_per_minute: int
176 178 return _problem(request, 500, "Internal server error", f"{type(exc).__name__}: {exc}")
177 179
178 180 # ------------------------------------------------------------------ routers
179 for r in (health.router, countries.router, indicators.router, series.router, rankings.router, compare.router, regions.router,
180 search.router, home.router, changes.router, sources.router, methodology.router, download.router, admin.router):
181 + # quality before countries/indicators: `/indicators/{slug}/quality` and `/countries/{id}/quality` are fixed-suffix routes that must
182 + # win over the `/{id}/{topic}`-style paths of the generic routers.
183 + for r in (health.router, quality.router, countries.router, indicators.router, series.router, rankings.router, compare.router,
184 + regions.router, search.router, home.router, changes.router, sources.router, methodology.router, download.router,
185 + analytics.router, admin.router):
181 186 app.include_router(r, prefix=API_PREFIX)
182 187
183 188 @app.get("/", include_in_schema=False)
added src/countryatlas/api/routers/analytics.py +887 −0
@@ -0,0 +1,887 @@
1 +"""Analytics endpoints (API 1.1): /pulse, /movers, /extremes, /scatter, /trajectory, /finder, /peers.
2 +
3 +All deterministic, country-only (kind='country'), canonical annual frequency, non-forecast, non-quarantined. Statistics come
4 +from countryatlas.stats; per-snapshot precomputations (the latest cross-section matrix) live in `snap._cached`. Wording is
5 +neutral: increase / decrease — "improvement" / "deterioration" only when the indicator declares `higher_is_better`.
6 +"""
7 +from __future__ import annotations
8 +
9 +import math
10 +from typing import Any
11 +
12 +import numpy as np
13 +from fastapi import APIRouter, Depends, Query
14 +
15 +from countryatlas import stats
16 +from countryatlas.api import schemas
17 +from countryatlas.api.common import (
18 + clean_float,
19 + country_card,
20 + indicator_card,
21 + merged_indicator,
22 + meta_block,
23 + parse_csv,
24 + resolve_country,
25 + resolve_group,
26 + resolve_indicator,
27 +)
28 +from countryatlas.api.db import Snapshot, get_snapshot
29 +from countryatlas.api.errors import bad_request
30 +from countryatlas.api.formatting import format_change, format_value
31 +from countryatlas.api.provenance import build_provenance
32 +from countryatlas.registry import topics as registry_topics
33 +
34 +router = APIRouter(tags=["analytics"])
35 +
36 +NEAREST_YEARS = 3
37 +LEVEL_FORMATS = {"currency", "number", "tonnes", "kwh", "per_1000", "per_100k", "per_million", "km", "ha"}
38 +CATEGORY_TOPICS: dict[str, list[str]] = {
39 + "economic": ["economy", "government", "trade", "income"],
40 + "demographic": ["population"],
41 + "health": ["health"],
42 + "energy": ["energy"],
43 + "climate": ["climate", "environment"],
44 + "digital": ["digital", "innovation"],
45 + "housing": ["housing"],
46 + "labor": ["labor"],
47 +}
48 +CATEGORIES = ["all", *CATEGORY_TOPICS]
49 +KIND_FILTERS = ["all", "improvement", "deterioration", "increase", "decrease", "record", "reversal", "acceleration", "structural"]
50 +UP_KINDS = {"yoy_jump", "record_high", "n_year_high", "accelerating", "change_5y_up", "change_10y_up"}
51 +DOWN_KINDS = {"yoy_drop", "record_low", "n_year_low", "decelerating", "change_5y_down", "change_10y_down"}
52 +KIND_GROUPS: dict[str, set[str]] = {
53 + "increase": {"yoy_jump", "record_high", "n_year_high", "accelerating"},
54 + "decrease": {"yoy_drop", "record_low", "n_year_low", "decelerating"},
55 + "record": {"record_high", "record_low"},
56 + "reversal": {"sign_flip", "trend_reversal"},
57 + "acceleration": {"accelerating", "decelerating"},
58 + "structural": {"structural_break", "volatility_spike"},
59 +}
60 +NOTE_CORRELATION = "Correlation does not imply causation. These are descriptive cross-sectional statistics."
61 +PEER_PAIRS = [
62 + {"x": "gdp-per-capita-ppp", "y": "life-expectancy", "label": "Life expectancy given income"},
63 + {"x": "gdp-per-capita-ppp", "y": "expected-years-of-schooling", "label": "Expected schooling given income"},
64 + {"x": "gdp-per-capita-ppp", "y": "co2-per-capita", "label": "CO₂ per capita given income"},
65 + {"x": "health-expenditure-per-capita", "y": "life-expectancy", "label": "Life expectancy given health spending"},
66 + {"x": "gdp-per-capita-ppp", "y": "internet-users", "label": "Internet adoption given income"},
67 + {"x": "gdp-per-capita-ppp", "y": "infant-mortality-rate", "label": "Infant mortality given income"},
68 +]
69 +
70 +
71 +# ------------------------------------------------------------------------------------------------ shared helpers
72 +def country_ids(snap: Snapshot) -> list[str]:
73 + """ISO3 of kind='country' rows, stable order (by id)."""
74 + return snap._cached("country_ids", lambda: sorted(c["id"] for c in snap.countries().values() if (c.get("kind") or "country") == "country"))
75 +
76 +
77 +def point_country(c: dict[str, Any]) -> dict[str, Any]:
78 + return {"id": c["id"], "slug": c.get("slug"), "name": c.get("short_name"), "flag": c.get("flag_emoji"), "region": c.get("region_wb"),
79 + "income": c.get("income_group")}
80 +
81 +
82 +def importance(ind: dict[str, Any], headline: set[str]) -> float:
83 + if ind["id"] in headline:
84 + return 1.0
85 + return 0.9 if ind.get("featured") else 0.7
86 +
87 +
88 +def headline_ids(snap: Snapshot) -> set[str]:
89 + return set(registry_topics()["headline"])
90 +
91 +
92 +def featured_ids(snap: Snapshot) -> list[str]:
93 + """Headline first, then featured (registry order), deduplicated, present in the snapshot."""
94 + out: list[str] = []
95 + for s in registry_topics()["headline"]:
96 + if s in snap.indicators() and s not in out:
97 + out.append(s)
98 + for i in snap.indicators().values():
99 + m = merged_indicator(i)
100 + if m.get("featured") and m["id"] not in out:
101 + out.append(m["id"])
102 + return out
103 +
104 +
105 +def is_level(ind: dict[str, Any]) -> bool:
106 + """Positive level series (GDP, population, emissions…): analysed on relative changes / log scale."""
107 + fmt = (ind.get("format") or "number").lower()
108 + lo = ind.get("bounds_min")
109 + return fmt in LEVEL_FORMATS and lo is not None and float(lo) >= 0
110 +
111 +
112 +def delta_mode(ind: dict[str, Any]) -> str:
113 + """'points' for percent-like, 'pct' for positive level series, 'abs' otherwise."""
114 + fmt = (ind.get("format") or "").lower()
115 + if fmt == "percent" or (ind.get("unit") or "").startswith("%"):
116 + return "points"
117 + if is_level(ind):
118 + return "pct"
119 + return "abs"
120 +
121 +
122 +def auto_log(ind: dict[str, Any], values: np.ndarray | None = None) -> bool:
123 + if not is_level(ind):
124 + return False
125 + if values is None:
126 + return True
127 + v = values[np.isfinite(values)]
128 + v = v[v > 0]
129 + return bool(len(v) >= 3 and v.max() / v.min() > 50)
130 +
131 +
132 +def interpretation(ind: dict[str, Any], direction: str) -> str | None:
133 + hib = ind.get("higher_is_better")
134 + if hib is None:
135 + return None
136 + return "improvement" if (direction == "up") == bool(hib) else "deterioration"
137 +
138 +
139 +class CrossSection:
140 + """Latest cross-section: countries × indicators value/year matrices (from `latest`, countries only)."""
141 +
142 + def __init__(self, snap: Snapshot) -> None:
143 + self.countries = country_ids(snap)
144 + self.idx = {c: i for i, c in enumerate(self.countries)}
145 + self.indicators = sorted(snap.indicators())
146 + self.iidx = {i: k for k, i in enumerate(self.indicators)}
147 + n, m = len(self.countries), len(self.indicators)
148 + self.values = np.full((n, m), np.nan)
149 + self.years = np.full((n, m), np.nan)
150 + rows = snap.query_rows(
151 + "SELECT l.country_id, l.indicator_id, l.value, l.year FROM latest l JOIN countries c ON c.id = l.country_id "
152 + "WHERE coalesce(c.kind, 'country') = 'country' AND l.value IS NOT NULL"
153 + )
154 + for cid, iid, v, y in rows:
155 + i, j = self.idx.get(cid), self.iidx.get(iid)
156 + if i is None or j is None:
157 + continue
158 + self.values[i, j] = float(v)
159 + self.years[i, j] = int(y)
160 + with np.errstate(invalid="ignore"):
161 + self.max_year = np.nanmax(self.years, axis=0)
162 +
163 + def col(self, indicator_id: str, within: int | None = NEAREST_YEARS) -> tuple[np.ndarray, np.ndarray]:
164 + """(values, years) for one indicator; values older than `within` years of the indicator's max year → nan."""
165 + j = self.iidx.get(indicator_id)
166 + if j is None:
167 + return np.full(len(self.countries), np.nan), np.full(len(self.countries), np.nan)
168 + v, y = self.values[:, j].copy(), self.years[:, j].copy()
169 + if within is not None and np.isfinite(self.max_year[j]):
170 + stale = y < self.max_year[j] - within
171 + v[stale] = np.nan
172 + y[stale] = np.nan
173 + return v, y
174 +
175 +
176 +def cross_section(snap: Snapshot) -> CrossSection:
177 + return snap._cached("cross_section", lambda: CrossSection(snap))
178 +
179 +
180 +def indicator_provenances(snap: Snapshot) -> dict[str, dict[str, Any]]:
181 + """indicator_id → provenance of its dominant source (most rows), cached per snapshot."""
182 +
183 + def load() -> dict[str, dict[str, Any]]:
184 + rows = snap.query(
185 + """SELECT indicator_id, source_id, source_dataset, source_series_code, max(retrieved_at) AS ra, max(source_updated_at) AS su,
186 + count(*) AS n
187 + FROM observations WHERE NOT is_forecast GROUP BY 1, 2, 3, 4 QUALIFY row_number() OVER (PARTITION BY indicator_id ORDER BY n DESC) = 1"""
188 + )
189 + return {r["indicator_id"]: build_provenance(snap, r["indicator_id"], r["source_id"], r["source_dataset"], r["source_series_code"],
190 + r["ra"], r["su"]) for r in rows}
191 +
192 + return snap._cached("indicator_provenances", load)
193 +
194 +
195 +def population_latest(snap: Snapshot) -> dict[str, float]:
196 + return snap._cached("population_latest", lambda: {r[0]: float(r[1]) for r in snap.query_rows(
197 + "SELECT country_id, value FROM latest WHERE indicator_id = 'population' AND value IS NOT NULL")})
198 +
199 +
200 +def filter_note(min_pop: int | None) -> str | None:
201 + if not min_pop:
202 + return None
203 + return f"Countries above {min_pop / 1_000_000:g}M inhabitants"
204 +
205 +
206 +def latest_common_year(snap: Snapshot, indicator_id: str, min_countries: int = 50) -> int | None:
207 + from countryatlas.api.routers.indicators import latest_common_year as _lcy
208 +
209 + y, _ = _lcy(snap, indicator_id, min_countries)
210 + return y
211 +
212 +
213 +def group_members_or_all(snap: Snapshot, group: str) -> tuple[dict[str, Any], list[str] | None]:
214 + g = resolve_group(snap, group)
215 + if g["id"] == "world":
216 + return g, None
217 + return g, snap.group_members(g["id"])
218 +
219 +
220 +def _mover_lite(snap: Snapshot, cid: str, ind: dict[str, Any], value: float | None, ref: float | None, year: int | None) -> dict[str, Any]:
221 + delta = None if value is None or ref is None else value - ref
222 + return {"country": country_card(snap.countries()[cid]), "value": value, "ref_value": ref, "delta": delta,
223 + "delta_pct": stats.yoy(value, ref), "formatted": format_value(value, ind), "year": year}
224 +
225 +
226 +def _pct_txt(v: float) -> str:
227 + return f"{v:.0f} %"
228 +
229 +
230 +# ------------------------------------------------------------------------------------------------ /pulse
231 +PULSE_MIN_COUNTRIES = 30
232 +
233 +
234 +@router.get("/pulse", response_model=schemas.PulseResponse, summary="World Pulse — what is changing globally (latest year vs previous)")
235 +def pulse(min_population: int | None = Query(1_000_000, ge=0, description="Population floor for the top movers only (counts use every country)"),
236 + snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
237 + ids = featured_ids(snap)
238 + pop = population_latest(snap)
239 + ph = ",".join("?" * len(ids))
240 + rows = snap.query(
241 + f"""SELECT l.indicator_id, l.country_id, l.value, l.prev_value, l.change_abs, l.change_pct, l.year
242 + FROM latest l JOIN countries c ON c.id = l.country_id
243 + WHERE coalesce(c.kind, 'country') = 'country' AND l.indicator_id IN ({ph}) AND l.value IS NOT NULL""",
244 + ids,
245 + )
246 + max_years = {r[0]: int(r[1]) for r in snap.query_rows(f"SELECT indicator_id, max(year) FROM latest WHERE indicator_id IN ({ph}) GROUP BY 1", ids)}
247 + # reference = latest year of the annual headline pillars (monthly/quarterly series may already carry the current year)
248 + year_reference = max([max_years.get(k, 0) for k in ("population", "gdp", "life-expectancy")] or [0]) or (max(max_years.values()) if max_years else 0)
249 + records = {(r[0], r[1]): int(r[2]) for r in snap.query_rows(
250 + f"""SELECT ch.indicator_id, ch.kind, count(*) FROM changes ch JOIN countries c ON c.id = ch.country_id
251 + WHERE coalesce(c.kind, 'country') = 'country' AND ch.kind IN ('record_high', 'record_low') AND ch.indicator_id IN ({ph})
252 + GROUP BY 1, 2""", ids)}
253 + n_changes = int(snap.scalar("SELECT count(*) FROM changes ch JOIN countries c ON c.id = ch.country_id WHERE coalesce(c.kind,'country') = 'country'") or 0)
254 + by_ind: dict[str, list[dict[str, Any]]] = {}
255 + for r in rows:
256 + by_ind.setdefault(r["indicator_id"], []).append(r)
257 + # convergence inputs: values 10 years before the indicator max year and at the max year
258 + conv_rows = snap.query(
259 + f"""SELECT o.indicator_id, o.country_id, o.year, o.value FROM observations o JOIN countries c ON c.id = o.country_id
260 + JOIN indicators i ON i.id = o.indicator_id
261 + WHERE o.indicator_id IN ({ph}) AND o.frequency = i.frequency AND NOT o.is_forecast AND o.value IS NOT NULL
262 + AND coalesce(o.status, 'imported') <> 'quarantined' AND coalesce(c.kind, 'country') = 'country'
263 + AND o.year IN (SELECT unnest([y, y - 10]) FROM (SELECT indicator_id AS ii, max(year) AS y FROM latest GROUP BY 1) WHERE ii = o.indicator_id)""",
264 + ids,
265 + )
266 + conv: dict[str, dict[str, dict[int, float]]] = {}
267 + for r in conv_rows:
268 + conv.setdefault(r["indicator_id"], {}).setdefault(r["country_id"], {})[int(r["year"])] = float(r["value"])
269 + provs = indicator_provenances(snap)
270 + items = []
271 + reporting: set[str] = set()
272 + for iid in ids:
273 + ind = merged_indicator(snap.indicators()[iid])
274 + my = max_years.get(iid)
275 + if my is None or my < year_reference - 1:
276 + continue
277 + recent = [r for r in by_ind.get(iid, []) if r["year"] is not None and int(r["year"]) >= my - 1 and r.get("change_abs") is not None]
278 + if len(recent) < PULSE_MIN_COUNTRIES:
279 + continue
280 + for r in recent:
281 + reporting.add(r["country_id"])
282 + big = np.array([not min_population or pop.get(r["country_id"], 0) >= min_population for r in recent])
283 + if big.sum() < 20:
284 + big = np.ones(len(recent), dtype=bool)
285 + mode = delta_mode(ind)
286 + key = "change_pct" if mode == "pct" else "change_abs"
287 + deltas = np.array([clean_float(r[key]) if clean_float(r[key]) is not None else np.nan for r in recent])
288 + absd = np.array([clean_float(r["change_abs"]) or 0.0 for r in recent])
289 + n = len(recent)
290 + n_up = int(np.sum(absd > 0))
291 + n_down = int(np.sum(absd < 0))
292 + n_flat = n - n_up - n_down
293 + share_up = round(100.0 * n_up / n, 1)
294 + share_down = round(100.0 * n_down / n, 1)
295 + hib = ind.get("higher_is_better")
296 + sem = "neutral" if hib is None else ("higher_is_better" if hib else "lower_is_better")
297 + dd = np.where(big, deltas, np.nan)
298 + order = np.argsort(np.nan_to_num(dd, nan=-np.inf))
299 + top_up = recent[int(order[-1])] if np.isfinite(dd[order[-1]]) and dd[order[-1]] > 0 else None
300 + order_lo = np.argsort(np.nan_to_num(dd, nan=np.inf))
301 + top_down = recent[int(order_lo[0])] if np.isfinite(dd[order_lo[0]]) and dd[order_lo[0]] < 0 else None
302 + rh, rl = records.get((iid, "record_high"), 0), records.get((iid, "record_low"), 0)
303 + name = ind.get("short_name") or ind.get("name")
304 + # headline template
305 + if rh >= 20 and rh >= n * 0.2 and share_up >= 50:
306 + headline = f"{name} hit a record high in {rh} countries"
307 + elif rl >= 20 and rl >= n * 0.2 and share_down >= 50:
308 + headline = f"{name} fell to a record low in {rl} countries"
309 + elif share_down >= 60:
310 + headline = f"{name} fell in {_pct_txt(share_down)} of {n} reporting countries"
311 + elif share_up >= 60:
312 + headline = f"{name} rose in {_pct_txt(share_up)} of {n} reporting countries"
313 + elif iid == "population" and n_down:
314 + headline = f"Population is shrinking in {n_down} countries"
315 + else:
316 + headline = f"{name} rose in {n_up} and fell in {n_down} of {n} reporting countries"
317 + # convergence over 10 years
318 + cv = None
319 + cd = conv.get(iid, {})
320 + s0 = [cd[c].get(my - 10) for c in cd]
321 + s1 = [cd[c].get(my) for c in cd]
322 + pairs = [(a, b) for a, b in zip(s0, s1, strict=True) if a is not None and b is not None]
323 + if len(pairs) >= 10:
324 + c = stats.convergence([p[0] for p in pairs], [p[1] for p in pairs])
325 + if c:
326 + cv = {"direction": c["direction"], "cv_start": c["cv_start"], "cv_end": c["cv_end"], "n": c["n"], "from_year": my - 10}
327 + items.append({
328 + "indicator": indicator_card(ind), "year": my, "n": n, "n_up": n_up, "n_down": n_down, "n_flat": n_flat,
329 + "share_up": share_up, "share_down": share_down,
330 + "median_change_abs": clean_float(np.nanmedian(absd)) if n else None,
331 + "median_change_pct": clean_float(np.nanmedian([clean_float(r["change_pct"]) if clean_float(r["change_pct"]) is not None else np.nan for r in recent])),
332 + "direction_semantics": sem, "record_highs": rh, "record_lows": rl, "headline": headline,
333 + "top_up": _mover_lite(snap, top_up["country_id"], ind, clean_float(top_up["value"]), clean_float(top_up["prev_value"]), top_up["year"]) if top_up else None,
334 + "top_down": _mover_lite(snap, top_down["country_id"], ind, clean_float(top_down["value"]), clean_float(top_down["prev_value"]), top_down["year"]) if top_down else None,
335 + "convergence": cv, "provenance": provs.get(iid),
336 + })
337 + return {
338 + "meta": meta_block(snap), "year_reference": year_reference, "min_population": min_population, "filter_note": filter_note(min_population),
339 + "summary": {"n_indicators": len(items), "n_countries_reporting": len(reporting),
340 + "n_record_highs": sum(i["record_highs"] for i in items), "n_record_lows": sum(i["record_lows"] for i in items),
341 + "n_changes": n_changes},
342 + "items": items,
343 + }
344 +
345 +
346 +# ------------------------------------------------------------------------------------------------ /movers
347 +def _mover_item(snap: Snapshot, r: dict[str, Any], ind: dict[str, Any], kind: str, headline: str | None, severity: float | None,
348 + year: int | None, ref_year: int | None, prov: dict[str, Any] | None) -> dict[str, Any]:
349 + value, ref = clean_float(r.get("value")), clean_float(r.get("ref_value"))
350 + delta = None if value is None or ref is None else value - ref
351 + direction = "up" if (delta or 0) >= 0 else "down"
352 + if kind in UP_KINDS:
353 + direction = "up"
354 + elif kind in DOWN_KINDS:
355 + direction = "down"
356 + return {
357 + "country": country_card(snap.countries()[r["country_id"]]), "indicator": indicator_card(ind), "kind": kind, "year": year,
358 + "ref_year": ref_year, "value": value, "ref_value": ref, "delta": delta, "delta_pct": stats.yoy(value, ref),
359 + "formatted": format_value(value, ind), "formatted_ref": format_value(ref, ind), "severity": clean_float(severity),
360 + "direction": direction, "interpretation": interpretation(ind, direction), "headline": headline, "provenance": prov,
361 + }
362 +
363 +
364 +def _kind_matches(kind: str, ind: dict[str, Any], flt: str, direction: str) -> bool:
365 + if flt == "all":
366 + return True
367 + if flt in ("improvement", "deterioration"):
368 + return interpretation(ind, direction) == flt
369 + if flt == "increase":
370 + return direction == "up" and kind not in KIND_GROUPS["structural"] | KIND_GROUPS["reversal"]
371 + if flt == "decrease":
372 + return direction == "down" and kind not in KIND_GROUPS["structural"] | KIND_GROUPS["reversal"]
373 + return kind in KIND_GROUPS.get(flt, set())
374 +
375 +
376 +@router.get("/movers", response_model=schemas.MoversResponse, summary="Biggest movers by window / category / kind")
377 +def movers(
378 + window: int = Query(1, description="1 (latest detections), 5 or 10 years"),
379 + category: str = Query("all"),
380 + kind: str = Query("all"),
381 + limit: int = Query(40, ge=1, le=300),
382 + min_population: int | None = Query(None, ge=0),
383 + snap: Snapshot = Depends(get_snapshot),
384 +) -> dict[str, Any]:
385 + if window not in (1, 5, 10):
386 + raise bad_request("window must be 1, 5 or 10.")
387 + if category not in CATEGORIES:
388 + raise bad_request(f"category must be one of {', '.join(CATEGORIES)}.")
389 + if kind not in KIND_FILTERS:
390 + raise bad_request(f"kind must be one of {', '.join(KIND_FILTERS)}.")
391 + topics = CATEGORY_TOPICS.get(category)
392 + pop = population_latest(snap)
393 + provs = indicator_provenances(snap)
394 + hl = headline_ids(snap)
395 + items: list[dict[str, Any]] = []
396 + if window == 1:
397 + where = ["coalesce(c.kind, 'country') = 'country'"]
398 + params: list[Any] = []
399 + if topics:
400 + where.append(f"i.topic IN ({','.join('?' * len(topics))})")
401 + params += topics
402 + rows = snap.query(
403 + f"""SELECT ch.country_id, ch.indicator_id, ch.kind, ch.year, ch.value, ch.ref_value, ch.severity, ch.headline, ch.window_years
404 + FROM changes ch JOIN countries c ON c.id = ch.country_id JOIN indicators i ON i.id = ch.indicator_id
405 + WHERE {' AND '.join(where)} ORDER BY ch.severity DESC NULLS LAST, ch.year DESC, ch.country_id LIMIT 4000""",
406 + params,
407 + )
408 + for r in rows:
409 + if min_population and pop.get(r["country_id"], 0) < min_population:
410 + continue
411 + ind = merged_indicator(snap.indicators()[r["indicator_id"]])
412 + direction = "up" if r["kind"] in UP_KINDS else "down" if r["kind"] in DOWN_KINDS else ("up" if (clean_float(r["value"]) or 0) >= (clean_float(r["ref_value"]) or 0) else "down")
413 + if not _kind_matches(r["kind"], ind, kind, direction):
414 + continue
415 + ref_year = (int(r["year"]) - 1) if r["kind"] in ("yoy_jump", "yoy_drop", "sign_flip") else None
416 + items.append(_mover_item(snap, r, ind, r["kind"], r["headline"], r["severity"], int(r["year"]) if r["year"] is not None else None,
417 + ref_year, provs.get(r["indicator_id"])))
418 + if len(items) >= limit:
419 + break
420 + else:
421 + if kind in ("record", "reversal", "acceleration", "structural"):
422 + return {"meta": meta_block(snap), "window": window, "category": category, "kind": kind, "min_population": min_population,
423 + "filter_note": filter_note(min_population), "categories": CATEGORIES, "kinds": KIND_FILTERS, "n": 0, "items": []}
424 + where = ["coalesce(c.kind, 'country') = 'country'", "l.value IS NOT NULL", "o.value IS NOT NULL", "l.year >= (SELECT max(year) - 2 FROM latest)"]
425 + params = [window]
426 + if topics:
427 + where.append(f"i.topic IN ({','.join('?' * len(topics))})")
428 + params += topics
429 + rows = snap.query(
430 + f"""SELECT l.indicator_id, l.country_id, l.year, l.value, o.value AS ref_value, o.year AS ref_year
431 + FROM latest l JOIN countries c ON c.id = l.country_id JOIN indicators i ON i.id = l.indicator_id
432 + JOIN observations o ON o.country_id = l.country_id AND o.indicator_id = l.indicator_id AND o.frequency = l.frequency
433 + AND o.year = l.year - ? AND NOT o.is_forecast AND coalesce(o.status, 'imported') <> 'quarantined'
434 + WHERE {' AND '.join(where)}""",
435 + params,
436 + )
437 + by_ind: dict[str, list[dict[str, Any]]] = {}
438 + for r in rows:
439 + by_ind.setdefault(r["indicator_id"], []).append(r)
440 + for iid, rs in by_ind.items():
441 + ind = merged_indicator(snap.indicators()[iid])
442 + if not ind.get("ranking_eligible", True) or len(rs) < 20:
443 + continue
444 + mode = delta_mode(ind)
445 + v = np.array([float(r["value"]) for r in rs])
446 + ref = np.array([float(r["ref_value"]) for r in rs])
447 + with np.errstate(divide="ignore", invalid="ignore"):
448 + d = np.where((v > 0) & (ref > 0), np.log(v) - np.log(ref), np.nan) if mode == "pct" else v - ref
449 + z = stats.robust_zscore(d.tolist())
450 + w = importance(ind, hl)
451 + for r, zi, di in zip(rs, z, d, strict=True):
452 + if zi is None or not np.isfinite(di) or abs(zi) < 1.5:
453 + continue
454 + if min_population and pop.get(r["country_id"], 0) < min_population:
455 + continue
456 + direction = "up" if di > 0 else "down"
457 + k = f"change_{window}y"
458 + if not _kind_matches(k, ind, kind, direction):
459 + continue
460 + verb = "rose" if direction == "up" else "fell"
461 + ch = format_change(float(r["value"]) - float(r["ref_value"]), stats.yoy(float(r["value"]), float(r["ref_value"])), ind)
462 + headline = (f"{ind.get('short_name') or ind.get('name')} {verb} {ch.replace('+', '').replace('−', '') if ch else ''} in "
463 + f"{snap.countries()[r['country_id']]['short_name']} between {int(r['ref_year'])} and {int(r['year'])} "
464 + f"(from {format_value(float(r['ref_value']), ind)} to {format_value(float(r['value']), ind)}).")
465 + items.append(_mover_item(snap, r, ind, k, headline, min(1.0, abs(zi) / 4.0) * w, int(r["year"]), int(r["ref_year"]), provs.get(iid)))
466 + items.sort(key=lambda x: -(x["severity"] or 0))
467 + items = items[:limit]
468 + return {"meta": meta_block(snap), "window": window, "category": category, "kind": kind, "min_population": min_population,
469 + "filter_note": filter_note(min_population), "categories": CATEGORIES, "kinds": KIND_FILTERS, "n": len(items), "items": items}
470 +
471 +
472 +# ------------------------------------------------------------------------------------------------ /extremes
473 +FACETS: list[tuple[str, str, str, str, str]] = [ # (id, title, indicator, direction, metric)
474 + ("aging", "Most rapidly ageing", "median-age", "up", "abs"),
475 + ("urbanizing", "Fastest urbanising", "urban-population-share", "up", "points"),
476 + ("fertility-decline", "Largest fertility declines", "fertility-rate", "down", "abs"),
477 + ("life-expectancy-gains", "Biggest gains in life expectancy", "life-expectancy", "up", "abs"),
478 + ("gdp-transformations", "Largest GDP per capita transformations", "gdp-per-capita-ppp", "up", "pct"),
479 + ("digital-adoption", "Fastest digital adoption", "internet-users", "up", "points"),
480 + ("renewable-transitions", "Largest renewable transitions", "renewable-electricity-share", "up", "points"),
481 + ("co2-reductions", "Largest CO₂ per capita reductions", "co2-per-capita", "down", "pct"),
482 + ("population-decline", "Fastest shrinking populations", "population", "down", "pct"),
483 + ("population-boom", "Fastest growing populations", "population", "up", "pct"),
484 + ("inflation-surges", "Largest inflation surges", "inflation", "up", "points"),
485 + ("debt-buildup", "Largest public debt build-ups", "general-government-gross-debt-pct-gdp", "up", "points"),
486 + ("unemployment-falls", "Largest falls in unemployment", "unemployment-rate", "down", "points"),
487 +]
488 +EXTREME_WINDOWS = {"1": 1, "5": 5, "10": 10, "25": 25, "since1990": None}
489 +
490 +
491 +@router.get("/extremes", response_model=schemas.ExtremesResponse, summary="Curated extremes facets over a window")
492 +def extremes(
493 + window: str = Query("10", description="1 | 5 | 10 | 25 | since1990"),
494 + topic: str | None = Query(None),
495 + min_population: int | None = Query(1_000_000, ge=0),
496 + rows: int = Query(10, ge=3, le=50),
497 + snap: Snapshot = Depends(get_snapshot),
498 +) -> dict[str, Any]:
499 + if window not in EXTREME_WINDOWS:
500 + raise bad_request("window must be 1, 5, 10, 25 or since1990.")
501 + n_years = EXTREME_WINDOWS[window]
502 + pop = population_latest(snap)
503 + provs = indicator_provenances(snap)
504 + facets = []
505 + from_years: list[int] = []
506 + to_years: list[int] = []
507 + for fid, title, iid, direction, metric in FACETS:
508 + ind_row = snap.indicators().get(iid)
509 + if ind_row is None:
510 + continue
511 + ind = merged_indicator(ind_row)
512 + if topic and ind.get("topic") != topic:
513 + continue
514 + to_year = latest_common_year(snap, iid)
515 + if to_year is None:
516 + continue
517 + from_year = 1990 if n_years is None else to_year - n_years
518 + if from_year >= to_year:
519 + continue
520 + rs = snap.query(
521 + """WITH o AS (
522 + SELECT o.country_id, o.year, o.value FROM observations o JOIN countries c ON c.id = o.country_id JOIN indicators i ON i.id = o.indicator_id
523 + WHERE o.indicator_id = ? AND o.frequency = i.frequency AND NOT o.is_forecast AND o.value IS NOT NULL
524 + AND coalesce(o.status, 'imported') <> 'quarantined' AND coalesce(c.kind, 'country') = 'country'),
525 + s AS (SELECT country_id, arg_min(value, abs(year - ?)) AS v0, arg_min(year, abs(year - ?)) AS y0 FROM o WHERE abs(year - ?) <= 2 GROUP BY 1),
526 + e AS (SELECT country_id, arg_max(value, year) AS v1, max(year) AS y1 FROM o WHERE year BETWEEN ? AND ? GROUP BY 1)
527 + SELECT s.country_id, s.v0, s.y0, e.v1, e.y1 FROM s JOIN e ON e.country_id = s.country_id WHERE e.y1 > s.y0""",
528 + [iid, from_year, from_year, from_year, to_year - 2, to_year],
529 + )
530 + cand = []
531 + for r in rs:
532 + if min_population and pop.get(r["country_id"], 0) < min_population:
533 + continue
534 + v0, v1 = float(r["v0"]), float(r["v1"])
535 + delta = v1 - v0
536 + pct = stats.yoy(v1, v0)
537 + score = pct if metric == "pct" else delta
538 + if score is None or not math.isfinite(score):
539 + continue
540 + if (direction == "up" and score <= 0) or (direction == "down" and score >= 0):
541 + continue
542 + cand.append((abs(score), {
543 + "country": country_card(snap.countries()[r["country_id"]]), "value_start": v0, "value_end": v1,
544 + "year_start": int(r["y0"]), "year_end": int(r["y1"]), "delta": delta, "delta_pct": pct,
545 + "formatted_start": format_value(v0, ind), "formatted_end": format_value(v1, ind)}))
546 + if len(rs) < 30:
547 + continue
548 + cand.sort(key=lambda x: -x[0])
549 + from_years.append(from_year)
550 + to_years.append(to_year)
551 + facets.append({"id": fid, "title": title, "indicator": indicator_card(ind), "direction": direction, "metric": metric,
552 + "rows": [c[1] for c in cand[:rows]], "n": len(cand), "from_year": from_year, "to_year": to_year, "provenance": provs.get(iid)})
553 + return {"meta": meta_block(snap), "window": window, "from_year": min(from_years) if from_years else None,
554 + "to_year": max(to_years) if to_years else None, "min_population": min_population, "filter_note": filter_note(min_population),
555 + "facets": facets}
556 +
557 +
558 +# ------------------------------------------------------------------------------------------------ /scatter
559 +def _nearest_values(snap: Snapshot, indicator_id: str, year: int, members: list[str] | None, within: int = NEAREST_YEARS) -> dict[str, tuple[float, int]]:
560 + """country → (value, year) using the value nearest to `year` within ±within years (later year wins ties)."""
561 + where = ["o.indicator_id = ?", "o.frequency = i.frequency", "NOT o.is_forecast", "o.value IS NOT NULL",
562 + "coalesce(o.status, 'imported') <> 'quarantined'", "coalesce(c.kind, 'country') = 'country'", "abs(o.year - ?) <= ?"]
563 + params: list[Any] = [indicator_id, year, within]
564 + if members is not None:
565 + if not members:
566 + return {}
567 + where.append(f"o.country_id IN ({','.join('?' * len(members))})")
568 + params += members
569 + rows = snap.query_rows(
570 + f"""SELECT o.country_id, arg_min(o.value, abs(o.year - ?) * 2 - (o.year > ?)::INT) AS v, arg_min(o.year, abs(o.year - ?) * 2 - (o.year > ?)::INT) AS y
571 + FROM observations o JOIN countries c ON c.id = o.country_id JOIN indicators i ON i.id = o.indicator_id
572 + WHERE {' AND '.join(where)} GROUP BY 1""",
573 + [year, year, year, year, *params],
574 + )
575 + return {r[0]: (float(r[1]), int(r[2])) for r in rows}
576 +
577 +
578 +def _parse_log(v: str | None, ind: dict[str, Any], values: np.ndarray | None) -> bool:
579 + if v is None or v.lower() == "auto":
580 + return auto_log(ind, values)
581 + return v.lower() in ("1", "true", "yes")
582 +
583 +
584 +def _size_indicator(snap: Snapshot, size: str | None) -> dict[str, Any] | None:
585 + if size is None:
586 + size = "population"
587 + if size.lower() in ("none", "", "0"):
588 + return None
589 + return resolve_indicator(snap, size)
590 +
591 +
592 +@router.get("/scatter", response_model=schemas.ScatterResponse, summary="Cross-section scatter with Pearson / Spearman / OLS")
593 +def scatter(
594 + x: str = Query(...), y: str = Query(...), size: str | None = Query("population"), year: int | None = Query(None, ge=1800, le=2100),
595 + group: str = Query("world"), log_x: str | None = Query("auto"), log_y: str | None = Query("auto"),
596 + snap: Snapshot = Depends(get_snapshot),
597 +) -> dict[str, Any]:
598 + xi, yi = resolve_indicator(snap, x), resolve_indicator(snap, y)
599 + if xi["id"] == yi["id"]:
600 + raise bad_request("x and y must differ.")
601 + si = _size_indicator(snap, size)
602 + g, members = group_members_or_all(snap, group)
603 + if year is None:
604 + yx, yy = latest_common_year(snap, xi["id"]), latest_common_year(snap, yi["id"])
605 + year_used = min([v for v in (yx, yy) if v is not None], default=None)
606 + else:
607 + year_used = year
608 + points: list[dict[str, Any]] = []
609 + xs: list[float] = []
610 + ys: list[float] = []
611 + if year_used is not None:
612 + vx = _nearest_values(snap, xi["id"], year_used, members)
613 + vy = _nearest_values(snap, yi["id"], year_used, members)
614 + vs = _nearest_values(snap, si["id"], year_used, members) if si else {}
615 + for cid in sorted(set(vx) & set(vy)):
616 + c = snap.countries().get(cid)
617 + if c is None:
618 + continue
619 + p = point_country(c)
620 + p.update({"x": vx[cid][0], "y": vy[cid][0], "size": vs.get(cid, (None, None))[0], "year_x": vx[cid][1], "year_y": vy[cid][1]})
621 + points.append(p)
622 + xs.append(vx[cid][0])
623 + ys.append(vy[cid][0])
624 + xa, ya = np.array(xs, dtype=float), np.array(ys, dtype=float)
625 + lx, ly = _parse_log(log_x, xi, xa), _parse_log(log_y, yi, ya)
626 + with np.errstate(divide="ignore", invalid="ignore"):
627 + tx = np.where(xa > 0, np.log10(xa), np.nan) if lx else xa
628 + ty = np.where(ya > 0, np.log10(ya), np.nan) if ly else ya
629 + r, n = stats.pearson(tx.tolist(), ty.tolist())
630 + rho, _ = stats.spearman(xa.tolist(), ya.tolist())
631 + fit = stats.ols(tx.tolist(), ty.tolist())
632 + provs = indicator_provenances(snap)
633 + return {
634 + "meta": meta_block(snap), "x": indicator_card(xi), "y": indicator_card(yi), "size": indicator_card(si) if si else None,
635 + "year": year, "year_used": year_used, "nearest_years": NEAREST_YEARS,
636 + "group": {"id": g["id"], "slug": g.get("slug"), "name": g.get("name"), "kind": g.get("kind"), "wb_code": g.get("wb_code"), "n_members": g.get("n_members")},
637 + "n": len(points), "points": points,
638 + "stats": {"n": n, "pearson": r, "spearman": rho, "ols": {"slope": fit["slope"], "intercept": fit["intercept"], "r2": fit["r2"]} if fit else None,
639 + "log_x": lx, "log_y": ly},
640 + "note": NOTE_CORRELATION,
641 + "provenance": [p for p in (provs.get(xi["id"]), provs.get(yi["id"]), provs.get(si["id"]) if si else None) if p],
642 + }
643 +
644 +
645 +# ------------------------------------------------------------------------------------------------ /trajectory
646 +@router.get("/trajectory", response_model=schemas.TrajectoryResponse, summary="Gapminder-style frames for two indicators")
647 +def trajectory(
648 + x: str = Query("gdp-per-capita-ppp"), y: str = Query("life-expectancy"), size: str | None = Query("population"),
649 + from_: int | None = Query(None, alias="from", ge=1800, le=2100), to: int | None = Query(None, ge=1800, le=2100),
650 + group: str = Query("world"), snap: Snapshot = Depends(get_snapshot),
651 +) -> dict[str, Any]:
652 + xi, yi = resolve_indicator(snap, x), resolve_indicator(snap, y)
653 + if xi["id"] == yi["id"]:
654 + raise bad_request("x and y must differ.")
655 + si = _size_indicator(snap, size)
656 + g, members = group_members_or_all(snap, group)
657 + ids = [xi["id"], yi["id"]] + ([si["id"]] if si else [])
658 + where = [f"o.indicator_id IN ({','.join('?' * len(ids))})", "o.frequency = 'A'", "NOT o.is_forecast", "o.value IS NOT NULL",
659 + "coalesce(o.status, 'imported') <> 'quarantined'", "coalesce(c.kind, 'country') = 'country'"]
660 + params: list[Any] = list(ids)
661 + if members is not None:
662 + if not members:
663 + where.append("FALSE")
664 + else:
665 + where.append(f"o.country_id IN ({','.join('?' * len(members))})")
666 + params += members
667 + rows = snap.query_rows(f"SELECT o.indicator_id, o.country_id, o.year, o.value FROM observations o JOIN countries c ON c.id = o.country_id WHERE {' AND '.join(where)}", params)
668 + data: dict[str, dict[str, dict[int, float]]] = {i: {} for i in ids}
669 + for iid, cid, yr, v in rows:
670 + data[iid].setdefault(cid, {})[int(yr)] = float(v)
671 + # years with ≥ 50 countries on both axes
672 + count_by_year: dict[int, int] = {}
673 + for cid in set(data[xi["id"]]) & set(data[yi["id"]]):
674 + for yr in set(data[xi["id"]][cid]) & set(data[yi["id"]][cid]):
675 + count_by_year[yr] = count_by_year.get(yr, 0) + 1
676 + good = sorted(y_ for y_, n in count_by_year.items() if n >= 50)
677 + if not good:
678 + good = sorted(count_by_year)
679 + y0 = from_ if from_ is not None else (good[0] if good else None)
680 + y1 = to if to is not None else (good[-1] if good else None)
681 + years = list(range(y0, y1 + 1)) if y0 is not None and y1 is not None and y0 <= y1 else []
682 + countries_out: list[dict[str, Any]] = []
683 + series: dict[str, Any] = {}
684 + dom = {k: [math.inf, -math.inf] for k in ("x", "y", "size")}
685 + for cid in sorted(set(data[xi["id"]]) | set(data[yi["id"]])):
686 + c = snap.countries().get(cid)
687 + if c is None or not years:
688 + continue
689 + sx = [data[xi["id"]].get(cid, {}).get(yr) for yr in years]
690 + sy = [data[yi["id"]].get(cid, {}).get(yr) for yr in years]
691 + both = sum(1 for a, b in zip(sx, sy, strict=True) if a is not None and b is not None)
692 + if both < 0.3 * len(years) or both == 0:
693 + continue
694 + ss = [data[si["id"]].get(cid, {}).get(yr) for yr in years] if si else [None] * len(years)
695 + for k, arr in (("x", sx), ("y", sy), ("size", ss)):
696 + for v in arr:
697 + if v is not None:
698 + dom[k][0] = min(dom[k][0], v)
699 + dom[k][1] = max(dom[k][1], v)
700 + countries_out.append(point_country(c))
701 + series[cid] = {"x": sx, "y": sy, "size": ss}
702 + xs_all = np.array([v for s in series.values() for v in s["x"] if v is not None] or [np.nan])
703 + ys_all = np.array([v for s in series.values() for v in s["y"] if v is not None] or [np.nan])
704 + provs = indicator_provenances(snap)
705 +
706 + def dom_or_none(k: str) -> list[float] | None:
707 + return None if dom[k][0] == math.inf else dom[k]
708 +
709 + return {
710 + "meta": meta_block(snap), "x": indicator_card(xi), "y": indicator_card(yi), "size": indicator_card(si) if si else None,
711 + "group": {"id": g["id"], "slug": g.get("slug"), "name": g.get("name"), "kind": g.get("kind"), "wb_code": g.get("wb_code"), "n_members": g.get("n_members")},
712 + "years": years, "countries": countries_out, "series": series,
713 + "domains": {"x": dom_or_none("x") or [0, 1], "y": dom_or_none("y") or [0, 1], "size": dom_or_none("size")},
714 + "log_x": auto_log(xi, xs_all), "log_y": auto_log(yi, ys_all),
715 + "provenance": [p for p in (provs.get(i) for i in ids) if p],
716 + }
717 +
718 +
719 +# ------------------------------------------------------------------------------------------------ /finder
720 +FINDER_OPS = ("gt", "gte", "lt", "lte", "eq", "between")
721 +
722 +
723 +def _parse_filter(snap: Snapshot, raw: str) -> dict[str, Any]:
724 + parts = raw.split(":")
725 + if len(parts) != 3:
726 + raise bad_request(f"Filter '{raw}' must look like slug:op:value (ops: {', '.join(FINDER_OPS)}).")
727 + slug, op, val = parts[0].strip().lower(), parts[1].strip().lower(), parts[2].strip()
728 + if op not in FINDER_OPS:
729 + raise bad_request(f"Unknown operator '{op}' in '{raw}'.")
730 + ind = resolve_indicator(snap, slug)
731 + try:
732 + if op == "between":
733 + a, b = val.split("..")
734 + v1, v2 = float(a), float(b)
735 + if v1 > v2:
736 + v1, v2 = v2, v1
737 + else:
738 + v1, v2 = float(val), None
739 + except ValueError as e:
740 + raise bad_request(f"Bad value in filter '{raw}'.") from e
741 + return {"indicator": ind, "op": op, "value": v1, "value2": v2}
742 +
743 +
744 +def _apply(op: str, v: np.ndarray, a: float, b: float | None) -> np.ndarray:
745 + with np.errstate(invalid="ignore"):
746 + if op == "gt":
747 + return v > a
748 + if op == "gte":
749 + return v >= a
750 + if op == "lt":
751 + return v < a
752 + if op == "lte":
753 + return v <= a
754 + if op == "eq":
755 + return np.isclose(v, a)
756 + return (v >= a) & (v <= (b if b is not None else a))
757 +
758 +
759 +@router.get("/finder", response_model=schemas.FinderResponse, summary="Structured country finder over latest values")
760 +def finder(
761 + f: list[str] | None = Query(None, description="Repeatable filter slug:op:value (ops gt gte lt lte eq between a..b)"),
762 + mode: str = Query("and", pattern="^(and|or)$"),
763 + region: str | None = Query(None), income: str | None = Query(None), sort: str | None = Query(None),
764 + limit: int = Query(218, ge=1, le=300), snap: Snapshot = Depends(get_snapshot),
765 +) -> dict[str, Any]:
766 + filters = [_parse_filter(snap, raw) for raw in (f or []) if raw]
767 + if not filters:
768 + raise bad_request("Provide at least one filter, e.g. f=gdp-per-capita:gt:40000.")
769 + cs = cross_section(snap)
770 + n = len(cs.countries)
771 + pool = np.ones(n, dtype=bool)
772 + for gslug in (region, income):
773 + if gslug:
774 + g = resolve_group(snap, gslug)
775 + if g["id"] != "world":
776 + mem = set(snap.group_members(g["id"]))
777 + pool &= np.array([c in mem for c in cs.countries])
778 + matched_matrix = np.zeros((n, len(filters)), dtype=bool)
779 + evaluated = np.zeros(n, dtype=bool)
780 + for k, flt in enumerate(filters):
781 + v, _y = cs.col(flt["indicator"]["id"], within=None)
782 + evaluated |= np.isfinite(v)
783 + matched_matrix[:, k] = _apply(flt["op"], v, flt["value"], flt["value2"]) & np.isfinite(v)
784 + ok = matched_matrix.all(axis=1) if mode == "and" else matched_matrix.any(axis=1)
785 + ok &= pool
786 + # sort
787 + sort_slug, sort_dir = filters[0]["indicator"]["id"], "desc"
788 + if sort:
789 + parts = sort.split(":")
790 + sort_slug = resolve_indicator(snap, parts[0])["id"]
791 + if len(parts) > 1 and parts[1].lower() in ("asc", "desc"):
792 + sort_dir = parts[1].lower()
793 + sv, _ = cs.col(sort_slug, within=None)
794 + idx = [i for i in range(n) if ok[i]]
795 + idx.sort(key=lambda i: ((0, -sv[i] if sort_dir == "desc" else sv[i]) if np.isfinite(sv[i]) else (1, 0)))
796 + slugs = list(dict.fromkeys([flt["indicator"]["id"] for flt in filters] + [sort_slug]))
797 + provs = indicator_provenances(snap)
798 + items = []
799 + for i in idx[:limit]:
800 + cid = cs.countries[i]
801 + vals: dict[str, Any] = {}
802 + for s in slugs:
803 + v, y = cs.col(s, within=None)
804 + ind = merged_indicator(snap.indicators()[s])
805 + vals[s] = {"value": clean_float(v[i]), "year": int(y[i]) if np.isfinite(y[i]) else None,
806 + "formatted": format_value(clean_float(v[i]), ind), "provenance": provs.get(s)}
807 + items.append({"country": country_card(snap.countries()[cid]), "matched": [flt["indicator"]["id"] for k, flt in enumerate(filters) if matched_matrix[i, k]],
808 + "values": vals})
809 + return {
810 + "meta": meta_block(snap), "mode": mode,
811 + "filters": [{"indicator": indicator_card(flt["indicator"]), "op": flt["op"], "value": flt["value"], "value2": flt["value2"],
812 + "year_used": int(cs.max_year[cs.iidx[flt["indicator"]["id"]]]) if flt["indicator"]["id"] in cs.iidx and np.isfinite(cs.max_year[cs.iidx[flt["indicator"]["id"]]]) else None}
813 + for flt in filters],
814 + "n_matching": int(ok.sum()), "n_evaluated": int((evaluated & pool).sum()), "items": items,
815 + }
816 +
817 +
818 +# ------------------------------------------------------------------------------------------------ /peers
819 +PEERS_METHODOLOGY = (
820 + "A robust line (Theil–Sen: median of pairwise slopes; or ordinary least squares) is fitted across countries for the chosen year, "
821 + "y on x (x on a log10 scale when the indicator spans orders of magnitude). Each country's residual is its value minus the fitted "
822 + "value at its x; residual_z divides the residual by 1.4826 × MAD of all residuals. 'Above' and 'below' describe position relative "
823 + "to the fitted line for that year only — a descriptive cross-sectional deviation, not a causal or normative judgement."
824 +)
825 +
826 +
827 +@router.get("/peers", response_model=schemas.PeersResponse, summary="Above / below expected given another indicator (robust fit)")
828 +def peers(
829 + y: str = Query("life-expectancy"), x: str = Query("gdp-per-capita-ppp"), year: int | None = Query(None, ge=1800, le=2100),
830 + method: str = Query("theil-sen", pattern="^(theil-sen|ols)$"), log_x: str | None = Query("auto"), top: int = Query(12, ge=1, le=50),
831 + snap: Snapshot = Depends(get_snapshot),
832 +) -> dict[str, Any]:
833 + xi, yi = resolve_indicator(snap, x), resolve_indicator(snap, y)
834 + if xi["id"] == yi["id"]:
835 + raise bad_request("x and y must differ.")
836 + if year is None:
837 + yx, yy = latest_common_year(snap, xi["id"]), latest_common_year(snap, yi["id"])
838 + year_used = min([v for v in (yx, yy) if v is not None], default=None)
839 + else:
840 + year_used = year
841 + pts: list[dict[str, Any]] = []
842 + if year_used is not None:
843 + vx = _nearest_values(snap, xi["id"], year_used, None)
844 + vy = _nearest_values(snap, yi["id"], year_used, None)
845 + for cid in sorted(set(vx) & set(vy)):
846 + c = snap.countries().get(cid)
847 + if c is None:
848 + continue
849 + p = point_country(c)
850 + p.update({"x": vx[cid][0], "y": vy[cid][0]})
851 + pts.append(p)
852 + xa = np.array([p["x"] for p in pts], dtype=float)
853 + ya = np.array([p["y"] for p in pts], dtype=float)
854 + lx = _parse_log(log_x, xi, xa)
855 + with np.errstate(divide="ignore", invalid="ignore"):
856 + tx = np.where(xa > 0, np.log10(xa), np.nan) if lx else xa
857 + fit = stats.theil_sen(tx.tolist(), ya.tolist()) if method == "theil-sen" else stats.ols(tx.tolist(), ya.tolist())
858 + if fit:
859 + expected = fit["intercept"] + fit["slope"] * tx
860 + resid = ya - expected
861 + scale = fit.get("residual_mad") if method == "theil-sen" else None
862 + if not scale:
863 + fr = resid[np.isfinite(resid)]
864 + scale = float(np.median(np.abs(fr - np.median(fr))) * stats.MAD_SCALE) if len(fr) else None
865 + for p, e, r in zip(pts, expected, resid, strict=True):
866 + ok = bool(np.isfinite(e))
867 + p["expected"] = float(e) if ok else None
868 + p["residual"] = float(r) if ok else None
869 + p["residual_z"] = float(r / scale) if ok and scale else None
870 + else:
871 + scale = None
872 + for p in pts:
873 + p.update({"expected": None, "residual": None, "residual_z": None})
874 + ranked = sorted([p for p in pts if p["residual_z"] is not None], key=lambda p: p["residual_z"])
875 + return {
876 + "meta": meta_block(snap), "x": indicator_card(xi), "y": indicator_card(yi), "year_used": year_used, "n": len(pts), "method": method,
877 + "fit": {"slope": fit["slope"], "intercept": fit["intercept"], "r2": fit["r2"], "log_x": lx, "residual_scale": scale} if fit else None,
878 + "points": pts, "above": list(reversed(ranked[-top:])) if ranked else [], "below": ranked[:top],
879 + "pairs": [p for p in PEER_PAIRS if p["x"] in snap.indicators() and p["y"] in snap.indicators()],
880 + "note": "Countries above the fitted line have a higher value than the cross-country relationship would suggest for their x; "
881 + "below, a lower one. " + NOTE_CORRELATION,
882 + "methodology": PEERS_METHODOLOGY,
883 + }
884 +
885 +
886 +__all__ = ["CrossSection", "auto_log", "country_ids", "cross_section", "delta_mode", "indicator_provenances", "is_level", "latest_common_year",
887 + "parse_csv", "point_country", "population_latest", "resolve_country", "router"]
modified src/countryatlas/api/routers/countries.py +133 −6
@@ -416,19 +416,146 @@ DNA_LABELS = {
416 416 }
417 417
418 418
419 @router.get("/{id}/dna", response_model=schemas.DNAResponse, summary="Country DNA (9 percentile dimensions)")
420 def get_country_dna(id: str, snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
419 +def _dna_dims(snap: Snapshot, country_id: str) -> tuple[dict[str, float | None], int | None]:
421 420 import json
422 421
423 c = resolve_country(snap, id)
424 row = snap.one("SELECT * FROM country_dna WHERE country_id = ?", [c["id"]])
422 + row = snap.one("SELECT * FROM country_dna WHERE country_id = ?", [country_id])
425 423 dims: dict[str, float | None] = {}
426 424 if row and row.get("dims") is not None:
427 425 raw = row["dims"]
428 426 if isinstance(raw, str):
429 427 raw = json.loads(raw)
430 428 dims = {k: clean_float(v) for k, v in dict(raw).items()}
429 + return dims, (row.get("year_ref") if row else None)
430 +
431 +
432 +def _dna_reference(snap: Snapshot, c: dict[str, Any], reference: str | None, dims: dict[str, float | None]) -> dict[str, Any] | None:
433 + """world → 50 everywhere; region/income → median of the members' dims; a country → that country's dims."""
434 + import json
435 +
436 + import numpy as np
437 +
438 + if not reference:
439 + return None
440 + ref = reference.strip().lower()
441 + if ref == "world":
442 + return {"kind": "world", "id": "world", "label": "World median", "dims": {k: 50.0 for k in dims}}
443 + if ref in ("region", "income"):
444 + g = None
445 + for grp in snap.groups().values():
446 + if grp.get("kind") == ref and (grp.get("wb_code") or "").upper() == ((c.get("region_wb") if ref == "region" else c.get("income_group")) or "").upper():
447 + g = grp
448 + break
449 + if g is None:
450 + return None
451 + members = [m for m in snap.group_members(g["id"]) if m != c["id"]]
452 + if not members:
453 + return None
454 + rows = snap.query(f"SELECT dims FROM country_dna WHERE country_id IN ({','.join('?' * len(members))})", members)
455 + acc: dict[str, list[float]] = {}
456 + for r in rows:
457 + raw = r["dims"]
458 + d = json.loads(raw) if isinstance(raw, str) else dict(raw)
459 + for k, v in d.items():
460 + if v is not None:
461 + acc.setdefault(k, []).append(float(v))
462 + return {"kind": ref, "id": g["id"], "label": f"{g.get('name')} median",
463 + "dims": {k: (float(np.median(acc[k])) if acc.get(k) else None) for k in dims}}
464 + other = resolve_country(snap, ref)
465 + od, _ = _dna_dims(snap, other["id"])
466 + return {"kind": "country", "id": other["id"], "label": other.get("short_name"), "dims": {k: od.get(k) for k in dims}}
467 +
468 +
469 +@router.get("/{id}/dna", response_model=schemas.DNAResponse, summary="Country DNA (9 percentile dimensions) with optional reference profile")
470 +def get_country_dna(id: str, reference: str | None = Query(None, description="world | region | income | <ISO3 or slug>"),
471 + snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
472 + c = resolve_country(snap, id)
473 + dims, year_ref = _dna_dims(snap, c["id"])
431 474 dimensions = [{"id": k, "label": DNA_LABELS.get(k, (k.replace("_", " ").title(), None))[0],
432 475 "indicator": DNA_LABELS.get(k, (None, None))[1], "value": v} for k, v in dims.items()]
433 return {"meta": meta_block(snap), "country": country_card(c), "dims": dims, "year_ref": row.get("year_ref") if row else None,
434 "dimensions": dimensions}
476 + return {"meta": meta_block(snap), "country": country_card(c), "dims": dims, "year_ref": year_ref, "dimensions": dimensions,
477 + "reference": _dna_reference(snap, c, reference, dims), "note": "Percentile profile among all countries, not an overall country score."}
478 +
479 +
480 +# ------------------------------------------------------------------------------------------------ story (API 1.1)
481 +STORY_INDICATORS = ["population", "gdp-per-capita-ppp", "life-expectancy", "fertility-rate", "urban-population-share", "co2-per-capita",
482 + "internet-users", "renewable-electricity-share", "general-government-gross-debt-pct-gdp", "median-age",
483 + "energy-use-per-capita", "unemployment-rate"]
484 +STORY_FALLBACK = {"gdp-per-capita-ppp": "gdp-per-capita"}
485 +STORY_MIN_POINTS = 10
486 +STORY_MAX_ITEMS = 10
487 +
488 +
489 +@router.get("/{id}/story", response_model=schemas.StoryResponse, summary="How a country changed: long-run indicators, templated text")
490 +def get_country_story(id: str, snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
491 + from countryatlas import stats
492 + from countryatlas.api.formatting import format_change
493 + from countryatlas.api.routers.analytics import delta_mode
494 +
495 + c = resolve_country(snap, id)
496 + wanted: list[str] = []
497 + for s in STORY_INDICATORS:
498 + if s in snap.indicators():
499 + wanted.append(s)
500 + elif STORY_FALLBACK.get(s) in snap.indicators():
501 + wanted.append(STORY_FALLBACK[s])
502 + ph = ",".join("?" * len(wanted))
503 + rows = snap.query(
504 + f"""SELECT o.indicator_id, o.year, o.value, o.source_id, o.source_dataset, o.source_series_code, o.retrieved_at, o.source_updated_at
505 + FROM observations o JOIN indicators i ON i.id = o.indicator_id
506 + WHERE o.country_id = ? AND o.indicator_id IN ({ph}) AND o.frequency = 'A' AND NOT o.is_forecast AND o.value IS NOT NULL
507 + AND coalesce(o.status, 'imported') <> 'quarantined' ORDER BY o.indicator_id, o.year""",
508 + [c["id"], *wanted],
509 + )
510 + by_ind: dict[str, list[dict[str, Any]]] = {}
511 + for r in rows:
512 + by_ind.setdefault(r["indicator_id"], []).append(r)
513 + ranks = {(r[0], int(r[1])): (int(r[2]), int(r[3])) for r in snap.query_rows(
514 + f"SELECT indicator_id, year, rank, n FROM rankings WHERE country_id = ? AND indicator_id IN ({ph})", [c["id"], *wanted])}
515 + items = []
516 + since: int | None = None
517 + for iid in wanted:
518 + rs = by_ind.get(iid, [])
519 + if len(rs) < STORY_MIN_POINTS:
520 + continue
521 + ind = merged_indicator(snap.indicators()[iid])
522 + vals = [float(r["value"]) for r in rs]
523 + years = [int(r["year"]) for r in rs]
524 + # start from the first year with data (≥ 1960 preferred when the series is longer)
525 + start = next((i for i, y in enumerate(years) if y >= 1960), 0)
526 + vals, years, rs = vals[start:], years[start:], rs[start:]
527 + if len(vals) < STORY_MIN_POINTS:
528 + continue
529 + first, last = vals[0], vals[-1]
530 + y0, y1 = years[0], years[-1]
531 + since = y0 if since is None else min(since, y0)
532 + delta = last - first
533 + pct = stats.yoy(last, first)
534 + cg = stats.cagr(first, last, y1 - y0) if delta_mode(ind) == "pct" else None
535 + peak_i, trough_i = max(range(len(vals)), key=lambda i: vals[i]), min(range(len(vals)), key=lambda i: vals[i])
536 + mode = delta_mode(ind)
537 + name = ind.get("short_name") or ind.get("name")
538 + verb = "rose" if delta > 0 else "fell" if delta < 0 else "was unchanged"
539 + ch = format_change(delta, pct, ind)
540 + if mode == "points":
541 + tail = f"({ch})" if ch else ""
542 + else:
543 + cg_txt = f", {'+' if cg >= 0 else '−'}{abs(cg):.1f} % a year" if cg is not None else ""
544 + tail = f"({ch}{cg_txt})" if ch else ""
545 + text = f"{c['short_name']}: {name or iid} {verb} from {format_value(first, ind)} in {y0} to {format_value(last, ind)} in {y1} {tail}".strip() + "."
546 + prov = build_provenance(snap, iid, rs[-1].get("source_id"), rs[-1].get("source_dataset"), rs[-1].get("source_series_code"),
547 + rs[-1].get("retrieved_at"), rs[-1].get("source_updated_at"), c.get("iso2"))
548 + rf, rl = ranks.get((iid, y0)), ranks.get((iid, y1))
549 + items.append({
550 + "indicator": indicator_card(ind),
551 + "first": {"year": y0, "value": first, "formatted": format_value(first, ind)},
552 + "last": {"year": y1, "value": last, "formatted": format_value(last, ind)},
553 + "change_abs": delta, "change_pct": pct, "cagr": cg,
554 + "peak": {"year": years[peak_i], "value": vals[peak_i]}, "trough": {"year": years[trough_i], "value": vals[trough_i]},
555 + "rank_first": {"rank": rf[0], "n": rf[1], "year": y0} if rf else None,
556 + "rank_last": {"rank": rl[0], "n": rl[1], "year": y1} if rl else None,
557 + "series": [[y, v] for y, v in zip(years, vals, strict=True)], "text": text, "provenance": prov,
558 + })
559 + if len(items) >= STORY_MAX_ITEMS:
560 + break
561 + return {"meta": meta_block(snap), "country": country_card(c), "since": since, "items": items}
modified src/countryatlas/api/routers/health.py +1 −1
@@ -11,7 +11,7 @@ from countryatlas.api.db import get_database
11 11
12 12 router = APIRouter(tags=["health"])
13 13
14 API_VERSION = "0.1.0"
14 +API_VERSION = "1.1.0"
15 15
16 16
17 17 @router.get("/health", response_model=schemas.HealthResponse, summary="Health / snapshot status")
modified src/countryatlas/api/routers/indicators.py +157 −0
@@ -3,6 +3,7 @@ from __future__ import annotations
3 3
4 4 from typing import Any
5 5
6 +import numpy as np
6 7 from fastapi import APIRouter, Depends, Query
7 8
8 9 from countryatlas.api import schemas
@@ -14,6 +15,7 @@ from countryatlas.api.common import (
14 15 merged_indicator,
15 16 meta_block,
16 17 quantile_breaks,
18 + resolve_country,
17 19 resolve_group,
18 20 resolve_indicator,
19 21 )
@@ -339,3 +341,158 @@ def get_indicator_trend(
339 341 r["retrieved_at"], r["source_updated_at"]), n_values=r["n"]) for r in prov_rows],
340 342 "note": "Aggregates are computed across member countries present in the snapshot (no World Bank aggregate series is stored).",
341 343 }
344 +
345 +
346 +# ------------------------------------------------------------------------------------------------ analytics (API 1.1)
347 +from countryatlas import stats as _stats
348 +from countryatlas.api.errors import bad_request as _bad_request
349 +from countryatlas.api.routers import analytics as _an
350 +
351 +NOTE_CORRELATION = "Correlation does not imply causation. Descriptive cross-sectional statistics on latest values."
352 +
353 +
354 +def _log_matrix(snap: Snapshot):
355 + """Per-snapshot: cross-section values transformed for association (log10 for level series), aligned with CrossSection."""
356 +
357 + def load():
358 + cs = _an.cross_section(snap)
359 + m = np.full_like(cs.values, np.nan)
360 + logs: dict[str, bool] = {}
361 + for iid, j in cs.iidx.items():
362 + ind = merged_indicator(snap.indicators()[iid])
363 + v, _ = cs.col(iid)
364 + use_log = _an.auto_log(ind, v)
365 + logs[iid] = use_log
366 + with np.errstate(divide="ignore", invalid="ignore"):
367 + m[:, j] = np.where(v > 0, np.log10(v), np.nan) if use_log else v
368 + return m, logs
369 +
370 + return snap._cached("assoc_matrix", load)
371 +
372 +
373 +@router.get("/{slug}/related", response_model=schemas.RelatedResponse, summary="Statistically related indicators (descriptive)")
374 +def get_indicator_related(slug: str, limit: int = Query(12, ge=1, le=60), min_n: int = Query(40, ge=5),
375 + snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
376 + ind = resolve_indicator(snap, slug)
377 + cs = _an.cross_section(snap)
378 + m, logs = _log_matrix(snap)
379 + j = cs.iidx.get(ind["id"])
380 + items = []
381 + if j is not None:
382 + x = m[:, j]
383 + raw_x, _ = cs.col(ind["id"])
384 + for other, k in cs.iidx.items():
385 + if other == ind["id"]:
386 + continue
387 + y = m[:, k]
388 + ok = np.isfinite(x) & np.isfinite(y)
389 + if ok.sum() < min_n:
390 + continue
391 + raw_y, years_y = cs.col(other)
392 + rho, n = _stats.spearman(raw_x[ok].tolist(), raw_y[ok].tolist())
393 + r, _ = _stats.pearson(x[ok].tolist(), y[ok].tolist())
394 + if rho is None:
395 + continue
396 + o = merged_indicator(snap.indicators()[other])
397 + yrs = years_y[ok]
398 + items.append({"indicator": indicator_card(o), "pearson": r, "spearman": rho, "n": n,
399 + "year": int(np.nanmax(yrs)) if len(yrs) else None, "log_x": logs[ind["id"]], "log_y": logs[other],
400 + "direction": "positive" if rho >= 0 else "negative"})
401 + items.sort(key=lambda i: -abs(i["spearman"] or 0))
402 + year_used = int(cs.max_year[j]) if j is not None and np.isfinite(cs.max_year[j]) else None
403 + return {"meta": meta_block(snap), "indicator": indicator_card(ind), "year_used": year_used, "n_candidates": len(items),
404 + "items": items[:limit], "note": NOTE_CORRELATION}
405 +
406 +
407 +@router.get("/{slug}/distribution", response_model=schemas.DistributionResponse, summary="Distribution across countries for a year")
408 +def get_indicator_distribution(slug: str, year: int | None = Query(None, ge=1800, le=2100), highlight: str | None = Query(None),
409 + bins: int = Query(20, ge=5, le=60), snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
410 + ind = resolve_indicator(snap, slug)
411 + year_used = year if year is not None else latest_common_year(snap, ind["id"])[0]
412 + vals = _an._nearest_values(snap, ind["id"], year_used, None) if year_used is not None else {}
413 + ids = sorted(vals)
414 + arr = np.array([vals[c][0] for c in ids], dtype=float)
415 + use_log = _an.auto_log(ind, arr)
416 + fin = arr[np.isfinite(arr)]
417 +
418 + def pct(q: float) -> float | None:
419 + return float(np.percentile(fin, q)) if len(fin) else None
420 +
421 + st = {"min": float(fin.min()) if len(fin) else None, "p10": pct(10), "p25": pct(25), "median": pct(50),
422 + "mean": float(fin.mean()) if len(fin) else None, "p75": pct(75), "p90": pct(90), "max": float(fin.max()) if len(fin) else None}
423 + # group medians
424 + def group_medians(kind: str) -> list[dict[str, Any]]:
425 + out = []
426 + for g in snap.groups().values():
427 + if g.get("kind") != kind:
428 + continue
429 + mem = set(snap.group_members(g["id"]))
430 + gv = [vals[c][0] for c in ids if c in mem]
431 + if len(gv) >= 2:
432 + out.append({"group": {"id": g["id"], "slug": g.get("slug"), "name": g.get("name"), "kind": g.get("kind"), "wb_code": g.get("wb_code"),
433 + "n_members": g.get("n_members")}, "median": float(np.median(gv)), "n": len(gv)})
434 + out.sort(key=lambda x: -(x["median"] or 0))
435 + return out
436 +
437 + by_region, by_income = group_medians("region"), group_medians("income")
438 + hl = None
439 + if highlight:
440 + c = resolve_country(snap, highlight)
441 + v = vals.get(c["id"])
442 + if v is not None:
443 + pr = _stats.percentile_rank(arr.tolist())
444 + i = ids.index(c["id"])
445 + desc = ind.get("higher_is_better") is not False
446 + rk = _stats.rank(arr.tolist(), descending=desc)[i]
447 + reg = next((g for g in by_region if g["group"]["wb_code"] == c.get("region_wb")), None)
448 + inc = next((g for g in by_income if (g["group"]["wb_code"] or "").upper() == (c.get("income_group") or "").upper()), None)
449 + hl = {"country": country_card(c), "value": v[0], "year": v[1], "percentile": pr[i], "rank": rk, "n": len(fin),
450 + "region": reg["group"] if reg else None, "region_median": reg["median"] if reg else None,
451 + "income": inc["group"] if inc else None, "income_median": inc["median"] if inc else None}
452 + else:
453 + hl = {"country": country_card(c), "value": None, "year": None, "percentile": None, "rank": None, "n": len(fin), "region": None,
454 + "region_median": None, "income": None, "income_median": None}
455 + return {"meta": meta_block(snap), "indicator": indicator_card(ind), "year": year, "year_used": year_used, "n": len(fin), "log": use_log,
456 + "histogram": _stats.histogram(fin.tolist(), bins=bins, log=use_log), "stats": st, "highlight": hl,
457 + "by_region": by_region, "by_income": by_income, "provenance": _an.indicator_provenances(snap).get(ind["id"])}
458 +
459 +
460 +@router.get("/{slug}/frames", response_model=schemas.FramesResponse, summary="Multi-year map frames (time machine)")
461 +def get_indicator_frames(slug: str, from_: int | None = Query(None, alias="from", ge=1750, le=2100), to: int | None = Query(None, ge=1750, le=2100),
462 + step: int = Query(1, ge=1, le=10), group: str = Query("world"), snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
463 + ind = resolve_indicator(snap, slug)
464 + g, members = _an.group_members_or_all(snap, group)
465 + where = ["o.indicator_id = ?", "o.frequency = 'A'", "NOT o.is_forecast", "o.value IS NOT NULL", "coalesce(o.status, 'imported') <> 'quarantined'",
466 + "coalesce(c.kind, 'country') = 'country'"]
467 + params: list[Any] = [ind["id"]]
468 + if members is not None:
469 + if not members:
470 + where.append("FALSE")
471 + else:
472 + where.append(f"o.country_id IN ({','.join('?' * len(members))})")
473 + params += members
474 + if from_ is not None:
475 + where.append("o.year >= ?")
476 + params.append(from_)
477 + if to is not None:
478 + where.append("o.year <= ?")
479 + params.append(to)
480 + rows = snap.query_rows(f"SELECT o.country_id, o.year, o.value FROM observations o JOIN countries c ON c.id = o.country_id WHERE {' AND '.join(where)}", params)
481 + by_year: dict[int, dict[str, float]] = {}
482 + for cid, yr, v in rows:
483 + by_year.setdefault(int(yr), {})[cid] = float(v)
484 + years = sorted(y for y, d in by_year.items() if len(d) >= 20)
485 + if step > 1:
486 + years = [y for y in years if (y - years[0]) % step == 0] if years else []
487 + years = years[-80:]
488 + ids = sorted({c for y in years for c in by_year[y]})
489 + values = {c: [by_year[y].get(c) for y in years] for c in ids}
490 + pooled = [v for y in years for v in by_year[y].values()]
491 + breaks = quantile_breaks(pooled, 6)
492 + if from_ is not None and to is not None and from_ > to:
493 + raise _bad_request("`from` must be <= `to`.")
494 + return {"meta": meta_block(snap), "indicator": indicator_card(ind),
495 + "group": {"id": g["id"], "slug": g.get("slug"), "name": g.get("name"), "kind": g.get("kind"), "wb_code": g.get("wb_code"), "n_members": g.get("n_members")},
496 + "years": years, "values": values,
497 + "legend": {"min": min(pooled) if pooled else None, "max": max(pooled) if pooled else None, "breaks": breaks, "n_classes": len(breaks) + 1},
498 + "n_by_year": [len(by_year[y]) for y in years], "provenance": _an.indicator_provenances(snap).get(ind["id"])}
added src/countryatlas/api/routers/quality.py +202 −0
@@ -0,0 +1,202 @@
1 +"""Data quality and freshness (API 1.1): /indicators/{slug}/quality, /countries/{id}/quality, /updates.
2 +
3 +Badges vocabulary (shared): fresh · historical · sparse · limited-coverage · stale · flagged · forecast. No file paths, hosts
4 +or secrets are ever returned.
5 +"""
6 +from __future__ import annotations
7 +
8 +from typing import Any
9 +
10 +import numpy as np
11 +from fastapi import APIRouter, Depends, Query
12 +
13 +from countryatlas.api import schemas
14 +from countryatlas.api.common import country_card, indicator_card, merged_indicator, meta_block, resolve_country, resolve_indicator
15 +from countryatlas.api.db import Snapshot, get_snapshot
16 +from countryatlas.api.provenance import _iso
17 +from countryatlas.api.routers.analytics import country_ids, indicator_provenances, latest_common_year
18 +from countryatlas.api.routers.indicators import indicator_summary
19 +
20 +router = APIRouter(tags=["quality"])
21 +
22 +SPARSE_POINTS = 10
23 +FRESH_LAG = 1
24 +STALE_LAG = 3
25 +FLAGGED_SHARE = 0.05
26 +
27 +
28 +def reference_year(snap: Snapshot) -> int:
29 + return snap._cached("reference_year", lambda: int(snap.scalar(
30 + "SELECT max(l.year) FROM latest l JOIN countries c ON c.id = l.country_id WHERE coalesce(c.kind, 'country') = 'country' "
31 + "AND l.indicator_id IN ('population', 'gdp', 'life-expectancy')") or 0))
32 +
33 +
34 +def _badges(*, latest_year: int | None, first_year: int | None, points: float | None, coverage_pct: float | None, flagged_share: float,
35 + forecast: bool, ref: int) -> list[str]:
36 + out: list[str] = []
37 + if latest_year is not None and latest_year >= ref - FRESH_LAG:
38 + out.append("fresh")
39 + if first_year is not None and first_year <= 1970:
40 + out.append("historical")
41 + if points is not None and points < SPARSE_POINTS:
42 + out.append("sparse")
43 + if coverage_pct is not None and coverage_pct < 50:
44 + out.append("limited-coverage")
45 + if latest_year is not None and latest_year <= ref - STALE_LAG:
46 + out.append("stale")
47 + if flagged_share > FLAGGED_SHARE:
48 + out.append("flagged")
49 + if forecast:
50 + out.append("forecast")
51 + return out
52 +
53 +
54 +@router.get("/indicators/{slug}/quality", response_model=schemas.IndicatorQualityResponse, summary="Coverage / freshness / continuity of an indicator")
55 +def indicator_quality(slug: str, snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
56 + ind = resolve_indicator(snap, slug)
57 + total = len(country_ids(snap))
58 + ref = reference_year(snap)
59 + per_country = snap.query(
60 + """SELECT o.country_id, count(*) AS n, min(o.year) AS y0, max(o.year) AS y1,
61 + sum(CASE WHEN o.status = 'warning' THEN 1 ELSE 0 END) AS n_warn,
62 + max(CASE WHEN o.is_forecast THEN 1 ELSE 0 END) AS has_fc
63 + FROM observations o JOIN countries c ON c.id = o.country_id JOIN indicators i ON i.id = o.indicator_id
64 + WHERE o.indicator_id = ? AND o.frequency = i.frequency AND coalesce(c.kind, 'country') = 'country' AND o.value IS NOT NULL
65 + GROUP BY 1""",
66 + [ind["id"]],
67 + )
68 + actual = [r for r in per_country]
69 + n_countries = len(actual)
70 + years_rows = snap.query_rows(
71 + """SELECT o.year, count(*) FROM observations o JOIN countries c ON c.id = o.country_id JOIN indicators i ON i.id = o.indicator_id
72 + WHERE o.indicator_id = ? AND o.frequency = i.frequency AND NOT o.is_forecast AND o.value IS NOT NULL
73 + AND coalesce(c.kind, 'country') = 'country' GROUP BY 1 ORDER BY 1""",
74 + [ind["id"]],
75 + )
76 + latest_years = [int(r["y1"]) for r in actual]
77 + points = [int(r["n"]) for r in actual]
78 + n_warn = sum(int(r["n_warn"]) for r in actual)
79 + n_obs = sum(points)
80 + first_year = min((int(r["y0"]) for r in actual), default=None)
81 + last_year = max(latest_years, default=None)
82 + coverage = round(100.0 * n_countries / max(total, 1), 1)
83 + med_points = float(np.median(points)) if points else None
84 + prov = indicator_provenances(snap).get(ind["id"])
85 + src_rows = snap.query(
86 + "SELECT source_id, source_dataset, source_series_code, max(retrieved_at) AS ra, max(source_updated_at) AS su, count(*) AS n "
87 + "FROM observations WHERE indicator_id = ? GROUP BY 1, 2, 3 ORDER BY n DESC", [ind["id"]])
88 + from countryatlas.api.provenance import build_provenance
89 +
90 + sources = [dict(build_provenance(snap, ind["id"], r["source_id"], r["source_dataset"], r["source_series_code"], r["ra"], r["su"]), n_values=r["n"])
91 + for r in src_rows]
92 + return {
93 + "meta": meta_block(snap), "indicator": indicator_card(ind), "n_countries": n_countries, "n_countries_total": total, "coverage_pct": coverage,
94 + "first_year": first_year, "last_year": last_year, "latest_common_year": latest_common_year(snap, ind["id"]),
95 + "n_years": len(years_rows), "years_with_50plus": sum(1 for _, n in years_rows if n >= 50),
96 + "median_points_per_country": med_points, "sparse_countries": sum(1 for p in points if p < SPARSE_POINTS),
97 + "stale_countries": sum(1 for y in latest_years if y <= ref - STALE_LAG), "flagged_values": n_warn,
98 + "sources": sources,
99 + "badges": _badges(latest_year=last_year, first_year=first_year, points=med_points, coverage_pct=coverage,
100 + flagged_share=(n_warn / n_obs) if n_obs else 0.0, forecast=any(int(r["has_fc"]) for r in actual), ref=ref),
101 + "provenance": prov,
102 + }
103 +
104 +
105 +@router.get("/countries/{id}/quality", response_model=schemas.CountryQualityResponse, summary="Per-indicator data quality for a country")
106 +def country_quality(id: str, snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
107 + c = resolve_country(snap, id)
108 + ref = reference_year(snap)
109 + rows = snap.query(
110 + """SELECT o.indicator_id, count(*) AS n, min(o.year) AS y0, max(o.year) AS y1, count(DISTINCT o.year) AS ny,
111 + sum(CASE WHEN o.status = 'warning' THEN 1 ELSE 0 END) AS n_warn,
112 + arg_max(o.status, o.period) AS status, arg_max(o.source_id, o.period) AS source,
113 + max(o.source_updated_at) AS su, max(o.retrieved_at) AS ra, max(CASE WHEN o.is_forecast THEN 1 ELSE 0 END) AS has_fc
114 + FROM observations o JOIN indicators i ON i.id = o.indicator_id
115 + WHERE o.country_id = ? AND o.frequency = i.frequency AND NOT o.is_forecast AND o.value IS NOT NULL GROUP BY 1 ORDER BY 1""",
116 + [c["id"]],
117 + )
118 + items = []
119 + n_fresh = n_stale = n_sparse = n_flagged = 0
120 + latest = None
121 + for r in rows:
122 + ind = merged_indicator(snap.indicators()[r["indicator_id"]])
123 + y0, y1, n = int(r["y0"]), int(r["y1"]), int(r["n"])
124 + expected = y1 - y0 + 1 if ind.get("frequency", "A") == "A" else n
125 + missing = max(0, expected - int(r["ny"]))
126 + cont = round(100.0 * int(r["ny"]) / expected, 1) if expected else None
127 + badges = _badges(latest_year=y1, first_year=y0, points=n, coverage_pct=None, flagged_share=int(r["n_warn"]) / n if n else 0.0,
128 + forecast=bool(int(r["has_fc"])), ref=ref)
129 + n_fresh += "fresh" in badges
130 + n_stale += "stale" in badges
131 + n_sparse += "sparse" in badges
132 + n_flagged += "flagged" in badges
133 + latest = y1 if latest is None else max(latest, y1)
134 + items.append({"indicator": indicator_card(ind), "latest_year": y1, "first_year": y0, "n_points": n, "expected_points": expected,
135 + "missing_years": missing, "continuity_pct": cont, "status": r["status"], "source": r["source"],
136 + "source_updated_at": _iso(r["su"]), "retrieved_at": _iso(r["ra"]), "badges": badges})
137 + total = len(snap.indicators())
138 + return {"meta": meta_block(snap), "country": country_card(c),
139 + "summary": {"n_indicators": total, "n_with_data": len(items), "coverage_pct": round(100.0 * len(items) / max(total, 1), 1),
140 + "latest_year": latest, "n_fresh": n_fresh, "n_stale": n_stale, "n_sparse": n_sparse, "n_flagged": n_flagged},
141 + "items": items}
142 +
143 +
144 +@router.get("/updates", response_model=schemas.UpdatesResponse, summary="Freshness dashboard: sources, runs, changed values")
145 +def updates(runs_limit: int = Query(20, ge=1, le=100), snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
146 + run_id = snap.run_id
147 + ref = reference_year(snap)
148 + changed = {r[0]: (int(r[1]), int(r[2])) for r in snap.query_rows(
149 + "SELECT coalesce(new_source_id, old_source_id) AS s, count(*), count(DISTINCT country_id) FROM observation_revisions WHERE run_id = ? GROUP BY 1",
150 + [run_id])}
151 + src_stats = {r["source_id"]: r for r in snap.query(
152 + """SELECT source_id, count(*) AS n_obs, count(DISTINCT indicator_id) AS n_ind, count(DISTINCT source_dataset) AS n_ds,
153 + max(CASE WHEN NOT is_forecast THEN year END) AS latest_year, max(retrieved_at) AS ra, max(source_updated_at) AS su
154 + FROM observations GROUP BY 1""")}
155 + run_status = {r["connector"]: r for r in snap.query(
156 + """SELECT connector, arg_max(status, coalesce(finished_at, started_at)) AS status, max(finished_at) AS finished,
157 + sum(CASE WHEN status IN ('failed', 'quarantined') THEN 1 ELSE 0 END) AS n_failed, count(*) AS n
158 + FROM import_runs GROUP BY 1""")}
159 + sources = []
160 + for sid, s in snap.sources().items():
161 + st = src_stats.get(sid)
162 + rs = run_status.get(sid)
163 + if st is None and rs is None:
164 + continue
165 + latest_year = int(st["latest_year"]) if st and st.get("latest_year") is not None else None
166 + if rs is None:
167 + status = "unknown"
168 + elif rs["status"] == "failed" or (rs["n"] and rs["n_failed"] == rs["n"]):
169 + status = "failed"
170 + elif rs["n_failed"] or rs["status"] in ("partial", "quarantined"):
171 + status = "partial"
172 + elif latest_year is not None and latest_year <= ref - STALE_LAG:
173 + status = "stale"
174 + else:
175 + status = "ok"
176 + ch = changed.get(sid, (0, 0))
177 + sources.append({
178 + "source": {"id": sid, "name": s.get("name"), "organization": s.get("organization"), "url": s.get("url"), "licence": s.get("licence")},
179 + "status": status, "last_success_at": _iso(s.get("last_success_at")), "last_retrieved_at": _iso(st["ra"]) if st else None,
180 + "source_updated_at": _iso(st["su"]) if st else None, "n_datasets": int(st["n_ds"]) if st else 0,
181 + "n_indicators": int(st["n_ind"]) if st else 0, "n_observations": int(st["n_obs"]) if st else 0, "latest_year": latest_year,
182 + "values_changed": ch[0], "countries_affected": ch[1],
183 + "runs": {"n": int(rs["n"]) if rs else 0, "n_failed": int(rs["n_failed"]) if rs else 0},
184 + })
185 + sources.sort(key=lambda s: -s["n_observations"])
186 + runs = snap.query(
187 + "SELECT run_id, connector, dataset, started_at, finished_at, status, rows_valid, warnings, errors, message FROM import_runs "
188 + "ORDER BY coalesce(finished_at, started_at) DESC NULLS LAST LIMIT ?", [runs_limit])
189 + for r in runs:
190 + r["started_at"], r["finished_at"] = _iso(r.get("started_at")), _iso(r.get("finished_at"))
191 + if r.get("message"):
192 + r["message"] = str(r["message"])[:200]
193 + inds = [merged_indicator(i) for i in snap.indicators().values() if i.get("latest_source_updated_at") is not None]
194 + inds.sort(key=lambda i: _iso(i.get("latest_source_updated_at")) or "", reverse=True)
195 + total_changed = int(snap.scalar("SELECT count(*) FROM observation_revisions WHERE run_id = ?", [run_id]) or 0)
196 + return {
197 + "meta": meta_block(snap),
198 + "snapshot": {"run_id": run_id, "built_at": snap.built_at, "observations": int(snap.meta.get("observation_count") or snap.table_count("observations")),
199 + "indicators": len(snap.indicators()), "countries": len(country_ids(snap)), "values_changed": total_changed,
200 + "values_changed_by_source": {k: v[0] for k, v in changed.items()}, "reference_year": ref},
201 + "sources": sources, "recent_runs": runs, "indicators_recently_updated": [indicator_summary(snap, i) for i in inds[:12]],
202 + }
modified src/countryatlas/api/routers/rankings.py +46 −0
@@ -182,3 +182,49 @@ def get_ranking_history(indicator: str, countries: str = Query(..., description=
182 182 "pct_rank": clean_float(r.get("pct_rank"))})
183 183 return {"meta": meta_block(snap), "indicator": indicator_card(ind), "countries": [country_card(c) for c in cs],
184 184 "years": sorted(years), "series": series}
185 +
186 +
187 +@router.get("/{indicator}/race", response_model=schemas.RaceResponse, summary="Rank race frames (top N per year)")
188 +def get_ranking_race(indicator: str, from_: int | None = Query(None, alias="from"), to: int | None = Query(None),
189 + top: int = Query(10, ge=3, le=30), group: str = Query("world"), snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
190 + ind = resolve_indicator(snap, indicator)
191 + g = resolve_group(snap, group)
192 + members = snap.group_members(g["id"]) if g["id"] != "world" else None
193 + where, params = ["r.indicator_id = ?"], [ind["id"]]
194 + if members is not None:
195 + if not members:
196 + where.append("FALSE")
197 + else:
198 + where.append(f"r.country_id IN ({','.join('?' * len(members))})")
199 + params += members
200 + if from_ is not None:
201 + where.append("r.year >= ?")
202 + params.append(from_)
203 + if to is not None:
204 + where.append("r.year <= ?")
205 + params.append(to)
206 + direction = "ASC" if ind.get("higher_is_better") is False else "DESC"
207 + rows = snap.query_rows(
208 + f"""SELECT year, country_id, value, rk FROM (
209 + SELECT r.year, r.country_id, r.value, row_number() OVER (PARTITION BY r.year ORDER BY r.value {direction}, r.country_id) AS rk,
210 + count(*) OVER (PARTITION BY r.year) AS n
211 + FROM rankings r WHERE {' AND '.join(where)})
212 + WHERE rk <= ? AND n >= 20 ORDER BY year, rk""",
213 + [*params, top],
214 + )
215 + frames: dict[int, list[dict[str, Any]]] = {}
216 + ids: set[str] = set()
217 + max_value = None
218 + for year, cid, value, rk in rows:
219 + v = clean_float(value)
220 + frames.setdefault(int(year), []).append({"id": cid, "value": v, "rank": int(rk)})
221 + ids.add(cid)
222 + if v is not None:
223 + max_value = v if max_value is None else max(max_value, v)
224 + years = sorted(frames)
225 + from countryatlas.api.routers.analytics import indicator_provenances
226 +
227 + return {"meta": meta_block(snap), "indicator": indicator_card(ind), "group": group_card(g), "top": top, "years": years,
228 + "frames": [{"year": y, "rows": frames[y]} for y in years],
229 + "countries": {cid: country_card(snap.countries()[cid]) for cid in sorted(ids) if cid in snap.countries()},
230 + "max_value": max_value, "provenance": indicator_provenances(snap).get(ind["id"])}
modified src/countryatlas/api/routers/regions.py +94 −0
@@ -13,6 +13,7 @@ from countryatlas.api.common import (
13 13 indicator_card,
14 14 merged_indicator,
15 15 meta_block,
16 + parse_csv,
16 17 resolve_group,
17 18 resolve_indicator,
18 19 )
@@ -87,6 +88,99 @@ def list_regions(kind: str | None = Query(None), snap: Snapshot = Depends(get_sn
87 88 return {"meta": meta_block(snap), "n": len(items), "items": items}
88 89
89 90
91 +COMPARE_DEFAULT = ["population", "gdp", "gdp-per-capita", "life-expectancy", "co2-per-capita", "internet-users", "gdp-growth", "inflation"]
92 +COMPARE_HISTORY = ["population", "gdp", "gdp-per-capita", "life-expectancy"]
93 +AGG_KIND = {s[0]: s[1] for s in AGG_SPECS}
94 +
95 +
96 +def _agg_kind(ind: dict[str, Any]) -> str:
97 + from countryatlas.api.common import is_per_capita_or_share
98 +
99 + if ind["id"] in AGG_KIND:
100 + return AGG_KIND[ind["id"]]
101 + if (ind.get("aggregation") or "none") == "sum":
102 + return "sum"
103 + return "weighted_mean" if is_per_capita_or_share(ind) else "median"
104 +
105 +
106 +@router.get("/compare", response_model=schemas.RegionCompareResponse, summary="Group vs group: aggregates, world shares, history")
107 +def compare_regions(a: str = Query("g7"), b: str = Query("brics"), indicators: str | None = Query(None),
108 + snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
109 + ga, gb = resolve_group(snap, a), resolve_group(snap, b)
110 + ids = [resolve_indicator(snap, s)["id"] for s in parse_csv(indicators, limit=12)] if indicators else [i for i in COMPARE_DEFAULT if i in snap.indicators()]
111 + groups = [(ga, snap.group_members(ga["id"])), (gb, snap.group_members(gb["id"]))]
112 + world = {r[0]: float(r[1]) for r in snap.query_rows(
113 + "SELECT l.indicator_id, sum(l.value) FROM latest l JOIN countries c ON c.id = l.country_id WHERE coalesce(c.kind,'country') = 'country' "
114 + "AND l.indicator_id IN ('population', 'gdp') GROUP BY 1")}
115 + rows_out: list[dict[str, Any]] = []
116 + shares: dict[str, Any] = {}
117 + stats_by_group: dict[str, dict[str, Any]] = {}
118 + for g, members in groups:
119 + ph = ",".join("?" * len(members)) if members else "NULL"
120 + rows = snap.query(
121 + f"""WITH l AS (SELECT * FROM latest WHERE country_id IN ({ph}) AND value IS NOT NULL AND indicator_id IN ({','.join('?' * len(ids))})),
122 + pop AS (SELECT country_id, value AS pop FROM latest WHERE indicator_id = 'population' AND country_id IN ({ph}))
123 + SELECT l.indicator_id, sum(l.value) AS total, median(l.value) AS med, count(*) AS n, max(l.year) AS year,
124 + sum(l.value * pop.pop) / nullif(sum(CASE WHEN l.value IS NOT NULL THEN pop.pop END), 0) AS wmean
125 + FROM l LEFT JOIN pop ON pop.country_id = l.country_id GROUP BY 1""",
126 + [*members, *ids, *members] if members else [*ids],
127 + )
128 + stats_by_group[g["id"]] = {r["indicator_id"]: r for r in rows}
129 + pop_sum = next((clean_float(r["total"]) for r in rows if r["indicator_id"] == "population"), None)
130 + gdp_sum = next((clean_float(r["total"]) for r in rows if r["indicator_id"] == "gdp"), None)
131 + if pop_sum is None or gdp_sum is None:
132 + extra = {r[0]: float(r[1]) for r in snap.query_rows(
133 + f"SELECT indicator_id, sum(value) FROM latest WHERE country_id IN ({ph}) AND indicator_id IN ('population','gdp') GROUP BY 1", members)} if members else {}
134 + pop_sum = pop_sum if pop_sum is not None else extra.get("population")
135 + gdp_sum = gdp_sum if gdp_sum is not None else extra.get("gdp")
136 + shares[g["id"]] = {"population_share_pct": round(100 * pop_sum / world["population"], 2) if pop_sum and world.get("population") else None,
137 + "gdp_share_pct": round(100 * gdp_sum / world["gdp"], 2) if gdp_sum and world.get("gdp") else None}
138 + for iid in ids:
139 + ind = merged_indicator(snap.indicators()[iid])
140 + kind = _agg_kind(ind)
141 + label = {"sum": "Total", "median": "Median", "weighted_mean": "Population-weighted mean"}[kind]
142 + values: dict[str, Any] = {}
143 + for g, _m in groups:
144 + r = stats_by_group[g["id"]].get(iid)
145 + if r is None:
146 + values[g["id"]] = {"value": None, "formatted": None, "n": 0, "year": None}
147 + continue
148 + val = clean_float({"sum": r["total"], "median": r["med"], "weighted_mean": r["wmean"] if r["wmean"] is not None else r["med"]}[kind])
149 + values[g["id"]] = {"value": val, "formatted": format_value(val, ind), "n": int(r["n"]), "year": r["year"]}
150 + rows_out.append({"indicator": indicator_card(ind), "kind": kind, "label": f"{label} {ind.get('short_name') or ind.get('name')}", "values": values})
151 + # history (annual): per year aggregate over members with >= 60 % reporting
152 + history: dict[str, Any] = {}
153 + for iid in [i for i in COMPARE_HISTORY if i in ids]:
154 + ind = merged_indicator(snap.indicators()[iid])
155 + kind = _agg_kind(ind)
156 + per_group: dict[str, dict[int, float]] = {}
157 + all_years: set[int] = set()
158 + for g, members in groups:
159 + if not members:
160 + per_group[g["id"]] = {}
161 + continue
162 + ph = ",".join("?" * len(members))
163 + rows = snap.query(
164 + f"""SELECT o.year, median(o.value) AS med, sum(o.value) AS total, count(*) AS n,
165 + sum(o.value * p.value) / nullif(sum(CASE WHEN o.value IS NOT NULL THEN p.value END), 0) AS wmean
166 + FROM observations o
167 + LEFT JOIN observations p ON p.indicator_id = 'population' AND p.country_id = o.country_id AND p.year = o.year AND p.frequency = 'A' AND NOT p.is_forecast
168 + WHERE o.indicator_id = ? AND o.frequency = 'A' AND NOT o.is_forecast AND o.value IS NOT NULL AND o.country_id IN ({ph})
169 + GROUP BY o.year HAVING count(*) >= ? ORDER BY o.year""",
170 + [iid, *members, max(1, int(0.6 * len(members)))],
171 + )
172 + d = {}
173 + for r in rows:
174 + v = clean_float({"sum": r["total"], "median": r["med"], "weighted_mean": r["wmean"] if r["wmean"] is not None else r["med"]}[kind])
175 + if v is not None:
176 + d[int(r["year"])] = v
177 + per_group[g["id"]] = d
178 + all_years |= set(d)
179 + years = sorted(all_years)
180 + history[iid] = {"years": years, "kind": kind, **{gid: [per_group[gid].get(y) for y in years] for gid in per_group}}
181 + return {"meta": meta_block(snap), "groups": [group_card(ga), group_card(gb)], "rows": rows_out, "shares": shares, "history": history}
182 +
183 +
90 184 @router.get("/{slug}", response_model=schemas.RegionResponse, summary="Group page: members, aggregates, member ranking")
91 185 def get_region(slug: str, indicator: str = Query("gdp-per-capita", description="Indicator for the member ranking"),
92 186 sort: str | None = Query(None, pattern="^(asc|desc)$"), snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
modified src/countryatlas/api/routers/search.py +115 −0
@@ -134,12 +134,125 @@ def typed_hint(snap: Snapshot, r: dict[str, Any]) -> str:
134 134 return " · ".join(filter(None, [label, raw_hint]))
135 135
136 136
137 +# ------------------------------------------------------------------------------------------------ intents (API 1.1)
138 +INTENT_STOP = {"the", "of", "in", "and", "vs", "versus", "v", "compare", "rank", "ranking", "rankings", "map", "explore", "to", "with", "for", "by"}
139 +
140 +
141 +def _country_hit(snap: Snapshot, text: str, minsim: float = 0.9) -> dict[str, Any] | None:
142 + t = text.strip()
143 + if len(t) in (2, 3) and t.isalpha(): # ISO2 / ISO3 code ("usa", "fr")
144 + c = snap.countries().get(t.upper()) or next((x for x in snap.countries().values() if (x.get("iso2") or "").lower() == t.lower()), None)
145 + if c:
146 + return {"type": "country", "id": c["id"], "slug": c.get("slug"), "name": c.get("short_name"), "score": 1.0, "country": country_card(c)}
147 + hits = run_search(snap, t, 1, {"country"}, minsim=minsim)
148 + return hits[0] if hits else None
149 +
150 +
151 +def _indicator_hit(snap: Snapshot, text: str) -> dict[str, Any] | None:
152 + hits = run_search(snap, text, 1, {"indicator"}, minsim=0.86)
153 + return hits[0] if hits else None
154 +
155 +
156 +def _group_hit(snap: Snapshot, text: str) -> dict[str, Any] | None:
157 + t = text.strip().lower()
158 + for g in snap.groups().values():
159 + if g["id"] == "world":
160 + continue
161 + if t in {(g.get("slug") or "").lower(), (g.get("name") or "").lower(), (g.get("wb_code") or "").lower(), g["id"].lower()}:
162 + return g
163 + return None
164 +
165 +
166 +def _action(action: str, ident: str, name: str, hint: str, url: str) -> dict[str, Any]:
167 + return {"type": "action", "action": action, "id": ident, "slug": ident, "name": name, "hint": hint, "score": 1.0, "url": url,
168 + "country": None, "topic": None, "indicator": None}
169 +
170 +
171 +def _split_countries(snap: Snapshot, tokens: list[str]) -> list[dict[str, Any]]:
172 + """Left-to-right grouping of tokens into country names: an exact single word first, then 2–3-word names, then a fuzzy word."""
173 + out: list[dict[str, Any]] = []
174 + i = 0
175 + while i < len(tokens):
176 + found = None
177 + if tokens[i] not in INTENT_STOP:
178 + h = _country_hit(snap, tokens[i], minsim=0.98)
179 + if h and h["score"] >= 0.95:
180 + found, i = h, i + 1
181 + if found is None:
182 + for w in (3, 2):
183 + chunk = " ".join(tokens[i:i + w])
184 + if len(tokens[i:i + w]) < w:
185 + continue
186 + h = _country_hit(snap, chunk, minsim=0.92)
187 + if h and h["score"] >= 0.97: # (near-)exact multi-word name only — a fuzzy 3-word hit would swallow the next country
188 + found, i = h, i + w
189 + break
190 + if found is None and tokens[i] not in INTENT_STOP:
191 + h = _country_hit(snap, tokens[i], minsim=0.9)
192 + if h and h["score"] >= 0.88:
193 + found, i = h, i + 1
194 + if found is None:
195 + return []
196 + if all(found["id"] != o["id"] for o in out):
197 + out.append(found)
198 + return out
199 +
200 +
201 +def parse_intents(snap: Snapshot, q: str) -> list[dict[str, Any]]:
202 + """Deterministic intent parsing: compare X Y [Z], rank <indicator>, <indicator> <group>, <indicator> map, X vs Y."""
203 + ql = q.strip().lower()
204 + tokens = [t for t in re.split(r"[\s,/]+", ql) if t]
205 + if len(tokens) < 2:
206 + return []
207 + out: list[dict[str, Any]] = []
208 + # X vs Y | compare X Y Z
209 + if tokens[0] in ("compare",) or any(t in ("vs", "versus", "v") for t in tokens):
210 + body = [t for t in tokens if t not in ("compare", "vs", "versus", "v", "and", "with", "to")]
211 + cs = _split_countries(snap, body)
212 + if len(cs) >= 2:
213 + names = " vs ".join(c["name"] for c in cs)
214 + out.append(_action("compare", ":".join(c["id"] for c in cs), names, "Compare · side by side", "/compare/" + "/".join(c["slug"] for c in cs)))
215 + # rank <indicator>
216 + if tokens[0] in ("rank", "ranking", "rankings", "top") and len(tokens) >= 2:
217 + ind = _indicator_hit(snap, " ".join(tokens[1:]))
218 + if ind:
219 + out.append(_action("ranking", ind["id"], f"{ind['name']} ranking", "Ranking · all countries", f"/rankings/{ind['slug']}"))
220 + # <indicator> map|explore | map <indicator>
221 + if tokens[-1] in ("map", "explore") or tokens[0] in ("map", "explore"):
222 + rest = tokens[1:] if tokens[0] in ("map", "explore") else tokens[:-1]
223 + ind = _indicator_hit(snap, " ".join(rest))
224 + if ind:
225 + out.append(_action("explore", ind["id"], f"{ind['name']} on the map", "World Explorer · map + time slider", f"/explore?indicator={ind['slug']}"))
226 + # <indicator> <group> | <group> <indicator>
227 + if not out:
228 + for split in range(1, len(tokens)):
229 + for left, right in ((tokens[:split], tokens[split:]), (tokens[split:], tokens[:split])):
230 + g = _group_hit(snap, " ".join(right))
231 + if not g:
232 + continue
233 + ind = _indicator_hit(snap, " ".join(left))
234 + if ind:
235 + out.append(_action("group_ranking", f"{ind['id']}:{g['id']}", f"{ind['name']} in {g.get('name')}", f"Ranking · {g.get('name')}",
236 + f"/rankings/{ind['slug']}?group={g.get('slug') or g['id']}"))
237 + break
238 + if out:
239 + break
240 + seen = set()
241 + uniq = []
242 + for h in out:
243 + if (h["action"], h["id"]) not in seen:
244 + seen.add((h["action"], h["id"]))
245 + uniq.append(h)
246 + return uniq
247 +
248 +
137 249 @router.get("/search", response_model=schemas.SearchResponse, summary="Search countries, indicators, topics, regions, sources")
138 250 def search(q: str = Query(..., min_length=1, max_length=120), limit: int = Query(10, ge=1, le=50),
139 251 type: str | None = Query(None, description="Restrict to a type: country|indicator|topic|region|source"),
140 252 snap: Snapshot = Depends(get_snapshot)) -> dict[str, Any]:
141 253 types = {type} if type else None
142 254 hits = run_search(snap, q, limit, types)
255 + intents = parse_intents(snap, q) if not type else []
143 256 # combos: "<topic|indicator> <country>" or "<country> <topic|indicator>" → country + topic/indicator + combined hit
144 257 tokens = [t for t in re.split(r"[\s,/]+", q.strip().lower()) if t]
145 258 if len(tokens) >= 2 and not type:
@@ -183,4 +296,6 @@ def search(q: str = Query(..., min_length=1, max_length=120), limit: int = Query
183 296 seen.add(k)
184 297 merged.append(h)
185 298 hits = merged[:limit]
299 + if intents:
300 + hits = (intents + [h for h in hits if h["type"] != "action"])[: max(limit, len(intents) + 3)]
186 301 return {"meta": meta_block(snap), "q": q, "n": len(hits), "hits": hits}
modified src/countryatlas/api/schemas.py +336 −0
@@ -568,3 +568,339 @@ class HealthResponse(Base):
568 568 db_path: str | None = None
569 569 version: str | None = None
570 570 cache: dict[str, int] | None = None
571 +
572 +
573 +# ------------------------------------------------------------------------------------------------ analytics (API 1.1)
574 +class MoverLite(Base):
575 + country: CountryCard
576 + value: float | None = None
577 + ref_value: float | None = None
578 + delta: float | None = None
579 + delta_pct: float | None = None
580 + formatted: str | None = None
581 + year: int | None = None
582 +
583 +
584 +class PulseItem(Base):
585 + indicator: IndicatorCard
586 + year: int
587 + n: int
588 + n_up: int
589 + n_down: int
590 + n_flat: int
591 + share_up: float
592 + share_down: float
593 + median_change_abs: float | None = None
594 + median_change_pct: float | None = None
595 + direction_semantics: str
596 + record_highs: int
597 + record_lows: int
598 + headline: str
599 + top_up: MoverLite | None = None
600 + top_down: MoverLite | None = None
601 + convergence: dict[str, Any] | None = None
602 + provenance: Provenance | None = None
603 +
604 +
605 +class PulseResponse(Base):
606 + meta: Meta
607 + year_reference: int
608 + summary: dict[str, Any]
609 + items: list[PulseItem]
610 +
611 +
612 +class MoverItem(Base):
613 + country: CountryCard
614 + indicator: IndicatorCard
615 + kind: str
616 + year: int | None = None
617 + ref_year: int | None = None
618 + value: float | None = None
619 + ref_value: float | None = None
620 + delta: float | None = None
621 + delta_pct: float | None = None
622 + formatted: str | None = None
623 + formatted_ref: str | None = None
624 + severity: float | None = None
625 + direction: str
626 + interpretation: str | None = None
627 + headline: str | None = None
628 + provenance: Provenance | None = None
629 +
630 +
631 +class MoversResponse(Base):
632 + meta: Meta
633 + window: int
634 + category: str
635 + kind: str
636 + min_population: int | None = None
637 + filter_note: str | None = None
638 + categories: list[str]
639 + kinds: list[str]
640 + n: int
641 + items: list[MoverItem]
642 +
643 +
644 +class ExtremeRow(Base):
645 + country: CountryCard
646 + value_start: float | None = None
647 + value_end: float | None = None
648 + year_start: int | None = None
649 + year_end: int | None = None
650 + delta: float | None = None
651 + delta_pct: float | None = None
652 + formatted_start: str | None = None
653 + formatted_end: str | None = None
654 +
655 +
656 +class ExtremeFacet(Base):
657 + id: str
658 + title: str
659 + indicator: IndicatorCard
660 + direction: str
661 + metric: str
662 + rows: list[ExtremeRow]
663 + n: int
664 + provenance: Provenance | None = None
665 +
666 +
667 +class ExtremesResponse(Base):
668 + meta: Meta
669 + window: str
670 + from_year: int | None = None
671 + to_year: int | None = None
672 + min_population: int | None = None
673 + filter_note: str | None = None
674 + facets: list[ExtremeFacet]
675 +
676 +
677 +class PointCountry(Base):
678 + id: str
679 + slug: str | None = None
680 + name: str | None = None
681 + flag: str | None = None
682 + region: str | None = None
683 + income: str | None = None
684 +
685 +
686 +class ScatterPoint(PointCountry):
687 + x: float | None = None
688 + y: float | None = None
689 + size: float | None = None
690 + year_x: int | None = None
691 + year_y: int | None = None
692 +
693 +
694 +class ScatterResponse(Base):
695 + meta: Meta
696 + x: IndicatorCard
697 + y: IndicatorCard
698 + size: IndicatorCard | None = None
699 + year: int | None = None
700 + year_used: int | None = None
701 + nearest_years: int
702 + group: GroupCard
703 + n: int
704 + points: list[ScatterPoint]
705 + stats: dict[str, Any]
706 + note: str
707 +
708 +
709 +class TrajectoryResponse(Base):
710 + meta: Meta
711 + x: IndicatorCard
712 + y: IndicatorCard
713 + size: IndicatorCard | None = None
714 + group: GroupCard
715 + years: list[int]
716 + countries: list[PointCountry]
717 + series: dict[str, dict[str, list[float | None]]]
718 + domains: dict[str, Any]
719 + log_x: bool
720 + log_y: bool
721 + provenance: list[Provenance] = Field(default_factory=list)
722 +
723 +
724 +class FinderFilter(Base):
725 + indicator: IndicatorCard
726 + op: str
727 + value: float
728 + value2: float | None = None
729 + year_used: int | None = None
730 +
731 +
732 +class FinderItem(Base):
733 + country: CountryCard
734 + matched: list[str]
735 + values: dict[str, Any]
736 +
737 +
738 +class FinderResponse(Base):
739 + meta: Meta
740 + mode: str
741 + filters: list[FinderFilter]
742 + n_matching: int
743 + n_evaluated: int
744 + items: list[FinderItem]
745 +
746 +
747 +class PeerPoint(PointCountry):
748 + x: float | None = None
749 + y: float | None = None
750 + expected: float | None = None
751 + residual: float | None = None
752 + residual_z: float | None = None
753 +
754 +
755 +class PeersResponse(Base):
756 + meta: Meta
757 + x: IndicatorCard
758 + y: IndicatorCard
759 + year_used: int | None = None
760 + n: int
761 + method: str
762 + fit: dict[str, Any] | None = None
763 + points: list[PeerPoint]
764 + above: list[PeerPoint]
765 + below: list[PeerPoint]
766 + pairs: list[dict[str, str]]
767 + note: str
768 + methodology: str
769 +
770 +
771 +class RelatedItem(Base):
772 + indicator: IndicatorCard
773 + pearson: float | None = None
774 + spearman: float | None = None
775 + n: int
776 + year: int | None = None
777 + log_x: bool
778 + log_y: bool
779 + direction: str
780 +
781 +
782 +class RelatedResponse(Base):
783 + meta: Meta
784 + indicator: IndicatorCard
785 + year_used: int | None = None
786 + n_candidates: int
787 + items: list[RelatedItem]
788 + note: str
789 +
790 +
791 +class DistributionResponse(Base):
792 + meta: Meta
793 + indicator: IndicatorCard
794 + year: int | None = None
795 + year_used: int | None = None
796 + n: int
797 + log: bool
798 + histogram: dict[str, Any]
799 + stats: dict[str, float | None]
800 + highlight: dict[str, Any] | None = None
801 + by_region: list[dict[str, Any]]
802 + by_income: list[dict[str, Any]]
803 + provenance: Provenance | None = None
804 +
805 +
806 +class FramesResponse(Base):
807 + meta: Meta
808 + indicator: IndicatorCard
809 + group: GroupCard
810 + years: list[int]
811 + values: dict[str, list[float | None]]
812 + legend: MapLegend
813 + n_by_year: list[int]
814 + provenance: Provenance | None = None
815 +
816 +
817 +class IndicatorQualityResponse(Base):
818 + meta: Meta
819 + indicator: IndicatorCard
820 + n_countries: int
821 + n_countries_total: int
822 + coverage_pct: float
823 + first_year: int | None = None
824 + last_year: int | None = None
825 + latest_common_year: int | None = None
826 + n_years: int
827 + years_with_50plus: int
828 + median_points_per_country: float | None = None
829 + sparse_countries: int
830 + stale_countries: int
831 + flagged_values: int
832 + sources: list[Provenance] = Field(default_factory=list)
833 + badges: list[str]
834 +
835 +
836 +class RaceResponse(Base):
837 + meta: Meta
838 + indicator: IndicatorCard
839 + group: GroupCard
840 + top: int
841 + years: list[int]
842 + frames: list[dict[str, Any]]
843 + countries: dict[str, CountryCard]
844 + max_value: float | None = None
845 + provenance: Provenance | None = None
846 +
847 +
848 +class RegionCompareResponse(Base):
849 + meta: Meta
850 + groups: list[GroupCard]
851 + rows: list[dict[str, Any]]
852 + shares: dict[str, Any]
853 + history: dict[str, Any]
854 +
855 +
856 +class StoryItem(Base):
857 + indicator: IndicatorCard
858 + first: dict[str, Any]
859 + last: dict[str, Any]
860 + change_abs: float | None = None
861 + change_pct: float | None = None
862 + cagr: float | None = None
863 + peak: dict[str, Any] | None = None
864 + trough: dict[str, Any] | None = None
865 + rank_first: dict[str, Any] | None = None
866 + rank_last: dict[str, Any] | None = None
867 + series: list[list[float | None]]
868 + text: str
869 + provenance: Provenance | None = None
870 +
871 +
872 +class StoryResponse(Base):
873 + meta: Meta
874 + country: CountryCard
875 + since: int | None = None
876 + items: list[StoryItem]
877 +
878 +
879 +class CountryQualityItem(Base):
880 + indicator: IndicatorCard
881 + latest_year: int | None = None
882 + first_year: int | None = None
883 + n_points: int
884 + expected_points: int
885 + missing_years: int
886 + continuity_pct: float | None = None
887 + status: str | None = None
888 + source: str | None = None
889 + source_updated_at: str | None = None
890 + retrieved_at: str | None = None
891 + badges: list[str]
892 +
893 +
894 +class CountryQualityResponse(Base):
895 + meta: Meta
896 + country: CountryCard
897 + summary: dict[str, Any]
898 + items: list[CountryQualityItem]
899 +
900 +
901 +class UpdatesResponse(Base):
902 + meta: Meta
903 + snapshot: dict[str, Any]
904 + sources: list[dict[str, Any]]
905 + recent_runs: list[dict[str, Any]]
906 + indicators_recently_updated: list[IndicatorSummary]
modified src/countryatlas/connectors/base.py +35 −10
@@ -11,12 +11,12 @@ import json
11 11 import threading
12 12 import time
13 13 from abc import ABC, abstractmethod
14 from datetime import datetime, timezone
14 +from datetime import UTC, datetime
15 15 from pathlib import Path
16 16 from typing import Any, ClassVar
17 17
18 18 import httpx
19 from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter
19 +from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
20 20
21 21 from countryatlas.config import settings
22 22 from countryatlas.models import (
@@ -57,6 +57,28 @@ class TransientHTTPError(Exception):
57 57 pass
58 58
59 59
60 +RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504)
61 +RETRY_ATTEMPTS = 5
62 +RETRY_AFTER_CAP = 120.0 # seconds; a longer Retry-After is honoured up to this cap
63 +
64 +
65 +def retry_after_seconds(value: str | None) -> float | None:
66 + """Parse a Retry-After header (delta-seconds or HTTP-date) into seconds ≥ 0, capped; None when absent/unparseable."""
67 + if not value:
68 + return None
69 + v = value.strip()
70 + if v.isdigit():
71 + return min(float(v), RETRY_AFTER_CAP)
72 + try:
73 + from email.utils import parsedate_to_datetime
74 +
75 + dt = parsedate_to_datetime(v)
76 + except Exception: # noqa: BLE001
77 + return None
78 + delta = (dt - datetime.now(UTC)).total_seconds()
79 + return min(max(delta, 0.0), RETRY_AFTER_CAP)
80 +
81 +
60 82 class Connector(ABC):
61 83 id: ClassVar[str] = ""
62 84 name: ClassVar[str] = ""
@@ -79,19 +101,22 @@ class Connector(ABC):
79 101 )
80 102
81 103 # ---------------------------------------------------------------- HTTP
104 + # Deterministic exponential backoff (2, 4, 8, 16 s … capped at 60 s, no jitter so runs are reproducible) on transient
105 + # statuses (408/425/429/5xx) and transport errors (timeouts, resets); 5 attempts. A `Retry-After` header is honoured
106 + # (up to RETRY_AFTER_CAP) before the backoff wait.
82 107 @retry(
83 108 reraise=True,
84 stop=stop_after_attempt(5),
85 wait=wait_exponential_jitter(initial=2, max=60),
109 + stop=stop_after_attempt(RETRY_ATTEMPTS),
110 + wait=wait_exponential(multiplier=2, min=2, max=60),
86 111 retry=retry_if_exception_type((TransientHTTPError, httpx.TransportError)),
87 112 )
88 113 def get(self, url: str, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> httpx.Response:
89 114 self._limiter.acquire()
90 115 r = self._client.get(url, params=params, headers=headers)
91 if r.status_code in (429, 500, 502, 503, 504):
92 retry_after = r.headers.get("Retry-After")
93 if retry_after and retry_after.isdigit():
94 time.sleep(min(int(retry_after), 120))
116 + if r.status_code in RETRY_STATUSES:
117 + wait = retry_after_seconds(r.headers.get("Retry-After"))
118 + if wait:
119 + time.sleep(wait)
95 120 raise TransientHTTPError(f"{r.status_code} {url}")
96 121 r.raise_for_status()
97 122 return r
@@ -111,7 +136,7 @@ class Connector(ABC):
111 136 dataset=dataset,
112 137 code=code,
113 138 url=str(r.url),
114 retrieved_at=datetime.now(timezone.utc),
139 + retrieved_at=datetime.now(UTC),
115 140 status_code=r.status_code,
116 141 content_type=r.headers.get("Content-Type"),
117 142 body=r.content,
@@ -197,7 +222,7 @@ class Connector(ABC):
197 222 def apply_transform(value: float, transform: str | None) -> float:
198 223 if not transform:
199 224 return value
200 return float(eval(transform, {"__builtins__": {}}, {"x": value})) # noqa: S307 — registry-controlled expressions
225 + return float(eval(transform, {"__builtins__": {}}, {"x": value}))
201 226
202 227 def close(self) -> None:
203 228 self._client.close()
modified src/countryatlas/pipeline/build.py +66 −3
@@ -33,6 +33,7 @@ SCHEMA_SQL = Path(__file__).resolve().parents[1] / "storage" / "schema.sql"
33 33 MIN_OBSERVATIONS_WITH_WB = 100_000
34 34 HEADLINE_MIN_COUNTRIES = 100
35 35 HEADLINE_MIN_N_WORLD = 50 # warning when a headline indicator's latest row is ranked in a pool smaller than this
36 +MIN_RETENTION_VS_PREVIOUS = 0.90 # never publish a snapshot with < 90 % of the previous snapshot's observations
36 37
37 38 # Public facts about sources whose connector module is not implemented yet (so `sources` is complete for the API).
38 39 SOURCE_DEFAULTS: dict[str, dict[str, str]] = {
@@ -287,6 +288,53 @@ def _merge_observations(con: duckdb.DuckDBPyConnection) -> tuple[int, int, int]:
287 288 return n, n_alt, n_fresher
288 289
289 290
291 +def _previous_observation_count(previous_db: Path) -> int | None:
292 + """`observation_count` of the live snapshot (its `meta` first, else a count), None when there is no previous snapshot."""
293 + if not previous_db.exists():
294 + return None
295 + try:
296 + prev = duckdb.connect(str(previous_db), read_only=True)
297 + except Exception as e: # noqa: BLE001
298 + log.warning("previous snapshot unreadable (%s) — retention check skipped", e)
299 + return None
300 + try:
301 + row = prev.execute("SELECT value FROM meta WHERE key = 'observation_count'").fetchone()
302 + if row and str(row[0]).isdigit():
303 + return int(row[0])
304 + return int(prev.execute("SELECT count(*) FROM observations").fetchone()[0])
305 + except Exception as e: # noqa: BLE001
306 + log.warning("previous snapshot count unavailable (%s)", e)
307 + return None
308 + finally:
309 + prev.close()
310 +
311 +
312 +def _source_health(con: duckdb.DuckDBPyConnection) -> dict[str, dict[str, int]]:
313 + """Per connector: ok / partial / failed / quarantined spec counts from the latest import_runs."""
314 + out: dict[str, dict[str, int]] = {}
315 + for connector, status, n in con.execute(
316 + "SELECT connector, status, count(*) FROM import_runs GROUP BY 1, 2 ORDER BY 1, 2"
317 + ).fetchall():
318 + out.setdefault(connector, {"ok": 0, "partial": 0, "failed": 0, "quarantined": 0})
319 + out[connector][status if status in out[connector] else "failed"] += int(n)
320 + return out
321 +
322 +
323 +def _write_build_log(run_id: str, counts: dict[str, Any], warnings: list[str], files: list[Path], duration_s: float,
324 + published: bool, error: str | None = None) -> None:
325 + """`logs/build-<run_id>.json`: counts, warnings, staging files used, source health — for audit and the admin console."""
326 + try:
327 + settings.logs_dir.mkdir(parents=True, exist_ok=True)
328 + doc = {
329 + "run_id": run_id, "finished_at": datetime.now(UTC).isoformat(), "published": published, "error": error,
330 + "duration_s": round(duration_s, 1), "counts": counts, "warnings": warnings,
331 + "staging_files": [f"{p.parent.name}/{p.name}" for p in files],
332 + }
333 + (settings.logs_dir / f"build-{run_id}.json").write_text(json.dumps(doc, indent=1, default=str))
334 + except Exception as e: # noqa: BLE001
335 + log.warning("could not write build log: %s", e)
336 +
337 +
290 338 def _carry_revisions(con: duckdb.DuckDBPyConnection, previous_db: Path, run_id: str) -> tuple[int, int]:
291 339 """Compare with the previous live snapshot; record changed values and copy the old revisions table."""
292 340 if not previous_db.exists():
@@ -355,12 +403,15 @@ def _load_runs_and_issues(con: duckdb.DuckDBPyConnection) -> tuple[int, int]:
355 403
356 404
357 405 # ------------------------------------------------------------------------------------------------ integrity + swap
358 def _integrity(con: duckdb.DuckDBPyConnection, files: list[Path], strict: bool) -> list[str]:
406 +def _integrity(con: duckdb.DuckDBPyConnection, files: list[Path], strict: bool, previous_count: int | None = None) -> list[str]:
359 407 problems: list[str] = []
360 408 n_obs = con.execute("SELECT count(*) FROM observations").fetchone()[0]
361 409 has_wb = any(p.parent.name == "worldbank" for p in files)
362 410 if has_wb and n_obs < MIN_OBSERVATIONS_WITH_WB:
363 411 problems.append(f"only {n_obs} observations although World Bank staging is present (< {MIN_OBSERVATIONS_WITH_WB})")
412 + if previous_count and n_obs < MIN_RETENTION_VS_PREVIOUS * previous_count:
413 + problems.append(f"only {n_obs} observations vs {previous_count} in the previous snapshot "
414 + f"({n_obs / previous_count:.1%} < {MIN_RETENTION_VS_PREVIOUS:.0%}) — refusing to publish a shrunken snapshot")
364 415 staged_indicators = {r[0] for r in con.execute("SELECT DISTINCT indicator_id FROM staging_all").fetchall()}
365 416 cov = dict(con.execute("SELECT indicator_id, count(*) FROM latest GROUP BY indicator_id").fetchall())
366 417 for slug in registry.topics()["headline"]:
@@ -414,6 +465,11 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
414 465 for o in orphans: # spec removed/renamed in the registry since the file was staged → must not leak into the snapshot
415 466 log.warning("ignoring orphan staging file (no matching source spec in the registry): %s", o)
416 467 log.info("build %s: %d staging files (%d orphans ignored) → %s", run_id, len(files), len(orphans), build_path)
468 + by_conn: dict[str, int] = {}
469 + for f in files:
470 + by_conn[f.parent.name] = by_conn.get(f.parent.name, 0) + 1
471 + log.info("build %s: staging files per connector: %s", run_id, ", ".join(f"{k}={v}" for k, v in sorted(by_conn.items())))
472 + previous_count = _previous_observation_count(settings.db_path)
417 473 counts: dict[str, int] = {}
418 474 warnings: list[str] = []
419 475 con = duckdb.connect(str(build_path))
@@ -478,16 +534,20 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
478 534 _connector_meta())
479 535 counts["indicators_with_data"] = con.execute("SELECT count(DISTINCT indicator_id) FROM observations").fetchone()[0]
480 536 counts["countries_with_data"] = con.execute("SELECT count(DISTINCT country_id) FROM observations").fetchone()[0]
481 warnings = _integrity(con, files, strict)
537 + warnings = _integrity(con, files, strict, previous_count)
482 538 for w in warnings:
483 539 log.warning("integrity: %s", w)
540 + health = _source_health(con)
541 + counts["specs_failed"] = sum(h["failed"] + h["quarantined"] for h in health.values())
484 542 derived.write_meta(con, {
485 543 "schema_version": SCHEMA_VERSION, "build_run_id": run_id, "observation_count": counts["observations"],
544 + "previous_observation_count": previous_count if previous_count is not None else "",
486 545 "observations_alt_count": counts["observations_alt"], "indicator_count": counts["indicators_with_data"],
487 546 "country_count": counts["countries_with_data"], "latest_count": counts["latest"],
488 547 "rankings_count": counts["rankings"], "changes_count": counts["changes"], "events_count": counts["events"],
489 548 "insights_count": counts["insights"], "similarity_count": counts["similarity"],
490 549 "staging_files": len(files), "connectors": ",".join(sorted({p.parent.name for p in files})),
550 + "source_health": json.dumps(health, sort_keys=True),
491 551 "registry_dir": str(settings.registry_dir), "repo": str(ROOT),
492 552 "build_duration_s": f"{time.monotonic() - t_start:.1f}",
493 553 "integrity_warnings": " | ".join(warnings),
@@ -495,7 +555,7 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
495 555 con.execute("CHECKPOINT")
496 556 con.close()
497 557 _timer("meta + checkpoint", t0)
498 except Exception:
558 + except Exception as exc:
499 559 try:
500 560 con.close()
501 561 except Exception as e: # noqa: BLE001
@@ -503,6 +563,8 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
503 563 build_path.unlink(missing_ok=True)
504 564 build_path.with_suffix(".duckdb.wal").unlink(missing_ok=True)
505 565 log.exception("build %s FAILED — live database untouched", run_id)
566 + _write_build_log(run_id, counts, warnings, files, time.monotonic() - t_start, published=False,
567 + error=f"{type(exc).__name__}: {exc}")
506 568 raise
507 569
508 570 snap: Path | None = None
@@ -511,6 +573,7 @@ def build(run_id: str | None = None, strict: bool = True, swap: bool = True) ->
511 573 log.info("build %s swapped into %s (snapshot %s)", run_id, settings.db_path, snap.name)
512 574 dur = time.monotonic() - t_start
513 575 log.info("build %s done in %.1fs: %s", run_id, dur, json.dumps(counts))
576 + _write_build_log(run_id, counts, warnings, files, dur, published=swap)
514 577 return BuildResult(run_id=run_id, db_path=settings.db_path if swap else build_path, snapshot_path=snap, counts=counts,
515 578 duration_s=dur, warnings=warnings)
516 579
modified src/countryatlas/pipeline/changes.py +109 −1
@@ -1,7 +1,13 @@
1 1 """Deterministic change / event detectors (ARCHITECTURE §7) with templated English headlines — no LLM anywhere.
2 2
3 3 `changes` = detectors evaluated at the LATEST period of each country×indicator series
4 `events` = detectors evaluated over the WHOLE history (yoy jumps/drops, records after a ≥5-year gap, sign flips)
4 +`events` = detectors evaluated over the WHOLE history (yoy jumps/drops, records after a ≥5-year gap, sign flips,
5 + one structural break per series)
6 +
7 +Change detection 2.0 kinds (countryatlas.stats): `structural_break` (single mean shift, gain ≥ 0.5 of the total sum of
8 +squares, |shift| ≥ 1.5 sd, segments ≥ 5 years; `changes` only when the break is within the last 10 years), `trend_reversal`
9 +(three yearly moves of one sign after three of the opposite sign, cumulative move ≥ floor), `volatility_spike` (sd of the
10 +last 5 yearly differences ≥ 3 × sd of the previous 15 and ≥ floor). Severities 0.45–0.7 before the importance weight.
5 11
6 12 Working scale: percent-like indicators are analysed on absolute differences ("points"); positive level series
7 13 (GDP, population, emissions…) on log-differences (reported as % change); everything else on absolute differences.
@@ -21,6 +27,7 @@ from typing import Any
21 27 import numpy as np
22 28 import polars as pl
23 29
30 +from countryatlas import stats
24 31 from countryatlas.pipeline.format import delta_mode, fmt_delta, fmt_value
25 32 from countryatlas.registry import Indicator
26 33
@@ -43,6 +50,15 @@ EVENT_MIN_SEVERITY = 0.35 # events below this are noise and are dropped
43 50 EVENTS_PER_INDICATOR_YEAR = 3 # timeline cap per (country, indicator, year)
44 51 RECORD_GAP_YEARS = 5
45 52 SIGN_FLIP_HINTS = ("growth", "balance", "net-migration", "inflation", "change")
53 +# --- change detection 2.0 (structural break, trend reversal, volatility spike) ---
54 +BREAK_MIN_SEGMENT = 5 # years on each side of a mean shift
55 +BREAK_MIN_GAIN = 0.5 # share of the total sum of squares explained by the split
56 +BREAK_MIN_SHIFT_RATIO = 1.5 # |shift| / sd(series)
57 +BREAK_RECENT_YEARS = 10 # `changes` only when the break is within the last 10 years
58 +REVERSAL_RUN = 3 # 3 yearly moves of one sign after 3 of the opposite sign
59 +VOL_RECENT = 5 # sd of the last 5 yearly diffs …
60 +VOL_BASELINE = 15 # … vs the sd of the previous 15
61 +VOL_RATIO = 3.0
46 62
47 63 EVENT_COLUMNS = [
48 64 "id", "country_id", "indicator_id", "kind", "period", "year", "value", "ref_value", "delta", "delta_pct",
@@ -332,9 +348,96 @@ def detect_changes(s: Series, ind: Indicator) -> list[dict[str, Any]]:
332 348 f"{name} has fallen faster for three consecutive years, reaching {fmt_value(cur, ind)} in {year}.",
333 349 {"diffs": [float(x) for x in dd], "mode": mode}, 3, "changes")
334 350 )
351 +
352 + # --- change detection 2.0 -----------------------------------------------------------------------------------
353 + brk = _structural_break(s, ind, w)
354 + if brk is not None and year - brk["year"] <= BREAK_RECENT_YEARS:
355 + out.append(_break_row(s, ind, brk, "changes"))
356 + rev = _trend_reversal(s, ind, v, floor, floor_abs, mode)
357 + if rev is not None:
358 + out.append(rev)
359 + vol = _volatility_spike(s, ind, v, d, floor, floor_abs, mode)
360 + if vol is not None:
361 + out.append(vol)
335 362 return out
336 363
337 364
365 +# ------------------------------------------------------------------------------------------------ detectors 2.0
366 +def _structural_break(s: Series, ind: Indicator, w: np.ndarray) -> dict[str, Any] | None:
367 + """Single mean shift on the working-scale series (stats.structural_break): gain ≥ 0.5, |shift| ≥ 1.5 sd, segments ≥ 5.
368 + Returns {index, year, before, after (raw-scale means), gain, shift_ratio} or None. Monotone series are skipped (a trend
369 + is not a level shift)."""
370 + if len(w) < 2 * BREAK_MIN_SEGMENT or not np.all(np.isfinite(w)) or _is_monotone(s.values):
371 + return None
372 + b = stats.structural_break([float(x) for x in w], min_segment=BREAK_MIN_SEGMENT)
373 + if b is None or b["gain"] < BREAK_MIN_GAIN or b["shift_ratio"] < BREAK_MIN_SHIFT_RATIO:
374 + return None
375 + i = int(b["index"])
376 + raw = s.values
377 + return {"index": i, "year": int(s.years[i]), "before": float(np.nanmean(raw[:i])), "after": float(np.nanmean(raw[i:])),
378 + "gain": float(b["gain"]), "shift_ratio": float(b["shift_ratio"])}
379 +
380 +
381 +def _break_row(s: Series, ind: Indicator, brk: dict[str, Any], scope: str) -> dict[str, Any]:
382 + name = ind.display_name
383 + i = brk["index"]
384 + sev = min(0.7, 0.45 + 0.1 * (brk["gain"] - BREAK_MIN_GAIN) / 0.5 + 0.05 * min(3.0, brk["shift_ratio"] - BREAK_MIN_SHIFT_RATIO))
385 + direction = "higher" if brk["after"] > brk["before"] else "lower"
386 + headline = (f"{name} shifted to a {direction} level around {brk['year']}: average {fmt_value(brk['before'], ind)} before, "
387 + f"{fmt_value(brk['after'], ind)} since.")
388 + return _row(s, ind, "structural_break", i, brk["before"], sev, headline,
389 + {"break_year": brk["year"], "before_mean": brk["before"], "after_mean": brk["after"], "gain": round(brk["gain"], 3),
390 + "shift_ratio": round(brk["shift_ratio"], 2), "min_segment": BREAK_MIN_SEGMENT, "min_gain": BREAK_MIN_GAIN,
391 + "min_shift_ratio": BREAK_MIN_SHIFT_RATIO, "n_points": len(s.values)}, len(s.values) - i, scope)
392 +
393 +
394 +def _trend_reversal(s: Series, ind: Indicator, v: np.ndarray, floor: float, floor_abs: bool, mode: str) -> dict[str, Any] | None:
395 + """stats.persistent_reversal(run=3): the cumulative move over the last 3 years must also clear the indicator floor."""
396 + direction = stats.persistent_reversal([float(x) for x in v], run=REVERSAL_RUN)
397 + if direction is None:
398 + return None
399 + i = len(v) - 1
400 + cur, ref = float(v[i]), float(v[i - REVERSAL_RUN])
401 + mag = abs(cur - ref) if (floor_abs or mode != "relative") else abs(np.log(cur / ref)) if cur > 0 and ref > 0 else 0.0
402 + if not (mag >= floor > 0):
403 + return None
404 + name = ind.display_name
405 + now_verb, before_verb = ("risen", "falling") if direction == "up" else ("fallen", "rising")
406 + headline = (f"{name} has {now_verb} for three consecutive years after {before_verb} for three, reaching "
407 + f"{fmt_value(cur, ind)} in {int(s.years[i])}.")
408 + return _row(s, ind, "trend_reversal", i, ref, 0.55, headline,
409 + {"direction": direction, "run": REVERSAL_RUN, "turning_year": int(s.years[i - REVERSAL_RUN]), "mode": mode},
410 + REVERSAL_RUN, "changes")
411 +
412 +
413 +def _volatility_spike(s: Series, ind: Indicator, v: np.ndarray, d: np.ndarray, floor: float, floor_abs: bool,
414 + mode: str) -> dict[str, Any] | None:
415 + """sd of the last 5 yearly (working-scale) differences ≥ 3 × sd of the previous 15, and ≥ the indicator floor."""
416 + if len(d) < VOL_RECENT + VOL_BASELINE or not np.all(np.isfinite(d[-(VOL_RECENT + VOL_BASELINE):])):
417 + return None
418 + recent, base = d[-VOL_RECENT:], d[-(VOL_RECENT + VOL_BASELINE):-VOL_RECENT]
419 + sd_r, sd_b = float(np.std(recent, ddof=1)), float(np.std(base, ddof=1))
420 + if sd_b <= 0 or sd_r < VOL_RATIO * sd_b:
421 + return None
422 + floor_w = floor
423 + if floor_abs and mode == "relative":
424 + ref = float(np.nanmedian(np.abs(v[-VOL_RECENT - 1:])))
425 + floor_w = floor / ref if ref else 0.0
426 + if not (sd_r >= floor_w > 0):
427 + return None
428 + i = len(v) - 1
429 + name = ind.display_name
430 + ratio = sd_r / sd_b
431 + sev = min(0.7, 0.45 + 0.05 * min(5.0, ratio - VOL_RATIO))
432 + swing = fmt_delta(float(np.max(np.abs(np.diff(v[-VOL_RECENT - 1:])))), None if mode != "relative" else
433 + float(np.max(np.abs(recent)) * 100.0), ind)
434 + headline = (f"{name} has become unusually volatile: yearly moves over {int(s.years[i - VOL_RECENT])}{int(s.years[i])} were "
435 + f"{ratio:.1f}× as dispersed as in the previous {VOL_BASELINE} years (largest swing {swing}).")
436 + return _row(s, ind, "volatility_spike", i, float(v[i - VOL_RECENT]), sev, headline,
437 + {"sd_recent": sd_r, "sd_baseline": sd_b, "ratio": round(ratio, 2), "recent_years": VOL_RECENT,
438 + "baseline_years": VOL_BASELINE, "min_ratio": VOL_RATIO, "mode": mode}, VOL_RECENT, "changes")
439 +
440 +
338 441 # ------------------------------------------------------------------------------------------------ events (history)
339 442 def detect_events(s: Series, ind: Indicator) -> list[dict[str, Any]]:
340 443 n = len(s.values)
@@ -401,6 +504,11 @@ def detect_events(s: Series, ind: Indicator) -> list[dict[str, Any]]:
401 504 f"{name} turned {direction} in {int(s.years[i])} ({fmt_value(float(v[i]), ind)}).",
402 505 {"direction": direction}, 1, "events"))
403 506
507 + # Structural break anywhere in the history (one per series at most)
508 + brk = _structural_break(s, ind, w)
509 + if brk is not None:
510 + out.append(_break_row(s, ind, brk, "events"))
511 +
404 512 if len(out) > MAX_EVENTS_PER_SERIES:
405 513 out.sort(key=lambda r: r["severity"], reverse=True)
406 514 out = out[:MAX_EVENTS_PER_SERIES]
modified src/countryatlas/pipeline/fetch.py +6 −5
@@ -25,7 +25,7 @@ from countryatlas.models import ImportRun, IndicatorSourceSpec, RawPayload
25 25 from countryatlas.pipeline import new_run_id
26 26 from countryatlas.pipeline.staging import (
27 27 latest_raw_payloads,
28 read_previous_rows,
28 + read_previous_frame,
29 29 rows_to_frame,
30 30 spec_paths,
31 31 write_issues,
@@ -33,7 +33,7 @@ from countryatlas.pipeline.staging import (
33 33 write_parquet_atomic,
34 34 write_run,
35 35 )
36 from countryatlas.pipeline.validate import validate_frame
36 +from countryatlas.pipeline.validate import PreviousStats, validate_frame
37 37 from countryatlas.registry import indicators_by_id, source_specs
38 38
39 39 log = logging.getLogger(__name__)
@@ -109,10 +109,11 @@ def process_spec(connector: Connector, spec: IndicatorSourceSpec, run_id: str, m
109 109 )
110 110 write_issues(spec, report.issues)
111 111 return _finish(spec, run)
112 # 4. generic validation
112 + # 4. generic validation (schema, units, partial download, null spike, vintage shift, bounds, years, jumps, stale)
113 113 frame = rows_to_frame(rows)
114 prev_rows = read_previous_rows(spec) if mode == "fetch" else None
115 gv = validate_frame(frame, ind, previous_rows=prev_rows)
114 + prev_frame = read_previous_frame(spec) if mode == "fetch" else None
115 + previous = PreviousStats.from_frame(prev_frame) if prev_frame is not None else None
116 + gv = validate_frame(frame, ind, previous=previous)
116 117 issues = list(report.issues) + gv.issues
117 118 run.warnings = gv.warnings + report.warnings
118 119 run.errors = gv.errors + report.errors
modified src/countryatlas/pipeline/similarity.py +6 −1
@@ -4,7 +4,8 @@ Inputs are the `latest` values (non-forecast, non-quarantined latest observation
4 4 transformed (log / log1p), z-scored across countries (nan-aware), and compared with a weighted Euclidean distance over
5 5 the features both countries have (rescaled to the mode's full weight; a pair needs ≥ 50 % of the weight in common).
6 6 score = 100·exp(−d/d0), d0 = median of all pairwise distances of the mode. Top 12 peers stored, with per-feature
7 contributions (share of the squared distance) so every score is explainable.
7 +contributions (share of the squared distance, z-scores AND the raw latest values `value_a` / `value_b`) so every score is
8 +explainable in plain numbers.
8 9 """
9 10 from __future__ import annotations
10 11
@@ -76,6 +77,8 @@ def compute_similarity(latest_df: pl.DataFrame, countries: list[str]) -> pl.Data
76 77 names = [f["indicator"] if not f.get("per") else f"{f['indicator']}/{f['per']}" for f in feats]
77 78 weights = np.array([float(f.get("weight", 1.0)) for f in feats])
78 79 m = np.column_stack([_feature_vector(latest, countries, f) for f in feats]) # (n_countries, n_features)
80 + # raw values (after the optional `per` ratio, BEFORE the log transform) so the UI can quote "US$55k vs US$65k"
81 + raw = np.column_stack([_feature_vector(latest, countries, {**f, "transform": "none"}) for f in feats])
79 82 present = np.isfinite(m)
80 83 coverage = (present * weights).sum(axis=1) / weights.sum()
81 84 eligible = coverage >= MIN_FEATURE_COVERAGE
@@ -117,6 +120,8 @@ def compute_similarity(latest_df: pl.DataFrame, countries: list[str]) -> pl.Data
117 120 names[k]: {
118 121 "z_a": round(float(z[a, k]), 3) if present[a, k] else None,
119 122 "z_b": round(float(z[b, k]), 3) if present[b, k] else None,
123 + "value_a": float(raw[a, k]) if present[a, k] and np.isfinite(raw[a, k]) else None,
124 + "value_b": float(raw[b, k]) if present[b, k] and np.isfinite(raw[b, k]) else None,
120 125 "weight": float(weights[k]),
121 126 "contribution": round(float(contrib_raw[k] / total), 4) if shared[a_pos, b_pos, k] else None,
122 127 }
modified src/countryatlas/pipeline/staging.py +12 −0
@@ -137,6 +137,18 @@ def read_previous_rows(spec: IndicatorSourceSpec) -> int | None:
137 137 return None
138 138
139 139
140 +def read_previous_frame(spec: IndicatorSourceSpec) -> pl.DataFrame | None:
141 + """Key columns + value of the previous staging file for this spec (for null-spike / vintage-shift checks)."""
142 + p = spec_paths(spec)["parquet"]
143 + if not p.exists():
144 + return None
145 + try:
146 + return pl.read_parquet(p, columns=["country_id", "period", "frequency", "value"])
147 + except Exception as e: # noqa: BLE001
148 + log.warning("previous staging file unreadable (%s): %s", p, e)
149 + return None
150 +
151 +
140 152 def list_staging_files(connector: str | None = None) -> list[Path]:
141 153 base = settings.staging_dir
142 154 if not base.exists():
modified src/countryatlas/pipeline/validate.py +112 −15
@@ -1,13 +1,18 @@
1 1 """Generic, deterministic validation rules (ARCHITECTURE §6) applied to one staging frame (one spec).
2 2
3 3 Row-level outcomes only ever *flag* (status column); nothing is deleted:
4 out_of_bounds value outside registry `bounds` → status quarantined
5 extreme_jump |Δ| > jump_threshold × 1.4826·MAD(country diffs), ≥5 points → status warning
6 stale the country's latest period ended > stale_after_days ago → status stale (that latest row only)
4 + out_of_bounds value outside registry `bounds` or non-finite → status quarantined
5 + impossible_year year < 1750 or > current year + 10 → status quarantined
6 + extreme_jump |Δ| > jump_threshold × 1.4826·MAD(country diffs), ≥5 points → status warning
7 + stale the country's latest period ended > stale_after_days ago → status stale (that latest row only)
7 8 Dataset-level outcomes (quarantine_dataset=True → the pipeline keeps the previous staging file):
8 duplicate same key twice (connector.validate already errors on this)
9 unit_mismatch rows whose `unit` differs from the registry unit
9 + schema_change a required column is missing or its dtype is incompatible with the staging schema
10 + duplicate same key twice (connector.validate already errors on this)
11 + unit_mismatch rows whose `unit` differs from the registry unit
10 12 partial_download rows < 30 % of the previous staging file for the same spec
13 +Dataset-level warnings (kept, logged, surfaced in validation_issues):
14 + null_spike share of null values > 3 × the previous run's share AND > 5 %
15 + vintage_shift median |relative change| of the keys shared with the previous staging file > 25 %
11 16 """
12 17 from __future__ import annotations
13 18
@@ -28,15 +33,48 @@ MIN_POINTS_FOR_JUMP = 5
28 33 MAX_ROW_ISSUES = 2000 # per code per spec, to keep validation_issues manageable
29 34 LOG_FLOOR = 0.10 # log-diff floor ≈ 10 % relative change
30 35 RANGE_FLOOR = 0.02 # absolute-diff floor = 2 % of the country's series range
36 +MIN_YEAR = 1750
37 +FUTURE_YEARS = 10 # years beyond the current year still accepted (projections)
38 +NULL_SPIKE_RATIO = 3.0
39 +NULL_SPIKE_MIN_SHARE = 0.05
40 +VINTAGE_SHIFT_MAX = 0.25 # median |rel. change| over overlapping keys
41 +VINTAGE_MIN_OVERLAP = 20
31 42 # formats whose level series grow multiplicatively → compare on log-differences when the registry lower bound is ≥ 0
32 43 LOG_FORMATS = {"currency", "number", "tonnes", "kwh", "per_1000", "per_100k", "per_million", "km", "ha"}
33 44
45 +REQUIRED_COLUMNS: dict[str, tuple[type[pl.DataType], ...]] = {
46 + "country_id": (pl.Utf8,),
47 + "indicator_id": (pl.Utf8,),
48 + "period": (pl.Date,),
49 + "year": (pl.Int8, pl.Int16, pl.Int32, pl.Int64, pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64),
50 + "frequency": (pl.Utf8,),
51 + "value": (pl.Float32, pl.Float64, pl.Int32, pl.Int64),
52 + "unit": (pl.Utf8,),
53 + "source_id": (pl.Utf8,),
54 +}
55 +
34 56
35 57 def uses_log_diffs(indicator: Indicator) -> bool:
36 58 lo = (indicator.bounds or [None, None])[0]
37 59 return indicator.format in LOG_FORMATS and lo is not None and float(lo) >= 0
38 60
39 61
62 +@dataclass
63 +class PreviousStats:
64 + """What the previous staging file for the same spec looked like (for partial / null-spike / vintage-shift checks)."""
65 +
66 + rows: int
67 + null_share: float = 0.0
68 + values: pl.DataFrame | None = None # country_id, period, frequency, value
69 +
70 + @classmethod
71 + def from_frame(cls, df: pl.DataFrame) -> PreviousStats:
72 + n = df.height
73 + nulls = int(df.get_column("value").null_count()) if "value" in df.columns and n else 0
74 + cols = [c for c in ("country_id", "period", "frequency", "value") if c in df.columns]
75 + return cls(rows=n, null_share=(nulls / n) if n else 0.0, values=df.select(cols) if len(cols) == 4 else None)
76 +
77 +
40 78 @dataclass
41 79 class GenericValidation:
42 80 frame: pl.DataFrame
@@ -66,24 +104,52 @@ def _period_end(freq: str) -> pl.Expr:
66 104 return p.dt.offset_by("1y") - pl.duration(days=1)
67 105
68 106
107 +def check_schema(df: pl.DataFrame) -> list[str]:
108 + """Names of required columns that are missing or have an incompatible dtype (empty = OK)."""
109 + problems: list[str] = []
110 + for col, dtypes in REQUIRED_COLUMNS.items():
111 + if col not in df.columns:
112 + problems.append(f"missing column {col}")
113 + continue
114 + dt = df.schema[col]
115 + if not any(dt == d for d in dtypes):
116 + problems.append(f"column {col} has dtype {dt}")
117 + return problems
118 +
119 +
69 120 def validate_frame(
70 121 df: pl.DataFrame,
71 122 indicator: Indicator,
72 123 previous_rows: int | None = None,
73 124 now: datetime | None = None,
74 125 check_partial: bool = True,
126 + previous: PreviousStats | None = None,
75 127 ) -> GenericValidation:
76 """Apply the generic rules and return the frame with updated `status` + issues."""
128 + """Apply the generic rules and return the frame with updated `status` + issues.
129 +
130 + `previous` (stats of the previous staging file) enables the partial-download, null-spike and vintage-shift checks;
131 + `previous_rows` is kept for backward compatibility (partial-download only).
132 + """
77 133 now = now or datetime.now(UTC)
78 134 issues: list[ValidationIssue] = []
79 135 quarantine = False
80 136 message: str | None = None
81 137 ind_id = indicator.slug
82 138 n = df.height
139 + if previous is not None and previous_rows is None:
140 + previous_rows = previous.rows
83 141
84 142 if n == 0:
85 143 return GenericValidation(frame=df, issues=issues, quarantine_dataset=False, message="no rows")
86 144
145 + # --- dataset-level: schema -----------------------------------------------------------------------------------
146 + schema_problems = check_schema(df)
147 + if schema_problems:
148 + message = "schema change: " + "; ".join(schema_problems)
149 + issues.append(ValidationIssue(severity="error", code="schema_change", message=message, indicator_id=ind_id))
150 + log.error("%s: %s", ind_id, message)
151 + return GenericValidation(frame=df, issues=issues, quarantine_dataset=True, message=message)
152 +
87 153 # --- dataset-level: unit mismatch -------------------------------------------------------------------------
88 154 units = df.get_column("unit").drop_nulls().unique().to_list()
89 155 bad_units = [u for u in units if u != indicator.unit]
@@ -109,7 +175,27 @@ def validate_frame(
109 175 country_id=r["country_id"], period=r["period"])
110 176 )
111 177
112 # --- row-level: bounds -------------------------------------------------------------------------------------
178 + # --- dataset-level warnings vs the previous vintage: null spike, vintage shift -----------------------------
179 + if previous is not None:
180 + null_share = df.get_column("value").null_count() / n
181 + if null_share > NULL_SPIKE_MIN_SHARE and null_share > NULL_SPIKE_RATIO * previous.null_share:
182 + msg = (f"null spike: {null_share:.1%} of values are null (previous run {previous.null_share:.1%})")
183 + issues.append(ValidationIssue(severity="warning", code="null_spike", message=msg, indicator_id=ind_id))
184 + log.warning("%s: %s", ind_id, msg)
185 + if previous.values is not None and previous.values.height:
186 + joined = df.select("country_id", "period", "frequency", "value").join(
187 + previous.values.rename({"value": "_prev"}), on=["country_id", "period", "frequency"], how="inner"
188 + ).filter(pl.col("value").is_not_null() & pl.col("_prev").is_not_null() & (pl.col("_prev") != 0))
189 + if joined.height >= VINTAGE_MIN_OVERLAP:
190 + rel = ((pl.col("value") - pl.col("_prev")).abs() / pl.col("_prev").abs())
191 + med = float(joined.select(rel.median()).item())
192 + if med > VINTAGE_SHIFT_MAX:
193 + msg = (f"vintage shift: median |relative change| of {joined.height} overlapping values is {med:.1%} "
194 + f"(> {VINTAGE_SHIFT_MAX:.0%}) — source revised or changed definition?")
195 + issues.append(ValidationIssue(severity="warning", code="vintage_shift", message=msg, indicator_id=ind_id))
196 + log.warning("%s: %s", ind_id, msg)
197 +
198 + # --- row-level: bounds + impossible years --------------------------------------------------------------------
113 199 lo, hi = (indicator.bounds or [None, None])[:2]
114 200 oob = pl.lit(False)
115 201 if lo is not None:
@@ -117,7 +203,8 @@ def validate_frame(
117 203 if hi is not None:
118 204 oob = oob | (pl.col("value") > float(hi))
119 205 oob = oob | ~pl.col("value").is_finite()
120 df = df.with_columns(oob.alias("_oob"))
206 + bad_year = (pl.col("year") < MIN_YEAR) | (pl.col("year") > now.year + FUTURE_YEARS)
207 + df = df.with_columns(oob.fill_null(True).alias("_oob"), bad_year.fill_null(False).alias("_bad_year"))
121 208
122 209 # --- row-level: extreme jumps (per country series, robust MAD of first differences) -----------------------
123 210 # Level series that grow multiplicatively (GDP, population, emissions…) are compared on log-differences so that a
@@ -158,26 +245,33 @@ def validate_frame(
158 245 df = df.with_columns(stale.alias("_stale"))
159 246
160 247 # --- statuses: quarantined > warning > stale > imported -----------------------------------------------------
248 + quarantined = pl.col("_oob") | pl.col("_bad_year")
161 249 df = df.with_columns(
162 pl.when(pl.col("_oob"))
250 + pl.when(quarantined)
163 251 .then(pl.lit("quarantined"))
164 252 .when(pl.col("_jump"))
165 253 .then(pl.lit("warning"))
166 254 .when(pl.col("_stale"))
167 255 .then(pl.lit("stale"))
168 256 .otherwise(pl.lit("imported"))
169 .alias("status")
257 + .alias("status"),
258 + quarantined.alias("_q"),
170 259 )
171 n_q = int(df.get_column("_oob").sum())
172 n_w = int((df.get_column("_jump") & ~df.get_column("_oob")).sum())
173 n_s = int((df.get_column("_stale") & ~df.get_column("_oob") & ~df.get_column("_jump")).sum())
260 + n_q = int(df.get_column("_q").sum())
261 + n_w = int((df.get_column("_jump") & ~df.get_column("_q")).sum())
262 + n_s = int((df.get_column("_stale") & ~df.get_column("_q") & ~df.get_column("_jump")).sum())
174 263
175 264 for r in df.filter(pl.col("_oob")).head(MAX_ROW_ISSUES).iter_rows(named=True):
176 265 issues.append(
177 266 ValidationIssue(severity="warning", code="out_of_bounds", indicator_id=ind_id, country_id=r["country_id"],
178 267 period=r["period"], message=f"value {r['value']!r} outside bounds [{lo}, {hi}]")
179 268 )
180 for r in df.filter(pl.col("_jump") & ~pl.col("_oob")).head(MAX_ROW_ISSUES).iter_rows(named=True):
269 + for r in df.filter(pl.col("_bad_year") & ~pl.col("_oob")).head(MAX_ROW_ISSUES).iter_rows(named=True):
270 + issues.append(
271 + ValidationIssue(severity="warning", code="impossible_year", indicator_id=ind_id, country_id=r["country_id"],
272 + period=r["period"], message=f"year {r['year']} outside [{MIN_YEAR}, {now.year + FUTURE_YEARS}]")
273 + )
274 + for r in df.filter(pl.col("_jump") & ~pl.col("_q")).head(MAX_ROW_ISSUES).iter_rows(named=True):
181 275 issues.append(
182 276 ValidationIssue(severity="warning", code="extreme_jump", indicator_id=ind_id, country_id=r["country_id"],
183 277 period=r["period"],
@@ -194,7 +288,10 @@ def validate_frame(
194 288 ValidationIssue(severity="warning", code="stale", indicator_id=ind_id,
195 289 message=f"dataset latest period {latest_period} is older than {stale_days} days")
196 290 )
291 + if n_q:
292 + log.warning("%s: %d rows quarantined (out of bounds / impossible year) — kept for audit, excluded from derived tables",
293 + ind_id, n_q)
197 294
198 out = df.drop(["_oob", "_jump", "_diff", "_thr", "_stale"])
295 + out = df.drop(["_oob", "_bad_year", "_q", "_jump", "_diff", "_thr", "_stale"])
199 296 return GenericValidation(frame=out, issues=issues, quarantine_dataset=quarantine, message=message,
200 297 n_quarantined=n_q, n_warning=n_w, n_stale=n_s)
added tests/api/test_analytics.py +207 −0
@@ -0,0 +1,207 @@
1 +"""Analytics endpoints (API 1.1) on the synthetic fixture (8 countries × 14 indicators, 1990–2024)."""
2 +from __future__ import annotations
3 +
4 +import pytest
5 +
6 +from tests.api.conftest import assert_meta, assert_provenance, value
7 +
8 +
9 +def test_pulse(get):
10 + body = get("/pulse").json()
11 + assert_meta(body)
12 + assert body["year_reference"] == 2024
13 + # fixture: 8 countries only, below the 30-country pool threshold → no items but a valid envelope
14 + assert body["items"] == [] and body["summary"]["n_countries_reporting"] == 0
15 + assert "n_record_highs" in body["summary"]
16 +
17 +
18 +def test_movers_window_1_and_kinds(get):
19 + body = get("/movers?window=1&limit=10").json()
20 + assert body["window"] == 1 and body["categories"][0] == "all" and "structural" in body["kinds"]
21 + assert body["n"] == len(body["items"]) > 0
22 + it = body["items"][0]
23 + for k in ("country", "indicator", "kind", "value", "delta", "direction", "interpretation", "headline", "provenance"):
24 + assert k in it
25 + assert it["direction"] in ("up", "down")
26 + assert_provenance(it["provenance"])
27 + rec = get("/movers?window=1&kind=record&limit=50").json()
28 + assert rec["items"] and all(i["kind"] in ("record_high", "record_low") for i in rec["items"])
29 + imp = get("/movers?window=1&kind=improvement&limit=50").json()
30 + assert all(i["interpretation"] == "improvement" for i in imp["items"])
31 + econ = get("/movers?window=1&category=economic&limit=50").json()
32 + assert all(i["indicator"]["topic"] in ("economy", "government", "trade", "income") for i in econ["items"])
33 + get("/movers?window=3", status=400)
34 + get("/movers?category=weird", status=400)
35 +
36 +
37 +def test_movers_multi_year_window_needs_pool(get):
38 + body = get("/movers?window=10&limit=10").json() # 8 countries < 20 → empty but valid
39 + assert body["window"] == 10 and body["items"] == []
40 + body = get("/movers?window=5&kind=record").json()
41 + assert body["items"] == []
42 +
43 +
44 +def test_extremes_envelope(get):
45 + body = get("/extremes?window=10").json()
46 + assert body["window"] == "10" and body["filter_note"] == "Countries above 1M inhabitants"
47 + assert body["facets"] == [] # every facet needs ≥ 30 countries in the window
48 + get("/extremes?window=7", status=400)
49 +
50 +
51 +def test_scatter(get):
52 + body = get("/scatter?x=gdp-per-capita&y=life-expectancy&year=2024&log_x=true").json()
53 + assert_meta(body)
54 + assert body["year_used"] == 2024 and body["n"] == 8 and body["size"]["id"] == "population"
55 + p = next(pt for pt in body["points"] if pt["id"] == "CAN")
56 + assert p["x"] == pytest.approx(value("CAN", "gdp-per-capita", 2024)) and p["y"] == pytest.approx(value("CAN", "life-expectancy", 2024))
57 + assert p["size"] == pytest.approx(value("CAN", "population", 2024)) and p["year_x"] == 2024
58 + st = body["stats"]
59 + assert st["n"] == 8 and -1 <= st["pearson"] <= 1 and -1 <= st["spearman"] <= 1 and st["ols"]["slope"] is not None
60 + assert st["log_x"] is True and st["log_y"] is False
61 + assert "causation" in body["note"]
62 + none = get("/scatter?x=gdp-per-capita&y=life-expectancy&size=none").json()
63 + assert none["size"] is None and none["stats"]["log_x"] is False and none["year_used"] == 2024 # auto: ratio max/min < 50 in the fixture
64 + get("/scatter?x=gdp&y=gdp", status=400)
65 + get("/scatter?x=gdp&y=nope", status=404)
66 + grp = get("/scatter?x=gdp-per-capita&y=life-expectancy&group=g7").json()
67 + assert grp["group"]["id"] == "g7" and grp["n"] == 5
68 +
69 +
70 +def test_trajectory(get):
71 + body = get("/trajectory?x=gdp-per-capita&y=life-expectancy&from=2000&to=2005").json()
72 + assert body["years"] == [2000, 2001, 2002, 2003, 2004, 2005]
73 + assert len(body["countries"]) == 8 and set(body["series"]) == {c["id"] for c in body["countries"]}
74 + s = body["series"]["IND"]
75 + assert len(s["x"]) == 6 and s["x"][0] == pytest.approx(value("IND", "gdp-per-capita", 2000))
76 + assert s["size"][0] == pytest.approx(value("IND", "population", 2000))
77 + assert body["domains"]["x"][0] <= min(v for v in s["x"]) and body["log_x"] is True # pooled x spans > 50× → log
78 + assert body["provenance"]
79 + auto = get("/trajectory?x=gdp-per-capita&y=life-expectancy").json()
80 + assert auto["years"][0] == 1990 and auto["years"][-1] == 2024 # < 50 countries → every year with data
81 + get("/trajectory?x=gdp&y=gdp", status=400)
82 +
83 +
84 +def test_finder(get):
85 + body = get("/finder?f=gdp-per-capita:gt:40000&f=population:gt:100000000").json()
86 + assert body["mode"] == "and" and [f["indicator"]["id"] for f in body["filters"]] == ["gdp-per-capita", "population"]
87 + assert body["filters"][0]["op"] == "gt" and body["filters"][0]["value"] == 40000 and body["filters"][0]["year_used"] == 2024
88 + ids = [i["country"]["id"] for i in body["items"]]
89 + assert ids == ["JPN", "USA"] # rich (2024 gpc > 40k) and populous; sorted by gpc desc (JPN 69k > USA 65k in the fixture)
90 + assert body["n_matching"] == 2 and body["n_evaluated"] == 8
91 + assert body["items"][0]["matched"] == ["gdp-per-capita", "population"]
92 + assert_provenance(body["items"][0]["values"]["gdp-per-capita"]["provenance"])
93 + any_ = get("/finder?f=gdp-per-capita:gt:40000&f=population:gt:1000000000&mode=or").json()
94 + assert "IND" in [i["country"]["id"] for i in any_["items"]] # India by population only
95 + between = get("/finder?f=life-expectancy:between:60..70&sort=population:asc").json()
96 + assert all(60 <= i["values"]["life-expectancy"]["value"] <= 70 for i in between["items"])
97 + reg = get("/finder?f=population:gt:1&region=g7").json()
98 + assert reg["n_matching"] == 5
99 + get("/finder", status=400)
100 + get("/finder?f=gdp-per-capita:huge:1", status=400)
101 + get("/finder?f=nope:gt:1", status=404)
102 +
103 +
104 +def test_peers(get):
105 + body = get("/peers?y=life-expectancy&x=gdp-per-capita&log_x=true").json()
106 + assert body["year_used"] == 2024 and body["n"] == 8 and body["method"] == "theil-sen"
107 + assert body["fit"]["log_x"] is True and body["fit"]["residual_scale"] is not None
108 + pts = {p["id"]: p for p in body["points"]}
109 + assert pts["CAN"]["expected"] is not None and pts["CAN"]["residual"] == pytest.approx(pts["CAN"]["y"] - pts["CAN"]["expected"])
110 + assert body["above"][0]["residual_z"] >= body["above"][-1]["residual_z"] and body["below"][0]["residual_z"] <= body["below"][-1]["residual_z"]
111 + assert body["pairs"] and "descriptive" in body["note"]
112 + ols = get("/peers?y=life-expectancy&x=gdp-per-capita&method=ols").json()
113 + assert ols["fit"]["r2"] is not None
114 + get("/peers?y=gdp&x=gdp", status=400)
115 +
116 +
117 +def test_related_distribution_frames(get):
118 + rel = get("/indicators/life-expectancy/related?min_n=5").json()
119 + assert rel["indicator"]["id"] == "life-expectancy" and rel["items"] and "causation" in rel["note"]
120 + assert all(i["indicator"]["id"] != "life-expectancy" for i in rel["items"])
121 + assert abs(rel["items"][0]["spearman"]) >= abs(rel["items"][-1]["spearman"])
122 + assert get("/indicators/life-expectancy/related").json()["items"] == [] # min_n 40 > 8 countries
123 +
124 + d = get("/indicators/life-expectancy/distribution?highlight=JPN").json()
125 + assert d["year_used"] == 2024 and d["n"] == 8 and sum(d["histogram"]["counts"]) == 8 and len(d["histogram"]["edges"]) == 21
126 + assert d["stats"]["min"] <= d["stats"]["median"] <= d["stats"]["max"]
127 + assert d["highlight"]["country"]["id"] == "JPN" and d["highlight"]["rank"] == 1 and d["highlight"]["percentile"] == pytest.approx(100.0)
128 + assert d["by_income"] and d["by_region"]
129 + assert_provenance(d["provenance"])
130 + lg = get("/indicators/gdp-per-capita/distribution?bins=5").json()
131 + assert lg["log"] is False and len(lg["histogram"]["counts"]) == 5 # auto-log needs a > 50× spread; the fixture's 2024 spread is ~25×
132 +
133 + fr = get("/indicators/gdp-per-capita/frames?from=2000&to=2003").json()
134 + assert fr["years"] == [] or fr["years"][0] >= 2000 # fixture has 8 countries (< 20 per year) → no frames
135 + assert fr["legend"]["n_classes"] >= 1 and isinstance(fr["values"], dict)
136 +
137 +
138 +def test_quality_and_updates(get):
139 + q = get("/indicators/life-expectancy/quality").json()
140 + assert q["n_countries"] == 8 and q["n_countries_total"] == 8 and q["coverage_pct"] == 100.0
141 + assert q["first_year"] == 1990 and q["last_year"] == 2024 and q["years_with_50plus"] == 0
142 + assert "fresh" in q["badges"] and "historical" not in q["badges"] and q["sources"]
143 + get("/indicators/nope/quality", status=404)
144 + cq = get("/countries/canada/quality").json()
145 + assert cq["country"]["id"] == "CAN" and cq["summary"]["n_with_data"] == 14 and cq["summary"]["latest_year"] == 2024
146 + it = next(i for i in cq["items"] if i["indicator"]["id"] == "government-debt-pct-gdp")
147 + assert it["first_year"] == 1995 and it["n_points"] == 30 and it["missing_years"] == 0 and it["continuity_pct"] == 100.0
148 + assert "fresh" in it["badges"]
149 + u = get("/updates").json()
150 + assert_meta(u)
151 + assert u["snapshot"]["observations"] > 0 and u["snapshot"]["values_changed"] == 0
152 + ids = {s["source"]["id"]: s for s in u["sources"]}
153 + assert ids["worldbank"]["status"] == "ok" and ids["who"]["status"] == "failed" and ids["owid"]["status"] == "partial"
154 + assert ids["worldbank"]["n_observations"] > 0 and ids["worldbank"]["latest_year"] == 2024
155 + assert len(u["recent_runs"]) == 6 and all("raw_path" not in r for r in u["recent_runs"])
156 + assert u["indicators_recently_updated"]
157 +
158 +
159 +def test_race_and_regions_compare(get):
160 + r = get("/rankings/gdp/race?top=3&from=2020").json()
161 + assert r["years"] == [] or r["frames"][0]["year"] >= 2020 # fixture rankings have 8 countries (< 20) → no frames
162 + assert r["top"] == 3 and r["indicator"]["id"] == "gdp" and r["group"]["id"] == "world"
163 + get("/rankings/nope/race", status=404)
164 + rc = get("/regions/compare?a=g7&b=brics").json()
165 + assert [g["id"] for g in rc["groups"]] == ["g7", "brics"]
166 + row = next(x for x in rc["rows"] if x["indicator"]["id"] == "population")
167 + assert row["kind"] == "sum" and row["values"]["g7"]["n"] == 5 and row["values"]["brics"]["n"] == 2
168 + assert rc["shares"]["g7"]["population_share_pct"] + rc["shares"]["brics"]["population_share_pct"] < 100.01
169 + h = rc["history"]["gdp-per-capita"]
170 + assert h["kind"] == "weighted_mean" and h["years"][0] == 1990 and len(h["g7"]) == len(h["years"])
171 + get("/regions/compare?a=nope&b=g7", status=404)
172 +
173 +
174 +def test_story_and_dna_reference(get):
175 + s = get("/countries/canada/story").json()
176 + assert s["country"]["id"] == "CAN" and s["since"] == 1990 and s["items"]
177 + pop = next(i for i in s["items"] if i["indicator"]["id"] == "population")
178 + assert pop["first"]["year"] == 1990 and pop["last"]["year"] == 2024 and pop["change_pct"] > 0 and pop["cagr"] == pytest.approx(1.0, abs=0.05)
179 + assert pop["rank_first"]["year"] == 1990 and pop["rank_last"]["rank"] >= 1 and len(pop["series"]) == 35
180 + assert pop["text"].startswith("Canada: Population") and "1990" in pop["text"] and "2024" in pop["text"]
181 + assert_provenance(pop["provenance"])
182 + le = next(i for i in s["items"] if i["indicator"]["id"] == "life-expectancy")
183 + assert le["cagr"] is None and "yrs" in le["text"]
184 + d = get("/countries/canada/dna?reference=world").json()
185 + assert d["reference"]["kind"] == "world" and set(d["reference"]["dims"].values()) == {50.0}
186 + d = get("/countries/canada/dna?reference=income").json()
187 + assert d["reference"]["kind"] == "income" and d["reference"]["id"] == "hic" and d["reference"]["dims"]["income"] is not None
188 + d = get("/countries/canada/dna?reference=fra").json()
189 + assert d["reference"]["kind"] == "country" and d["reference"]["id"] == "FRA"
190 + assert get("/countries/canada/dna").json()["reference"] is None
191 + get("/countries/canada/dna?reference=xxx", status=404)
192 +
193 +
194 +def test_search_intents(get):
195 + hits = get("/search?q=compare%20canada%20usa").json()["hits"]
196 + assert hits[0]["type"] == "action" and hits[0]["action"] == "compare" and hits[0]["url"] == "/compare/canada/united-states"
197 + hits = get("/search?q=canada%20vs%20france%20vs%20japan").json()["hits"]
198 + assert hits[0]["url"] == "/compare/canada/france/japan"
199 + hits = get("/search?q=rank%20gdp").json()["hits"]
200 + assert hits[0]["action"] == "ranking" and hits[0]["url"] == "/rankings/gdp"
201 + hits = get("/search?q=inflation%20g7").json()["hits"]
202 + assert hits[0]["action"] == "group_ranking" and hits[0]["url"] == "/rankings/inflation?group=g7"
203 + hits = get("/search?q=life%20expectancy%20map").json()["hits"]
204 + assert hits[0]["action"] == "explore" and hits[0]["url"] == "/explore?indicator=life-expectancy"
205 + # plain queries are unchanged
206 + assert get("/search?q=canada").json()["hits"][0]["type"] == "country"
207 + assert all(h["type"] != "action" for h in get("/search?q=gdp&type=indicator").json()["hits"])
modified tests/test_changes.py +73 −0
@@ -127,3 +127,76 @@ def test_driver_over_frame() -> None:
127 127 assert ch.filter(pl.col("kind") == "yoy_drop").height == 2
128 128 assert set(ch.columns) >= {"id", "country_id", "indicator_id", "kind", "headline", "severity", "detected_at"}
129 129 assert ev.height >= 2
130 +
131 +
132 +# ------------------------------------------------------------------------------------------------ change detection 2.0
133 +def test_structural_break_in_changes_and_events() -> None:
134 + ind = indicators_by_id()["unemployment-rate"]
135 + # 12 years around 5 % (with small noise so the series is not monotone), then 8 years around 12 % → break in 2012
136 + vals = [5.0, 5.2, 4.9, 5.1, 5.0, 5.3, 4.8, 5.1, 5.0, 5.2, 4.9, 5.1, 12.0, 12.2, 11.9, 12.1, 12.0, 12.3, 11.8, 12.1]
137 + ch = detect_changes(_series("unemployment-rate", vals, 2000), ind) # last year 2019, break 2012 → within 10 years
138 + brk = [r for r in ch if r["kind"] == "structural_break"]
139 + assert len(brk) == 1
140 + b = brk[0]
141 + assert b["year"] == 2012 and "shifted to a higher level around 2012" in b["headline"]
142 + assert 0.45 <= b["severity"] <= 0.7
143 + import json as _json
144 +
145 + d = b["detail"] if isinstance(b["detail"], dict) else _json.loads(b["detail"])
146 + assert d["break_year"] == 2012 and d["gain"] >= 0.5 and d["shift_ratio"] >= 1.5 and d["min_segment"] == 5
147 + ev = detect_events(_series("unemployment-rate", vals, 2000), ind)
148 + assert len([r for r in ev if r["kind"] == "structural_break"]) == 1
149 + # an old break (25 years ago) stays an event but is not a recent change
150 + old = vals + [12.0, 12.2, 11.9, 12.1, 12.0, 12.3, 11.8, 12.1, 12.0, 12.2, 11.9, 12.1, 12.0, 12.3, 11.8]
151 + ch_old = detect_changes(_series("unemployment-rate", old, 1988), ind) # last year 2022, break 2000 → 22 years ago
152 + assert not any(r["kind"] == "structural_break" for r in ch_old)
153 + assert any(r["kind"] == "structural_break" for r in detect_events(_series("unemployment-rate", old, 1988), ind))
154 + # a smooth trend is not a level shift
155 + trend = [5.0 + 0.3 * k for k in range(20)]
156 + assert not any(r["kind"] == "structural_break" for r in detect_changes(_series("unemployment-rate", trend, 2000), ind))
157 +
158 +
159 +def test_trend_reversal() -> None:
160 + ind = indicators_by_id()["unemployment-rate"] # floor 1 point
161 + vals = [8.0, 8.0, 8.0, 9.0, 8.0, 7.0, 6.0, 7.0, 8.0, 9.0] # 3 falls (9→6) then 3 rises (6→9): +3 points ≥ floor
162 + ch = detect_changes(_series("unemployment-rate", vals, 2010), ind)
163 + rev = [r for r in ch if r["kind"] == "trend_reversal"]
164 + assert len(rev) == 1
165 + assert rev[0]["headline"].startswith("Unemployment has risen for three consecutive years after falling for three")
166 + assert rev[0]["severity"] == pytest.approx(0.55) and rev[0]["ref_value"] == 6.0
167 + down = [3.0, 3.0, 3.0, 2.0, 3.0, 4.0, 5.0, 4.0, 3.0, 2.0]
168 + r2 = [r for r in detect_changes(_series("unemployment-rate", down, 2010), ind) if r["kind"] == "trend_reversal"]
169 + assert len(r2) == 1 and "has fallen for three consecutive years after rising for three" in r2[0]["headline"]
170 + # too small a swing (0.3 points) is silent
171 + tiny = [8.0, 8.0, 8.0, 8.3, 8.2, 8.1, 8.0, 8.1, 8.2, 8.3]
172 + assert not any(r["kind"] == "trend_reversal" for r in detect_changes(_series("unemployment-rate", tiny, 2010), ind))
173 +
174 +
175 +def test_volatility_spike() -> None:
176 + ind = indicators_by_id()["inflation"] # floor 2 points
177 + calm = [2.0 + 0.1 * ((-1) ** k) for k in range(16)] # 15 tiny diffs (±0.2)
178 + wild = [2.0, 9.0, 1.0, 8.0, 0.0] # 5 large swings
179 + ch = detect_changes(_series("inflation", calm + wild, 2005), ind)
180 + vol = [r for r in ch if r["kind"] == "volatility_spike"]
181 + assert len(vol) == 1
182 + assert "unusually volatile" in vol[0]["headline"] and 0.45 <= vol[0]["severity"] <= 0.7
183 + import json as _json
184 +
185 + d = vol[0]["detail"] if isinstance(vol[0]["detail"], dict) else _json.loads(vol[0]["detail"])
186 + assert d["ratio"] >= 3.0 and d["recent_years"] == 5 and d["baseline_years"] == 15
187 + # same pattern but the recent swings are below the 2-point floor → silent
188 + small = calm + [2.0, 2.5, 2.0, 2.6, 2.1]
189 + assert not any(r["kind"] == "volatility_spike" for r in detect_changes(_series("inflation", small, 2005), ind))
190 + # too short a history → silent
191 + assert not any(r["kind"] == "volatility_spike" for r in detect_changes(_series("inflation", wild * 2, 2015), ind))
192 +
193 +
194 +def test_new_kinds_flow_through_driver_with_weights() -> None:
195 + inds = indicators_by_id()
196 + vals = [5.0, 5.2, 4.9, 5.1, 5.0, 5.3, 4.8, 5.1, 5.0, 5.2, 4.9, 5.1, 12.0, 12.2, 11.9, 12.1, 12.0, 12.3, 11.8, 12.1]
197 + rows = [{"country_id": "CAN", "indicator_id": "unemployment-rate", "period": date(2006 + k, 1, 1), "year": 2006 + k, "value": v}
198 + for k, v in enumerate(vals)]
199 + ch, ev = compute_changes_and_events(pl.DataFrame(rows), inds, headline_ids={"unemployment-rate"}, now=datetime(2026, 9, 1, tzinfo=UTC))
200 + kinds = set(ch["kind"].to_list())
201 + assert "structural_break" in kinds
202 + assert ev.filter(pl.col("kind") == "structural_break").height == 1
added tests/test_pipeline_reliability.py +125 −0
@@ -0,0 +1,125 @@
1 +"""Pipeline reliability: HTTP retries/backoff honouring Retry-After, country mapping, never publishing a shrunken snapshot,
2 +source-health meta and build logs."""
3 +from __future__ import annotations
4 +
5 +import json
6 +
7 +import duckdb
8 +import httpx
9 +import pytest
10 +import respx
11 +
12 +from countryatlas.config import settings
13 +from countryatlas.connectors import base as base_mod
14 +from countryatlas.connectors.base import Connector, retry_after_seconds
15 +from countryatlas.pipeline import build as build_mod
16 +from countryatlas.registry import lookup
17 +from tests.test_build import _tiny_staging
18 +
19 +
20 +class _Dummy(Connector):
21 + id = "worldbank"
22 + name = "dummy"
23 + rate_per_minute = 100_000
24 +
25 + def fetch(self, spec): # pragma: no cover - not used
26 + raise NotImplementedError
27 +
28 + def normalize(self, raw, spec): # pragma: no cover - not used
29 + return []
30 +
31 +
32 +def test_retry_after_parsing() -> None:
33 + assert retry_after_seconds(None) is None and retry_after_seconds("abc") is None
34 + assert retry_after_seconds("7") == 7.0
35 + assert retry_after_seconds("999") == base_mod.RETRY_AFTER_CAP
36 + assert retry_after_seconds("Wed, 21 Oct 2015 07:28:00 GMT") == 0.0 # in the past → no wait
37 +
38 +
39 +@respx.mock
40 +def test_get_retries_on_transient_statuses_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
41 + sleeps: list[float] = []
42 + monkeypatch.setattr(base_mod.time, "sleep", lambda s: sleeps.append(s)) # Retry-After sleep
43 + # tenacity's wait: make it instant but record the computed deterministic delays
44 + waits: list[float] = []
45 + orig_wait = _Dummy.get.retry.wait
46 +
47 + def fake_wait(retry_state):
48 + w = orig_wait(retry_state)
49 + waits.append(w)
50 + return 0
51 +
52 + monkeypatch.setattr(_Dummy.get.retry, "wait", fake_wait)
53 + route = respx.get("https://example.test/x").mock(side_effect=[
54 + httpx.Response(429, headers={"Retry-After": "3"}),
55 + httpx.Response(503),
56 + httpx.Response(200, json={"ok": True}),
57 + ])
58 + c = _Dummy()
59 + r = c.get("https://example.test/x")
60 + assert r.status_code == 200 and route.call_count == 3
61 + assert [s for s in sleeps if s] == [3.0] # Retry-After honoured once (zero-length tenacity naps filtered)
62 + assert waits == [2.0, 4.0] # deterministic exponential backoff, no jitter
63 + c.close()
64 +
65 +
66 +@respx.mock
67 +def test_get_gives_up_after_five_attempts(monkeypatch: pytest.MonkeyPatch) -> None:
68 + monkeypatch.setattr(_Dummy.get.retry, "wait", lambda rs: 0)
69 + route = respx.get("https://example.test/down").mock(return_value=httpx.Response(502))
70 + c = _Dummy()
71 + with pytest.raises(base_mod.TransientHTTPError):
72 + c.get("https://example.test/down")
73 + assert route.call_count == base_mod.RETRY_ATTEMPTS
74 + c.close()
75 + # a 404 is not retried
76 + route2 = respx.get("https://example.test/missing").mock(return_value=httpx.Response(404))
77 + c2 = _Dummy()
78 + with pytest.raises(httpx.HTTPStatusError):
79 + c2.get("https://example.test/missing")
80 + assert route2.call_count == 1
81 + c2.close()
82 +
83 +
84 +def test_country_mapping_drops_aggregates_and_unknowns() -> None:
85 + lk = lookup()
86 + assert lk.any("CAN") == "CAN" and lk.any("ca") == "CAN" and lk.any("124") == "CAN"
87 + assert lk.any("Viet Nam") == "VNM" and lk.any("Türkiye") == "TUR" and lk.any("Kosovo") == "XKX"
88 + for agg in ("WLD", "OED", "EUU", "HIC", "EU27_2020", "OAVG"):
89 + assert lk.from_iso3(agg) is None
90 + assert lk.any("Atlantis") is None and lk.any("ZZZ") is None and lk.any("999") is None
91 + assert lk.from_iso2("EL") == "GRC" and lk.from_iso2("UK") == "GBR" and lk.from_iso2("XK") == "XKX"
92 +
93 +
94 +@pytest.mark.usefixtures("data_dir")
95 +def test_shrunken_snapshot_is_never_published_and_meta_has_health() -> None:
96 + _tiny_staging()
97 + first = build_mod.build(run_id="20260101T000000Z", strict=False)
98 + con = duckdb.connect(str(settings.db_path), read_only=True)
99 + meta = dict(con.execute("SELECT key, value FROM meta").fetchall())
100 + con.close()
101 + assert meta["previous_observation_count"] == "" # no previous snapshot
102 + health = json.loads(meta["source_health"])
103 + assert health["worldbank"]["ok"] >= 5 and set(health["worldbank"]) == {"ok", "partial", "failed", "quarantined"}
104 + log_path = settings.logs_dir / f"build-{first.run_id}.json"
105 + assert log_path.exists()
106 + doc = json.loads(log_path.read_text())
107 + assert doc["published"] is True and doc["counts"]["observations"] > 0 and any(f.startswith("worldbank/") for f in doc["staging_files"])
108 + before = settings.db_path.stat()
109 + # second build sees the previous count
110 + second = build_mod.build(run_id="20260102T000000Z", strict=False)
111 + con = duckdb.connect(str(settings.db_path), read_only=True)
112 + meta2 = dict(con.execute("SELECT key, value FROM meta").fetchall())
113 + con.close()
114 + assert int(meta2["previous_observation_count"]) == first.counts["observations"] and second.counts["observations"] == first.counts["observations"]
115 + after = settings.db_path.stat()
116 + # drop most staging files → < 90 % retention → strict integrity failure, live DB untouched, build log says not published
117 + for f in sorted(settings.staging_dir.glob("*/*.parquet"))[:-1]:
118 + f.unlink()
119 + with pytest.raises(build_mod.IntegrityError, match="refusing to publish"):
120 + build_mod.build(run_id="20260103T000000Z", strict=True)
121 + assert settings.db_path.stat().st_ino == after.st_ino and settings.db_path.stat().st_mtime_ns == after.st_mtime_ns
122 + assert not list(settings.build_dir.glob("atlas-20260103*"))
123 + failed_log = json.loads((settings.logs_dir / "build-20260103T000000Z.json").read_text())
124 + assert failed_log["published"] is False and "refusing to publish" in (failed_log["error"] or "")
125 + assert before.st_ino != after.st_ino or before.st_mtime_ns != after.st_mtime_ns # the second build did publish
modified tests/test_validate.py +58 −0
@@ -53,3 +53,61 @@ def test_stale_flag_on_latest_row_only() -> None:
53 53 gv = validate_frame(df, ind, now=datetime(2026, 9, 1, tzinfo=UTC))
54 54 statuses = gv.frame.sort("period")["status"].to_list()
55 55 assert statuses[-1] == "stale" and statuses[:-1] == ["imported"] * 4
56 +
57 +
58 +def test_impossible_years_are_quarantined_not_deleted() -> None:
59 + ind = indicators_by_id()["gdp-per-capita"]
60 + df = _rows("gdp-per-capita", ind.unit, {"CAN": [100.0, 101.0, 102.0, 103.0, 104.0, 105.0]}, 2020) # 2020–2025
61 + bad = pl.DataFrame({"country_id": ["CAN", "CAN"], "indicator_id": ["gdp-per-capita"] * 2, "period": [date(1700, 1, 1), date(2099, 1, 1)],
62 + "year": [1700, 2099], "frequency": ["A", "A"], "value": [50.0, 500.0], "unit": [ind.unit] * 2,
63 + "source_id": ["worldbank"] * 2, "source_dataset": ["WDI"] * 2, "source_series_code": ["X"] * 2,
64 + "is_estimate": [False] * 2, "is_forecast": [False] * 2, "retrieved_at": [datetime(2026, 1, 1, tzinfo=UTC).replace(tzinfo=None)] * 2,
65 + "source_updated_at": [None, None], "status": ["imported"] * 2, "metadata": [None, None]}).cast(df.schema)
66 + gv = validate_frame(pl.concat([df, bad]), ind, now=datetime(2026, 9, 1, tzinfo=UTC))
67 + assert gv.frame.height == 8 # nothing deleted
68 + by_year = dict(zip(gv.frame["year"].to_list(), gv.frame["status"].to_list(), strict=True))
69 + assert by_year[1700] == "quarantined" and by_year[2099] == "quarantined" and by_year[2024] == "imported"
70 + assert gv.n_quarantined == 2 and not gv.quarantine_dataset
71 + assert {i.code for i in gv.issues} >= {"impossible_year"}
72 + # a projection 6 years out is fine
73 + assert dict(zip(gv.frame["year"].to_list(), gv.frame["status"].to_list(), strict=True))[2025] == "imported"
74 +
75 +
76 +def test_duplicate_keys_quarantine_dataset() -> None:
77 + ind = indicators_by_id()["gdp-per-capita"]
78 + df = _rows("gdp-per-capita", ind.unit, {"CAN": [1.0, 2.0, 3.0]})
79 + gv = validate_frame(pl.concat([df, df.tail(1)]), ind)
80 + assert gv.quarantine_dataset and any(i.code == "duplicate" for i in gv.issues)
81 + assert gv.frame.height == 4 # rows kept for audit
82 +
83 +
84 +def test_schema_change_quarantines_dataset() -> None:
85 + ind = indicators_by_id()["gdp-per-capita"]
86 + df = _rows("gdp-per-capita", ind.unit, {"CAN": [1.0, 2.0, 3.0]})
87 + gv = validate_frame(df.drop("period"), ind)
88 + assert gv.quarantine_dataset and gv.issues[0].code == "schema_change" and "period" in gv.issues[0].message
89 + gv2 = validate_frame(df.with_columns(pl.col("value").cast(pl.Utf8)), ind)
90 + assert gv2.quarantine_dataset and "dtype" in gv2.issues[0].message
91 +
92 +
93 +def test_null_spike_and_vintage_shift_are_warnings_only() -> None:
94 + from countryatlas.pipeline.validate import PreviousStats
95 +
96 + ind = indicators_by_id()["gdp-per-capita"]
97 + series = {c: [100.0 + k for k in range(25)] for c in ("CAN", "FRA", "DEU")}
98 + prev = _rows("gdp-per-capita", ind.unit, series)
99 + # null spike: 20 % of values null now vs 0 % before
100 + cur = prev.with_columns(pl.when(pl.col("year") % 5 == 0).then(None).otherwise(pl.col("value")).alias("value"))
101 + gv = validate_frame(cur, ind, previous=PreviousStats.from_frame(prev))
102 + assert not gv.quarantine_dataset and any(i.code == "null_spike" for i in gv.issues)
103 + # vintage shift: every overlapping value 40 % higher → warning, dataset kept
104 + shifted = prev.with_columns((pl.col("value") * 1.4).alias("value"))
105 + gv2 = validate_frame(shifted, ind, previous=PreviousStats.from_frame(prev))
106 + codes = {i.code for i in gv2.issues}
107 + assert "vintage_shift" in codes and not gv2.quarantine_dataset and gv2.frame.height == shifted.height
108 + # a 3 % revision is normal
109 + gv3 = validate_frame(prev.with_columns((pl.col("value") * 1.03).alias("value")), ind, previous=PreviousStats.from_frame(prev))
110 + assert not any(i.code in ("vintage_shift", "null_spike") for i in gv3.issues)
111 + # partial download via PreviousStats too
112 + gv4 = validate_frame(prev.head(5), ind, previous=PreviousStats.from_frame(prev))
113 + assert gv4.quarantine_dataset and any(i.code == "partial_download" for i in gv4.issues)
56 114