- Зачем:
- холодное ревью отметило, что это решение, а не калибровка: от выбора
шага зависит, как «покупатель» читается в данных, и оставлять выбор
реализации нельзя.
- Что:
- в блоке решений #40 записано, что помеченный отличается на обоих шагах
воронки — и до корзины доходит чаще, и бросает её реже; числа под
каждый шаг остаются калибровкой.
- Проверка:
- чтением: docs/specs/2026-08-01-generator.md, раздел 9, блок #40.
68 KiB
Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
Статус: Accepted — принята владельцем 2026-08-01 (PR #35). Дата: 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: эта спека лишь не должна ему мешать. Один осознанный выход за границы: решение о хранении снимка (раздел 5) формально касается этапа 7 — расширение подтверждено владельцем в резолюции «Производительности».
Целевая картина одним взглядом
- Мир — функция, не состояние. Состав мира — чистая функция зерна; модельный день D — функция (зерно, D). Между прогонами живут только зерно и позиция на оси времени.
- Детерминизм до байта. Одно зерно — побайтово тот же снимок; сверка — хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
- Схема — контракт генератора. Python-модуль с чистыми данными; хранилище строится по рендеренной документации, границу сторожит contract-тест.
- Один сериализатор, глупые приёмники. День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- Числа. Средний день ~50 тыс. событий; эталонный снимок — 14 дней; в git — только манифест; автоматический порог один — день ≤ 30 с.
1. Модель мира
Резолюция развилки «Модель мира».
- Мир функциональный, ничего не мутирует. Постоянный состав мира — популяция посетителей, их привычки, календарь двухкуковых пар — чистая функция зерна; целиком не вычисляется и не хранится, спрашивается по дням (форма плана — ниже). День D — функция (зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из плана состава, а не копятся в состоянии. Глобальные инварианты («каждый двухкуковый покупатель заказал с обеих кук») гарантируются планом; счётчики состава — посетители, пары, приток — известны до генерации событий, торговые счётчики сложатся, когда торговое поведение определит #40.
- Состав не замкнут: посетители появляются и затухают. План состава задаёт календарь появления — у каждого посетителя есть дата первого дня и профиль возвратов, включая затухание: заметная доля кук одноразовая, как в живом трафике. Новые посетители появляются на всём протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная аудитории не сходятся в одно число. Приток — часть плана, а не мутация: счётчики состава по-прежнему известны до генерации (уточнение по вычитке владельца, 2026-08-01); его числа — раздел 9.
- Форма плана — ленивая, по когортам дня (уточнение при исполнении #38, 2026-08-02). Глобальный список посетителей не строится: когорта дня D — функция (зерно, D), её случайность — подпоток состава мира, ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт последних дней: окно активности — хвост возвратов (константа мира — раздел 9), отсчитанный от первого дня человека и общий на обе его куки (уточнение при исполнении #38, 2026-08-02: окно от рождения каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад, которым ленивая форма и держится). За краем окна кука не возвращается, а профиль возвратов затухает к краю, поэтому обрыв в данных не виден. Стоимость дня не зависит от прожитого — день 500 стоит как день 5, горизонт не является входом. Счётчики состава считаются прогоном плана по дням, без генерации событий.
- Предыстория: полка с первого дня. Когорты существуют и до D0 — на глубину хвоста возвратов. Событий они не порождают (ось событий начинается в D0) — только дают, кому возвращаться в первые дни: дневная аудитория на полке с самого D0, разгона «пустого магазина» нет.
- Гарантия двухкуковых пар — назначенные заказы в плане. Единица здесь — человек, не кука: план помечает часть людей покупателями (доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку. Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в пределах его окна активности, без фиксированного зазора — по тому же затухающему профилю, что и возвраты (уточнение при исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар, реализованных внутри снимка. Каждой паре план назначает дни гарантированных заказов: по одному на куку, из дней активности этой куки, на оси от D0 и позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара не назначается. День-функция обязана назначенные заказы реализовать; остальные покупки — вольные, их решает генератор торговых событий (#40). Манифест считает пары, реализованные в горизонте снимка. Условность в данных не видна: дни назначены той же случайностью, просто брошенной планом один раз.
- Своя ось модельного времени. Ось событий начинается в
фиксированный день D0 (понедельник — см. раздел 5); реальный
календарь в модели не участвует. В
EventDate/UTCEventTimeдни оси ложатся конкретными датами, но это константа мира, от даты запуска не зависящая (значение — раздел 9). Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — мир замер, потом продолжил. - Два режима движения по одной оси. Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»; включается по требованию, не постоянный фон.
- Граница суток — единственный структурный шов. Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день переигрывается (раздел 4).
- Поток и пакет совместимы по построению. День-функция выдаёт один упорядоченный поток событий; режимы отличаются только способом проигрывания — пачкой или с темпом.
Отклонено с доводами:
- Мутирующее состояние мира («мир стареет»): ломает параллельность по дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум; ни один урок стенда на старении не стоит.
- Чистая функция без слоя состава: глобальные инварианты пришлось бы выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
- Привязка модельного времени к реальному календарю (T-1 с догоном): конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты эталонного мира зависимыми от даты запуска, манифест теряет воспроизводимость.
Отклонено при исполнении #38 (2026-08-02):
- Разгон вместо предыстории («первые дни малы — магазин запустился»): зерновой мир и половина снимка оказались бы на разгоне, недельная лаба сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы быть средним — пришлось бы двигать принятые числа раздела 5.
- Материализованный план на горизонт: горизонт становится обязательным входом каждого запуска, стоимость старта растёт с прожитым; префиксную устойчивость даёт и ленивая форма — даром, через подпотоки по номеру дня.
- Замкнутый пул кук со сменой поколений: постоянная память ценой фальшивой константы — потолка одновременно живущих кук, которого в жизни нет и который менти нечем объяснить.
- Вероятностная гарантия пар («почти наверняка купит с обеих кук»): не гарантия — однажды манифест покраснеет, а чинить нечем, кроме смены зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм этой лотереи невидим.
- Покупка пары в первый визит куки: гарантия железная и дешёвая, но узор «все пары покупают в первый день» виден в данных ровно там, куда лаба склейки смотрит пристальнее всего. Условность допустима, пока она не видна в данных.
2. Детерминизм от зерна
Резолюция развилки «Детерминизм от зерна».
- Обещание — содержимое до байта. Два прогона с одним зерном дают тот же
набор событий: те же
WatchID/VisitID, поля, метки модельного времени. Снимок при пересборке побайтово совпадает: канонический порядок ключей и строк; сверка — по хешам манифеста, а два локально пересобранных снимка сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,_ingested_at, темп живого дня. - Условия обещания. Детерминизм держится при зафиксированном
uv.lockи внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на любой платформе — баг генератора, а не допуск. - Раздача зерна — иерархией подпотоков. Корневое зерно → подпоток состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается по дням — раздел 1; уточнение при исполнении #38). Дни предыстории отрицательны, а позиция в дереве — неотрицательное целое, поэтому у предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении #38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, торговые события, расхождения, опоздания — в фиксированном порядке. По построению: параллельный прогон равен последовательному; продление истории днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть снимка — в манифесте меняются хеши только затронутых дней, дифф двух локальных пересборок читаем.
- Механизм подпотоков —
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-имя, группа полей, порядок), никакой логики. Читаемость для
менти несёт рендеренная таблица в доках, не модуль. Уточнение при
исполнении (#36): нормализованное имя — имя источника, приведённое к
нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою
модель и называет атрибуты по ней;
dds.v_eventэти имена берёт (раздел 7 мастер-спеки), но контракт их не диктует и тестами не сторожит. - Сторона хранилища пишется по документации, не генерируется. 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— живая демонстрация детерминизма. Честная потеря — страховка на случай платформенного бага: раньше менти с расходящимися байтами мог взять готовый снимок из git, теперь он упрётся в красный чек манифеста; смягчение — 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).
Внесены при исполнении #40 (2026-08-02):
- Раздел 1: «в параллельных массивах одной длины» уточнено — длина общая
внутри группы, а
purchase*иproduct*между собой разной длины. - Разделы 4 и 7: механика класса
amount_deltaназвана честнее. ОкругленияFloat64сами по себе расхождения не дают: деньги считаются целыми копейками, и дробное число в событии одно. Класс создаёт генератор намеренно — так мастер-спека и допускала вторым вариантом.
8. Хвосты следующим этапам
- Этап 3 (заказы бэкенда): заказу, у которого в клиентском событии стоит промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе стороны берут один и тот же (раздел 9).
- Этап 4 (сверка): копейки в ценах каталога дают разрыв внутри события —
productPriceокруглён форматом,purchaseRevenueточна. В сверку он сам не попадает: клиент шлёт точную сумму, и она сходится сitems_totalбэкенда. Классamount_deltaэтап 4 всё равно создаёт намеренно — мастер-спека (раздел 4) это и допускает; материал для разговора про Float64 теперь есть. - Этап 5 (Airflow): живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- Этап 7 (эталонный мир): пересборка — это манифест, не артефакт; CI-генерация на amd64 и arm64.
- Будущие лабы: перезаливка дня X пакетным режимом проигрывателя — готовая демонстрация идемпотентности конвейера.
9. Вопросы нарезки этапа 2 (#34): решения и остатки
Туман карты разложен нарезкой по тикетам; решения фиксируются здесь по мере исполнения.
Решено при исполнении #38 (2026-08-02):
- Числа притока и состава. Приток — ~3 800 новых кук в средний день,
модулируется недельным профилем мира; трафик наследует эту волну через
дневную аудиторию, а не отдельным умножением (уточнение при исполнении
#39 — ниже). Доля одноразовых кук — 75%;
возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все
в первые 7–10 дней, тонкий хвост поздних возвратов и повторных
покупок — до края окна (цикл повторной покупки магазина — месяцы).
Хвост возвратов — окно активности человека от его первого дня,
общее на обе его куки, — и глубина предыстории: 90 дней (решение
владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы
старых посетителей продолжаются весь прогон; плата — чуть меньше пар,
полностью реализованных внутри 14-дневного снимка). Покупатели
считаются людьми, не куками; доля покупателей — 5% людей когорты, а
людей в когорте почти столько же, сколько кук: вторые куки пар
добавляют меньше процента. Доля — число плана, а не торгового тикета: без него не
отобрать двухкуковые пары; #40 наследует его, не переоткрывая.
Сходимость с разделом 5: средняя кука активна ≈1,9 дня → дневная
аудитория ≈7 100 — середина вилки 6–8 тыс.; уникумов за 14 дней
снимка — под 70 тыс.: новые куки плюс возвраты предыстории (точные
числа — в измерении ниже; не путать с ~50 тыс. событий одного дня) —
рост
uniq(ClientID)с горизонтом виден сразу; новых покупателей ~190 в день → конверсия ~2% на сессию. - D0 = 2026-06-01, понедельник — решение владельца при нарезке (2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели свежими; привязки к реальному календарю у констант мира всё равно нет.
- Измерено на собранном плане (#38, канонический мир, 14 дней): приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 — обе величины в вилках раздела 5. Накопленная аудитория за снимок — 68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории (при нарезке возвраты предыстории оценили вдвое скромнее — отсюда ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных заказа попали внутрь 14 дней, — 170; остальные пары горизонт переживают, их вторая кука приходит позже. Цифры пересчитываются прогоном плана, событий для них не нужно.
- Конфигурация мира — модуль чистых данных рядом с контрактом схемы: все числа мира в одном месте, написанном как приглашение любопытному менти крутить. Правка модуля — смена мира: чек манифеста честно краснеет, манифест сторожит только канон. Вне модуля — лишь то, что мира не меняет: своё зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и env-переопределения — переменная мира, которую паспорт манифеста не видит; файл-конфиг в репозитории — по смыслу равен модулю, но платит загрузчиком и валидацией (довод раздела 3 против YAML).
Решено при исполнении #39 (2026-08-02) — решения владельца до реализации:
- Каталог товаров заводится здесь, а не в #40. У карточки товара есть
Title— имя товара, а имена живут в каталоге: карточка без каталога невозможна, и без карточек #39 сделал бы примерно половину трафика. Файлdata/catalog/products.csv(sku,name,category,brand,price) — общий у генератора и словаря ClickHouse; #40 получает готовый. Решена форма, а не длина: артикул — четыре буквы категории и четыре цифры, цена — целые копейки, категорий шесть. Строки дописываются механически, поэтому ни код, ни тесты их не считают, а товар для карточки выбирается равномерно внутри категории. - Ассортимент — непродовольственная розница (товары для дома, текстиль, посуда, мелкая бытовая техника, детское, одежда и обувь), ~150–200 sku. Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит. Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38.
- География — один регион присутствия: Поволжье с центром в Самаре, города своего региона и тонкий хвост остальной страны. «Топ городов России» дал бы магазину с одним складом карту, которой у него не бывает.
- Модельные сутки считаются в часовом поясе счётчика (UTC+4), как в
выгрузке Метрики:
EventDate— дата в поясе счётчика,UTCEventTime— абсолютная метка. Шов суток приходится на местную полночь и не режет утренние сессии, а регион мира выбирается свободно. Суточная волна задана в местном времени посетителя: гостю из другого пояса профиль поворачивается на разницу, и пик слегка размазывается — как в жизни. Следствие для соседних этапов:toDate(UTCEventTime)≠EventDate; оно записано в описание выгрузки, иначе сторона хранилища выведет дату сама и разойдётся на несколько часов данных. - Паспорт куки — постоянная часть мира, а не поведение дня. Браузер, ОС,
устройство, экран и город у куки одни во всех её днях: кука — это браузер
на устройстве. Держит их план состава (
CohortиDayAudienceприрастают двумя массивами), разворачивает в поля события день-функция. Выводить паспорт арифметикой изClientIDдешевле, но про двухкуковые пары знает только план, а два города у одного человека — ложь в данных; пара получает один город и разные устройства («телефон и ноутбук», мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому измеренные числа канонического мира не сдвинулись: прогон счётчиков до и после дал те же 3 828 / 6 235–7 124 / 68 119 / 170. - Справочники — таблицы-литералы рядом с конфигурацией мира; Faker и mimesis не подключаются. Нужны не случайные строки, а связки (город → id региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их фейкер не даёт, таблицу пришлось бы написать всё равно, а новая зависимость молча меняла бы мир при обновлении своих словарей.
- Гео-id — настоящие числа геобазы Яндекса. Проверка 2026-08-02: id
подтверждались обращением к живым сервисам Яндекса по тому же номеру
(
yandex.ru/pogoda/<id>,yandex.ru/maps/225/russia/) — страница открывает ожидаемое место. Оговорка стоит в коде: опубликованной таблицы геобазы найти не удалось, а чтоRegionCityIDМетрики нумерует регионы той же геобазой — обоснованное допущение, не подтверждённый источником факт. Выдуманные по памяти числа не годятся ни в каком случае. - IP-адреса — нероутируемые диапазоны: документационные сети RFC 5737 и benchmark-сеть 198.18/15 по ломтю на город, телефонам — CGNAT 100.64/10, как у настоящих операторов. Правдоподобные публичные адреса принадлежат живым организациям, и в учебных данных им не место.
- Словарь: «визит» остаётся за сессией и полем
VisitID. Одноимённое понятие плана состава переименовано в «день активности»: у куки он один на день, а визитов внутри дня бывает несколько.
Решено при исполнении #39 самой реализацией:
- Правила резки визитов (мастер-спека, раздел 1.2 требовала их
задокументировать): визит принадлежит одной куке; пауза дольше 30 минут
рвёт визит надвое; граница модельных суток режет визит, события за
полночь в день не попадают. Единственное исключение — визит с заказом,
обещанным планом двухкуковых пар: его старт сдвигается назад, чтобы
воронка уместилась в сутки. Это принятое ограничение модели, а не
недосмотр: обещание плана — гарантия, на которой стоит лаба склейки, и
ради неё мы сужаем свободу старта. Цена названа и мала: около 24 визитов
в день никогда не начинаются в последние минуты суток (из ~9,5 тыс.).
Правила лежат в докстринге
day.py, и тест собирает визиты по ним заново, сверяя сборку сVisitID. - Числа дня: ≈1,4 визита на день активности куки и ≈4,8 страницы на визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка 8% визитов до корзины → 45% из них до оформления → 55% из них до подтверждения, то есть конверсия визита ~2%. Итог сложится после торговых событий (#40) и ляжет в манифест.
- Недельная волна применяется один раз. Профиль ведёт приток, трафик наследует его через дневную аудиторию; измеренный размах трафика — ±7% против ±10% у притока. Второе умножение удвоило бы недельный размах. Если недельной лабе однажды не хватит сигнала, принципиальный путь — отдельный, более слабый профиль активности, а не повторное применение профиля притока: механизмы разные, и «на выходных приходит меньше новых людей» — не тот же факт, что «на выходных каждый ходит меньше».
ParsedParamsKey1остаётся пустой — это отложенное решение, а не дыра: вариант A/B-теста был бы постоянной куки, а не поведением дня, и заводится тикетом #47.- Шов для #40: день отдаёт, кроме колонок, два выровненных по строкам
ряда — какая это страница магазина и какой товар показывала карточка. По
ним #40 знает, куда вешать торговое событие и что посетитель на самом
деле смотрел: товар в корзине, которого никто не открывал, — видимая
глупость в воронке. Визит с назначенным заказом всегда доходит до
/confirmation, а перед корзиной у него всегда есть карточка. Своей случайности #39 у #40 не занимает: подпотокCOMMERCEне тронут.
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации. Три решения выходят за границы тикета: правятся план состава (#38), воронка (#39) и каталог товаров (#39). Сейчас это дёшево — манифест ещё не собран (#42); позже обошлось бы его пересборкой. Часть решений принята после холодного ревью первой редакции блока.
- Корзина шире заказа. Посетитель кладёт в корзину товары тех карточек, которые открывал в этом визите, а покупает не всё: около 15% позиций остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был бы её точной копией, и сравнивать было бы нечего. Замерено на мире до правок: у покупающего визита 2,1 карточки, в заказе выходит 1,9 позиции, однопозиционных заказов около половины. Карточка, открытая в визите дважды, даёт одну позицию, а не две: повторный просмотр — раздумье, а не второй товар. Количество штук — свой бросок: обычно одна, изредка две-три. Отклонено: заказ всегда из одного товара — список позиций как урок умирает, а на нём стоит единственный носитель навыка «вложенный JSON» (мастер-спека, раздел 2); досыпать в корзину товары, которых посетитель не открывал — ломает шов #39 ровно там, ради чего он строился; бросать много (40% и выше) — две трети заказов схлопываются в один товар.
- Покупатель перестаёт быть словом — двумя рычагами сразу. Метка плана («этот человек склонен покупать», 5% людей когорты) до сих пор ни на что не влияла: кто купит, решал одинаковый для всех бросок в дне. Теперь помеченный, во-первых, доходит до заказа заметно чаще прочих, во-вторых, дольше живёт и чаще возвращается. Первый рычаг — про правдоподобие: склонность покупать и вправду свойство человека, а не свойство визита. Второй — про видимость: метки в событии нет и не будет (кликстрим анонимен), поэтому «постоянный покупатель» читается в данных только как кука, которая ходит неделями и покупает не раз. Одним лифтом это недостижимо: кука живёт меньше двух визитов за снимок, и второй покупке негде случиться — лифт поднял бы долю кук с двумя покупками с 2% до 6% и на том исчерпался. Рычаг жизни лежит в плане (#38), рычаг конверсии — в воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки: и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал бы половину картины. Числа под каждый шаг — калибровка. Границы правки — вилки раздела 5: дневная аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс. событий, конверсия визита — около 2%. Расти внутри вилки аудитории разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин. Измеренные числа состава (блок #38 выше) после правки перемеряются и переписываются; база до правки — в средний день аудитория 6 710, визитов 9 249, событий 44 436, визитов с корзиной 780 (8,4%) и с покупкой 221 (2,4%). Уточнение к разделу 1: вольные покупки по-прежнему решает день, но случайность в них больше не одинакова для всех. Отклонено: оставить монетку — «покупатель» остаётся именем без следа в данных; один лифт без долгой жизни — довод выше, повторных покупок он почти не прибавляет; покупают только помеченные — 5% людей дают около 40 заказов в день вместо 220, вернуть их можно, лишь подняв долю покупателей до 10–30% людей, а с ней вырастет и число двухкуковых пар.
- Событие корзины садится на карточку товара, событие покупки — на страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на карточке, а не открытием страницы корзины; при нескольких товарах в заказе иначе его и не разложить — событий столько, со скольких карточек положили.
- Номер заказа читаемый: день модельного времени и порядковый номер
покупки в этом дне (
20260603-0042). Магазины так и нумеруют, и по номеру сразу видно день. Он же —order_idбэкенда: обе стороны получают один номер, по нему соединяется сверка (мастер-спека, раздел 4). Нумеруются все покупки, дошедшие до потока дня, в порядке событий — до всяких потерь. Расхождения этапа 6 выбрасывают событие, когда номер уже присвоен: иначе одна потеря перенумеровала бы чужие заказы, и мост к бэкенду разъехался бы. - Промокод в событии есть, скидки в сумме нет. Часть заказов уходит с
промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и
доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта.
Класса расхождения промокод не даёт: сверка приведена к
items_total, где скидки и нет (мастер-спека, раздел 7). Он объясняет разрыв между клиентской суммой и итогом заказа — одну из причин, по которым «суммы не сойдутся» (мастер-спека, раздел 4). Таблица «код → скидка» — число мира и живёт здесь: этап 3 берёт её готовой, а не сочиняет заново. Отклонено: промокода нет никогда — колонка формата осталась бы мёртвой. - Деньги: копейки внутри, рубли в колонках, дробь только в выручке — и
копейки в части цен каталога. Генератор считает целыми копейками; цена
товара в событии — целые рубли, как у Метрики (
productPriceтамInt64); дробное число одно —purchaseRevenueтипаFloat64. Все 180 цен каталога кратны рублю, и это делало урок про Float64 беспредметным: округлять нечего, копейки существуют только на словах, а расхождение сумм пришлось бы выдумывать вероятностью. Поэтому часть цен получает копейки (1 299,90 ₽ — обычная розница):productPriceокругляется самим форматом, аpurchaseRevenueнесёт точную сумму. Разрыв живёт внутри события — считать выручку по разобранным массивам нельзя, и это настоящий урок формата, а не придуманный. - Массивы
purchase*иproduct*разной длины между собой. Внутри своей группы длина общая:purchase*— по элементу на заказ (у нас всегда один),product*— по элементу на товар. Мастер-спека (раздел 1) говорит о «параллельных массивах одной длины», и прочесть это как «все массивы события одной длины» легко — поэтому в описании выгрузки сказано прямо. - Сырой
ecommerceнесёт больше, чем плоские колонки. Лаба «сырое против разобранного» (мастер-спека, раздел 1.2) имеет смысл, только если в сыром лежит то, чего в массивах нет: бренд товара, его вариант, блокactionFieldцеликом. Собирает строку канонический сериализатор — руками это второй сериализатор со своим экранированием; зависимостьorjson, названная рабочим выбором в разделе 4, появляется здесь, а не в #41. - Торговый хвост умещается в сутки резервом, а не зажимом. Событие покупки встаёт на несколько секунд позже просмотра страницы подтверждения, событие корзины — позже своей карточки, а граница суток режет всё, что за неё вышло. Визит с обещанным планом заказом уже подвинут так, чтобы уместиться в сутки (#39), но подвинут по страницам — торгового хвоста в том расчёте не было. Резерв расширяется на хвост: тогда правило «покупка на секунды позже подтверждения» держится всегда, а зажим к последней секунде ломал бы его ровно там, ради чего написан. Визит, у которого страница подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа не было.
Остаётся открытым, за тикетами:
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
проигрывателя; кто его зовёт в стенде — даги
world_init/next_dayиз оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41); - как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки): с манифестным решением напрашивается мини-манифест зернового мира — форма за #42;
- паспорт мира в манифесте (зерно и версия генератора) файл каталога не накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш каталога — хвост для #42.