Files
clickstream-data-platform/docs/specs/2026-08-01-generator.md
T
ddadminandClaude Fable 5 c1b61d81b9 feat(generator): эталонный мир зафиксирован равным стартовому
- Зачем:
  - у дней 8-13 описи не нашлось читателя: обещание эталона в 14 дней
    снято решением владельца 2026-08-30 (#192, этап 7 — #9).
- Что:
  - резолюция перенесена в обе спеки, докстринг описи и словарь CONTEXT.md;
  - добавлен тест: проигранный через CLI день совпадает с хешем описи;
  - сняты замеры: пересборка описи 20,5 с, снимок 460,7 МБ, make test
    176,5 с — записаны в карту проверок и таблицу бюджетов.
- Проверка:
  - cd generator && make lint && make typecheck && make test (437 passed);
  - git diff по data/world-inventory.json пуст.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:53:52 -04:00

98 KiB
Raw Blame History

Генератор (этап 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)» — разделы 1.4, 5, 9–11; заметка «Скорость батчевой генерации в Python».

Зачем

Мастер-спека решила, что генерирует стенд: широкое событие в 47 колонок, таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре развилки о том, как генератор устроен, она отложила: модель мира, границы детерминизма, источник истины схемы с разделением потока и пакета, числовые требования скорости. Карта #26 эти развилки прошла; спека собирает решения в одну картину. По ней этап 2 режется на тикеты (#34).

Здесь не переоткрывается решённое мастер-спекой: модель данных события, таксономия, анонимность, механика заказов (границы мандата #14). Генератор слепков заказов — этап 3: эта спека лишь не должна ему мешать. Один осознанный выход за границы: решение о хранении снимка (раздел 5) формально касается этапа 7 — расширение подтверждено владельцем в резолюции «Производительности».

Чем меряется генератор

Рамка владельца, по которой принимались решения ниже. Она шире этапа 2 и живёт дальше него: спеки следующих этапов ссылаются сюда, а не переписывают.

  • Генератор меряется тем, что приезжает на выход, а не устройством внутри. Он играет два источника с разными темпами: поток Метрики и ночную выгрузку бэкенда магазина. Любое внутреннее решение отвечает перед одним вопросом — правдоподобны ли данные на выходе.
  • Менти внутрь не смотрит. Он видит внешние эффекты и по ним составляет представление, как работает магазин и обо что там спотыкаются. Значит, конструкция внутри оправдана только наблюдаемым эффектом: без него это сложность, за которую никто не заплатил.
  • Повторимы должны быть эффекты, а не конкретные числа. Правка свойств мира сдвигает почти все числа разом — генератор случайных чисел выдаёт другую последовательность. Опираться на конкретные числа поэтому нечем. Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт молча: пересобрал опись — увидел diff.
  • Цена ошибки здесь мала. В худшем случае испорчен урок, и чинится он правкой генератора после взгляда на получившиеся распределения. Оборона, которая дороже ошибки, не заводится.
  • Сложность изолирована нарочно. Генератор вынесен отдельной службой, чтобы его сложность не расползалась по стенду. Плата за изоляцию — дешевизна правки: выбирается устройство, которое дёшево менять, а не то, которое всё предусмотрело.

Целевая картина одним взглядом

  • Мир — функция, не состояние. Состав мира — чистая функция зерна; модельный день D — функция (зерно, D). Между прогонами живут только зерно и позиция на оси времени.
  • Детерминизм до байта. Одно зерно — побайтово тот же снимок; сверка — хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
  • Схема — контракт генератора. Python-модуль с чистыми данными; хранилище строится по рендеренной документации, границу сторожит строгий приём на стороне хранилища.
  • Один сериализатор, глупые приёмники. День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
  • Числа. Средний день ~50 тыс. событий; эталонный снимок совпадает со стартовым миром — 8 дней; в git — только опись мира; автоматических порогов по времени нет.

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). Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
  • Два режима движения по одной оси. Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»; включается по требованию — постоянным фоном идёт, только пока включён выключатель пульта (ADR 0009).
  • Граница суток — единственный структурный шов. Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день переигрывается (раздел 4).
  • Поток и пакет совместимы по построению. День-функция выдаёт один упорядоченный поток событий; режимы отличаются только способом проигрывания — пачкой или с темпом.

Отклонено с доводами:

  • Мутирующее состояние мира («мир стареет»): ломает параллельность по дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум; ни один урок стенда на старении не стоит.
  • Чистая функция без слоя состава: глобальные инварианты пришлось бы выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
  • Привязка модельного времени к реальному календарю (T-1 с догоном): конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты эталонного мира зависимыми от даты запуска, опись теряет воспроизводимость.

Отклонено при исполнении #38 (2026-08-02):

  • Разгон вместо предыстории («первые дни малы — магазин запустился»): стартовый мир и половина снимка оказались бы на разгоне, недельная лаба сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы быть средним — пришлось бы двигать принятые числа раздела 5.
  • Материализованный план на горизонт: горизонт становится обязательным входом каждого запуска, стоимость старта растёт с прожитым; префиксную устойчивость даёт и ленивая форма — даром, через подпотоки по номеру дня.
  • Замкнутый пул кук со сменой поколений: постоянная память ценой фальшивой константы — потолка одновременно живущих кук, которого в жизни нет и который менти нечем объяснить.
  • Вероятностная гарантия пар («почти наверняка купит с обеих кук»): не гарантия — однажды опись покраснеет, а чинить нечем, кроме смены зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм этой лотереи невидим.
  • Покупка пары в первый визит куки: гарантия железная и дешёвая, но узор «все пары покупают в первый день» виден в данных ровно там, куда лаба склейки смотрит пристальнее всего. Условность допустима, пока она не видна в данных.

2. Детерминизм от зерна

Резолюция развилки «Детерминизм от зерна».

  • Обещание — содержимое до байта. Два прогона с одним зерном дают тот же набор событий: те же WatchID/VisitID, поля, метки модельного времени. Снимок при пересборке побайтово совпадает: канонический порядок ключей и строк; сверка — по хешам описи, а два локально пересобранных снимка сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала, _load_ts, темп живого дня.
  • Условия обещания. Детерминизм держится при зафиксированном 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.event_v эти имена берёт (раздел 7 мастер-спеки), но контракт их не диктует и тестами не сторожит.
  • Сторона хранилища пишется по документации, не генерируется. DDL ods.event, SELECT матвью, dds.event_v, трансформации — работа следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется к источнику. Границу сторожит боевой механизм — строгий приём (Nullable-разбор со сверкой набора ключей, таблицы *_errors — раздел 6 мастер-спеки). Сверка объявлений (system.columns поднятого стенда против контракта) здесь стояла вторым механизмом и снята при исполнении #43: сверх строгого приёма она ловила ровно одно — смену типа колонки, — а её ловит и сверка разобранного события, причём на живых данных, а не на объявлениях.

Отклонено с доводами:

  • Автогенерация DDL хранилища из контракта (буква раздела 1.4 мастер-спеки до правки): пересекает границу ответственности компонент — в бою хранилище адаптируется к источнику руками, менти пришлось бы объяснять приём, которого в жизни нет. Data contract даёт тот же щит от дрейфа без этой условности.
  • YAML как форма контракта: красота ценой загрузчика и «схемы для схемы»; потребителя вне Python нет — хранилище читает рендеренную документацию, не машинный файл.

4. Поток и пакет: сериализатор, проигрыватель, приёмники

Резолюция развилки «Архитектура», часть вторая.

  • Один канонический сериализатор, глупые приёмники. День-функция выдаёт упорядоченный поток канонических байтов — единственное место, где событие превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для пересборки и проверок описи), Kafka пачкой — пакетный режим, Kafka с темпом ×60 — живой день. Новых топиков нет.

  • Одно событие — одно сообщение Kafka. «Пачкой» относится к темпу отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое отдельным сообщением. Контракт транспорта, не деталь реализации — сторона хранилища читает топик байтами и кладёт сообщение строкой (ADR 0005), поэтому склейка нескольких событий в одно сообщение сломала бы разбор целиком.

  • Даты и время на проводе — 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). То, что форма на проводе одна и каноническая, здесь работает на хранилище: раз запись ровно одна, разбирать её можно строго, не принимая заодно десяток чужих записей.

    Форму реализует сериализатор (#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. Числа: объёмы, режимы, бюджеты

Резолюция развилки «Производительность»; порядки величин — исследование #31.

  • Средний модельный день — ~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, теперь он упрётся в красный чек описи; смягчение — CI гоняет генерацию на amd64 и arm64.
  • Живой день — ×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 года). Порог оказался в восемнадцать раз выше факта: зелен при любой правдоподобной регрессии, то есть не сторож, а украшение. Как здесь говорят о времени, к тому дню уже решила карта целей: цена — замеренное число с датой, а не назначенный предел.

Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель печатает тайминги генерации и доставки раздельно, лаг живого дня — в его логе, а 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). Проигрыватель состояния не хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она переменной Airflow: отсутствие переменной означает, что стартовый мир ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, — значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла используют -1 для незавершённой очистки, 0 для чистого мира до генерации и ставят первый несыгранный день только после приёма стартового мира (ADR 0013). Довод — генератор отдельная и заменяемая сущность, привязывать его к хранилищу незачем. Полный make clean сносит метаданные Airflow вместе с данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с заново залитым миром. Расхождение переменной с данными (менти почистил партицию руками) лечится документацией, а не сторожем: постоянная сверка была бы проверкой, красной в норме (ADR 0002).
  • Этап 7 (эталонный мир): пересборка — это опись, не артефакт; CI-генерация на amd64 и arm64.
  • Будущие лабы: перезаливка дня X пакетным режимом проигрывателя — готовая демонстрация идемпотентности конвейера.

9. Вопросы нарезки этапа 2 (#34): решения и остатки

Журнал закрыт 28 августа 2026 года. Он хранит историю решений этапа 2; основания модели времени, паспорта куки и номера заказа вынесены в ADR 0015, ADR 0016 и ADR 0017. Новые решения генератора записывают в 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.
  • Слово «визит» остается за сессией (#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. Прежние счетчики пересчитаны при #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; по нему соединяется сверка (мастер-спека, раздел 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). Обычные русские слова убирают пару «манифест / мини-манифест» и необходимость различать две сущности там, где вещь одна.

Открытое на момент заморозки

  • ParsedParamsKey1 остается пустой (#39, 2026-08-02) — отложенное решение, а не дыра. Вариант A/B-теста был бы постоянным свойством куки, а не поведением дня; его заводит тикет #47.