# INF-15 «Полотно»: словарь данных

Пакет `grid` v1.0.0 (2026-07-26). Этот документ пишется специально под
**переиспользование следующим исследованием (INF-14, школы и медпункты)** —
раздел 2 и Приложение А ТЗ прямо это требуют: «правильно считать, сколько
людей живёт далеко от ближайшей школы, нужны люди по клеткам, а не
"население района, сваленное в центр района"». Формулы метрик —
`METHODS.md`; допущения — `params/assumptions.yaml`.

---

## 0. Самое важное — система координат (читайте это первым)

Приложение А ТЗ приводит ПРИМЕР контракта данных с полем
`"grid": {"crs": "EPSG:3857", ...}`. **Этот пакет считает и публикует НЕ в
EPSG:3857, а в ESRI:54009 (Молльвейде)** — это осознанное отступление от
буквы примера, зафиксированное `docs/decisions/INF-15.md`, запись **D-001**.

Причина: EPSG:3857 (Web Mercator) — конформная, НЕ равноплощадная проекция.
На широте Беларуси (51,3°–56,2° с.ш.) клетка «1000×1000 м» в координатах
EPSG:3857 покрывает на местности реальную площадь примерно в **2,5–3,2 раза
меньше** одного квадратного километра, причём искажение РАЗНОЕ на юге и на
севере страны. Если бы следующее исследование (INF-14) взяло значения
`grid.json` этого пакета и посчитало площадь ячейки по формуле
«`cell_m² = cell_m × cell_m`» в предположении EPSG:3857 (как подсказывает
пример Приложения А), результат был бы систематически и неравномерно по
широте занижен. **Практическое следствие для INF-14:** поле `grid.crs` в
данных этого пакета равно `"ESRI:54009"`; при любых собственных
геопространственных расчётах (например, привязка школы к клетке) нужно либо
работать в той же проекции, либо явно репроецировать, а НЕ считать площадь
ячейки по номинальным метрам, взятым из системы координат EPSG:3857.

---

## 1. `web/public/data/grid.json` — контракт данных сайта

Формат JSON, `ensure_ascii=false`, без отступов (компактная сериализация).
Размер на момент написания — **≈202 КБ** (206 694 байт), бюджет — 1536 КБ.
Файл собирается модулем `etl.grid_project::write_grid_json()` из
промежуточных агрегатов `data/raw/grid/*.json` — в архиве пакета он
материализуется сборкой манифеста (`python -m etl.artifacts.build grid`) как
`data/final/grid.json`.

### 1.1. Верхний уровень

| Ключ | Тип | Содержание |
|---|---|---|
| `version` | строка | `"1.2.0"` — версия пакета |
| `forecast_version` | строка | `"v2026.4"` — версия использованного прогноза районов |
| `grid` | объект | геопривязка единой сетки (раздел 1.2) |
| `years` | объект | `{observed: int[10], forecast: int[6]}` |
| `frames` | объект | `{pop, density}` - режим → ключ → путь к webp-кадру карты (раздел 1.3) |
| `national` | объект | национальные показатели (раздел 1.4) |
| `territories` | объект | 119 записей `territory_id → показатели района` (раздел 1.5) |
| `validation` | объект | сводки всех восьми проверок (раздел 1.6) |
| `meta` | объект | утверждения, их статус, плашка честности (раздел 1.7) |

### 1.2. `grid` — геопривязка единой сетки

| Поле | Тип | Значение / единица | Примечание |
|---|---|---|---|
| `crs` | строка | `"ESRI:54009"` | **НЕ** `"EPSG:3857"` из примера Приложения А — см. раздел 0 |
| `cell_m` | int | `1000` | сторона клетки, метров, В ПРОЕКЦИИ `crs` |
| `width` | int | `857` | клеток по оси X |
| `height` | int | `514` | клеток по оси Y |
| `transform` | float[6] | `[1000.0, 0.0, 1602000.0, 0.0, -1000.0, 6512000.0]` | аффинное преобразование пиксель→метр (см. раздел 3, стабильность ячеек) |
| `note` | строка | текст | явная ссылка на решение D-001 |

Полная (более подробная, с `bounds_moll`) версия этого объекта —
`sources/raw/grid_meta.json` пакета.

### 1.3. `years` и `frames`

`years.observed` — 10 наблюдаемых эпох `[1975, 1980, …, 2020]` (шаг 5 лет).
`years.forecast` — 6 прогнозных узлов `[2026, 2031, 2036, 2041, 2046, 2050]`
(последний — интерполяция, не «родной» узел прогноза района, см. раздел 4).

`frames` — объект с двумя режимами отрисовки, `{ pop: {...}, density: {...} }`
(доработка v1.1.0 — до этого был один плоский словарь `pop_*`; см.
`docs/decisions/INF-15.md` D-010, B-2). Каждый из `frames.pop` и
`frames.density` — плоский словарь, **46** записей (10 наблюдаемых + 6 лет
× 3 сценария × 2 варианта = 36 прогнозных), один и тот же набор ключей:

| Ключ | Формат | Пример (`frames.pop`) | Пример (`frames.density`) |
|---|---|---|---|
| наблюдаемый год | `"<year>"` | `"2020"` → `/data/grid_frames/pop_2020.webp` | `"2020"` → `/data/grid_frames/density_2020.webp` |
| прогнозный | `"<year>:<scenario>:<variant>"` | `"2050:base:A"` → `/data/grid_frames/pop_2050_base_A.webp` | `"2050:base:A"` → `/data/grid_frames/density_2050_base_A.webp` |

`frames.pop` кодирует непрерывную (log1p-нормированную) плотность — см.
ниже; `frames.density` красит клетку плоским цветом одного из 4 классов
порогов пререгистрации (`<1`, `1–5`, `5–50`, `≥50` чел/км²; RGB-палитра и
подписи — `grid_frames/meta.json → density_classes`, синхронизировано с
`etl/grid_frames.py:DENSITY_CLASSES`). Третий режим страницы — «метры сети
на жителя» — кадров вообще не имеет, это хороплет районов по
`territories[*].network_per_capita.road_km_per_1000` (снимок 2026 года,
без временной оси).

`scenario ∈ {base, optimistic, negative}`, `variant ∈ {A, B}`. Кадры —
картинки (webp, ≤220 КБ каждая), не числовой массив: цвет клетки кодирует
`log1p`-нормированную (потолок 5000 чел/км², гамма 0,92) плотность с
добавленным дизерингом (±0,5 к каналу яркости для «зажжённых» пикселей,
seed 42/43) — **из значения пикселя картинки нельзя точно восстановить
исходное число жителей клетки**, только визуально сравнимую яркость (см.
раздел 5 — как на самом деле читать значение ячейки).

### 1.4. `national` — национальные ряды

| Ключ | Тип | Единица | Диапазон ключей |
|---|---|---|---|
| `area_share_below["5"\|"1"\|"25"]` | объект `{ключ_года → float}` | доля площади страны, 0–1 | 10 набл. + 36 прогнозных ключей |
| `arithmetic_density` | объект | чел/км² | то же |
| `population_weighted_density` | объект | чел/км² | то же |
| `settlement_components[год]` | объект | `{n_components:int, largest_area_km2:float, largest_pop:float, largest_share_of_pop:float (0-1), largest_share_of_area:float (0-1)}` | 10 набл. + прогнозные ключи |
| `centroid_track` | массив объектов | `{year:int, lon:float, lat:float, dtype:"c"\|"e"\|"f", err_km:int, method?:string, n_raions?:int, n_cities?:int, pop_covered?:int}` | 1897…2050 (**18** точек — доработка v1.1.0, было 16, D-009) |
| `raion_reconciliation_national` | массив объектов | `{epoch:int, grid_sum:float(чел), official:float(чел), pct_diff:float(%), pass:bool}` | 10 набл. эпох — сверка НАЦИОНАЛЬНОЙ суммы клеток с офиц. рядом (G-1.2, сырой продукт, до calibровки; после calibровки расхождение отражает только точность интерполяции официального ряда, не сам факт калибровки) |

`centroid_track[i].dtype`: `c` — точное совпадение с годом переписи/сеткой
наблюдаемой эпохи (1975–2020, метод `method:"raion_population_weighted"`
и для 1970); `e` — оценка более грубым методом (1897,
`method:"city_population_weighted_1897_census"`); `f` — модельная
экстраполяция (прогнозные годы 2026–2050, включая 2050). `err_km`: 10 для
1970–2050 (полное районное покрытие или модель), 40 для 1897 (оценка по
городскому населению переписи — охватывает только ≈9% населения страны
того года, `pop_covered`/`n_cities` в самой точке фиксируют охват).
**v1.1.0 (D-009):** до этой версии `centroid_pre_grid()` усреднял по
единственному району с данными на 1897/1913/1926/1939/1959
(`data.json → territories[*].pop` покрывает эти годы только для
`BY-HM`), из-за чего трек молча вырождался в координаты Минска на этих
годах — визуально «не сломано» (нет исключения), но содержательно
неверно (создавало ложное впечатление нулевого смещения). Годы без
устойчивой оценки (1913, 1926, 1939, 1959) в треке **не строятся** —
отсутствие точки честнее вырожденной.

### 1.5. `territories[territory_id]` — показатели по 119 территориям

`territory_id` — тот же реестр, что и в остальном проекте: `r-*` (118
районов) + `BY-HM` (город Минск). **Важно:** это агрегаты НА УРОВНЕ РАЙОНА,
а не отдельных клеток — «клетки района, свёрнутые в один ряд по времени»,
не координатный список.

| Поле | Тип | Единица | Примечание |
|---|---|---|---|
| `pop_grid_sum` | объект `{ключ_года → float}` | человек | сумма клеток района на эту эпоху/прогнозный ключ; для наблюдаемых эпох — ТОЖДЕСТВЕННО официальному ряду (calibровка, раздел 3 METHODS.md), для прогнозных — тождественно прогнозу района (G-2) |
| `area_share_below["5"]` | объект `{год(только наблюдаемые) → float}` | доля площади района, 0–1 | только 10 наблюдаемых эпох, только порог 5 |
| `network_per_capita` | объект \| отсутствует | `{road_km_per_1000:float, road_km_per_km2:float, road_km_total:float(км)}` | снимок OSM на 2026 год (не история); присутствует у территорий, для которых есть данные сети |

### 1.6. `validation` — сводки восьми проверок

Каждый ключ — копия (или ссылка на) соответствующего промежуточного отчёта
из `sources/raw/`: `g1_reconciliation_summary` (← `metrics_observed.json →
reconciliation_summary`), `g2_summary` (← `metrics_forecast.json →
g2_report_summary`), `g3_nightlights_crosscheck` (← `g3_nightlights_
crosscheck.json` целиком), `g4_g5` (← `metrics_observed.json → validation`),
`g6_network` (← `network_metrics.json → g6`), `c3_correlation` (←
`network_metrics.json → correlation_c3`), `g9_concentration_check` (доработка
v1.2.0 — см. ниже). Точные числа и пороги — `VALIDATION.md`.

**`g9_concentration_check`** (v1.2.0, `docs/decisions/INF-15.md` D-013) —
проверка внутрирайонной концентрации: G-2 сверяет только СУММУ клеток
района с прогнозом, не форму распределения внутри района, поэтому не
ловит самоусиление log-linear экстраполяции по отдельной клетке при
переносе за пределы окна подгонки. G-9 сравнивает исторический (1975–2020)
максимальный 5-летний темп роста доли населения района в его топ-5 самых
плотных клетках с прогнозным темпом 2020→2050 (сценарий base, вариант A).

| Поле | Тип | Значение |
|---|---|---|
| `national_mean_top5_share_by_year` | объект `{год → float}` | средняя по стране доля населения района в топ-5 клетках, 1975–2020 — почти не меняется (25,56%→25,84%) |
| `national_mean_top5_share_2050_base_a` | float | то же для 2050 base/A — модель прогнозирует скачок до 55,41% |
| `n_raions_checked` / `n_raions_violated` / `pct_raions_violated` | int/int/float | 118 / 62 / 52,5% — районов, где прогнозный темп превышает исторический максимум в `rate_multiplier_threshold` (3,0×) раз и более |
| `max_ratio_to_historical` / `median_ratio_to_historical` | float | 26,75× / 3,4× |
| `pass` | bool | `false` — **намеренно и честно НЕ пройдена**: находка (а не отсутствие проверки) — вариант A/Б переконцентрирует население внутри района сильнее, чем допускает исторический темп, см. D-013 |

Полный разрез по каждому из 118 районов (не только сводка выше) —
`sources/raw/g9_concentration_check.json` пакета (поле `per_raion`).

### 1.7. `meta`

| Поле | Значение |
|---|---|
| `claims` | `["C-001", "C-002", "C-003", "C-004", "C-005"]` (доработка v1.2.0) |
| `claims_status` | `{"C-001": "open_question", "C-002": "open_question", "C-003": "verified", "C-004": "verified", "C-005": "open_question"}` |
| `artifact` | `"by-maps-grid-v1.2.0.zip"` |
| `honesty` | текст плашки честности (дословно на странице, без прокрутки, раздел 3 ТЗ) |

**C-004** («правило пятисот» — плотность 500–2000 чел/км² растёт, ниже и
выше убывает) — подтверждено точным независимым пересчётом
(`docs/preregistration/grid-v0.2.md` §3). **C-005** (население тяготеет к
магистралям) — статус «открытый вопрос»: качественный градиент
подтверждён (убыль монотонно усиливается с 2 до 20 км), но метод
растеризации дорог даёт малые расхождения с независимым пересчётом на
уровне района — не разворот знака, но недостаточно для «подтверждено» без
отдельной районной проверки (см. preregistration v0.2 §3, открытый пункт).

---

## 2. Единицы измерения — сводная таблица

| Величина | Единица |
|---|---|
| Население клетки/района (`pop_grid_sum`, `pop`) | человек (может быть дробным — артефакт дазиметрической интерполяции и calibровки, не подделка точности) |
| Плотность (`arithmetic_density`, `population_weighted_density`) | человек/км² |
| Доля площади (`area_share_below`, `largest_share_of_area`) | безразмерная доля, 0–1 (НЕ проценты) |
| Доля населения (`largest_share_of_pop`) | безразмерная доля, 0–1 |
| Координаты (`centroid_track[].lat/lon`, `frames`-привязка) | градусы WGS84 (EPSG:4326) — единственное место контракта, где координаты в градусах, а не в метрах Молльвейде |
| Погрешность центра масс (`err_km`) | километры |
| Длина сети (`road_km_*`) | километры |
| Площадь клетки/района | км² (1 клетка = 1 км² РОВНО, т.к. `crs` равноплощадная) |
| Отклонения сверки (`pct_diff`, `delta_pct`) | проценты (не доли) |
| Корреляция (`c3_correlation.spearman_rho`) | безразмерный коэффициент Спирмена, [-1, 1] |

---

## 3. Стабильность идентификаторов ячеек

Единая сетка (`grid.width=857`, `grid.height=514`, `grid.transform`) —
**одна и та же для ВСЕХ эпох**, наблюдаемых и прогнозных: это гарантируется
на уровне кода — `etl/grid_extract.py` собирает мозаику и клип с одинаковым
`bounds`/`res` для каждой из 10 эпох (докстринг модуля: «Решётка одинакова
для всех эпох (общий transform/width/height) — обязательное условие
сравнимости кадров и проверки G-2»), а прогнозный модуль
(`etl/grid_project.py::load_observed_stack()`) переиспользует ТОТ ЖЕ
`transform`/`shape`, что и наблюдаемый стек. Поэтому **клетка одного и того
же места на местности находится по одному и тому же индексу (номер строки,
номер столбца) в любую эпоху 1975–2050** — не нужно искать соответствие
между растрами разных лет, оно тождественно по построению.

**Как вычислить координаты клетки по индексу.** Аффинный `transform =
[a, b, c, d, e, f]` (порядок GDAL/rasterio) даёт координаты ВЕРХНЕГО ЛЕВОГО
угла клетки `(col, row)`:

```
x_moll = c + a * col          # метры, ESRI:54009
y_moll = f + e * row          # e отрицательный (сетка растёт вниз)
```

Координаты ЦЕНТРА клетки (так считается центр масс населения в
`etl/grid.py::centroid_grid`) — `col + 0.5`, `row + 0.5` в той же формуле.
Полные параметры зафиксированы в `sources/raw/grid_meta.json` (и
эквивалентно — в `grid.json → grid`).

**Стабильный идентификатор клетки** для внешнего использования (например,
следующим исследованием INF-14) — пара `(row, col)` относительно этого
фиксированного `transform`, либо эквивалентный линейный индекс
`row * width + col` (0-индексация, `width = 857`). Оба варианта стабильны
между версиями `grid.json`, пока `grid.transform`/`width`/`height` не
меняются (любое изменение сетки — новая MAJOR-версия пакета, раздел 8
`ARTIFACT_STANDARD.md`).

---

## 4. Правила интерполяции и метки достоверности (dtype)

| Годы | Что это | Метка |
|---|---|---|
| 1975, 1980, …, 2020 (10 эпох) | наблюдаемые эпохи GHS-POP, calibровано под официальный ряд района | наблюдение (клеточная сетка); `centroid_track.dtype = "c"` |
| 1970 | центр масс до появления клеточной сетки, посчитан по офиц. численности всех 118 районов на их геогр. центроидах (первый год с полным районным покрытием) | `dtype = "c"`, `method = "raion_population_weighted"` |
| 1897 | грубее: центр масс по ГОРОДСКОМУ населению переписи 1897 года (`data/raw/shocks/census_lang.json`) — районных данных на этот год нет ни у одного района, кроме Минска | `dtype = "e"`, `method = "city_population_weighted_1897_census"`, `err_km = 40` |
| 2026, 2031, 2036, 2041, 2046 | «родные» узлы `forecast.json` (шаг 5 лет), прогноз по клеткам, варианты A/Б | модель `[MODEL]`; в `centroid_track` — `dtype = "f"` |
| 2050 | НЕ «родной» узел `forecast.json` (шаг 5 лет от 2026 не попадает в 2050) — линейная интерполяция между узлами 2046 и 2051 | модель `[MODEL]`, `dtype = "f"` — обязательно наравне с остальными прогнозными точками, а не как отдельная, более достоверная категория |

Официальный ряд района к узлам НАБЛЮДАЕМЫХ эпох (1975–2020) приводится
линейной интерполяцией между ближайшими точками `data.json`
(`etl/grid.py::interp_official`) — точно совпадают с годом ряда только
эпохи **2005 и 2015**; остальные 8 эпох — интерполированные значения
официального ряда (не самих клеток — калибровочный коэффициент вычисляется
именно из интерполированного официального числа). Экстраполяция за пределы
диапазона официального ряда не выполняется нигде в конвейере.

---

## 5. Как читать значение отдельной ячейки

Раздел «Контракт данных» Приложения А ТЗ требует зафиксировать выбор между
двумя способами клиентского чтения значения клетки — сделано, запись
**D-003** в `docs/decisions/INF-15.md` (не пустая заготовка).

**Выбор: типизированный бинарный формат с ленивой подгрузкой.**
`web/public/data/grid_cells/pop_<epoch>.bin` — сырой `Uint16` (little-endian),
row-major, `width×height` из `grid.json.grid` (857×514 = 880 996 байт на
файл; население округлено до целого человека). 10 файлов на наблюдаемые
эпохи (860 КБ каждый) + 2 файла на прогноз `2050:base:A`/`2050:base:B`
(самая вероятная точка клика) — не все 36 комбинаций сценарий×вариант×год,
осознанное и раскрытое ограничение охвата (см. D-003 за обоснованием).
Индексация: `row = floor((transform[3] − y_moll) / |transform[4]|)`,
`col = floor((x_moll − transform[2]) / transform[0])`; значение —
`view[row*width + col]`. Клиентская реализация — `web/lib/grid.ts`
(`loadCellGrid`, `lonLatToCellIndex`, `cellValue`), покрыта тестами
`web/lib/grid.test.ts` (сверка проекции Молльвейде с `rasterio.warp.
transform` до <1 мм — важно: `ESRI:54009` в реализации PROJ/GDAL здесь
псевдосферическая, `R = 6378137` (большая полуось WGS84), БЕЗ перевода в
авторическую широту — типовая формула сферической Молльвейде с
авторической широтой или радиусом 6371007 даёт ошибку 7–20 км).

**Что можно использовать уже сейчас (для полного, не облегчённого
воспроизведения или для офлайн-анализа следующим исследованием):**
точные значения численности каждой клетки на каждую наблюдаемую эпоху
лежат в откалиброванных растрах `sources/raw/rasters/pop_<epoch>.tif`
(GeoTIFF, `float32`, округление до 3 знаков, `nodata=0.0`, та же сетка
857×514 в ESRI:54009 — раздел 3 выше). Прогнозные значения по клеткам НЕ
персистятся отдельным файлом (см. `etl/grid_project.py`, докстринг:
«прогнозные растры не персистятся на диск — считаются на лету... чтобы не
раздувать репозиторий») — их нужно пересчитать модулем `etl.grid_project`
(функция `project_epoch()`) заново, если нужны точные прогнозные значения
клеток, а не только агрегаты по району из `territories[].pop_grid_sum`.

**Чего делать НЕ следует:** восстанавливать численность клетки из цвета
пикселя webp-кадра (`frames`) — кодирование через `log1p`-нормировку с
фиксированным потолком 5000 чел/км², гамма-коррекцией и дизерингом
(раздел 1.3 выше) необратимо теряет точность и специально проектировалось
только для визуального восприятия карты, не для численного чтения.

---

## 6. `web/public/data/grid_story.json` — данные режима «История» (v1.2.0)

Отдельный от `grid.json` файл (25,8 КБ, бюджет 100 КБ, `etl/grid_story.py`
→ `sources/raw/story_metrics.json` пакета). Требование брифа доработки
(`handoff/09_next_research/INF-15_grid_v2_brief.md` §11): ни одно число в
7 шагах истории не хардкодится в компоненте — все `{{токены}}` текста
(`web/public/content/research/grid-story.ru.md` / `.be.md`,
`web/lib/gridStory.ts::buildStoryValues`) читаются из этого файла (плюс уже
загруженного `grid.json` для `settlement_components`/G-3).

| Ключ | Тип | Содержание |
|---|---|---|
| `rule500_frame` | строка | путь к RGBA-маске «правило пятисот» (клетки 500–2000 чел/км² синим, вне диапазона — оранжевым, поверх обычного кадра плотности, шаг 2 истории) |
| `c004_density_bands` | объект | `{bounds_pop_per_km2:int[6], bands:[{lo,hi,n_cells,pop_1975,pop_2020,change_pct,share_2020_pct}]}` — 6 полос по плотности клетки, гипотеза C-004 |
| `c005_highway_distance` | объект | `{road_classes:string[3], bands_km:int[5], n_edges:int, bands:[{lo,hi,n_cells,pop_1975,pop_2020,change_pct}]}` — 5 полос по расстоянию клетки до ближайшей магистрали (motorway/trunk/primary), гипотеза C-005 |
| `half_population_area_km2` | объект `{ключ_года → int}` | площадь наименьшего набора клеток, вмещающего половину населения страны на эту эпоху (1975/2020/2050:base:A) |
| `country_area_km2` | int | площадь страны по маске `etl.grid.country_mask()` (209 307 клеток × 1 км² — НЕ прямоугольный bbox растра, который на ~110 тыс. клеток больше; см. `docs/decisions/INF-15.md`, находка при разработке v1.2.0) |
| `cells_ge5_pop` | объект `{ключ_года → int}` | число клеток с населением ≥5 человек |
| `top_cell_concentration[год]` | объект | `{top100_pct:float, top500_pct:float}` — доля нац. населения в 100/500 самых населённых клетках |
| `raions.shrunk_most` / `raions.grew_most` | массив (top-8) | `{id,name_ru,name_be,pop_1975,pop_2020,change_pct}` — районы с наибольшим сокращением/ростом 1975→2020, для карточек-примеров |
| `raions.minsk_city` | объект \| null | тот же формат записи, для `BY-HM` отдельно (не входит в ранжирование районов) |
| `raions.by_id` | объект `{territory_id → запись}` | **все 119 территорий**, тот же формат — используется для интерполяции текста истории, когда шаг называет район вне top-8 по рангу (см. ниже) |
| `raions.n_raions_total` / `n_raions_shrunk` / `n_raions_shrunk_40pct_or_more` | int | 118 / 99 / 61 |
| `raions.national_pop_1975` / `national_pop_2020` / `national_change_pct` | float/float/float | 9 318 956 / 9 381 546 / +0,67% |
| `chernobyl_zone_class` | объект `{territory_id → int}` | `1` = зона отчуждения/отселения, `2` = зона с правом на отселение (INF-07), используется для оверлея на шаге 4 |

**Регресс `raions.by_id` (найден в браузере при живой проверке шага 4
v1.2.0):** до фикса `web/lib/gridStory.ts::findRaion()` искал район только в
`shrunk_most`/`grew_most` (top-8 по рангу убыли/роста). Свислочский район
упомянут в тексте шага 4 как часть географически связной «западной группы»
опустевших районов, но по рангу убыли он 9-й (−57,3%), на одно место ниже
top-8 — интерполяция молча подставляла id вместо названия и 0,0% вместо
реального числа. Урок: подборка по рангу не гарантирует покрытие
районов, упомянутых в тексте по содержательным (не ранговым) причинам —
любой список, используемый для интерполяции текста, должен либо содержать
ВСЕ территории (`by_id`), либо явно проверяться тестом на конкретные
id, которые текст называет (`etl/tests/test_grid_story.py::
test_raion_by_id_covers_named_examples_outside_top8`).

### 6.1. `web/public/data/geo/grid_highways.geojson` — слой магистралей

Отдельный ленивый (грузится только на шаге 3 истории или при deep-link
`?story=3` и позже) GeoJSON `FeatureCollection` из `LineString`, WGS84.
Источник — дорожный граф INF-04 (`etl/osm_graph.py`, классы motorway/
trunk/primary), 176 199 сырых рёбер сведены `shapely.linemerge` в 4 613
непрерывных полилиний и упрощены `simplify(150 м, в проекции Молльвейде)`
+ округление координат до 5 знаков — 662,8 КБ (бюджет 2048 КБ, было бы
24 МБ без сведения+упрощения). Только геометрия для визуального слоя на
карте истории — для числовых метрик по расстоянию до дороги (гипотеза
C-005) используется `c005_highway_distance` выше, не этот файл.
