- Зачем:
- справочники читает человек со стороны, а «пульт мира», «опись мира»,
«работник» и «сырьё» звучали шуткой или калькой времён разработки
(тикет #214, выбор владельца).
- Что:
- CONTEXT.md: статьи «Паспорт мира», «Даги модельного времени»,
«Шаговый даг», «Сырые данные», старые слова в строках «Избегать»;
пометки в ADR 0005, 0008, 0009 без переписывания решений.
- замены по README, справочникам, путеводителю, лабам, спекам,
комментариям дагов и SQL, тегу дага и панели Grafana; имена в коде
не тронуты.
- world.md и stand.md: 35 находок вычитки внесены, регистр выровнен.
- Проверка:
- make config-test; make lint в корне и в generator/; make test в
generator/ (437 passed); скрипт ссылок из #212 пуст; grep старых
слов вне ADR, research и CONTEXT.md пуст.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
957 lines
99 KiB
Markdown
957 lines
99 KiB
Markdown
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
|
||
|
||
Статус: Accepted — принята владельцем 2026-08-01 (PR #35); закрыта
|
||
2026-08-28 как документ этапа 2. Несущие решения — в ADR 0015–0017, новые
|
||
решения генератора — в ADR.
|
||
Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
|
||
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
|
||
(#29), производительность (#30); исследование скорости (#31).
|
||
Источники: мастер-спека
|
||
[«Боевой реализм стенда (v2)»](2026-07-30-stand-v2-realism.md) — разделы 1.4,
|
||
5, 9–11; заметка
|
||
[«Скорость батчевой генерации в Python»](../research/2026-08-01-python-batch-generation-speed.md).
|
||
|
||
## Зачем
|
||
|
||
Мастер-спека решила, **что** генерирует стенд: широкое событие в 47 колонок,
|
||
таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре
|
||
развилки о том, **как** генератор устроен, она отложила: модель мира,
|
||
границы детерминизма, источник истины схемы с разделением потока и пакета,
|
||
числовые требования скорости. Карта #26 эти развилки прошла; спека собирает
|
||
решения в одну картину. По ней этап 2 режется на тикеты (#34).
|
||
|
||
Здесь не переоткрывается решённое мастер-спекой: модель данных события,
|
||
таксономия, анонимность, механика заказов (границы мандата #14). Генератор
|
||
слепков заказов — этап 3: эта спека лишь не должна ему мешать. Один
|
||
осознанный выход за границы: решение о хранении снимка (раздел 5) формально
|
||
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
||
«Производительности».
|
||
|
||
## Чем меряется генератор
|
||
|
||
Рамка владельца, по которой принимались решения ниже. Она шире этапа 2 и
|
||
живёт дальше него: спеки следующих этапов ссылаются сюда, а не переписывают.
|
||
|
||
- **Генератор меряется тем, что приезжает на выход, а не устройством
|
||
внутри.** Он играет два источника с разными темпами: поток Метрики и
|
||
ночную выгрузку бэкенда магазина. Любое внутреннее решение отвечает перед
|
||
одним вопросом — правдоподобны ли данные на выходе.
|
||
- **Менти внутрь не смотрит.** Он видит внешние эффекты и по ним составляет
|
||
представление, как работает магазин и обо что там спотыкаются. Значит,
|
||
конструкция внутри оправдана только наблюдаемым эффектом: без него это
|
||
сложность, за которую никто не заплатил.
|
||
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
|
||
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт другую
|
||
последовательность. Опираться на конкретные числа поэтому нечем. Хеши паспорта
|
||
обещают не постоянство чисел, а то, что их сдвиг не пройдёт молча: пересобрал
|
||
паспорт — увидел diff.
|
||
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
|
||
правкой генератора после взгляда на получившиеся распределения. Оборона,
|
||
которая дороже ошибки, не заводится.
|
||
- **Сложность изолирована нарочно.** Генератор вынесен отдельной службой,
|
||
чтобы его сложность не расползалась по стенду. Плата за изоляцию —
|
||
дешевизна правки: выбирается устройство, которое дёшево менять, а не то,
|
||
которое всё предусмотрело.
|
||
|
||
## Целевая картина одним взглядом
|
||
|
||
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
||
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
||
и позиция на оси времени.
|
||
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
||
хешами паспорта. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
||
хранилище строится по рендеренной документации, границу сторожит строгий
|
||
приём на стороне хранилища.
|
||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
|
||
стартовым миром — 8 дней; в git — только паспорт мира; автоматических порогов
|
||
по времени нет.
|
||
|
||
## 1. Модель мира
|
||
|
||
Резолюция развилки [«Модель мира»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/27).
|
||
|
||
- **Мир функциональный, ничего не мутирует.** Постоянный состав мира —
|
||
популяция посетителей, их привычки, календарь двухкуковых пар — чистая
|
||
функция зерна; целиком не вычисляется и не хранится, спрашивается по
|
||
дням (форма плана — ниже). День 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). Между прогонами живут только зерно и
|
||
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
|
||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||
с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой
|
||
день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»;
|
||
включается по требованию — постоянным фоном идёт, только пока включён
|
||
выключатель `world_live` ([ADR 0009](../adr/0009-world-control.md)).
|
||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||
переигрывается (раздел 4).
|
||
- **Поток и пакет совместимы по построению.** День-функция выдаёт один
|
||
упорядоченный поток событий; режимы отличаются только способом
|
||
проигрывания — пачкой или с темпом.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
||
дням, требует чекпоинтов, счётчики паспорта узнаваемы только постфактум; ни
|
||
один урок стенда на старении не стоит.
|
||
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
||
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
||
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты
|
||
эталонного мира зависимыми от даты запуска, паспорт теряет воспроизводимость.
|
||
|
||
Отклонено при исполнении #38 (2026-08-02):
|
||
|
||
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
|
||
стартовый мир и половина снимка оказались бы на разгоне, недельная лаба
|
||
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
|
||
быть средним — пришлось бы двигать принятые числа раздела 5.
|
||
- *Материализованный план на горизонт*: горизонт становится обязательным
|
||
входом каждого запуска, стоимость старта растёт с прожитым; префиксную
|
||
устойчивость даёт и ленивая форма — даром, через подпотоки по номеру дня.
|
||
- *Замкнутый пул кук со сменой поколений*: постоянная память ценой фальшивой
|
||
константы — потолка одновременно живущих кук, которого в жизни нет и
|
||
который менти нечем объяснить.
|
||
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
||
гарантия — однажды паспорт покраснеет, а чинить нечем, кроме смены зерна; при
|
||
этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм
|
||
этой лотереи невидим.
|
||
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
||
«все пары покупают в первый день» виден в данных ровно там, куда лаба
|
||
склейки смотрит пристальнее всего. Условность допустима, пока она не
|
||
видна в данных.
|
||
|
||
## 2. Детерминизм от зерна
|
||
|
||
Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28).
|
||
|
||
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
||
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
||
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
||
строк; сверка — по хешам паспорта, а два локально пересобранных снимка
|
||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания —
|
||
транспорт: офсеты и партиции Kafka, какая нода прочитала, `_load_ts`, темп
|
||
живого дня.
|
||
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже;
|
||
единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость
|
||
любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с
|
||
паспортом». Расхождение на любой платформе — баг генератора, а не допуск.
|
||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
||
состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается
|
||
по дням — раздел 1; уточнение при исполнении #38). Дни предыстории
|
||
отрицательны, а позиция в дереве — неотрицательное целое, поэтому у
|
||
предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении
|
||
#38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки
|
||
компонентов: трафик, торговые события, заказная и событийная стороны (судьба
|
||
заказов и порча событийного потока) — в фиксированном порядке (уточнение
|
||
[судьбой заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||
«опоздания» были названы по классам витрины, а компонент называет часть мира).
|
||
По построению: параллельный прогон равен последовательному; продление истории
|
||
днём 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. Контракт схемы
|
||
|
||
Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть первая.
|
||
|
||
- **Граница вывода — по шву «трекер | хранилище», как data contract.**
|
||
Контракт схемы — собственность генератора, как формат выгрузки —
|
||
собственность Метрики. Из контракта выводятся: сам генератор, его
|
||
валидация и публичное «описание выгрузки» в доках — рендеренная таблица
|
||
колонок, аналог документации Метрики.
|
||
- **Форма контракта — импортируемый python-модуль с чистыми данными**:
|
||
описатели колонок (имя Метрики, тип ClickHouse, тип numpy, нормализованное
|
||
snake_case-имя, группа полей, порядок), никакой логики. Читаемость для
|
||
менти несёт рендеренная таблица в доках, не модуль. Уточнение при
|
||
исполнении (#36): нормализованное имя — имя источника, приведённое к
|
||
нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою
|
||
модель и называет атрибуты по ней; `dds.event_v` эти имена берёт (раздел 7
|
||
мастер-спеки), но контракт их не диктует и тестами не сторожит.
|
||
- **Сторона хранилища пишется по документации, не генерируется.** DDL
|
||
`ods.event`, SELECT матвью, `dds.event_v`, трансформации — работа
|
||
следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется
|
||
к источнику. Границу сторожит боевой механизм — строгий приём
|
||
(`Nullable`-разбор со сверкой набора ключей, таблицы `*_errors` — раздел 6
|
||
мастер-спеки). Сверка объявлений (`system.columns` поднятого стенда против
|
||
контракта) здесь стояла вторым механизмом и снята при исполнении #43:
|
||
сверх строгого приёма она ловила ровно одно — смену типа колонки, — а её
|
||
ловит и сверка разобранного события, причём на живых данных, а не на
|
||
объявлениях.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Автогенерация DDL хранилища из контракта* (буква раздела 1.4
|
||
мастер-спеки до правки): пересекает границу ответственности компонент —
|
||
в бою хранилище адаптируется к источнику руками, менти пришлось бы
|
||
объяснять приём, которого в жизни нет. Data contract даёт тот же щит от
|
||
дрейфа без этой условности.
|
||
- *YAML как форма контракта*: красота ценой загрузчика и «схемы для схемы»;
|
||
потребителя вне Python нет — хранилище читает рендеренную документацию,
|
||
не машинный файл.
|
||
|
||
## 4. Поток и пакет: сериализатор, проигрыватель, приёмники
|
||
|
||
Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть вторая.
|
||
|
||
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
||
упорядоченный поток канонических байтов — единственное место, где событие
|
||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для
|
||
пересборки и проверок паспорта), Kafka пачкой — пакетный режим, Kafka с темпом
|
||
×60 — живой день. Новых топиков нет.
|
||
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
||
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
||
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
|
||
хранилища читает топик байтами и кладёт сообщение строкой
|
||
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
|
||
событий в одно сообщение сломала бы разбор целиком.
|
||
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
|
||
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырых данных:
|
||
весь смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте
|
||
и разбирает событие глазами, а число эпохи этот урок убивает. Колонка
|
||
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
|
||
Метрика.
|
||
|
||
Оговорка про цену разбора, вписанная сюда 6 августа и оказавшаяся неверной:
|
||
здесь стояло, что `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')`
|
||
«принимает ISO без плясок». Не принимает — на строке с суффиксом `Z` он
|
||
отдаёт NULL, и при исполнении #43 это увело бы в брак все события до
|
||
единого. Измерено на стенде 7 августа 2026 года; форма на проводе от этого
|
||
не меняется, меняется выражение разбора на стороне хранилища — `parseDateTime`
|
||
по буквально названному формату вместо `JSONExtract`
|
||
([ADR 0005](../adr/0005-event-ingestion.md)). То, что форма на проводе одна и
|
||
каноническая, здесь работает на хранилище: раз запись ровно одна, разбирать
|
||
её можно строго, не принимая заодно десяток чужих записей.
|
||
|
||
Форму реализует сериализатор (#41), хранилище (#43) читает то, что он
|
||
положил: порядок тикетов развёрнут 6 августа 2026 года, и отправитель идёт
|
||
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
|
||
разойдётся это уже после приёмки.
|
||
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
||
numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена
|
||
библиотеки меняет канонические байты, поэтому проходит как обновление
|
||
зависимости: осознанная пересборка паспорта одним PR.
|
||
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
||
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
||
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
||
повторы: `WatchID` детерминированы, повтор — та же строка для
|
||
ReplacingMergeTree.
|
||
- **Эталонный снимок при создании мира — через Kafka, пакетным режимом
|
||
проигрывателя.** Отдельный механизм заливки не строится: `world_initialize`
|
||
прогоняет весь конвейер на настоящих данных при первом `make up` и при явном
|
||
`make rebuild-storage`. На готовом мире повторный `make up` заливку пропускает.
|
||
Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой
|
||
загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий —
|
||
расчётно минута-две, запас есть.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
||
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
||
паспортом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||
мастер-спеки).
|
||
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
||
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
||
остаётся только там, где нужен кэш.
|
||
- *Прямая загрузка снимка в ClickHouse* (и гибрид с ручным заполнением
|
||
сырого слоя): второй путь приёма, пустой либо поддельный `stg.hits_raw` —
|
||
теряются переобработка дня X по `event_date` и урок виртуальных колонок
|
||
«какая нода читала топик».
|
||
|
||
## 5. Числа: объёмы, режимы, бюджеты
|
||
|
||
Резолюция развилки [«Производительность»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/30);
|
||
порядки величин — [исследование #31](../research/2026-08-01-python-batch-generation-speed.md).
|
||
|
||
- **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до
|
||
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
||
правдоподобный средний магазин.
|
||
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
|
||
по понедельник, полная неделя и один закрытый день за окном K = 7. Обещание в
|
||
14 дней снято на этапе 7 (решение владельца 2026-08-30): у дней 8–13 не
|
||
нашлось читателя — лабы проигрывают мир дальше без паспорта, транспорт байты с
|
||
паспортом не сверяет, а проверке «пересгенерируй день N — сравни хеш» день 3
|
||
годится так же, как день 13. Замеры и тесты, меряющие мир на окне в две
|
||
недели, не меняются: мир — функция без края, окну замера паспорт не нужен.
|
||
Удлинение паспорта остаётся дешёвым ходом (пересборка) и делается, когда
|
||
читатель появится — например, если растяжке `classes_vs_inventory` понадобится
|
||
сверять больше одного закрытого дня.
|
||
- **В git — только паспорт, снимок не хранится.** Снимок генерируется при
|
||
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
|
||
git не хранят. Паспорт несёт шапку, счётчики и хеши по дням; проверки «пустой
|
||
git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Первый `make
|
||
up` пустого стенда — живая демонстрация детерминизма; готовый мир повторный
|
||
подъём не пересчитывает. Честная потеря — страховка на случай платформенного
|
||
бага: раньше менти с расходящимися байтами мог взять готовый снимок из git,
|
||
теперь он упрётся в красный чек паспорта; смягчение — дисциплина целочисленной
|
||
случайности (раздел 2): расхождения amd64/arm64 сняты устройством, а не
|
||
сторожем. CI ради одного прогона не заводится (решение владельца 2026-08-30).
|
||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||
в пиковые часы сильных дней — до ~100. Число —
|
||
значение по умолчанию, переопределяется флагом проигрывателя: ускорение —
|
||
свойство транспорта, вне обещания воспроизводимости, константой мира не
|
||
делается.
|
||
|
||
### Бюджеты и способ замера
|
||
|
||
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
|
||
железе кончаются ритуальным удалением проверки.
|
||
|
||
Ориентиры на референсной машине — в спеке, без автоматики. Где стоит замер,
|
||
там он с датой; остальное — порядок величины, пока не мерили:
|
||
|
||
| Операция | Ориентир |
|
||
|---|---|
|
||
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
||
| Пересборка эталонного мира (8 дней + паспорт, без транспорта) | 20,5 с (замер 30 августа 2026 года) |
|
||
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
||
| Лаг живого дня | секунды |
|
||
|
||
**Автоматических порогов нет ни одного** — решение владельца 7 августа
|
||
2026 года при исполнении #42. Здесь стоял единственный: «полный день
|
||
генерируется ≤ 30 с». Назначен он был до того, как генератор написали, а
|
||
первый замер настоящего кода дал **1,7 секунды на день в 50 626 событий**
|
||
(машина стенда, 7 августа 2026 года). Порог оказался в восемнадцать раз выше
|
||
факта: зелен при любой правдоподобной регрессии, то есть не сторож, а
|
||
украшение. Как здесь говорят о времени, к тому дню уже решила [карта
|
||
целей](../architecture/testing.md): цена — замеренное число с датой, а не
|
||
назначенный предел.
|
||
|
||
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
|
||
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
|
||
логе, а `world_initialize` гоняет генератор по восемь дней при первом создании
|
||
мира и явной пересборке. Замедлись он на порядок — операция встанет колом, и
|
||
это увидит первый же человек, который создаёт мир; повторный `make up` готовый
|
||
мир не пересчитывает.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
|
||
впритык — повторение истории с памятью (ADR 0004). Оставленный было один
|
||
грубый порог снят при исполнении #42 (выше).
|
||
- *Снимок в 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 — хранилище пишется по документации, границу сторожит строгий
|
||
приём (раздел 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 минуты); генератор не дорабатывается.
|
||
- **Позиция на оси времени** (хвост отдан дагам модельного времени раньше этапа
|
||
5 —
|
||
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||
она переменной Airflow: отсутствие переменной означает, что стартовый мир ещё
|
||
не создан. В обычной жизни её ставит шаговый даг, который сыграл день, —
|
||
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
|
||
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации и
|
||
ставят первый несыгранный день только после приёма стартового мира ([ADR
|
||
0013](../adr/0013-world-lifecycle.md)). Довод — генератор отдельная и
|
||
заменяемая сущность, привязывать его к хранилищу незачем. Полный `make clean`
|
||
сносит метаданные Airflow вместе с данными ClickHouse, а узкая пересборка явно
|
||
сводит сохранённую позицию с заново залитым миром. Расхождение переменной с
|
||
данными (менти почистил партицию руками) лечится документацией, а не сторожем:
|
||
постоянная сверка была бы проверкой, красной в норме ([ADR
|
||
0002](../adr/0002-monitoring-scope.md)).
|
||
- **Этап 7 (эталонный мир)**: пересборка обновляет паспорт мира. Отдельного
|
||
артефакта у неё нет. CI-генерация на amd64 и arm64 снята — резолюция в разделе
|
||
5.
|
||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||
готовая демонстрация идемпотентности конвейера.
|
||
|
||
## 9. Вопросы нарезки этапа 2 (#34): решения и остатки
|
||
|
||
Журнал закрыт 28 августа 2026 года. Он хранит историю решений этапа 2; основания
|
||
модели времени, паспорта куки и номера заказа вынесены в
|
||
[ADR 0015](../adr/0015-counter-timezone.md),
|
||
[ADR 0016](../adr/0016-cookie-passport.md) и
|
||
[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора
|
||
записывают в ADR и в спеке этапа, на котором их принимают.
|
||
|
||
Журнал собран по темам, но дата и тикет остались у каждого решения. По журналу
|
||
видно, какие границы затрагивала каждая правка: иногда она меняла сразу план
|
||
состава, воронку и каталог. При #40 это было дёшево — паспорт мира ещё не
|
||
собрали; позже такой ход потребовал бы пересборки паспорта мира.
|
||
|
||
### План состава и числа мира
|
||
|
||
- **Числа притока и состава** (#38, 2026-08-02). Приток — ~3 800 новых кук в
|
||
средний день, модулируется недельным профилем мира; трафик наследует эту волну
|
||
через дневную аудиторию, а не отдельное умножение (уточнение #39). Доля
|
||
одноразовых кук — 75%; возвращающиеся дают в среднем 3–4 возврата, почти все в
|
||
первые 7–10 дней, а тонкий хвост поздних возвратов и повторных покупок тянется
|
||
до края окна: цикл повторной покупки магазина измеряется месяцами. Хвост
|
||
возвратов — общее на обе куки окно активности человека от первого дня и
|
||
глубина предыстории: 90 дней (решение владельца 2026-08-02: дольше квартала
|
||
стенд никто не гоняет, а заказы старых посетителей продолжаются весь прогон;
|
||
плата — чуть меньше пар, полностью реализованных внутри 14-дневного снимка).
|
||
Покупатели считаются людьми, не куками; их доля — 5% людей когорты. Людей
|
||
почти столько же, сколько кук: вторые куки пар добавляют меньше процента. Доля
|
||
— число плана: без него не отобрать двухкуковые пары, и #40 его не
|
||
переоткрывает. Сходимость с разделом 5: средняя кука активна ≈1,9 дня, дневная
|
||
аудитория ≈7 100, уникумов за 14 дней — под 70 тыс. Это новые куки и возвраты
|
||
предыстории; их не надо путать с ~50 тыс. событий одного дня. Новых
|
||
покупателей ~190 в день, что дает конверсию ~2% на сессию.
|
||
- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с
|
||
контрактом схемы: все числа мира собраны в одном месте, которое менти может
|
||
крутить. Правка модуля меняет мир, и проверка паспорта честно краснеет:
|
||
паспорт сторожит только канон. Вне модуля остается то, что мира не меняет, —
|
||
свое зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
|
||
переопределения окружением — переменная мира, которой нет в шапке паспорта;
|
||
файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и
|
||
валидацией (довод раздела 3 против YAML).
|
||
- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение
|
||
владельца до реализации, 2026-08-02). Помеченный планом покупатель заметно
|
||
чаще доходит до заказа, дольше живет и чаще возвращается. Склонность покупать
|
||
— свойство человека, а ее след в анонимном кликстриме виден только как долгая
|
||
жизнь куки и повторные покупки. Один рычаг этого не дает: кука живет меньше
|
||
двух визитов за снимок, и второй покупке негде случиться. Рычаг жизни лежит в
|
||
плане (#38), рычаг конверсии — в воронке (#39); помеченный отличается на обоих
|
||
шагах — чаще доходит до корзины и реже бросает ее. Помеченному дано 18% до
|
||
корзины и 60% из корзины в оформление против 6% и 45% у обычного посетителя.
|
||
Обычная доля снижена с 8% до 6%: помеченные живут дольше и занимают большую
|
||
долю дневной аудитории, поэтому прежнее число увело бы общую конверсию за
|
||
вилку раздела 5. Рычаг метки не должен оплачиваться ростом конверсии; расти
|
||
внутри вилки аудитории миру можно. Вольные покупки по-прежнему решает день
|
||
(уточнение к разделу 1), но не одинаковой для всех случайностью. Отклонено:
|
||
*оставить одинаковый бросок* — покупатель остается именем без следа; *поднять
|
||
только конверсию* — короткой жизни не хватает для повторных покупок;
|
||
*разрешить покупать только помеченным* — их малая доля дала бы слишком мало
|
||
заказов, а ее рост завысил бы число двухкуковых пар. Промежуточные числа
|
||
пересчитаны при #50; актуальные — в таблице замера #50.
|
||
- **Счетчика покупок в плане состава не заводится** (#40, решение владельца до
|
||
реализации, 2026-08-02). Тикет просил свести с планом покупки и двухкуковые
|
||
пары, но раздел 1 обещает до генерации только состав — посетителей, приток и
|
||
пары. Торговые счетчики складываются из поведения дня. Квота заставила бы день
|
||
добирать точное число и сделала бы конверсию подозрительно ровной: условность
|
||
была бы видна именно там, куда смотрит лаба. Сходятся пары; покупки
|
||
проверяются после генерации по дню и вилке конверсии.
|
||
|
||
### Время, визиты и границы дня
|
||
|
||
- **D0 = 2026-06-01, понедельник** (#38, решение владельца 2026-08-01). Недавняя
|
||
дата делает первые месяцы данных свежими, но к календарю запуска константа
|
||
мира не привязана.
|
||
- **Модельные сутки считаются в часовом поясе счетчика** (#39, решение владельца
|
||
до реализации, 2026-08-02), а местная волна гостя поворачивается —
|
||
[ADR 0015](../adr/0015-counter-timezone.md).
|
||
- **Слово «визит» остается за сессией** (#39, решение владельца до реализации,
|
||
2026-08-02) и полем `VisitID`. Одноименное понятие плана переименовано в «день
|
||
активности»: у куки он один на день, а визитов внутри дня бывает несколько.
|
||
- **Правила резки визитов** (#39, 2026-08-02; мастер-спека, раздел 1.2,
|
||
требовала их задокументировать): визит принадлежит одной куке; пауза дольше 30
|
||
минут рвет его надвое; граница модельных суток режет визит, события за полночь
|
||
в день не попадают. Единственное исключение — визит с заказом, обещанным
|
||
планом двухкуковых пар: его старт сдвигается назад, чтобы воронка уместилась в
|
||
сутки. Это принятое ограничение модели, а не недосмотр: обещание плана —
|
||
гарантия, на которой стоит лаба склейки, и ради нее свобода старта сужена.
|
||
Цена названа и мала: около 24 визитов из ~9,5 тыс. в день никогда не
|
||
начинаются в последние минуты суток. Правила лежат в докстринге `day.py`, а
|
||
тест заново собирает визиты и сверяет их с `VisitID`.
|
||
- **Недельная волна применяется один раз** (#39, 2026-08-02). Профиль ведет
|
||
приток, а трафик наследует его через дневную аудиторию; измеренный размах
|
||
трафика — ±7% против ±10% у притока. Второе умножение удвоило бы недельный
|
||
размах. Если недельной лабе не хватит сигнала, нужен отдельный слабый профиль
|
||
активности, а не повторное умножение: «приходит меньше новых людей» и «каждый
|
||
ходит меньше» — разные механизмы.
|
||
- **Торговый хвост умещается в сутки резервом, а не зажимом** (#40, решение
|
||
владельца до реализации, 2026-08-02). Событие покупки встает на несколько
|
||
секунд позже страницы подтверждения, корзина — позже карточки, а граница суток
|
||
режет вышедшее за нее. Визит с обещанным заказом уже был подвинут так, чтобы
|
||
воронка уместилась в сутки, но без учета торгового хвоста. Резерв расширен на
|
||
хвост: зажим к последней секунде сломал бы правило задержки ровно в
|
||
пограничном случае. Визит со срезанным полуночью подтверждением покупки не
|
||
дает.
|
||
- **Торговый хвост разводит соседние визиты** (#40, 2026-08-02): таймаут
|
||
считается от последнего события, а не страницы. Иначе лаба сессий склеила бы
|
||
два визита и разошлась с `VisitID`; решение поймано тестом сборки визитов.
|
||
- **Полночь забирает подтверждение вместе с покупкой** (#40, 2026-08-02).
|
||
Сначала резерв считался только для обещанных планом заказов, поэтому у вольной
|
||
покупки оставалось окно: подтверждение дожило до потока, а покупка вышла за
|
||
полночь. Это создало бы потерю на клиентской стороне по недосмотру. Теперь
|
||
запас есть у всякой страницы подтверждения: либо за границу суток уходят обе
|
||
строки, либо остаются обе. В каноническом мире этот случай за 14 дней не
|
||
выпал.
|
||
- **Задержка торгового события короче самой короткой паузы между страницами**
|
||
(#40, 2026-08-02). Событие корзины не обгоняет страницу кнопки и не выходит за
|
||
полночь: за ним в визите всегда есть страница, пережившая полночь.
|
||
|
||
### Каталог, география и паспорт куки
|
||
|
||
- **Каталог товаров заводится в #39, а не в #40** (#39, решение владельца до
|
||
реализации, 2026-08-02). Карточке нужен `Title`, а имя товара живет в
|
||
каталоге: без каталога #39 сделал бы лишь половину трафика.
|
||
`data/catalog/products.csv` (`sku`, `name`, `category`, `brand`, `price`)
|
||
общий для генератора и словаря ClickHouse; #40 получает его готовым. Решена
|
||
форма, а не длина: артикул — четыре буквы категории и четыре цифры, цена —
|
||
целые копейки, категорий шесть. Строки дописываются механически, поэтому ни
|
||
код, ни тесты их не считают; товар для карточки выбирается равномерно внутри
|
||
категории.
|
||
- **Ассортимент — непродовольственная розница** (#39, решение владельца до
|
||
реализации, 2026-08-02): товары для дома, текстиль, посуда, мелкая бытовая
|
||
техника, детское, одежда и обувь, ~150–200 sku. Довод не вкусовой: под этот
|
||
профиль приняты месячный цикл повторной покупки, 75% одноразовых кук и
|
||
конверсия ~2% на визит. Продуктовая сеть потребовала бы других чисел и
|
||
переоткрыла бы #38.
|
||
- **География — один регион присутствия** (#39, решение владельца до реализации,
|
||
2026-08-02): Поволжье с центром в Самаре, города региона и тонкий хвост
|
||
остальной страны. «Топ городов России» дал бы магазину с одним складом карту,
|
||
которой у него не бывает.
|
||
- **Паспорт куки хранит план состава на всю ее жизнь** (#39, решение владельца
|
||
до реализации, 2026-08-02); пара получает один город и разные устройства
|
||
(«телефон и ноутбук», мастер-спека, раздел 5), а броски паспорта стоят
|
||
последними — [ADR 0016](../adr/0016-cookie-passport.md). Прежние счетчики
|
||
пересчитаны при #40; актуальные числа — в таблице замера #50.
|
||
- **Справочники — таблицы-литералы** (#39, решение владельца до реализации,
|
||
2026-08-02) рядом с конфигурацией мира; Faker и mimesis не подключаются. Нужны
|
||
связки вроде «город → id региона → часовой пояс» и «телефон → Safari → iOS →
|
||
экран»: генератор строк их не дает, таблицу пришлось бы написать все равно, а
|
||
обновление зависимости молча меняло бы мир.
|
||
- **Гео-id — настоящие числа геобазы Яндекса** (#39, решение владельца до
|
||
реализации; проверка 2026-08-02). Id подтверждались обращением к живым
|
||
сервисам Яндекса по тому же номеру (`yandex.ru/pogoda/<id>`,
|
||
`yandex.ru/maps/225/russia/`) — страница открывает ожидаемое место. Оговорка
|
||
стоит в коде: опубликованной таблицы геобазы найти не удалось, а что
|
||
`RegionCityID` Метрики нумерует регионы той же геобазой — обоснованное
|
||
допущение, не подтвержденный источником факт. Выдуманные по памяти числа не
|
||
годятся ни в каком случае.
|
||
- **IP-адреса — нероутируемые диапазоны** (#39, решение владельца до реализации,
|
||
2026-08-02): сети RFC 5737 и 198.18/15 по ломтю на город, телефонам — CGNAT
|
||
100.64/10, как у настоящих операторов. Правдоподобные публичные адреса
|
||
принадлежат живым организациям, и в учебных данных им не место.
|
||
- **Вариант товара — справочник мира** (#40, 2026-08-02). Сырой `ecommerce`
|
||
несет `variant`, которого нет в плоских колонках; строки живут рядом со
|
||
справочниками устройств и городов, а не сочиняются на месте.
|
||
|
||
### Корзина и состав заказа
|
||
|
||
После приемки #40 (2026-08-02) на грилинге «просмотр → корзина» нашли два
|
||
точных равенства. В живых данных таких равенств нет: аналитик прочитает
|
||
их как склейку разметки вместо поведения людей.
|
||
|
||
- **Корзина шире заказа** (#40, решение владельца до реализации, 2026-08-02).
|
||
Посетитель кладет товары открытых карточек, а покупает не все. Иначе событие
|
||
корзины ничего не рассказывало бы сверх покупки: заказ был бы ее точной
|
||
копией. Карточка, открытая дважды, дает одну позицию — повторный просмотр
|
||
означает раздумье, а не второй товар. Количество штук — отдельный бросок:
|
||
обычно одна, изредка две-три. Отклонено: *заказ всегда из одного товара* —
|
||
список позиций как урок умирает, а на нем стоит единственный носитель навыка
|
||
«вложенный JSON» (мастер-спека, раздел 2); *досыпать неоткрытые товары* —
|
||
ломается шов #39; *бросать много позиций* — большинство заказов схлопывается в
|
||
один товар. Доля брошенных позиций позже пересмотрена в #50.
|
||
- **Событие корзины садится на карточку товара** (#40, решение владельца до
|
||
реализации, 2026-08-02), событие покупки — на подтверждение. В жизни корзину
|
||
шлет кнопка на карточке, а при нескольких товарах события иначе не разложить
|
||
по открытым карточкам.
|
||
- **Корзина отвязана от страницы `/cart`** (#40, 2026-08-02). До правки
|
||
множества визитов с событием корзины и со страницей `/cart` точно совпадали;
|
||
пересчитано при #50, актуальные числа — в таблице замера #50. Теперь положить
|
||
может любой визит, открывший карточку; `/cart` остается шагом воронки, а заказ
|
||
по-прежнему рисуется только после подтверждения. У визита на `/cart` всегда
|
||
есть хотя бы одна позиция — страница пустой корзины невозможна, — но открыть
|
||
корзину после добавления необязательно. Так в мире появляются те, кто положил
|
||
товар и не открыл корзину, — массовый сюжет магазина. Ошибка прошла нарезку,
|
||
потому что воронку #39 описали страницами, решение #40 — событиями, а вопрос
|
||
«какой визит кладет» заново не задали: страничное правило молча переехало на
|
||
уровень событий.
|
||
- **Намерение визита берется из шага воронки, а не из метки покупателя** (#50,
|
||
2026-08-02). Визит на `/cart` кладет почти все сравненное, прочий — редко.
|
||
Метка уже влияет через воронку; второе слагаемое посчитало бы ее дважды и
|
||
протянуло бы план состава через шов страниц и товаров. Плата: у визита без
|
||
намерения добавление не связано со свойством человека, но склонные покупать и
|
||
так преобладают среди дошедших до `/cart`.
|
||
- **Доля брошенных позиций опущена с 15% до 10%** (#50, 2026-08-02). Корзина
|
||
сузилась с 2,4 позиции до 1,7, и прежние 15% чаще выносили ее целиком:
|
||
спасенная позиция возвращала заказ к одному товару, и однопозиционных заказов
|
||
набиралось под 60%. Свойство сохранено, число перемерено — ровно так, как блок
|
||
#40 и оговаривал.
|
||
|
||
### Спрос товара и границы калибровки
|
||
|
||
- **Класть или нет решает и товар, а не только визит** (#40, 2026-08-02). Раньше
|
||
внутри воронки клали все карточки, вне ее — ни одной: конверсия по sku давала
|
||
чистый шум, и вопрос «какие товары кладут чаще» не имел ответа. Теперь в
|
||
каталоге три уровня спроса: магнит, обычный и залеживающийся, а их вероятности
|
||
— числа мира. Отклонено: *свой процент у каждого из 180 sku* — необоснованные
|
||
рукописные числа в механически дополняемом каталоге; *выводить спрос из цены*
|
||
— жесткая связь, в которой менти найдет только вложенное авторами правило.
|
||
- **Два ряда вероятностей, а не ряд с множителем** (#50, 2026-08-02): один для
|
||
визита с намерением, другой для смотрящего. У намеренного визита вероятности
|
||
высоки, иначе корзина и заказ схлопываются до одного товара; возле 100%
|
||
множитель съел бы разницу уровней и сделал бы конверсию уровней одинаковой.
|
||
Разрыв внутри рядов разный: у смотрящего товар решает почти все, а у
|
||
пришедшего покупать заслонен намерением.
|
||
- **Ни одному уровню спроса нельзя дать нулевую вероятность** (#50, 2026-08-02).
|
||
Ноль у залежавшегося товара вернул бы исходное равенство на четверти каталога:
|
||
добавление случалось бы только внутри визита с `/cart`. Отвязка либо относится
|
||
ко всему ассортименту, либо это не отвязка. Поэтому уровни разводятся не нулем
|
||
внизу второго ряда, а ослаблением залежавшегося товара в ряду визита с
|
||
намерением: пришедший покупать берет его реже ходового.
|
||
- **Уровень спроса — колонка `demand` в конце файла каталога** (#50,
|
||
2026-08-02), значения словами («магнит», «обычный», «залёживается»). Раздан он
|
||
одной колодой на весь каталог: доли по категориям расходятся сами (магнитов от
|
||
четырёх до девяти на тридцать строк). Раскладывать колоду поровну в каждую
|
||
категорию было бы той же неправдоподобной ровностью, какую тикет вычищал из
|
||
данных. Связь с ценой отклонена выше, и тест сторожит её отсутствие — по
|
||
четвертям цены и по медиане, потому что одни только края списка обманываются
|
||
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
|
||
не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
|
||
правкой; паспорту нужен хеш каталога (хвост #42) тем более, потому что теперь
|
||
в файле живёт ещё и поведение.
|
||
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%**
|
||
(#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои
|
||
позиции, а визит с намерением должен класть почти все сравненное; обе границы
|
||
тянут число вверх, и опустить долю можно лишь ценой одной из них. Место до 9%
|
||
невелико, следующая правка торговой половины считается против него.
|
||
- **Границы правки — вилки раздела 5** (#40, 2026-08-02): аудитория 6–8 тыс.,
|
||
средний день около 50 тыс. событий, конверсия визита около 2%. Корзина и
|
||
состав заказа перемеряются вместе; сохраняется свойство — заказ уже корзины,
|
||
однопозиционных заказов около половины.
|
||
- **Вилка 5–9% — ориентир, а не граница** (#50, решение владельца 2026-08-02). У
|
||
живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе
|
||
ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий
|
||
предел считается в другой валюте — дневной бюджет событий, на котором стоит
|
||
паспорт #42. Если будущая правка упрется в него, двигать надо бюджет и его
|
||
причины, а не долю.
|
||
|
||
### Номер заказа, промокод и деньги
|
||
|
||
- **Номер заказа — день и порядковый номер покупки** (#40, решение владельца до
|
||
реализации, 2026-08-02): событийная и бэкенд-сторона берут один номер из плана
|
||
— [ADR 0017](../adr/0017-order-numbering.md); по нему соединяется сверка
|
||
(мастер-спека, раздел 4).
|
||
- **Промокод в событии есть, скидки в сумме нет** (#40, решение владельца до
|
||
реализации, 2026-08-02). Клиент шлет сумму позиций без скидки и доставки,
|
||
поэтому код объясняет разрыв с итогом заказа. Класса расхождения промокод не
|
||
дает: сверка приведена к `items_total`, где скидки нет (мастер-спека, раздел
|
||
7). Код объясняет одну из причин, по которым «суммы не сойдутся»
|
||
(мастер-спека, раздел 4). Так и в жизни: код на сайте знает корзину, а не
|
||
окончательный расчет. Таблица «код → скидка» — число мира, которое этап 3
|
||
берет готовым, а не сочиняет заново. Отклонено: *промокода нет никогда* —
|
||
колонка формата осталась бы мертвой.
|
||
- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке** (#40,
|
||
решение владельца до реализации, 2026-08-02). Генератор считает целыми
|
||
копейками; цена товара в событии — целые рубли, как у Метрики (`productPrice`
|
||
там `Int64`); дробное число одно — `purchaseRevenue` типа `Float64`. Все 180
|
||
цен каталога были кратны рублю, и это делало урок про `Float64` беспредметным:
|
||
округлять нечего, копейки существовали только на словах. Поэтому часть цен
|
||
получила копейки (1 299,90 ₽ — обычная розница): `productPrice` округляется
|
||
самим форматом, а `purchaseRevenue` несет точную сумму. Разрыв живет внутри
|
||
события — считать выручку по разобранным массивам нельзя, и это настоящий урок
|
||
формата, а не придуманный.
|
||
|
||
### Событийный шов, формат и случайность
|
||
|
||
- **Шов для #40** (#39, 2026-08-02): день отдает выровненные по строкам ряды
|
||
страницы магазина и товара на карточке. По ним торговая половина знает, куда
|
||
повесить событие и какой товар посетитель видел: товар в корзине, которого
|
||
никто не открывал, был бы видимой ошибкой воронки. Назначенный заказ всегда
|
||
доходит до `/confirmation`, перед корзиной есть карточка. #39 не занимает
|
||
случайность #40: подпоток `COMMERCE` остается нетронутым.
|
||
- **Номера торговых событий — из торгового подпотока** (#40, 2026-08-02).
|
||
`WatchID` корзины и покупки рисует `COMMERCE`, поэтому правка торговли не
|
||
сдвигает трафиковые номера. Ряды подпотоков проверяются на непересечение:
|
||
уникальность нужна дедупликации при переигровке дня.
|
||
- **Массивы `purchase*` и `product*` разной длины между собой** (#40, решение
|
||
владельца до реализации, 2026-08-02). Внутри группы длина общая: `purchase*` —
|
||
по элементу на заказ, здесь всегда один, `product*` — по элементу на товар.
|
||
Мастер-спека (раздел 1) говорит о «параллельных массивах одной длины», и
|
||
прочесть это как общую длину всех массивов события легко — поэтому в описании
|
||
выгрузки сказано прямо.
|
||
- **Сырой `ecommerce` несет больше, чем плоские колонки** (#40, решение
|
||
владельца до реализации, 2026-08-02): бренд, вариант и `actionField` целиком.
|
||
Лаба «сырое против разобранного» (мастер-спека, раздел 1.2) имеет смысл,
|
||
только если в сыром есть сведения, которых нет в массивах. Строку собирает
|
||
канонический `orjson`-сериализатор, а не второй ручной сериализатор со своим
|
||
экранированием (рабочий выбор раздела 4).
|
||
|
||
### Замеры канонического мира
|
||
|
||
- **Измерено на собранном плане** (#38, перемерено при #40, 2026-08-02): приток
|
||
3 828 кук в день в среднем, дневная аудитория 6 637–7 595 — обе величины в
|
||
вилках раздела 5. Накопленная аудитория за 14 дней — 70,2 тыс. кук: 53,6 тыс.
|
||
новых и 16,6 тыс. возвратов предыстории. У 192 пар оба назначенных заказа
|
||
попали в снимок; остальные горизонт переживают. Числа считаются прогоном плана
|
||
без событий. Предыдущий замер пересчитан при #40; актуальные числа — здесь и в
|
||
таблице замера #50.
|
||
- **Числа дня** (#39, перемерено при #40, 2026-08-02): ≈1,4 визита на день
|
||
активности куки и ≈4,8 страницы на визит. Обычному посетителю дано 6% до
|
||
корзины, затем 45% и 55%; помеченному покупателю — 18% и 60%. Средний день: 9
|
||
879 визитов, 47 433 pageview, 4,80 страницы на визит, конверсия 2,4%.
|
||
|
||
**Измерено после правки #50** (2026-08-02; канонический мир, 14 дней; правый
|
||
столбец — до правки). Доли по уровням и состав заказа считаются по всему окну, а
|
||
не как среднее дневных долей: около 240 заказов в дне дают слишком шумную долю.
|
||
Трафиковая половина не сдвинулась ни на строку.
|
||
|
||
| величина | стало | было |
|
||
|---|---|---|
|
||
| дневная аудитория | 6 637–7 595, средняя 7 166 | без изменений |
|
||
| визитов в день | 9 879 | без изменений |
|
||
| событий в день, средних | 49 834 | 49 509 |
|
||
| просмотров карточек в день | 25 067 | без изменений |
|
||
| событий корзины в день | 2 161, в 1 306 визитах | 1 836, в 765 визитах |
|
||
| из них визиты без страницы `/cart` | 541 — 41,4% | 0 |
|
||
| визитов со страницей `/cart` без позиции | 0 | 0 |
|
||
| просмотров карточек, дошедших до корзины | 8,62% | 7,32% |
|
||
| конверсия карточки в корзину: магнит / обычный / залёживается | 13,1% / 9,2% / 5,8% | 7,7% у всех |
|
||
| событий корзины вне `/cart`: магнит / обычный / залёживается | 42,0% / 20,2% / 17,1% | 0 у всех |
|
||
| конверсия визита в покупку | 2,43% | без изменений |
|
||
| позиций на заказ | 1,74; однопозиционных 56,4% | 1,87; 50,3% |
|
||
| брошено позиций в покупающих корзинах | 7,25% | 12,0% |
|
||
|
||
Знаменатель «просмотров карточек» здесь — только просмотры страниц. При
|
||
постановке #50 в него попали и строки событий корзины: событие несет ту же
|
||
страницу, отчего вышло 26 903 просмотра и 6,82% вместо 25 067 и 7,32%. Величина
|
||
одна и та же, счет разный.
|
||
|
||
### Запуск, упаковка и приемники
|
||
|
||
Первые два решения владелец принял на грилинге 7 августа 2026 года при #41;
|
||
остальные приняты при исполнении #41 (2026-08-07).
|
||
|
||
- **Генератор живет в своем контейнере** (#41, 2026-08-07), и на этапе 5 даг
|
||
запускает контейнер, а не импортирует пакет в воркере. Так менти видит
|
||
отдельные окружение, образ и процесс задачи; зависимости не смешиваются с
|
||
Airflow. Это ступенька, из которой в бою вырастает `KubernetesPodOperator`, и
|
||
единственный вариант, где «генератор — отдельная и заменяемая сущность» виден
|
||
глазами. Хостовый `uv` остается для `make test`, `make lint` и `make
|
||
typecheck` из каталога `generator/`. Цена названа: сокет Docker в Airflow дает
|
||
доступ уровня root на хосте; для учебного стенда это принято, в бою так не
|
||
делают. Отклонено: *пакет в образе Airflow* — менти увидит лишь вызов функции,
|
||
а стенд заплатит сменой базового образа, контекста сборки и смешением
|
||
зависимостей; *`ExternalPythonOperator`* — изоляция есть и сокет не нужен, но
|
||
граница видна в конфигурации, а не в `docker ps`. Это запасной путь, если
|
||
сокет станет неприемлем.
|
||
- **Клиент Kafka — `confluent-kafka`** (#41, 2026-08-07). Он уже есть в образе
|
||
Airflow и пробнике `dags/test_kafka.py`, его использует официальный провайдер.
|
||
Клиент объявлен необязательной группой `[kafka]`, а приемник импортирует его
|
||
внутри функции, поэтому пакет живет и без Kafka — с файловым приемником. На
|
||
стенде остается один клиент вместо двух, а образ ставит его из файла
|
||
блокировки зависимостей.
|
||
- **Интерфейс запуска — `python -m clickstream_generator batch|live`** (#41,
|
||
2026-08-07). Параметры приходят аргументами или переменными окружения
|
||
(`GENERATOR_SEED`, `GENERATOR_DAY`, `GENERATOR_DAYS`, `GENERATOR_LIMIT`,
|
||
`GENERATOR_SPEED`, `GENERATOR_FILE`, `KAFKA_BOOTSTRAP_SERVERS`,
|
||
`KAFKA_TOPIC`), логи идут в стандартный вывод, итог виден кодом возврата.
|
||
Общие параметры — `--seed`, `--day`, `--days` и приемник `--file` либо
|
||
`--brokers` с `--topic`. Состояния нет: позицию ведет зовущий (раздел 8).
|
||
`--days` играет несколько дней одним запуском, чтобы стартовый мир не требовал
|
||
восьми запусков службы. Отклонено: *режимы через `--speed 0`* — у пакетного
|
||
режима есть пачка без ожидания, у живого — ожидание без пачки, а флаг прячет
|
||
разное устройство за числом; *приемник по умолчанию* — молчание может написать
|
||
в файл то, чего ждали в Kafka. Не назвать приемник — ошибка, назвать оба —
|
||
тоже.
|
||
- **Умолчание есть только у того, что описывает мир** (#41, решение владельца
|
||
2026-08-07). Каноническое зерно, `--days 1` и темп живого дня ×60 — свойства
|
||
модели: умолчание здесь означает тот же верный ответ. `--day`, адрес брокера,
|
||
топик и путь файла — факты стенда; без них прогон падает с ненулевым кодом до
|
||
первой строки. Генератор отдельный и переносимый, поэтому сведения об этом
|
||
стенде приносит зовущий — Compose, `make` или даг. Иначе забытый день мог бы
|
||
переиграть D0, выйти с успехом, подвинуть позицию и молча разойтись с миром.
|
||
Отличие от `--days 1` в том, что единица — минимальный верный ответ, а нулевой
|
||
день в забытом параметре — конкретный неверный. Переменные окружения — другой
|
||
канал зовущего, не умолчания; обвязка передает топик и день явно.
|
||
- **Ограниченная пачка — `--limit N` пакетного режима** (#41, 2026-08-07):
|
||
потолок событий на весь прогон. Она сочетается с файлом и дает быстро взять
|
||
срез, еще не приезжавший в ODS, для знакомства с конвейером. Менти не должен
|
||
ждать полный дневной поток, чтобы посмотреть путь данных. Число отправленного
|
||
печатается всегда и нужно проверкам #43. На живой день ограничение не
|
||
действует: ожидание там снимает ускорение.
|
||
- **Ключа у сообщения Kafka нет** (#41, 2026-08-07). Уникальный `WatchID` не
|
||
дает обещания порядка: сообщения с таким ключом не повторяются. Осмысленным
|
||
ключом был бы `ClientID`, но раскладку по разделам стенд намеренно оставляет
|
||
транспорту (раздел 2), и лаба о чтении топика живет тем, что она не
|
||
предрешена. Проверено 2026-08-07 по `CONFIGURATION.md` librdkafka и
|
||
`rd_kafka_conf_dump` библиотеки `confluent-kafka` 2.15.0: без ключа
|
||
`consistent_random` выбирает раздел, а `sticky.partitioning.linger.ms = 10`
|
||
временно прикрепляет соседние сообщения к одному разделу. Документация
|
||
умолчаний не называет, поэтому значения сняты с самой библиотеки. День идет
|
||
секунды и ложится в оба раздела, короткая пачка может уложиться в одно окно;
|
||
поэтому чтение обеими нодами проверяется полным днем и разово.
|
||
- **Приемников два, а режимов три** (#41, 2026-08-07). Kafka пачкой и Kafka с
|
||
темпом ×60 используют один приемник у разных зовущих; темп держит
|
||
проигрыватель, знающий время события. Приемник с ускорением снова знал бы о
|
||
содержимом — именно это запрещает правило глупых приемников из раздела 4.
|
||
- **Раскладка образа повторяет репозиторий** (#41, 2026-08-07): `/app` — корень,
|
||
пакет лежит в `/app/generator/src` и виден через `PYTHONPATH`, каталог — в
|
||
`/app/data`. Путь к каталогу считается от `catalog.py` вверх на `parents[3]`.
|
||
Обычная установка пакета перенесла бы модуль в `site-packages`, и тот же
|
||
подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
|
||
нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
|
||
|
||
### Паспорт мира и словарь
|
||
|
||
- **Паспорт мира один** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
|
||
2026-08-30). Стартовый мир этапа 2 — первые восемь дней, понедельник по
|
||
понедельник: полная неделя и первый замкнутый цикл K = 7.
|
||
`data/world-inventory.json` — тот самый паспорт, который обещает раздел 5.
|
||
Этап 7 оставил его восьмидневным: у хвоста в шесть дней не нашлось читателя
|
||
(резолюция — в разделе 5); вырастет он продолжением этого же файла, а не
|
||
вторым. В JSON лежат шапка паспорта — зерно, версия генератора и хеш каталога
|
||
— и строки дней с датой, числом событий и хешем. Паспорт собирает `make
|
||
inventory`, свежесть сторожит `test_inventory.py` тем же способом, что
|
||
свежесть описания выгрузки. Отклонено: *два файла, младший и полный* — две
|
||
правды об одном мире и лишнее слово в словаре.
|
||
- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
|
||
Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
|
||
каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
|
||
менялось: разошлись дни и каталог — CSV, только дни — код.
|
||
- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
|
||
(#42, решение владельца 2026-08-07; позднее тикет #214 сменил «опись мира» на
|
||
«паспорт мира»). Обычные русские слова убирают пару «манифест / мини-манифест»
|
||
и необходимость различать две сущности там, где вещь одна.
|
||
|
||
### Открытое на момент заморозки
|
||
|
||
- **`ParsedParamsKey1` остается пустой** (#39, 2026-08-02) — отложенное решение,
|
||
а не дыра. Вариант A/B-теста был бы постоянным свойством куки, а не поведением
|
||
дня; его заводит тикет #47.
|