feat(stand): make up наполняет стенд стартовым миром, опись сторожит его

Зачем
Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с
ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому
же состоянию, а в git лежит то, чем это состояние проверяется.

Что
- Опись мира `data/world-inventory.json`: паспорт (зерно, версия
  генератора, хеш каталога) и по строке на каждый из восьми дней — дата,
  число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит
  `test_inventory.py` — тем же способом, что свежесть описания выгрузки.
- Разовая служба `world-init` вышла из-под профиля и играет в топик восемь
  дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait`
  считает успешно отработавшую службу упавшей.
- `scripts/wait-for-world.sh` — вторая половина `make up`: приём
  асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения
  заливки. Ограниченный цикл опроса, не пауза наугад.
- Девятая проверка `make check-clickhouse`: подневный счёт событий против
  описи, рамка по датам стартового мира, счёт через `FINAL`. При
  расхождении называет, где искать, — в событиях или в браке.
- Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал
  1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с
  датой. Раздел 9 спеки закрыт: открытых вопросов не осталось.
- Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром
  (решение владельца). Оба заведены в словарь CONTEXT.md.

Проверка
`make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий.
`make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с),
`make test` — 407 тестов за 71 с, `make lint`, `make typecheck`,
`make config-test` зелёные.

Что проверка умеет краснеть, снято двумя поломками: снос партиции
2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье —
«сломан разбор». Строки опыта убраны, день переигран, счёт вернулся.
Тест свежести проверен молчаливой правкой цены в каталоге: покраснел.

Ссылка: #42
This commit is contained in:
2026-08-07 18:37:01 +03:00
parent 30a1e9e567
commit 7c9eeedc40
20 changed files with 625 additions and 132 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
Зовущих трое, и все трое видны в форме команд:
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
- заливка зернового мира (#42) — восемь дней подряд одним запуском: `--days`;
- заливка стартового мира — восемь дней подряд одним запуском: `--days`;
- проверки хранилища (#43) — ограниченная пачка в файл: `--limit` и `--file`.
**Режимы разведены командами, а не флагом**, потому что различаются не темпом
@@ -0,0 +1,99 @@
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти.
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
он сам, а опись: паспорт мира, число событий по дням и хеш байтов каждого дня.
Сам мир пересчитывается когда угодно, а опись отвечает на единственный вопрос —
**тот ли это мир, что был вчера**. Разошлись хеши — мир уехал, и дальше уже
неважно, чего от него ждали проверки.
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением
этой же описи, а не вторым файлом.
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
сдвинется, — версия останется прежней, а хеши покраснеют; наоборот не бывает.
Хеш каталога стоит здесь по третьему основанию — ни сторожить, ни описывать, а
**объяснять**. Правка цены в `data/catalog/products.csv` меняет мир так же
молча, как правка кода, и по одним хешам эти два случая неразличимы. С хешем
каталога различимы: разошлись хеши дней и каталога — правили CSV; разошлись
только дни — правили код.
Хеш дня — sha256 тех самых байтов, что уезжают в Kafka, с переводом строки
после каждого события. Это ровно то, что пишет файловый приёмник, поэтому
пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как
именно — в README репозитория).
Собирается опись из корня репозитория целью `make inventory`, а свежесть её
сторожит тест — как и у «описания выгрузки».
"""
import argparse
import hashlib
import json
from datetime import timedelta
from importlib.metadata import version
from pathlib import Path
from typing import Any
from clickstream_generator import day as day_module
from clickstream_generator import serialize, world
from clickstream_generator.catalog import CATALOG_PATH
from clickstream_generator.seeds import CANONICAL_SEED
# Сколько дней оси заливается в стенд при подъёме. То же число стоит у службы
# `world-init` в compose.yaml: YAML не читает Python, и одно из двух мест —
# лишнее по построению. Расхождение поймают счётчики make check-clickhouse.
STARTING_DAYS = 8
def build() -> dict[str, Any]:
"""Опись целиком: паспорт мира и по строке на каждый его день."""
return {
"seed": CANONICAL_SEED,
"generator_version": version("clickstream-generator"),
"catalog_sha256": _digest(CATALOG_PATH.read_bytes()),
"days": [_day(number) for number in range(STARTING_DAYS)],
}
def render() -> str:
"""Опись текстом файла: отступы в два пробела, кириллица как есть."""
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
def _day(number: int) -> dict[str, Any]:
"""Строка описи: номер дня, его дата, число событий и хеш байтов.
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
стенда обрамляют счёт в `ods.event`. Соври она — подневная сверка это и
покажет, каждый день сразу.
"""
payloads = serialize.events(day_module.stream(CANONICAL_SEED, number))
return {
"day": number,
"date": (world.ORIGIN + timedelta(days=number)).isoformat(),
"events": len(payloads),
"sha256": _digest(b"".join(payload + b"\n" for payload in payloads)),
}
def _digest(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def main() -> None:
parser = argparse.ArgumentParser(
description="Собирает опись мира: паспорт, счётчики и хеши дней."
)
parser.add_argument("output", type=Path, help="путь к файлу описи")
output = parser.parse_args().output
output.write_text(render(), encoding="utf-8")
print(f"Опись мира собрана: {output}")
if __name__ == "__main__":
main()
@@ -15,7 +15,7 @@
Тайминги печатаются раздельно — генерация и доставка, как требует спека
(раздел 5): это разные машины разной природы, и сложенные в одно число они
перестают что-либо говорить. Сериализация считается частью генерации: она
рождает те самые байты, которые сторожит манифест.
рождает те самые байты, которые сторожит опись.
"""
import logging
+2 -2
View File
@@ -25,8 +25,8 @@ from enum import IntEnum
import numpy as np
# Каноническое зерно эталонного мира — константа репозитория; манифест хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
# Каноническое зерно эталонного мира — константа репозитория; опись хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи.
CANONICAL_SEED = 20260601
+3 -3
View File
@@ -2,8 +2,8 @@
Модуль приглашение крутить: поменяйте число, пересоберите снимок и
посмотрите, что стало с данными. Правка любой константы здесь смена мира,
поэтому чек манифеста честно покраснеет: манифест сторожит только канонический
мир, свои миры менти собирает без его гарантий (спека генератора, раздел 9).
поэтому чек описи честно покраснеет: опись сторожит только канонический мир,
свои миры менти собирает без её гарантий (спека генератора, раздел 9).
Числа решены спекой и связаны между собой; связки сторожат тесты
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
@@ -26,7 +26,7 @@ COUNTER_TIMEZONE_MINUTES = 240
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
# зависит — иначе манифест перестал бы быть воспроизводимым.
# зависит — иначе опись перестала бы быть воспроизводимой.
ORIGIN = date(2026, 6, 1)
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит