- Зачем:
- вычитка спеки на приёмке нашла дыру: состав мира читался как
замкнутая труппа без новых посетителей — неправдоподобный магазин.
- Что:
- раздел 1: состав не замкнут — план задаёт календарь появления кук,
доля одноразовых высока; приток — часть плана, не мутация.
- раздел 6: «Faker» ослаблен до «посточные фейкеры (Faker, mimesis)»,
выбор библиотеки — этапу 2; там же оговорка про таблицы-литералы.
- раздел 9: к интерфейсу запуска добавлены вопросы «кто зовёт
генератор (даги world_init/next_day)» и «в каком контейнере живёт».
- CONTEXT.md: в «Состав мира» добавлен календарь появления.
- Проверка:
- вычитка; правки точечные, решения развилок не пересматриваются.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
30 KiB
Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
Статус: Proposed — ждёт приёмки владельцем (тикет #33). Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26. Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода (#29), производительность (#30); исследование скорости (#31). Источники: мастер-спека «Боевой реализм стенда (v2)» — разделы 1.4, 5, 9–11; заметка «Скорость батчевой генерации в Python».
Зачем
Мастер-спека решила, что генерирует стенд: широкое событие в 47 колонок, таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре развилки о том, как генератор устроен, она отложила: модель мира, границы детерминизма, источник истины схемы с разделением потока и пакета, числовые требования скорости. Карта #26 эти развилки прошла; спека собирает решения в одну картину. По ней этап 2 режется на тикеты (#34).
Здесь не переоткрывается решённое мастер-спекой: модель данных события, таксономия, анонимность, механика заказов (границы мандата #14). Генератор слепков заказов — этап 3: эта спека лишь не должна ему мешать.
Целевая картина одним взглядом
- Мир — функция, не состояние. Состав мира — чистая функция зерна; модельный день D — функция (зерно, D). Между прогонами живут только зерно и позиция на оси времени.
- Детерминизм до байта. Одно зерно — побайтово тот же снимок; сверка — хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
- Схема — контракт генератора. Python-модуль с чистыми данными; хранилище строится по рендеренной документации, границу сторожит contract-тест.
- Один сериализатор, глупые приёмники. День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- Числа. Средний день ~50 тыс. событий; эталонный снимок — 14 дней; в git — только манифест; автоматический порог один — день ≤ 30 с.
1. Модель мира
Резолюция развилки «Модель мира».
- Мир функциональный, ничего не мутирует. Постоянный состав мира — популяция посетителей, их привычки, календарь двухкуковых пар — чистая функция зерна, вычисляется при старте любого процесса. День D — функция (зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из плана состава, а не копятся в состоянии. Глобальные инварианты («каждый двухкуковый покупатель заказал с обеих кук») гарантируются планом — счётчики манифеста известны до генерации событий.
- Состав не замкнут: посетители появляются и уходят. План состава задаёт календарь появления — у каждого посетителя есть дата первого визита и профиль возвратов, заметная доля кук одноразовая, как в живом трафике. Новые посетители появляются на всём протяжении оси: uniq(ClientID) растёт с горизонтом, DAU и MAU не сходятся в одно число. Приток — часть плана, а не мутация: счётчики манифеста по-прежнему известны до генерации (уточнение по вычитке владельца, 2026-08-01).
- Своя ось модельного времени. Мир рождается в фиксированный день D0 (понедельник — см. раздел 5); реальный календарь в модели не участвует. Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
- Два режима движения по одной оси. Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»; включается по требованию, не постоянный фон.
- Граница суток — единственный структурный шов. Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день переигрывается (раздел 4).
- Поток и пакет совместимы по построению. День-функция выдаёт один упорядоченный поток событий; режимы отличаются только способом проигрывания — пачкой или с темпом.
Отклонено с доводами:
- Мутирующее состояние мира («мир стареет»): ломает параллельность по дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум; ни один урок стенда на старении не стоит.
- Чистая функция без слоя состава: глобальные инварианты пришлось бы выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
- Привязка модельного времени к реальному календарю (T-1 с догоном): конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты эталонного мира зависимыми от даты запуска, манифест теряет воспроизводимость.
2. Детерминизм от зерна
Резолюция развилки «Детерминизм от зерна».
- Обещание — содержимое до байта. Два прогона с одним зерном дают тот же
набор событий: те же
WatchID/VisitID, поля, метки модельного времени. Артефакт снимка при пересборке побайтово совпадает: канонический порядок ключей и строк, пустой diff означает «ничего не изменилось». Вне обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,_ingested_at, темп живого дня. - Условия обещания. Детерминизм держится при зафиксированном
uv.lockи внутри канонического контейнера (везде Linux; переменная — только архитектура CPU). Истина — CI на Linux; сходимость любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на любой платформе — баг генератора, а не допуск. - Раздача зерна — иерархией подпотоков. Корневое зерно → состав мира; (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, торговые события, расхождения, опоздания — в фиксированном порядке. По построению: параллельный прогон равен последовательному; продление истории днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть снимка — diff читаем.
- Механизм подпотоков —
numpy.random.SeedSequence. Сверено через Context7 по документации numpy (2026-08-01):spawn(n)порождает детей расширениемspawn_key, потомок полностью определяется парой (entropy, spawn_key) — позицией в дереве, а не порядком вычислений. Это ровно то свойство, на котором держатся три гарантии предыдущего пункта. Каждый подпоток кормитPCG64— генератор, рекомендованный numpy. - Дисциплина целочисленной случайности. Случайность тянется целыми
числами: диапазоны, выбор из таблиц. Плавающие распределения из системной
математики не используются — это снимает межархитектурные расхождения
amd64/arm64. Деньги считаются в целых копейках; Float64 — только
представление в клиентском
purchase(урок мастер-спеки о расхождениях). - Канонический seed и паспорт мира. Эталонный мир собирается одним каноническим зерном — константой репозитория; свои зёрна менти крутит без гарантий манифеста. Манифест хранит паспорт мира — зерно и версию генератора; чек-скрипты сверяют паспорт раньше счётчиков.
- Суточный профиль интенсивности задаёт день-функция. Форма — волны: ночной провал, обеденный и вечерний пики, различие будней и выходных; пики — до ~2× среднего. С детерминизмом профиль совместим: это часть функции дня, а не внешний шум.
Отклонено с доводами:
- Воспроизводимы только состав мира и счётчики: ломает доигрывание дня
через дедуп (другие
WatchID— дубли вместо склейки) и воспроизводимую отладку. - События те же, байты не обещаем: экономия копеечная, а честный diff снимка и тесты «хеш совпал» теряются.
- Общий RNG-поток на все дни: порядок исполнения менял бы результат — «параллельно равно последовательно» недостижимо.
- Обещание детерминизма поверх обновления зависимостей: numpy сознательно
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
словари. Фиксация —
uv.lock; обновление зависимостей — осознанная пересборка манифеста одним PR.
3. Контракт схемы
Резолюция развилки «Архитектура», часть первая.
- Граница вывода — по шву «трекер | хранилище», как data contract. Контракт схемы — собственность генератора, как формат выгрузки — собственность Метрики. Из контракта выводятся: сам генератор, его валидация и публичное «описание выгрузки» в доках — рендеренная таблица колонок, аналог документации Метрики.
- Форма контракта — импортируемый python-модуль с чистыми данными: описатели колонок (имя Метрики, тип ClickHouse, тип numpy, snake_case-имя для DDS, группа полей, порядок), никакой логики. Читаемость для менти несёт рендеренная таблица в доках, не модуль.
- Сторона хранилища пишется по документации, не генерируется. DDL
ods.event, SELECT матвью,dds.v_event, трансформации — работа следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется к источнику. Границу сторожат два боевых механизма: строгий приём (input_format_skip_unknown_fields = 0, таблицы*_errors— раздел 6 мастер-спеки) и contract-тест в smoke — сравнениеsystem.columnsподнятого стенда со схемой генератора.
Отклонено с доводами:
- Автогенерация DDL хранилища из контракта (буква раздела 1.4 мастер-спеки до правки): пересекает границу ответственности компонент — в бою хранилище адаптируется к источнику руками, менти пришлось бы объяснять приём, которого в жизни нет. Data contract даёт тот же щит от дрейфа без этой условности.
- YAML как форма контракта: красота ценой загрузчика и «схемы для схемы»; потребителя вне Python нет — хранилище читает рендеренную документацию, не машинный файл.
4. Поток и пакет: сериализатор, проигрыватель, приёмники
Резолюция развилки «Архитектура», часть вторая.
- Один канонический сериализатор, глупые приёмники. День-функция выдаёт упорядоченный поток канонических байтов — единственное место, где событие превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для пересборки и проверок манифеста), Kafka пачкой — пакетный режим, Kafka с темпом ×60 — живой день. Новых топиков нет.
- Рабочий выбор сериализатора — orjson: быстрее stdlib json в 5–14 раз, numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена библиотеки меняет канонические байты, поэтому проходит как обновление зависимости: осознанная пересборка манифеста одним PR.
- Промежуточные файлы не хранятся. Файл дня — кэш чистой функции: потерял — пересчитал. В git снимок не попадает (раздел 5).
- Обрыв любого режима — переигровка дня целиком; дедуп склеивает
повторы:
WatchIDдетерминированы, повтор — та же строка для ReplacingMergeTree. - Эталонный снимок при старте стенда — через Kafka, пакетным режимом
проигрывателя. Отдельный механизм заливки не строится: каждый
make upбесплатно прогоняет весь конвейер и contract-тест на настоящих данных. Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий — расчётно минута-две, запас есть.
Отклонено с доводами:
- Отдельный топик / Kafka как хранилище дней: офсеты и партиции вне обещания детерминизма, retention конечен, в git топик не положишь, хеш с манифестом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7 мастер-спеки).
- Файл как обязательная станция доставки: доигрывание обрыва уже решено через дедуп, канон держит единственный сериализатор, а не диск; файл остаётся только там, где нужен кэш.
- Прямая загрузка снимка в ClickHouse (и гибрид с ручным заполнением
сырого слоя): второй путь приёма, пустой либо поддельный
stg.hits_raw— теряются переобработка дня X поevent_dateи урок виртуальных колонок «какая нода читала топик».
5. Числа: объёмы, режимы, бюджеты
Резолюция развилки «Производительность»; порядки величин — исследование #31.
- Средний модельный день — ~50 тыс. событий; суточные волны с пиками до ~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей — правдоподобный средний магазин.
- Эталонный снимок — 14 дней: две полные календарные недели, D0 — понедельник. Самая короткая длина, при которой есть замороженная зона за окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до месяца — дешёвый ход (пересборка манифеста), если понадобится.
- В git — только манифест, снимок не хранится. Снимок генерируется при
make upи при проверках: артефакт — кэш чистой функции, кэш в git не хранят. Манифест несёт паспорт мира, счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Каждыйmake up— живая демонстрация детерминизма. Честная потеря — страховка от платформенного бага; смягчение — CI гоняет генерацию на amd64 и arm64. - Живой день — ×60 по умолчанию: модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах, темп ~35–100 событий/с. Число — значение по умолчанию, переопределяется флагом проигрывателя: ускорение — свойство транспорта, вне обещания воспроизводимости, константой мира не делается.
Бюджеты и способ замера
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном железе кончаются ритуальным удалением проверки.
Ориентиры на референсной машине — в спеке, без автоматики:
| Операция | Ориентир |
|---|---|
| Генерация одного дня | секунды |
| Пересборка эталонного мира (14 дней + манифест, без транспорта) | до минуты |
Заливка снимка при make up (Kafka → матвью → ODS) |
минуты |
| Лаг живого дня | секунды |
Автоматический порог один: полный день (50 тыс. событий) генерируется
≤ 30 с. Расчёт по планке исследования (~2–5×10⁵ событий/с на ядро) — около
секунды; порог держит машинный разброс ×2–5 и ловит деградацию на 1–2
порядка: Faker в горячем цикле, случайная квадратичность. Реализация —
pytest-тест с маркером perf и таймаутом-обрубанием: обязателен в CI,
исключён из быстрой локальной петли, зовётся отдельной целью при правках
горячего цикла.
Остальное — наблюдаемость без порогов: make up и smoke печатают тайминги
(генерация и доставка отдельно), проигрыватель логирует лаг. Прототип-замер
до этапа 2 не нужен: числа назначены с запасом порядок и больше от планки
исследования, планка подтверждена локальной проверкой на машине стенда;
первый замер настоящего кода — порог этапа 2.
Отклонено с доводами:
- Четыре жёстких CI-ворот на все бюджеты: машинный разброс против порогов впритык — повторение истории с памятью (ADR 0004); порог оставлен один, грубый, между «×5 шума» и «×100 беды».
- Снимок в git (статус-кво раздела 8 мастер-спеки): основание из v1 — медленный генератор — съедено детерминизмом и скоростью; остаётся только раздутый репозиторий. Снимок вложением релиза Gitea: страховка без раздутия, но лишняя машинерия и вторая правда.
- Снимок 7 дней: ни одного замороженного дня, сезонность без сравнения. 30 дней: нового урока не даёт — отложено как дешёвое удлинение.
- ×120 / ×1440: волна смазывается в перемотку либо превращается в пакет с анимацией — живой режим теряет смысл.
6. Правила кода этапа 2
Хвосты резолюций, обязательные для реализации:
- Целочисленная случайность — правило кода, а не пожелание: диапазоны и выбор из таблиц целыми, плавающие распределения системной математики не звать; деньги — в целых копейках.
- Случайные значения — векторно из numpy (PCG64); посточный цикл — лишь там, где логика действительно посточная (цепочки сессий).
- Посточные фейкеры (Faker, mimesis) — только для справочников (каталог и прочие справочные строки), не в горячем цикле: они медленнее numpy на 2–3 порядка. Выбор библиотеки — этапу 2: справочники генерируются один раз, скорость безразлична; возможно, хватит таблиц-литералов в коде и фейкер не понадобится вовсе.
- Сериализация — через единственный канонический сериализатор (orjson);
прямых
json.dumpsпо коду нет. - Распараллеливание — multiprocessing по модельным дням; внутри дня — однопоточно, единица работы и так крупная.
7. Расхождения с мастер-спекой
Внесены в мастер-спеку тем же коммитом, что и эта спека:
- Раздел 1.4: «из контракта выводятся DDL и валидация» заменено на data contract — хранилище пишется по документации, границу сторожит contract-тест (раздел 3 здесь).
- Раздел 8: артефакт
data/startup_history/в git заменён манифестом; снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования артефакта» закрыт этим же ходом: версионируется манифест. - Раздел 11: пункт «до этапа 3 зафиксировать требования производительности» закрыт числами раздела 5.
- Мелкие согласования там, где текст опирался на артефакт в git: источник переобработки при исчерпании retention Kafka (раздел 7), формулировка этапа 7 (раздел 9).
8. Хвосты следующим этапам
- Этап 5 (Airflow): живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- Этап 7 (эталонный мир): пересборка — это манифест, не артефакт; CI-генерация на amd64 и arm64.
- Будущие лабы: перезаливка дня X пакетным режимом проигрывателя — готовая демонстрация идемпотентности конвейера.
9. Решается при нарезке этапа 2 (#34)
Осталось из тумана карты — вопросы уровня тикетов, не развилок:
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
проигрывателя; кто его зовёт в стенде — даги
world_init/next_dayиз оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт; - формат описания мира и конфигурации (что константа кода, что параметр);
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки): с манифестным решением напрашивается мини-манифест зернового мира — форму выбрать при нарезке.