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:
ddadmin committed 2026-08-07 18:37:01 +03:00
1 parent 30a1e9e567
commit 7c9eeedc40
20 files changed
+625 -132

No files matched your search

+80 -58
View File
@@ -31,14 +31,14 @@
модельный день D — функция (зерно, D). Между прогонами живут только зерно
и позиция на оси времени.
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
хранилище строится по рендеренной документации, границу сторожит строгий
приём на стороне хранилища.
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
в git — только манифест; автоматический порог один — день ≤ 30 с.
в git — только опись мира; автоматических порогов по времени нет.
## 1. Модель мира
@@ -93,7 +93,7 @@
человеку предыстории, чьё окно активности таких дней не оставляет,
пара не назначается. День-функция обязана назначенные заказы
реализовать; остальные покупки — вольные, их решает генератор торговых
событий (#40). Манифест считает пары, реализованные в горизонте
событий (#40). Опись считает пары, реализованные в горизонте
снимка. Условность в данных не видна: дни назначены той же
случайностью, просто брошенной планом один раз.
- **Своя ось модельного времени.** Ось событий начинается в
@@ -117,19 +117,19 @@
Отклонено с доводами:
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум;
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум;
ни один урок стенда на старении не стоит.
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
даты эталонного мира зависимыми от даты запуска, манифест теряет
даты эталонного мира зависимыми от даты запуска, опись теряет
воспроизводимость.
Отклонено при исполнении #38 (2026-08-02):
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
зерновой мир и половина снимка оказались бы на разгоне, недельная лаба
стартовый мир и половина снимка оказались бы на разгоне, недельная лаба
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
быть средним — пришлось бы двигать принятые числа раздела 5.
- *Материализованный план на горизонт*: горизонт становится обязательным
@@ -139,7 +139,7 @@
константы — потолка одновременно живущих кук, которого в жизни нет и
который менти нечем объяснить.
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
гарантия — однажды манифест покраснеет, а чинить нечем, кроме смены
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
так что реализм этой лотереи невидим.
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
@@ -154,14 +154,14 @@
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
строк; сверка — по хешам манифеста, а два локально пересобранных снимка
строк; сверка — по хешам описи, а два локально пересобранных снимка
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
`_load_ts`, темп живого дня.
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на
любой платформе — баг генератора, а не допуск.
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
состава мира, ветвящийся по номеру дня на когорты плана (состав
@@ -173,7 +173,7 @@
торговые события, расхождения, опоздания — в фиксированном порядке.
По построению: параллельный прогон равен последовательному; продление
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
только его часть снимка — в манифесте меняются хеши только затронутых
только его часть снимка — в описи меняются хеши только затронутых
дней, дифф двух локальных пересборок читаем.
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
@@ -188,7 +188,7 @@
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
каноническим зерном — константой репозитория; свои зёрна менти крутит без
гарантий манифеста. Манифест хранит паспорт мира — зерно и версию
гарантий описи. Опись хранит паспорт мира — зерно и версию
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
ночной провал, обеденный и вечерний пики, различие будней и выходных;
@@ -207,7 +207,7 @@
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
пересборка манифеста одним PR.
пересборка описи одним PR.
## 3. Контракт схемы
@@ -255,7 +255,7 @@
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
упорядоченный поток канонических байтов — единственное место, где событие
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
для пересборки и проверок манифеста), Kafka пачкой — пакетный режим,
для пересборки и проверок описи), Kafka пачкой — пакетный режим,
Kafka с темпом ×60 — живой день. Новых топиков нет.
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
@@ -288,7 +288,7 @@
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
Смена библиотеки меняет канонические байты, поэтому проходит как
обновление зависимости: осознанная пересборка манифеста одним PR.
обновление зависимости: осознанная пересборка описи одним PR.
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
потерял — пересчитал. В git снимок не попадает (раздел 5).
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
@@ -306,7 +306,7 @@
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
манифестом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
мастер-спеки).
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
через дедуп, канон держит единственный сериализатор, а не диск; файл
@@ -327,15 +327,15 @@
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
понедельник. Самая короткая длина, при которой есть замороженная зона за
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
месяца — дешёвый ход (пересборка манифеста), если понадобится.
- **В git — только манифест, снимок не хранится.** Снимок генерируется при
месяца — дешёвый ход (пересборка описи), если понадобится.
- **В git — только опись, снимок не хранится.** Снимок генерируется при
`make up` и при проверках: артефакт — кэш чистой функции, кэш в git не
хранят. Манифест несёт паспорт мира, счётчики и хеши по дням; проверки
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём.
хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней.
Каждый `make up` — живая демонстрация детерминизма. Честная потеря —
страховка на случай платформенного бага: раньше менти с расходящимися
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
манифеста; смягчение — CI гоняет генерацию на amd64 и arm64.
описи; смягчение — CI гоняет генерацию на amd64 и arm64.
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
в пиковые часы сильных дней — до ~100. Число —
@@ -348,34 +348,37 @@
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
железе кончаются ритуальным удалением проверки.
Ориентиры на референсной машине — в спеке, без автоматики:
Ориентиры на референсной машине — в спеке, без автоматики. Где стоит замер,
там он с датой; остальное — порядок величины, пока не мерили:
| Операция | Ориентир |
|---|---|
| Генерация одного дня | секунды |
| Пересборка эталонного мира (14 дней + манифест, без транспорта) | до минуты |
| Заливка снимка при `make up` (Kafka → матвью → ODS) | минуты |
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
| Пересборка эталонного мира (14 дней + опись, без транспорта) | до минуты |
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
| Лаг живого дня | секунды |
**Автоматический порог один: полный день (50 тыс. событий) генерируется
≤ 30 с.** Расчёт по планке исследования (~2–5×10⁵ событий/с на ядро) — доли
секунды; порог держит машинный разброс ×2–5 и ловит деградацию на 1–2
порядка: Faker в горячем цикле, случайная квадратичность. Реализация —
pytest-тест с маркером `perf` и таймаутом-обрубанием: обязателен в CI,
исключён из быстрой локальной петли, зовётся отдельной целью при правках
горячего цикла.
**Автоматических порогов нет ни одного** — решение владельца 7 августа
2026 года при исполнении #42. Здесь стоял единственный: «полный день
генерируется ≤ 30 с». Назначен он был до того, как генератор написали, а
первый замер настоящего кода дал **1,7 секунды на день в 50 626 событий**
(машина стенда, 7 августа 2026 года). Порог оказался в восемнадцать раз выше
факта: зелен при любой правдоподобной регрессии, то есть не сторож, а
украшение. Как здесь говорят о времени, к тому дню уже решила [карта
целей](../architecture/testing.md): цена — замеренное число с датой, а не
назначенный предел.
Остальное — наблюдаемость без порогов: `make up` и smoke печатают тайминги
(генерация и доставка отдельно), проигрыватель логирует лаг. Прототип-замер
до этапа 2 не нужен: числа назначены с запасом порядок и больше от планки
исследования, планка подтверждена локальной проверкой на машине стенда;
первый замер настоящего кода — порог этапа 2.
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
логе, а `make up` после #42 гоняет генератор по восемь дней при каждом
подъёме стенда: замедлись он на порядок — подъём стенда встанет колом, и
заметит это первый же человек, который его поднял.
Отклонено с доводами:
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
впритык — повторение истории с памятью (ADR 0004); порог оставлен один,
грубый, между «×5 шума» и «×100 беды».
впритык — повторение истории с памятью (ADR 0004). Оставленный было один
грубый порог снят при исполнении #42 (выше).
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
медленный генератор — съедено детерминизмом и скоростью; остаётся только
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
@@ -411,9 +414,9 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
contract — хранилище пишется по документации, границу сторожит строгий
приём (раздел 3 здесь).
- **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом;
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью;
снимок генерируется на месте (раздел 5 здесь). Туман «политика
версионирования артефакта» закрыт этим же ходом: версионируется манифест.
версионирования артефакта» закрыт этим же ходом: версионируется опись.
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
производительности» закрыт числами раздела 5.
- Мелкие согласования там, где текст опирался на артефакт в git: источник
@@ -444,7 +447,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит
(раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она
переменной Airflow: отсутствие переменной означает мир в зерновом
переменной Airflow: отсутствие переменной означает мир в стартовом
состоянии, заводит и двигает её только даг `next_day` и только по успеху.
Довод — генератор отдельная и заменяемая сущность, привязывать его к
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
@@ -452,7 +455,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
Расхождение переменной с данными (менти почистил партицию руками) лечится
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт;
CI-генерация на amd64 и arm64.
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
готовая демонстрация идемпотентности конвейера.
@@ -503,11 +506,11 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
возвратами предыстории), 170 пар.
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
все числа мира в одном месте, написанном как приглашение любопытному
менти крутить. Правка модуля — смена мира: чек манифеста честно
краснеет, манифест сторожит только канон. Вне модуля — лишь то, что
менти крутить. Правка модуля — смена мира: чек описи честно
краснеет, опись сторожит только канон. Вне модуля — лишь то, что
мира не меняет: своё зерно и транспортные флаги проигрывателя.
Отклонено: внешний конфиг и env-переопределения — переменная мира,
которую паспорт манифеста не видит; файл-конфиг в репозитории — по
которую паспорт описи не видит; файл-конфиг в репозитории — по
смыслу равен модулю, но платит загрузчиком и валидацией (довод
раздела 3 против YAML).
@@ -590,7 +593,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
оттуда же они лягут в манифест.
оттуда же они лягут в опись.
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
@@ -611,8 +614,8 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации.
Три решения выходят за границы тикета: правятся план состава (#38), воронка
(#39) и каталог товаров (#39). Сейчас это дёшево — манифест ещё не собран
(#42); позже обошлось бы его пересборкой. Часть решений принята после
(#39) и каталог товаров (#39). Сейчас это дёшево — опись ещё не собрана
(#42); позже обошлось бы её пересборкой. Часть решений принята после
холодного ревью первой редакции блока.
- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек,
@@ -872,7 +875,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
цены и по медиане, потому что одни только края списка обманываются
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом
ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не
догонять правкой; манифесту нужен хеш каталога (хвост #42) тем более,
догонять правкой; описи нужен хеш каталога (хвост #42) тем более,
потому что теперь в файле живёт ещё и поведение.
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%
и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне
@@ -885,7 +888,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
пока число правдоподобно, само по себе оно ничего не сторожит, и
подгонять поведение под его край не надо. Настоящий предел здесь другой и
считается в другой валюте — дневной бюджет событий: события корзины
входят в те самые «около 50 тыс.», на которых стоят манифест (#42) и
входят в те самые «около 50 тыс.», на которых стоят опись (#42) и
порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его
причины, а не долю.
@@ -926,7 +929,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
итог виден кодом возврата. Общее у режимов: `--seed`, `--day`, `--days` и
приёмник — `--file` либо `--brokers` с `--topic`. Состояния нет: позицию на
оси ведёт зовущий (хвост этапу 5 — раздел 8). `--days` играет несколько
дней подряд одним запуском — этим зальётся зерновой мир (#42), восемь
дней подряд одним запуском — этим зальётся стартовый мир (#42), восемь
запусков службы внутри `make up` были бы плохим ответом. Отклонено:
*режимы флагом `--speed 0`* — различие тогда прячется за числом, тогда как
у режимов разное устройство: у пакетного есть пачка и нет ожидания, у
@@ -986,11 +989,30 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
модуль его не находит. Проверено запуском в контейнере; заодно день,
сыгранный в образе, совпал побайтово с днём, сыгранным на машине.
Остаётся открытым, за тикетами:
Решено при исполнении #42 (2026-08-07):
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
с манифестным решением напрашивается мини-манифест зернового мира —
форма за #42;
- паспорт мира в манифесте (зерно и версия генератора) файл каталога не
накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш
каталога — хвост для #42.
- **Опись мира одна, и она же растёт до эталонной.** Стартовый мир конца
этапа 2 — первые восемь дней оси, понедельник по понедельник (решение
владельца при нарезке, 2026-08-01): полная неделя с выходными и первый
замкнутый цикл окна K = 7. Отдельного «младшего» файла для них не
заводится: `data/world-inventory.json` — та самая опись, которую раздел 5
обещает эталонному миру, пока короткая. Этап 7 продлит её до четырнадцати
дней, а не заведёт вторую. Форма — JSON: паспорт мира (зерно, версия
генератора, хеш каталога) и по строке на день с датой, числом событий и
хешем. Собирается целью `make inventory`, свежесть сторожит тест
`test_inventory.py` — тем же способом, что свежесть «описания выгрузки».
Отклонено: *два файла, «мини» и полный* — две правды об одном мире и
лишнее слово в словаре.
- **Хеш каталога — в паспорте, и работа у него объяснительная.** Правка
цены в `data/catalog/products.csv` меняет мир так же молча, как правка
кода, но поймают её и без хеша: хеши дней разойдутся. Хеш каталога
отвечает на следующий вопрос — **что** правили: разошлись дни и каталог —
CSV; разошлись только дни — код.
- **Слова.** «Манифест» по всей спеке переименован в **опись мира**, а
«зерновой мир» — в **стартовый мир** (решение владельца 2026-08-07).
Довод — правило языка репозитория: есть обычное русское слово — берётся
оно. Заодно исчезла пара «манифест / мини-манифест», в которой читателю
пришлось бы различать две сущности там, где вещь одна.
Открытых вопросов за разделом не осталось: интерфейс запуска закрыт #41,
форма описи и хеш каталога — здесь.