Pipeline: canonical-frequency derived tables, rank-year fallback, integrity warnings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
15 changed files +169 −41
modified
docs/ARCHITECTURE.md
+11 −4
@@ -199,6 +199,11 @@ No table or column of `schema.sql` was changed. The following precisions/deviati | ||
| 199 | 199 | `series_fresher_source`. Consequence: forecast rows appear in `observations` only when the chosen source itself publishes |
| 200 | 200 | them (IMF-only indicators, or IMF chosen as fresher); the complete IMF series (history + forecasts) is always available in |
| 201 | 201 | `observations_alt` for alternative views. Series with forecast-only data fall back to plain priority. |
| 202 | +* **Canonical frequency only in derived tables**: `latest`, `rankings`, `changes`, `events`, `similarity`, `insights`, | |
| 203 | + `country_dna` and `coverage` use only observations whose `frequency` equals the indicator's registry `frequency` | |
| 204 | + (inflation A, policy-rate M, real-house-price-index Q…). Higher-frequency series (e.g. FRED monthly CPI for the USA) stay in | |
| 205 | + `observations` for the series/chart endpoints only. Integrity emits warnings when a `latest` row has a non-canonical | |
| 206 | + frequency or a headline indicator is ranked in a pool of fewer than 50 countries. | |
| 202 | 207 | * **Quarantined rows stay in `observations`** (never deleted) but are excluded from every derived table (`latest`, |
| 203 | 208 | `rankings`, `changes`, `events`, `similarity`, `insights`, `country_dna`). A lower-priority source is *not* promoted when |
| 204 | 209 | a row is quarantined (the value is flagged, not replaced). `observations_alt` = every row of every non-chosen source, |
@@ -210,10 +215,12 @@ No table or column of `schema.sql` was changed. The following precisions/deviati | ||
| 210 | 215 | compared on log-differences; others on absolute differences. Threshold = `jump_threshold` × 1.4826·MAD with a floor |
| 211 | 216 | (10 % relative, or 2 % of the country's series range) to avoid flagging smooth series; ≥ 5 points required. ~5 % of WDI |
| 212 | 217 | rows carry `warning`. |
| 213 | −* **`latest` ranks** are computed among *all* `kind='country'` values of the same indicator and the same **year** as the | |
| 214 | − country's latest observation (not only among countries whose latest year coincides); `latest.rank_year` = that year, the | |
| 215 | − API compares it with the indicator's max year. Q/M series use the last period of each year. `rank_income` is NULL when the | |
| 216 | − country has no income group. `change_10y_*` compares with the observation exactly 10 years earlier (same frequency). | |
| 218 | +* **`latest` ranks** are computed among *all* `kind='country'` values of the same indicator within one **year**: the most | |
| 219 | + recent year ≤ the country's latest year in which the country has a value AND at least 20 countries are ranked | |
| 220 | + (`MIN_COUNTRIES_FOR_RANKING`). `latest.rank_year` = that year — it may be older than `latest.year` when only a handful of | |
| 221 | + countries already report the newest year (e.g. CHN internet-users: value 2025, ranks from 2024); ranks are NULL when no | |
| 222 | + such year exists. Q/M series use the last period of each year. `rank_income` is NULL when the country has no income group. | |
| 223 | + `change_10y_*` compares with the observation exactly 10 years earlier (same frequency). | |
| 217 | 224 | * **Ranking direction**: rank 1 = lowest value when `higher_is_better = false`, otherwise the highest value — i.e. "best" |
| 218 | 225 | when `higher_is_better` is set, "highest" when it is null. `rankings.pct_rank = 1 − (rank − 1)/(n − 1)` (1.0 = rank 1). |
| 219 | 226 | `rankings` includes every year with ≥ 20 countries for `ranking_eligible` indicators. |
modified
docs/PIPELINE.md
+3 −1
@@ -96,7 +96,9 @@ country's differences, with a floor of 10 % (relative) or 2 % of the series rang | ||
| 96 | 96 | 3. `observation_revisions`: the previous `atlas.duckdb` is attached read-only; every key whose value or source changed |
| 97 | 97 | is recorded with the new `run_id`; the previous revisions table is copied over. |
| 98 | 98 | 4. `import_runs` (latest `run.json` per spec) and `validation_issues` (issues sidecars). |
| 99 | −5. Derived tables (`pipeline/derived.py`, SQL): `latest` (prev period, 10-year change, ranks within the same year among | |
| 99 | +5. Derived tables (`pipeline/derived.py`, SQL) are computed from `obs_ok` = non-forecast, non-quarantined observations at | |
| 100 | + the indicator's **canonical frequency** only (a monthly FRED series never becomes a country's "latest" for an annual | |
| 101 | + indicator): `latest` (prev period, 10-year change, ranks within the same year among | |
| 100 | 102 | `kind='country'` — world / WB region / income group), `rankings` (ranking-eligible indicators, years with ≥ 20 |
| 101 | 103 | countries), `coverage`, indicator/source coverage columns, `search_index`. |
| 102 | 104 | **Rank direction:** rank 1 = lowest value when `higher_is_better = false`, otherwise highest value ("best" when |
modified
src/countryatlas/api/common.py
+3 −2
@@ -8,7 +8,7 @@ from typing import Any | ||
| 8 | 8 | |
| 9 | 9 | from countryatlas.api.db import Snapshot |
| 10 | 10 | from countryatlas.api.errors import bad_request, not_found |
| 11 | −from countryatlas.api.formatting import format_change, format_value | |
| 11 | +from countryatlas.api.formatting import format_change, format_short, format_value | |
| 12 | 12 | from countryatlas.api.provenance import build_provenance |
| 13 | 13 | from countryatlas.registry import indicators_by_id as registry_indicators |
| 14 | 14 | from countryatlas.registry import topics as registry_topics |
@@ -264,6 +264,7 @@ def metric_from_latest(snap: Snapshot, row: dict[str, Any], ind: dict[str, Any], | ||
| 264 | 264 | "has_data": value is not None, |
| 265 | 265 | "value": value, |
| 266 | 266 | "formatted": format_value(value, ind), |
| 267 | + "formatted_short": format_short(value, ind), | |
| 267 | 268 | "period": row.get("period"), |
| 268 | 269 | "year": row.get("year"), |
| 269 | 270 | "frequency": row.get("frequency"), |
@@ -305,7 +306,7 @@ def metric_from_latest(snap: Snapshot, row: dict[str, Any], ind: dict[str, Any], | ||
| 305 | 306 | |
| 306 | 307 | def empty_metric(ind: dict[str, Any]) -> dict[str, Any]: |
| 307 | 308 | return {"indicator": ind["id"], "indicator_name": ind.get("short_name") or ind.get("name"), "has_data": False, |
| 308 | − "value": None, "formatted": "—", "unit": ind.get("unit"), "unit_short": ind.get("unit_short"), | |
| 309 | + "value": None, "formatted": "—", "formatted_short": "—", "unit": ind.get("unit"), "unit_short": ind.get("unit_short"), | |
| 309 | 310 | "format": ind.get("format"), "higher_is_better": ind.get("higher_is_better"), "sparkline": [], "provenance": None} |
| 310 | 311 | |
| 311 | 312 | |
modified
src/countryatlas/api/db.py
+13 −0
@@ -39,6 +39,18 @@ class DataNotBuilt(Exception): | ||
| 39 | 39 | """Raised by data endpoints when no snapshot exists yet.""" |
| 40 | 40 | |
| 41 | 41 | |
| 42 | +def invalidate_registry_caches() -> None: | |
| 43 | + """Drop the lru_cache of every registry loader so YAML edits (topics.yaml headline, indicators.yaml…) are re-read when a | |
| 44 | + new snapshot is opened. Registry-derived lists are therefore effectively cached per run_id.""" | |
| 45 | + from countryatlas import registry | |
| 46 | + | |
| 47 | + for name in ("countries", "countries_by_id", "countries_by_slug", "groups", "groups_by_id", "indicators", "indicators_by_id", | |
| 48 | + "topics", "lookup"): | |
| 49 | + fn = getattr(registry, name, None) | |
| 50 | + if fn is not None and hasattr(fn, "cache_clear"): | |
| 51 | + fn.cache_clear() | |
| 52 | + | |
| 53 | + | |
| 42 | 54 | class Snapshot: |
| 43 | 55 | """One open read-only connection to one physical DuckDB file, plus lazily loaded static lookups.""" |
| 44 | 56 | |
@@ -214,6 +226,7 @@ class Database: | ||
| 214 | 226 | log.error("cannot open %s: %s", self.path, e) |
| 215 | 227 | return None |
| 216 | 228 | self._snap = new |
| 229 | + invalidate_registry_caches() | |
| 217 | 230 | log.info("opened snapshot run_id=%s built_at=%s (%s)", new.run_id, new.built_at, self.path) |
| 218 | 231 | return new |
| 219 | 232 | |
modified
src/countryatlas/api/formatting.py
+11 −3
@@ -1,7 +1,8 @@ | ||
| 1 | 1 | """Server-side number formatting (search hints, OpenGraph, headlines). The web app formats too — keep both in sync. |
| 2 | 2 | |
| 3 | −`format_value(53372.1, gdp_per_capita)` → "53.4k"; `format_value(1.23e12, gdp)` → "1.2T"; percent → "3.4 %"; | |
| 4 | −years → "82.1 yrs"; tonnes → "5.2 t"; per_1000 → "3.2 per 1,000"; per_100k → "1.2 per 100k". | |
| 3 | +`format_value(53372.1, gdp_per_capita)` → "US$53.4k"; `format_value(1.23e12, gdp)` → "US$1.2T" (prefix = registry `unit_short`: | |
| 4 | +`US$`, `intl $`, `PPS`…); percent → "3.4 %"; years → "82.1 yrs"; tonnes → "5.2 t"; per_1000 → "3.2 per 1,000"; per_100k → "1.2 per 100k". | |
| 5 | +`format_short(value, indicator)` is the same without unit/prefix ("53.4k", "3.4"). | |
| 5 | 6 | """ |
| 6 | 7 | from __future__ import annotations |
| 7 | 8 | |
@@ -58,7 +59,9 @@ def format_value(value: float | None, indicator: Any = None, *, with_unit: bool | ||
| 58 | 59 | v = float(value) |
| 59 | 60 | |
| 60 | 61 | if fmt == "currency": |
| 61 | − return compact_number(v, precision=1) | |
| 62 | + s = compact_number(v, precision=1) | |
| 63 | + prefix = unit_short or "US$" | |
| 64 | + return f"{prefix}{s}" if with_unit else s | |
| 62 | 65 | if fmt == "percent": |
| 63 | 66 | s = f"{v:.{max(precision, 1)}f}" |
| 64 | 67 | return f"{s} %" if with_unit else s |
@@ -91,6 +94,11 @@ def format_value(value: float | None, indicator: Any = None, *, with_unit: bool | ||
| 91 | 94 | return s |
| 92 | 95 | |
| 93 | 96 | |
| 97 | +def format_short(value: float | None, indicator: Any = None) -> str: | |
| 98 | + """Compact number without unit prefix/suffix (for tight UI cells; the unit is shown elsewhere).""" | |
| 99 | + return format_value(value, indicator, with_unit=False) | |
| 100 | + | |
| 101 | + | |
| 94 | 102 | def format_change(change_abs: float | None, change_pct: float | None, indicator: Any = None) -> str | None: |
| 95 | 103 | """Human change: percent-like indicators → '+1.2 pts', otherwise '+3.4 %'.""" |
| 96 | 104 | fmt = _get(indicator, "format", "number") |
modified
src/countryatlas/api/routers/home.py
+17 −9
@@ -8,24 +8,31 @@ from fastapi import APIRouter, Depends | ||
| 8 | 8 | from countryatlas.api import schemas |
| 9 | 9 | from countryatlas.api.common import clean_float, country_card, indicator_card, merged_indicator, meta_block |
| 10 | 10 | from countryatlas.api.db import Snapshot, get_snapshot |
| 11 | −from countryatlas.api.formatting import format_value | |
| 11 | +from countryatlas.api.formatting import format_short, format_value | |
| 12 | 12 | from countryatlas.api.provenance import _iso, build_provenance |
| 13 | 13 | from countryatlas.api.routers.countries import change_item, query_changes |
| 14 | 14 | from countryatlas.api.routers.indicators import indicator_summary |
| 15 | 15 | |
| 16 | 16 | router = APIRouter(tags=["home"]) |
| 17 | 17 | |
| 18 | −# (key, title, indicator, order, min_population, extra description) | |
| 18 | +# (key, title, indicator, order, min_population, description) | |
| 19 | 19 | CURATED = [ |
| 20 | 20 | ("largest_economies", "Largest economies", "gdp", "desc", None, "GDP, current US$"), |
| 21 | − ("fastest_population_growth", "Fastest population growth", "population-growth", "desc", 1_000_000, "Countries above 1 M inhabitants"), | |
| 22 | − ("highest_life_expectancy", "Highest life expectancy", "life-expectancy", "desc", None, "Years at birth"), | |
| 23 | − ("energy_transition_leaders", "Energy transition leaders", "renewable-electricity-share", "desc", None, "Share of electricity from renewables"), | |
| 21 | + ("fastest_gdp_growth", "Fastest GDP growth", "gdp-growth", "desc", 1_000_000, "Real GDP growth, latest year"), | |
| 22 | + ("fastest_population_growth", "Fastest population growth", "population-growth", "desc", 1_000_000, "Annual population growth"), | |
| 23 | + ("highest_life_expectancy", "Highest life expectancy", "life-expectancy", "desc", 1_000_000, "Years at birth"), | |
| 24 | + ("energy_transition_leaders", "Energy transition leaders", "renewable-electricity-share", "desc", 1_000_000, "Share of electricity from renewables"), | |
| 24 | 25 | ("highest_gdp_per_capita_ppp", "Highest GDP per capita (PPP)", "gdp-per-capita-ppp", "desc", None, "International $"), |
| 25 | − ("lowest_unemployment", "Lowest unemployment", "unemployment-rate", "asc", 5_000_000, "Countries above 5 M inhabitants"), | |
| 26 | + ("lowest_unemployment", "Lowest unemployment", "unemployment-rate", "asc", 5_000_000, "Unemployment, % of labour force"), | |
| 26 | 27 | ] |
| 27 | 28 | |
| 28 | 29 | |
| 30 | +def filter_note(min_pop: int | None) -> str | None: | |
| 31 | + if not min_pop: | |
| 32 | + return None | |
| 33 | + return f"Countries above {min_pop // 1_000_000}M inhabitants" | |
| 34 | + | |
| 35 | + | |
| 29 | 36 | def curated_list(snap: Snapshot, indicator_id: str, order: str, min_pop: int | None, n: int = 8) -> dict[str, Any] | None: |
| 30 | 37 | ind_row = snap.indicators().get(indicator_id) |
| 31 | 38 | if ind_row is None: |
@@ -47,12 +54,13 @@ def curated_list(snap: Snapshot, indicator_id: str, order: str, min_pop: int | N | ||
| 47 | 54 | for i, r in enumerate(rows): |
| 48 | 55 | c = snap.countries().get(r["country_id"], {"id": r["country_id"]}) |
| 49 | 56 | v = clean_float(r["value"]) |
| 50 | − items.append({"rank": i + 1, "country": country_card(c), "value": v, "formatted": format_value(v, ind), "year": r["year"], | |
| 57 | + items.append({"rank": i + 1, "country": country_card(c), "value": v, "formatted": format_value(v, ind), "formatted_short": format_short(v, ind), | |
| 58 | + "year": r["year"], | |
| 51 | 59 | "change_pct": clean_float(r.get("change_pct")), "change_abs": clean_float(r.get("change_abs")), |
| 52 | 60 | "rank_world": r.get("rank_world"), "n_world": r.get("n_world"), |
| 53 | 61 | "provenance": build_provenance(snap, indicator_id, r["source_id"], r.get("source_dataset"), r.get("source_series_code"), |
| 54 | 62 | r.get("retrieved_at"), r.get("source_updated_at"), c.get("iso2"))}) |
| 55 | − return {"indicator": indicator_card(ind), "sort": order, "rows": items} | |
| 63 | + return {"indicator": indicator_card(ind), "sort": order, "min_population": min_pop, "filter_note": filter_note(min_pop), "rows": items} | |
| 56 | 64 | |
| 57 | 65 | |
| 58 | 66 | def global_snapshot(snap: Snapshot) -> dict[str, Any]: |
@@ -73,7 +81,7 @@ def global_snapshot(snap: Snapshot) -> dict[str, Any]: | ||
| 73 | 81 | return { |
| 74 | 82 | "world_population": clean_float(row.get("pop")), "world_population_formatted": format_value(clean_float(row.get("pop")), {"format": "number"}), |
| 75 | 83 | "world_population_year": row.get("pop_year"), |
| 76 | − "world_gdp": clean_float(row.get("gdp")), "world_gdp_formatted": format_value(clean_float(row.get("gdp")), {"format": "currency"}), | |
| 84 | + "world_gdp": clean_float(row.get("gdp")), "world_gdp_formatted": format_value(clean_float(row.get("gdp")), {"format": "currency", "unit_short": "US$"}), | |
| 77 | 85 | "world_gdp_year": row.get("gdp_year"), |
| 78 | 86 | "median_life_expectancy": clean_float(row.get("le")), "median_life_expectancy_year": row.get("le_year"), |
| 79 | 87 | "n_countries": n_countries, "n_territories": len(snap.countries()) - n_countries, |
modified
src/countryatlas/api/routers/indicators.py
+3 −1
@@ -18,7 +18,7 @@ from countryatlas.api.common import ( | ||
| 18 | 18 | resolve_indicator, |
| 19 | 19 | ) |
| 20 | 20 | from countryatlas.api.db import Snapshot, get_snapshot |
| 21 | −from countryatlas.api.formatting import format_value | |
| 21 | +from countryatlas.api.formatting import format_short, format_value | |
| 22 | 22 | from countryatlas.api.provenance import _iso, build_provenance, provenance_from_row, source_url |
| 23 | 23 | from countryatlas.registry import topics as registry_topics |
| 24 | 24 | |
@@ -132,6 +132,7 @@ def world_latest(snap: Snapshot, ind: dict[str, Any]) -> dict[str, Any] | None: | ||
| 132 | 132 | "kind": kind, |
| 133 | 133 | "value": value, |
| 134 | 134 | "formatted": format_value(value, ind), |
| 135 | + "formatted_short": format_short(value, ind), | |
| 135 | 136 | "year": year, |
| 136 | 137 | "n": row.get("n"), |
| 137 | 138 | "median": clean_float(row.get("med")), |
@@ -161,6 +162,7 @@ def ranked_extremes(snap: Snapshot, ind: dict[str, Any], year: int | None, n: in | ||
| 161 | 162 | def pack(r: dict[str, Any], rank: int) -> dict[str, Any]: |
| 162 | 163 | c = snap.countries().get(r["country_id"], {"id": r["country_id"]}) |
| 163 | 164 | return {"country": country_card(c), "value": clean_float(r["value"]), "formatted": format_value(clean_float(r["value"]), ind), |
| 165 | + "formatted_short": format_short(clean_float(r["value"]), ind), | |
| 164 | 166 | "year": r["year"], "rank": rank, "provenance": provenance_from_row(snap, r, ind["id"], c.get("iso2"))} |
| 165 | 167 | |
| 166 | 168 | top = [pack(r, i + 1) for i, r in enumerate(ordered[:n])] |
modified
src/countryatlas/api/routers/rankings.py
+2 −2
@@ -20,7 +20,7 @@ from countryatlas.api.common import ( | ||
| 20 | 20 | sparklines_for_countries, |
| 21 | 21 | ) |
| 22 | 22 | from countryatlas.api.db import Snapshot, get_snapshot |
| 23 | −from countryatlas.api.formatting import format_change, format_value | |
| 23 | +from countryatlas.api.formatting import format_change, format_short, format_value | |
| 24 | 24 | from countryatlas.api.provenance import provenance_from_row |
| 25 | 25 | from countryatlas.api.routers.indicators import indicator_summary |
| 26 | 26 | |
@@ -134,7 +134,7 @@ def get_ranking( | ||
| 134 | 134 | ch10["formatted"] = format_change(ch10["abs"], ch10["pct"], ind) |
| 135 | 135 | out_rows.append({ |
| 136 | 136 | "rank": int(r["rank_in_group"]), "rank_world": r.get("rank_world"), "n_world": r.get("n_world"), "pct_rank": clean_float(r.get("pct_rank")), |
| 137 | − "country": country_card(c), "value": v, "formatted": format_value(v, ind), "year": r["year"], | |
| 137 | + "country": country_card(c), "value": v, "formatted": format_value(v, ind), "formatted_short": format_short(v, ind), "year": r["year"], | |
| 138 | 138 | "change_1y": ch1, "change_10y": ch10, "sparkline": sparks.get(r["country_id"], []), |
| 139 | 139 | "provenance": provenance_from_row(snap, r, ind["id"], c.get("iso2")), |
| 140 | 140 | }) |
modified
src/countryatlas/api/routers/regions.py
+6 −3
@@ -17,7 +17,7 @@ from countryatlas.api.common import ( | ||
| 17 | 17 | resolve_indicator, |
| 18 | 18 | ) |
| 19 | 19 | from countryatlas.api.db import Snapshot, get_snapshot |
| 20 | −from countryatlas.api.formatting import format_value | |
| 20 | +from countryatlas.api.formatting import format_short, format_value | |
| 21 | 21 | from countryatlas.api.provenance import build_provenance |
| 22 | 22 | from countryatlas.registry import topics as registry_topics |
| 23 | 23 | |
@@ -62,6 +62,7 @@ def _group_stats(snap: Snapshot, members: list[str]) -> dict[str, Any]: | ||
| 62 | 62 | ind = merged_indicator(ind_row) |
| 63 | 63 | val = clean_float({"sum": r["total"], "median": r["med"], "weighted_mean": r["wmean"] or r["med"]}[agg]) |
| 64 | 64 | out[iid] = {"indicator": indicator_card(ind), "kind": agg, "label": label, "value": val, "formatted": format_value(val, ind), |
| 65 | + "formatted_short": format_short(val, ind), | |
| 65 | 66 | "n": r["n"], "year": r["year"]} |
| 66 | 67 | return out |
| 67 | 68 | |
@@ -117,7 +118,8 @@ def get_region(slug: str, indicator: str = Query("gdp-per-capita", description=" | ||
| 117 | 118 | if r is None or r.get("value") is None: |
| 118 | 119 | vals[iid] = None |
| 119 | 120 | continue |
| 120 | − vals[iid] = {"value": clean_float(r["value"]), "formatted": format_value(clean_float(r["value"]), ind), "year": r["year"], | |
| 121 | + vals[iid] = {"value": clean_float(r["value"]), "formatted": format_value(clean_float(r["value"]), ind), | |
| 122 | + "formatted_short": format_short(clean_float(r["value"]), ind), "year": r["year"], | |
| 121 | 123 | "rank_world": r.get("rank_world"), "n_world": r.get("n_world"), |
| 122 | 124 | "provenance": build_provenance(snap, iid, r["source_id"], r.get("source_dataset"), r.get("source_series_code"), |
| 123 | 125 | r.get("retrieved_at"), r.get("source_updated_at"), c.get("iso2"))} |
@@ -141,7 +143,8 @@ def get_region(slug: str, indicator: str = Query("gdp-per-capita", description=" | ||
| 141 | 143 | for i, r in enumerate(rows): |
| 142 | 144 | c = snap.countries().get(r["country_id"], {"id": r["country_id"]}) |
| 143 | 145 | ranking_rows.append({"rank": i + 1, "country": country_card(c), "value": clean_float(r["value"]), |
| 144 | − "formatted": format_value(clean_float(r["value"]), ind), "year": r["year"], "rank_world": r.get("rank_world"), | |
| 146 | + "formatted": format_value(clean_float(r["value"]), ind), "formatted_short": format_short(clean_float(r["value"]), ind), | |
| 147 | + "year": r["year"], "rank_world": r.get("rank_world"), | |
| 145 | 148 | "n_world": r.get("n_world"), "change_pct": clean_float(r.get("change_pct")), "change_abs": clean_float(r.get("change_abs")), |
| 146 | 149 | "provenance": build_provenance(snap, ind["id"], r["source_id"], r.get("source_dataset"), r.get("source_series_code"), |
| 147 | 150 | r.get("retrieved_at"), r.get("source_updated_at"), c.get("iso2"))}) |
modified
src/countryatlas/pipeline/build.py
+14 −0
@@ -32,6 +32,7 @@ SCHEMA_VERSION = 1 | ||
| 32 | 32 | 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 | +HEADLINE_MIN_N_WORLD = 50 # warning when a headline indicator's latest row is ranked in a pool smaller than this | |
| 35 | 36 | |
| 36 | 37 | # Public facts about sources whose connector module is not implemented yet (so `sources` is complete for the API). |
| 37 | 38 | SOURCE_DEFAULTS: dict[str, dict[str, str]] = { |
@@ -370,6 +371,19 @@ def _integrity(con: duckdb.DuckDBPyConnection, files: list[Path], strict: bool) | ||
| 370 | 371 | problems.append(f"headline indicator {slug} has only {n} countries in latest (< {HEADLINE_MIN_COUNTRIES})") |
| 371 | 372 | if problems and strict: |
| 372 | 373 | raise IntegrityError("; ".join(problems)) |
| 374 | + # soft checks (warnings only): frequency purity and thin ranking pools on headline indicators | |
| 375 | + n_mismatch = con.execute( | |
| 376 | + "SELECT count(*) FROM latest l JOIN indicators i ON i.id = l.indicator_id WHERE l.frequency <> i.frequency" | |
| 377 | + ).fetchone()[0] | |
| 378 | + if n_mismatch: | |
| 379 | + problems.append(f"{n_mismatch} latest rows whose frequency differs from the indicator's canonical frequency") | |
| 380 | + thin = con.execute( | |
| 381 | + f"""SELECT indicator_id, min(n_world) FROM latest | |
| 382 | + WHERE indicator_id IN (SELECT unnest($h::TEXT[])) AND n_world < {HEADLINE_MIN_N_WORLD} GROUP BY 1""", | |
| 383 | + {"h": list(registry.topics()["headline"])}, | |
| 384 | + ).fetchall() | |
| 385 | + for slug, n in thin: | |
| 386 | + problems.append(f"headline indicator {slug} has latest rows ranked among only {n} countries (n_world < {HEADLINE_MIN_N_WORLD})") | |
| 373 | 387 | return problems |
| 374 | 388 | |
| 375 | 389 | |
modified
src/countryatlas/pipeline/derived.py
+13 −4
@@ -29,15 +29,19 @@ _ORDER = ( | ||
| 29 | 29 | |
| 30 | 30 | |
| 31 | 31 | def create_base_views(con: duckdb.DuckDBPyConnection) -> None: |
| 32 | − """obs_ok = observations usable for derived tables (non-forecast, non-quarantined, finite value).""" | |
| 32 | + """obs_ok = observations usable for derived tables: non-forecast, non-quarantined, finite value, and ONLY the | |
| 33 | + indicator's canonical frequency (registry `frequency`). Higher-frequency series (e.g. FRED monthly inflation for the | |
| 34 | + USA) stay in `observations` for series/chart endpoints but never enter latest/rankings/changes/events/similarity.""" | |
| 33 | 35 | con.execute( |
| 34 | 36 | """ |
| 35 | 37 | CREATE OR REPLACE TEMP VIEW obs_ok AS |
| 36 | 38 | SELECT o.* |
| 37 | 39 | FROM observations o |
| 40 | + JOIN indicators i ON i.id = o.indicator_id | |
| 38 | 41 | WHERE NOT coalesce(o.is_forecast, false) |
| 39 | 42 | AND coalesce(o.status, 'imported') <> 'quarantined' |
| 40 | 43 | AND o.value IS NOT NULL AND isfinite(o.value) |
| 44 | + AND o.frequency = i.frequency | |
| 41 | 45 | """ |
| 42 | 46 | ) |
| 43 | 47 | |
@@ -74,7 +78,7 @@ def build_ranks(con: duckdb.DuckDBPyConnection) -> None: | ||
| 74 | 78 | |
| 75 | 79 | def build_latest(con: duckdb.DuckDBPyConnection) -> int: |
| 76 | 80 | con.execute( |
| 77 | − """ | |
| 81 | + f""" | |
| 78 | 82 | CREATE OR REPLACE TEMP TABLE lat AS |
| 79 | 83 | WITH ordered AS ( |
| 80 | 84 | SELECT *, |
@@ -92,12 +96,17 @@ def build_latest(con: duckdb.DuckDBPyConnection) -> int: | ||
| 92 | 96 | CASE WHEN l.prev_value IS NULL OR l.prev_value = 0 THEN NULL |
| 93 | 97 | ELSE (l.value - l.prev_value) / abs(l.prev_value) * 100 END AS change_pct, |
| 94 | 98 | r.rank_world, r.n_world, r.rank_region, r.n_region, r.rank_income, r.n_income, |
| 95 | − l.year AS rank_year, l.source_id, l.is_forecast, l.is_estimate, l.status, | |
| 99 | + r.year AS rank_year, l.source_id, l.is_forecast, l.is_estimate, l.status, | |
| 96 | 100 | t.value AS value_10y_ago, |
| 97 | 101 | l.value - t.value AS change_10y_abs, |
| 98 | 102 | CASE WHEN t.value IS NULL OR t.value = 0 THEN NULL ELSE (l.value - t.value) / abs(t.value) * 100 END |
| 99 | 103 | FROM lat l |
| 100 | − LEFT JOIN ranks_all r ON r.country_id = l.country_id AND r.indicator_id = l.indicator_id AND r.year = l.year | |
| 104 | + -- ranks come from the most recent year (≤ latest) in which the country has a value AND ≥ MIN_COUNTRIES_FOR_RANKING | |
| 105 | + -- countries are ranked; rank_year records that year (may be older than `year`); NULL when no such year exists | |
| 106 | + LEFT JOIN ( | |
| 107 | + SELECT *, row_number() OVER (PARTITION BY country_id, indicator_id ORDER BY year DESC) AS rn | |
| 108 | + FROM ranks_all WHERE n_world >= {MIN_COUNTRIES_FOR_RANKING} | |
| 109 | + ) r ON r.country_id = l.country_id AND r.indicator_id = l.indicator_id AND r.rn = 1 | |
| 101 | 110 | LEFT JOIN obs_ok t ON t.country_id = l.country_id AND t.indicator_id = l.indicator_id |
| 102 | 111 | AND t.period = CAST(l.period - INTERVAL 10 YEAR AS DATE) AND t.frequency = l.frequency; |
| 103 | 112 | """ |
modified
tests/api/test_countries.py
+11 −3
@@ -41,14 +41,22 @@ def test_country_overview(get): | ||
| 41 | 41 | assert "g7" in {g["id"] for g in body["groups"]} and "world" in {g["id"] for g in body["groups"]} |
| 42 | 42 | assert body["coverage"]["n_indicators"] == 14 |
| 43 | 43 | assert body["freshness"]["source_updated_at"].startswith("2026-07-01") and body["freshness"]["built_at"] |
| 44 | + from countryatlas.registry import topics as registry_topics | |
| 45 | + | |
| 44 | 46 | headline = {m["indicator"]: m for m in body["headline"]} |
| 45 | − assert list(headline) == ["population", "gdp", "gdp-per-capita", "gdp-growth", "inflation", "unemployment-rate", "life-expectancy", | |
| 46 | − "median-age", "government-debt-pct-gdp", "co2-per-capita", "renewable-electricity-share", "internet-users"] | |
| 47 | + assert list(headline) == registry_topics()["headline"] # read from topics.yaml at request time (cache cleared per snapshot) | |
| 48 | + assert list(headline)[:3] == ["population", "gdp", "gdp-per-capita"] | |
| 49 | + # indicators in the headline but absent from the snapshot still render as "no data" | |
| 50 | + absent = [m for m in body["headline"] if m["indicator"] not in ("population", "gdp", "gdp-per-capita", "gdp-growth", "inflation", | |
| 51 | + "unemployment-rate", "life-expectancy", "median-age", "co2-per-capita", "renewable-electricity-share", "internet-users", | |
| 52 | + "government-debt-pct-gdp")] | |
| 53 | + assert all(m["has_data"] is False and m["formatted"] == "—" and m["provenance"] is None for m in absent) | |
| 47 | 54 | gpc = headline["gdp-per-capita"] |
| 48 | 55 | assert gpc["has_data"] and gpc["year"] == 2024 and gpc["value"] == pytest.approx(value("CAN", "gdp-per-capita", 2024)) |
| 49 | 56 | assert gpc["change"]["pct"] == pytest.approx(3.0) and gpc["change"]["formatted"] == "+3.0 %" |
| 50 | 57 | assert gpc["rank_world"] and gpc["n_world"] == 8 and gpc["rank_region"] and gpc["n_region"] == 2 |
| 51 | − assert gpc["formatted"].endswith("k") | |
| 58 | + assert gpc["formatted"].startswith("US$") and gpc["formatted"].endswith("k") and gpc["formatted_short"] == gpc["formatted"][3:] | |
| 59 | + assert headline["inflation"]["formatted"].endswith(" %") and headline["life-expectancy"]["formatted"].endswith(" yrs") | |
| 52 | 60 | assert len(gpc["sparkline"]) == 30 and gpc["sparkline"][-1][0] == 2024 and isinstance(gpc["sparkline"][-1][0], int) |
| 53 | 61 | assert_provenance(gpc["provenance"]) |
| 54 | 62 | assert gpc["provenance"]["url"] == "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA" |
modified
tests/api/test_infra.py
+24 −4
@@ -10,7 +10,8 @@ import duckdb | ||
| 10 | 10 | from fastapi.testclient import TestClient |
| 11 | 11 | |
| 12 | 12 | from countryatlas.api.cache import ResponseCache |
| 13 | −from countryatlas.api.formatting import compact_number, format_change, format_value | |
| 13 | +from countryatlas.api.db import invalidate_registry_caches | |
| 14 | +from countryatlas.api.formatting import compact_number, format_change, format_short, format_value | |
| 14 | 15 | from countryatlas.api.main import create_app |
| 15 | 16 | from countryatlas.api.provenance import source_url |
| 16 | 17 | from countryatlas.config import settings |
@@ -170,9 +171,11 @@ def test_unknown_route_and_validation(client): | ||
| 170 | 171 | |
| 171 | 172 | |
| 172 | 173 | def test_formatting(): |
| 173 | − assert format_value(53372.1, {"format": "currency"}) == "53.4k" | |
| 174 | − assert format_value(1.23e12, {"format": "currency"}) == "1.2T" | |
| 175 | − assert format_value(45.3e9, {"format": "currency"}) == "45.3B" | |
| 174 | + assert format_value(53372.1, {"format": "currency", "unit_short": "US$"}) == "US$53.4k" | |
| 175 | + assert format_value(1.23e12, {"format": "currency"}) == "US$1.2T" # default prefix | |
| 176 | + assert format_value(45.3e9, {"format": "currency", "unit_short": "intl $"}) == "intl $45.3B" | |
| 177 | + assert format_short(53372.1, {"format": "currency", "unit_short": "US$"}) == "53.4k" | |
| 178 | + assert format_short(3.44, {"format": "percent"}) == "3.4" | |
| 176 | 179 | assert format_value(3.44, {"format": "percent", "precision": 1}) == "3.4 %" |
| 177 | 180 | assert format_value(82.13, {"format": "years"}) == "82.1 yrs" |
| 178 | 181 | assert format_value(5.234, {"format": "tonnes", "precision": 2}) == "5.23 t" |
@@ -186,6 +189,23 @@ def test_formatting(): | ||
| 186 | 189 | assert format_change(-100.0, -3.4, {"format": "currency"}) == "−3.4 %" |
| 187 | 190 | |
| 188 | 191 | |
| 192 | +def test_registry_caches_cleared_on_snapshot_open(tmp_path: Path, fixture_db: Path): | |
| 193 | + from countryatlas import registry | |
| 194 | + | |
| 195 | + registry.topics() | |
| 196 | + assert registry.topics.cache_info().currsize == 1 | |
| 197 | + invalidate_registry_caches() | |
| 198 | + assert registry.topics.cache_info().currsize == 0 and registry.indicators.cache_info().currsize == 0 | |
| 199 | + # opening a new snapshot (new run_id) clears the loaders again | |
| 200 | + registry.topics() | |
| 201 | + db_path = tmp_path / "atlas.duckdb" | |
| 202 | + shutil.copy(fixture_db, db_path) | |
| 203 | + app = create_app(db_path, rate_limit_per_minute=0, cache=ResponseCache()) | |
| 204 | + with TestClient(app) as c: | |
| 205 | + assert c.get("/api/v1/health").json()["status"] == "ok" | |
| 206 | + assert registry.topics.cache_info().currsize == 0 | |
| 207 | + | |
| 208 | + | |
| 189 | 209 | def test_source_urls(): |
| 190 | 210 | assert source_url("worldbank", "WDI", "NY.GDP.PCAP.CD", "CA") == "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA" |
| 191 | 211 | assert source_url("owid", "grapher", "median-age") == "https://ourworldindata.org/grapher/median-age" |
modified
tests/api/test_misc_routers.py
+8 −4
@@ -59,15 +59,19 @@ def test_home(get): | ||
| 59 | 59 | snap = body["snapshot"] |
| 60 | 60 | assert snap["n_countries"] == 8 and snap["n_indicators"] == 14 and snap["n_observations"] > 3000 and snap["built_at"] |
| 61 | 61 | assert snap["world_population"] == pytest.approx(sum(value(c, "population", 2024) for c in COUNTRIES)) |
| 62 | − assert snap["world_gdp_formatted"].endswith("T") and snap["median_life_expectancy"] > 60 | |
| 62 | + assert snap["world_gdp_formatted"].startswith("US$") and snap["world_gdp_formatted"].endswith("T") and snap["median_life_expectancy"] > 60 | |
| 63 | 63 | lists = body["lists"] |
| 64 | − assert set(lists) == {"largest_economies", "fastest_population_growth", "highest_life_expectancy", "energy_transition_leaders", | |
| 65 | − "highest_gdp_per_capita_ppp", "lowest_unemployment"} | |
| 64 | + assert set(lists) == {"largest_economies", "fastest_gdp_growth", "fastest_population_growth", "highest_life_expectancy", | |
| 65 | + "energy_transition_leaders", "highest_gdp_per_capita_ppp", "lowest_unemployment"} | |
| 66 | 66 | le = lists["largest_economies"] |
| 67 | 67 | assert le["rows"][0]["country"]["id"] == "USA" and le["rows"][0]["rank"] == 1 and len(le["rows"]) == 8 |
| 68 | + assert le["filter_note"] is None and le["rows"][0]["formatted"].startswith("US$") and le["rows"][0]["formatted_short"] | |
| 68 | 69 | assert_provenance(le["rows"][0]["provenance"]) |
| 69 | − assert lists["lowest_unemployment"]["rows"][0]["country"]["id"] == "JPN" | |
| 70 | + assert lists["lowest_unemployment"]["rows"][0]["country"]["id"] == "JPN" and lists["lowest_unemployment"]["filter_note"] == "Countries above 5M inhabitants" | |
| 70 | 71 | assert lists["energy_transition_leaders"]["rows"][0]["country"]["id"] == "BRA" |
| 72 | + for k in ("fastest_gdp_growth", "fastest_population_growth", "highest_life_expectancy", "energy_transition_leaders"): | |
| 73 | + assert lists[k]["filter_note"] == "Countries above 1M inhabitants" and lists[k]["min_population"] == 1_000_000 | |
| 74 | + assert lists["fastest_gdp_growth"]["indicator"]["id"] == "gdp-growth" and lists["fastest_gdp_growth"]["rows"][0]["year"] == 2024 | |
| 71 | 75 | assert len(body["recent_changes"]) == 12 and body["recent_changes"][0]["country"]["id"] and body["recent_changes"][0]["headline"] |
| 72 | 76 | assert body["recently_updated"] and body["featured_indicators"] and body["trending"] |
| 73 | 77 | assert all(i["featured"] for i in body["featured_indicators"]) |
modified
tests/test_build.py
+30 −1
@@ -52,6 +52,26 @@ def _tiny_staging() -> None: | ||
| 52 | 52 | _stage(ma, "years", 25, 1.005, range(2000, 2024)) |
| 53 | 53 | fr = IndicatorSourceSpec(indicator_id="fertility-rate", connector="worldbank", dataset="WDI", code="SP.DYN.TFRT.IN") |
| 54 | 54 | _stage(fr, "births per woman", 3.0, 0.99, range(2000, 2024)) |
| 55 | + # annual inflation for everyone + a MONTHLY FRED series for the USA (must stay out of latest/rankings) | |
| 56 | + infl = IndicatorSourceSpec(indicator_id="inflation", connector="worldbank", dataset="WDI", code="FP.CPI.TOTL.ZG") | |
| 57 | + _stage(infl, "annual %", 2.0, 1.01, range(2000, 2026)) | |
| 58 | + # CAN alone already has 2026 → its rank must fall back to 2025 (the last year with ≥ 20 ranked countries) | |
| 59 | + import polars as pl | |
| 60 | + | |
| 61 | + p = spec_paths(infl)["parquet"] | |
| 62 | + df = pl.read_parquet(p) | |
| 63 | + extra = df.filter((pl.col("country_id") == "CAN") & (pl.col("year") == 2025)).with_columns( | |
| 64 | + pl.lit(date(2026, 1, 1)).alias("period"), pl.lit(2026, dtype=pl.Int32).alias("year"), (pl.col("value") + 1.0).alias("value")) | |
| 65 | + write_parquet_atomic(pl.concat([df, extra]), p) | |
| 66 | + fred = IndicatorSourceSpec(indicator_id="inflation", connector="fred", dataset="FRED", code="CPIAUCSL", priority=2, | |
| 67 | + countries=["USA"], frequency="M") | |
| 68 | + now = datetime.now(UTC) | |
| 69 | + monthly = [NormalizedObservation(country_id="USA", indicator_id="inflation", period=date(2026, m, 1), year=2026, frequency="M", | |
| 70 | + value=3.0 + m / 10, unit="annual %", source_id="fred", source_dataset="FRED", | |
| 71 | + source_series_code="CPIAUCSL", retrieved_at=now) for m in range(1, 8)] | |
| 72 | + write_parquet_atomic(rows_to_frame(monthly), spec_paths(fred)["parquet"]) | |
| 73 | + write_run(fred, ImportRun(run_id="T1", connector="fred", dataset="FRED", started_at=now, finished_at=now, status="ok", | |
| 74 | + rows_norm=len(monthly), rows_valid=len(monthly))) | |
| 55 | 75 | |
| 56 | 76 | |
| 57 | 77 | @pytest.mark.usefixtures("data_dir") |
@@ -94,8 +114,17 @@ def test_build_tiny_staging_produces_all_tables() -> None: | ||
| 94 | 114 | assert con.execute("SELECT count(*) FROM search_index WHERE type='country'").fetchone()[0] >= 200 |
| 95 | 115 | assert con.execute("SELECT count(*) FROM insights").fetchone()[0] > 0 |
| 96 | 116 | assert con.execute("SELECT count(*) FROM country_dna").fetchone()[0] > 0 |
| 97 | − assert con.execute("SELECT count(*) FROM import_runs").fetchone()[0] == 6 | |
| 117 | + assert con.execute("SELECT count(*) FROM import_runs").fetchone()[0] == 8 | |
| 98 | 118 | assert r.counts["series_fresher_source"] == 1 |
| 119 | + # canonical frequency only: the USA monthly FRED series is in observations but not in latest/rankings | |
| 120 | + assert con.execute("SELECT count(*) FROM observations WHERE frequency='M'").fetchone()[0] == 7 | |
| 121 | + usa = con.execute("SELECT year, frequency, source_id, n_world FROM latest WHERE country_id='USA' AND indicator_id='inflation'").fetchone() | |
| 122 | + assert usa == (2025, "A", "worldbank", len(COUNTRIES)) | |
| 123 | + assert con.execute("SELECT count(*) FROM latest l JOIN indicators i ON i.id=l.indicator_id WHERE l.frequency<>i.frequency").fetchone()[0] == 0 | |
| 124 | + assert con.execute("SELECT count(*) FROM rankings WHERE indicator_id='inflation' AND year=2026").fetchone()[0] == 0 | |
| 125 | + # rank fallback: CAN's latest inflation is 2026 (only country) → ranked within 2025 among all 24 | |
| 126 | + can = con.execute("SELECT year, rank_year, n_world FROM latest WHERE country_id='CAN' AND indicator_id='inflation'").fetchone() | |
| 127 | + assert can[0] == 2026 and can[1] == 2025 and can[2] == len(COUNTRIES) | |
| 99 | 128 | meta = dict(con.execute("SELECT key, value FROM meta").fetchall()) |
| 100 | 129 | assert meta["schema_version"] == "1" and meta["build_run_id"] == "20260101T000000Z" |
| 101 | 130 | assert int(meta["observation_count"]) == n_obs |
| 102 | 131 | |