Files
clickstream-data-platform/docs/specs/2026-08-01-generator.md
T
ddadminandClaude Opus 5 923ebad80e docs(storage): связность восстановлена, форма дат на проводе задана
- Зачем:
  - холодное ревью связности нашло девять мест, где вставленный текст спорит с
    соседним; отдельно вскрылось, что представление дат в JSON не зафиксировано
    нигде, а #43 обязан его знать раньше, чем #41 напишет сериализатор.
- Что:
  - гарантия приёма переписана: после снятия синхронной вставки «хотя бы один
    раз» стало неправдой — есть и окно потери, и окно дубля.
  - критерий выбора пяти опорных колонок приведён к списку, который он
    порождает; `CounterID` оговорён отдельно.
  - «переобработки у ODS нет вовсе» смягчено до пакетной: ручная вставка из
    сырья в пределах окна возможна.
  - в спеку генератора добавлена форма дат на проводе — ISO-8601, с доводом от
    читаемости слоя сырья.
  - убраны осиротевшая фраза про порядок сервисов, дубль порядка классов брака,
    устаревшая датировка сверки и ещё три следа вставок.
- Проверка:
  - make config-test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:53:03 +03:00

879 lines
90 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
Статус: Accepted — принята владельцем 2026-08-01 (PR #35).
Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
(#29), производительность (#30); исследование скорости (#31).
Источники: мастер-спека
[«Боевой реализм стенда (v2)»](2026-07-30-stand-v2-realism.md) — разделы 1.4,
5, 911; заметка
[«Скорость батчевой генерации в Python»](../research/2026-08-01-python-batch-generation-speed.md).
## Зачем
Мастер-спека решила, **что** генерирует стенд: широкое событие в 47 колонок,
таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре
развилки о том, **как** генератор устроен, она отложила: модель мира,
границы детерминизма, источник истины схемы с разделением потока и пакета,
числовые требования скорости. Карта #26 эти развилки прошла; спека собирает
решения в одну картину. По ней этап 2 режется на тикеты (#34).
Здесь не переоткрывается решённое мастер-спекой: модель данных события,
таксономия, анонимность, механика заказов (границы мандата #14). Генератор
слепков заказов — этап 3: эта спека лишь не должна ему мешать. Один
осознанный выход за границы: решение о хранении снимка (раздел 5) формально
касается этапа 7 — расширение подтверждено владельцем в резолюции
«Производительности».
## Целевая картина одним взглядом
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
модельный день D — функция (зерно, D). Между прогонами живут только зерно
и позиция на оси времени.
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
хранилище строится по рендеренной документации, границу сторожит
contract-тест.
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
в git — только манифест; автоматический порог один — день ≤ 30 с.
## 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). Между прогонами живут только зерно и
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
с эталонного снимка, дальше «прожить следующий день» — явное действие.
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
«дышат»; включается по требованию, не постоянный фон.
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
переигрывается (раздел 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);
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
торговые события, расхождения, опоздания — в фиксированном порядке.
По построению: параллельный прогон равен последовательному; продление
истории днём 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
мастер-спеки) и contract-тест в smoke — сравнение `system.columns`
поднятого стенда со схемой генератора.
Отклонено с доводами:
- *Автогенерация 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` в обычном клиенте и
разбирает событие глазами, а число эпохи этот урок убивает. Разбору это
ничего не стоит: `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')`
принимает ISO без плясок. Колонка `ecommerce` — строка, внутри которой лежит
экранированный JSON, как отдаёт Метрика.
Форму пинит хранилище (#43) как первый потребитель, сериализатор (#41)
её соблюдает. Порядок тикетов обратный порядку зависимости, поэтому здесь она
и записана — иначе каждый выберет своё, и разойдётся это уже после приёмки.
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 514 раз,
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
Смена библиотеки меняет канонические байты, поэтому проходит как
обновление зависимости: осознанная пересборка манифеста одним PR.
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
потерял — пересчитал. В git снимок не попадает (раздел 5).
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
повторы: `WatchID` детерминированы, повтор — та же строка для
ReplacingMergeTree.
- **Эталонный снимок при старте стенда — через Kafka, пакетным режимом
проигрывателя.** Отдельный механизм заливки не строится: каждый `make up`
бесплатно прогоняет весь конвейер и contract-тест на настоящих данных.
Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой
загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий —
расчётно минута-две, запас есть.
Отклонено с доводами:
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
манифестом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
мастер-спеки).
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
через дедуп, канон держит единственный сериализатор, а не диск; файл
остаётся только там, где нужен кэш.
- *Прямая загрузка снимка в ClickHouse* (и гибрид с ручным заполнением
сырого слоя): второй путь приёма, пустой либо поддельный `stg.hits_raw`
теряются переобработка дня X по `event_date` и урок виртуальных колонок
«какая нода читала топик».
## 5. Числа: объёмы, режимы, бюджеты
Резолюция развилки [«Производительность»](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 тыс. посетителей —
правдоподобный средний магазин.
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
понедельник. Самая короткая длина, при которой есть замороженная зона за
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
месяца — дешёвый ход (пересборка манифеста), если понадобится.
- **В git — только манифест, снимок не хранится.** Снимок генерируется при
`make up` и при проверках: артефакт — кэш чистой функции, кэш в git не
хранят. Манифест несёт паспорт мира, счётчики и хеши по дням; проверки
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём.
Каждый `make up` — живая демонстрация детерминизма. Честная потеря —
страховка на случай платформенного бага: раньше менти с расходящимися
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
манифеста; смягчение — CI гоняет генерацию на amd64 и arm64.
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
в пиковые часы сильных дней — до ~100. Число —
значение по умолчанию, переопределяется флагом проигрывателя: ускорение —
свойство транспорта, вне обещания воспроизводимости, константой мира не
делается.
### Бюджеты и способ замера
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
железе кончаются ритуальным удалением проверки.
Ориентиры на референсной машине — в спеке, без автоматики:
| Операция | Ориентир |
|---|---|
| Генерация одного дня | секунды |
| Пересборка эталонного мира (14 дней + манифест, без транспорта) | до минуты |
| Заливка снимка при `make up` (Kafka → матвью → ODS) | минуты |
| Лаг живого дня | секунды |
**Автоматический порог один: полный день (50 тыс. событий) генерируется
≤ 30 с.** Расчёт по планке исследования (~2–5×10⁵ событий/с на ядро) — доли
секунды; порог держит машинный разброс ×2–5 и ловит деградацию на 1–2
порядка: Faker в горячем цикле, случайная квадратичность. Реализация —
pytest-тест с маркером `perf` и таймаутом-обрубанием: обязателен в CI,
исключён из быстрой локальной петли, зовётся отдельной целью при правках
горячего цикла.
Остальное — наблюдаемость без порогов: `make up` и smoke печатают тайминги
(генерация и доставка отдельно), проигрыватель логирует лаг. Прототип-замер
до этапа 2 не нужен: числа назначены с запасом порядок и больше от планки
исследования, планка подтверждена локальной проверкой на машине стенда;
первый замер настоящего кода — порог этапа 2.
Отклонено с доводами:
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
впритык — повторение истории с памятью (ADR 0004); порог оставлен один,
грубый, между «×5 шума» и «×100 беды».
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
медленный генератор — съедено детерминизмом и скоростью; остаётся только
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
раздутия, но лишняя машинерия и вторая правда.
- *Снимок 7 дней*: ни одного замороженного дня, сезонность без сравнения.
*30 дней*: нового урока не даёт — отложено как дешёвое удлинение.
- *×120 / ×1440*: волна смазывается в перемотку либо превращается в пакет с
анимацией — живой режим теряет смысл.
## 6. Правила кода этапа 2
Хвосты резолюций, обязательные для реализации:
- **Целочисленная случайность** — правило кода, а не пожелание: диапазоны и
выбор из таблиц целыми, плавающие распределения системной математики не
звать; деньги — в целых копейках.
- **Случайные значения — векторно из numpy (PCG64)**; посточный цикл — лишь
там, где логика действительно посточная (цепочки сессий).
- **Посточные фейкеры (Faker, mimesis) — только для справочников** (каталог
и прочие справочные строки), не в горячем цикле: они медленнее numpy на
2–3 порядка. Выбор библиотеки — этапу 2: справочники генерируются один
раз, скорость безразлична; возможно, хватит таблиц-литералов в коде и
фейкер не понадобится вовсе.
- **Сериализация — через единственный канонический сериализатор** (orjson);
прямых `json.dumps` по коду нет.
- **Распараллеливание — multiprocessing по модельным дням**; внутри дня —
однопоточно, единица работы и так крупная.
## 7. Расхождения с мастер-спекой
Внесены в мастер-спеку тем же коммитом, что и эта спека:
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
contract — хранилище пишется по документации, границу сторожит
contract-тест (раздел 3 здесь).
- **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом;
снимок генерируется на месте (раздел 5 здесь). Туман «политика
версионирования артефакта» закрыт этим же ходом: версионируется манифест.
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
производительности» закрыт числами раздела 5.
- Мелкие согласования там, где текст опирался на артефакт в git: источник
переобработки при исчерпании retention Kafka (раздел 7), формулировка
этапа 7 (раздел 9).
Внесены при исполнении #40 (2026-08-02):
- **Раздел 1**: «в параллельных массивах одной длины» уточнено — длина общая
внутри группы, а `purchase*` и `product*` между собой разной длины.
- **Разделы 4 и 7**: механика класса `amount_delta` названа честнее.
Округления `Float64` сами по себе расхождения не дают: деньги считаются
целыми копейками, и дробное число в событии одно. Класс создаёт генератор
намеренно — так мастер-спека и допускала вторым вариантом.
## 8. Хвосты следующим этапам
- **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит
промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе
стороны берут один и тот же (раздел 9).
- **Этап 4 (сверка)**: копейки в ценах каталога дают разрыв внутри события —
`productPrice` округлён форматом, `purchaseRevenue` точна. В сверку он сам
не попадает: клиент шлёт точную сумму, и она сходится с `items_total`
бэкенда. Класс `amount_delta` этап 4 всё равно создаёт намеренно —
мастер-спека (раздел 4) это и допускает; материал для разговора про Float64
теперь есть.
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
CI-генерация на amd64 и arm64.
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
готовая демонстрация идемпотентности конвейера.
## 9. Вопросы нарезки этапа 2 (#34): решения и остатки
Туман карты разложен нарезкой по тикетам; решения фиксируются здесь по
мере исполнения.
Решено при исполнении #38 (2026-08-02):
- **Числа притока и состава.** Приток — ~3 800 новых кук в средний день,
модулируется недельным профилем мира; трафик наследует эту волну через
дневную аудиторию, а не отдельным умножением (уточнение при исполнении
#39 — ниже). Доля одноразовых кук — 75%;
возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все
в первые 7–10 дней, тонкий хвост поздних возвратов и повторных
покупок — до края окна (цикл повторной покупки магазина — месяцы).
Хвост возвратов — окно активности человека от его первого дня,
общее на обе его куки, — и глубина предыстории: 90 дней (решение
владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы
старых посетителей продолжаются весь прогон; плата — чуть меньше пар,
полностью реализованных внутри 14-дневного снимка). Покупатели
считаются людьми, не куками; доля покупателей — 5% людей когорты, а
людей в когорте почти столько же, сколько кук: вторые куки пар
добавляют меньше процента. Доля — число плана, а не торгового тикета: без него не
отобрать двухкуковые пары; #40 наследует его, не переоткрывая.
Сходимость с разделом 5: средняя кука активна ≈1,9 дня → дневная
аудитория ≈7 100 — середина вилки 6–8 тыс.; уникумов за 14 дней
снимка — под 70 тыс.: новые куки плюс возвраты предыстории (точные
числа — в измерении ниже; не путать с ~50 тыс. событий одного дня) —
рост `uniq(ClientID)` с горизонтом виден сразу; новых покупателей
~190 в день → конверсия ~2% на сессию.
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
свежими; привязки к реальному календарю у констант мира всё равно нет.
- **Измерено на собранном плане** (канонический мир, 14 дней; числа
перемерены при исполнении #40 — рычаг долгой жизни помеченного
покупателя сдвинул состав): приток 3 828 кук в день в среднем, дневная
аудитория 6 637–7 595 — обе величины в вилках раздела 5. Накопленная
аудитория за снимок — 70,2 тыс. кук: 53,6 тыс. новыми плюс 16,6 тыс.
возвратами предыстории (при нарезке возвраты предыстории оценили
скромнее — отсюда ходившая раньше оценка ≈60 тыс.). Пар, у которых оба
назначенных заказа попали внутрь 14 дней, — 192; остальные пары горизонт
переживают, их вторая кука приходит позже. Цифры пересчитываются
прогоном плана, событий для них не нужно. До правки #40 те же измерения
давали: аудитория 6 235–7 124, накопленная 68,1 тыс. (14,5 тыс.
возвратами предыстории), 170 пар.
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
все числа мира в одном месте, написанном как приглашение любопытному
менти крутить. Правка модуля — смена мира: чек манифеста честно
краснеет, манифест сторожит только канон. Вне модуля — лишь то, что
мира не меняет: своё зерно и транспортные флаги проигрывателя.
Отклонено: внешний конфиг и env-переопределения — переменная мира,
которую паспорт манифеста не видит; файл-конфиг в репозитории — по
смыслу равен модулю, но платит загрузчиком и валидацией (довод
раздела 3 против YAML).
Решено при исполнении #39 (2026-08-02) — решения владельца до реализации:
- **Каталог товаров заводится здесь, а не в #40.** У карточки товара есть
`Title` — имя товара, а имена живут в каталоге: карточка без каталога
невозможна, и без карточек #39 сделал бы примерно половину трафика.
Файл `data/catalog/products.csv` (`sku`, `name`, `category`, `brand`,
`price`) — общий у генератора и словаря ClickHouse; #40 получает готовый.
Решена форма, а не длина: артикул — четыре буквы категории и четыре
цифры, цена — целые копейки, категорий шесть. Строки дописываются
механически, поэтому ни код, ни тесты их не считают, а товар для карточки
выбирается равномерно внутри категории.
- **Ассортимент — непродовольственная розница** (товары для дома, текстиль,
посуда, мелкая бытовая техника, детское, одежда и обувь), ~150200 sku.
Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной
покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит.
Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38.
- **География — один регион присутствия**: Поволжье с центром в Самаре,
города своего региона и тонкий хвост остальной страны. «Топ городов
России» дал бы магазину с одним складом карту, которой у него не бывает.
- **Модельные сутки считаются в часовом поясе счётчика** (UTC+4), как в
выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime`
абсолютная метка. Шов суток приходится на местную полночь и не режет
утренние сессии, а регион мира выбирается свободно. Суточная волна задана
в местном времени посетителя: гостю из другого пояса профиль
поворачивается на разницу, и пик слегка размазывается — как в жизни.
Следствие для соседних этапов: `toDate(UTCEventTime)``EventDate`; оно
записано в описание выгрузки, иначе сторона хранилища выведет дату сама и
разойдётся на несколько часов данных.
- **Паспорт куки — постоянная часть мира, а не поведение дня.** Браузер, ОС,
устройство, экран и город у куки одни во всех её днях: кука — это браузер
на устройстве. Держит их план состава (`Cohort` и `DayAudience` прирастают
двумя массивами), разворачивает в поля события день-функция. Выводить
паспорт арифметикой из `ClientID` дешевле, но про двухкуковые пары знает
только план, а два города у одного человека — ложь в данных; пара
получает один город и разные устройства («телефон и ноутбук»,
мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому
измеренные числа канонического мира не сдвинулись: прогон счётчиков до и
после дал те же 3 828 / 6 2357 124 / 68 119 / 170.
- **Справочники — таблицы-литералы** рядом с конфигурацией мира; Faker и
mimesis не подключаются. Нужны не случайные строки, а связки (город → id
региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их
фейкер не даёт, таблицу пришлось бы написать всё равно, а новая
зависимость молча меняла бы мир при обновлении своих словарей.
- **Гео-id — настоящие числа геобазы Яндекса.** Проверка 2026-08-02: id
подтверждались обращением к живым сервисам Яндекса по тому же номеру
(`yandex.ru/pogoda/<id>`, `yandex.ru/maps/225/russia/`) — страница
открывает ожидаемое место. Оговорка стоит в коде: опубликованной таблицы
геобазы найти не удалось, а что `RegionCityID` Метрики нумерует регионы
той же геобазой — обоснованное допущение, не подтверждённый источником
факт. Выдуманные по памяти числа не годятся ни в каком случае.
- **IP-адреса — нероутируемые диапазоны**: документационные сети RFC 5737 и
benchmark-сеть 198.18/15 по ломтю на город, телефонам — CGNAT 100.64/10,
как у настоящих операторов. Правдоподобные публичные адреса принадлежат
живым организациям, и в учебных данных им не место.
- **Словарь: «визит» остаётся за сессией** и полем `VisitID`. Одноимённое
понятие плана состава переименовано в «день активности»: у куки он один на
день, а визитов внутри дня бывает несколько.
Решено при исполнении #39 самой реализацией:
- **Правила резки визитов** (мастер-спека, раздел 1.2 требовала их
задокументировать): визит принадлежит одной куке; пауза дольше 30 минут
рвёт визит надвое; граница модельных суток режет визит, события за
полночь в день не попадают. Единственное исключение — визит с заказом,
обещанным планом двухкуковых пар: его старт сдвигается назад, чтобы
воронка уместилась в сутки. Это принятое ограничение модели, а не
недосмотр: обещание плана — гарантия, на которой стоит лаба склейки, и
ради неё мы сужаем свободу старта. Цена названа и мала: около 24 визитов
в день никогда не начинаются в последние минуты суток (из ~9,5 тыс.).
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
заново, сверяя сборку с `VisitID`.
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
визит. Воронка при исполнении #39 была общей для всех: 8% визитов до
корзины → 45% из них до оформления → 55% из них до подтверждения, то есть
конверсия визита ~2% при 9,5 тыс. визитов и ~45 тыс. pageview в средний
день. При исполнении #40 воронка развелась надвое (блок ниже): обычному
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
оттуда же они лягут в манифест.
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
недельной лабе однажды не хватит сигнала, принципиальный путь — отдельный,
более слабый профиль активности, а не повторное применение профиля
притока: механизмы разные, и «на выходных приходит меньше новых людей» —
не тот же факт, что «на выходных каждый ходит меньше».
- **`ParsedParamsKey1` остаётся пустой** — это отложенное решение, а не
дыра: вариант A/B-теста был бы постоянной куки, а не поведением дня, и
заводится тикетом #47.
- **Шов для #40**: день отдаёт, кроме колонок, два выровненных по строкам
ряда — какая это страница магазина и какой товар показывала карточка. По
ним #40 знает, куда вешать торговое событие и что посетитель на самом
деле смотрел: товар в корзине, которого никто не открывал, — видимая
глупость в воронке. Визит с назначенным заказом всегда доходит до
`/confirmation`, а перед корзиной у него всегда есть карточка. Своей
случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут.
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации.
Три решения выходят за границы тикета: правятся план состава (#38), воронка
(#39) и каталог товаров (#39). Сейчас это дёшево — манифест ещё не собран
(#42); позже обошлось бы его пересборкой. Часть решений принята после
холодного ревью первой редакции блока.
- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек,
которые открывал в этом визите, а покупает не всё: около 15% позиций
остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх
покупки — заказ был бы её точной копией, и сравнивать было бы нечего.
Замерено на мире до правок: у покупающего визита 2,1 карточки, в заказе
выходит 1,9 позиции, однопозиционных заказов около половины. Карточка,
открытая в визите дважды, даёт одну позицию, а не две: повторный просмотр —
раздумье, а не второй товар. Количество штук — свой бросок: обычно одна,
изредка две-три. Отклонено: *заказ всегда из одного товара* — список
позиций как урок умирает, а на нём стоит единственный носитель навыка
«вложенный JSON» (мастер-спека, раздел 2); *досыпать в корзину товары,
которых посетитель не открывал* — ломает шов #39 ровно там, ради чего он
строился; *бросать много* (40% и выше) — две трети заказов схлопываются в
один товар.
- **Покупатель перестаёт быть словом — двумя рычагами сразу.** Метка плана
(«этот человек склонен покупать», 5% людей когорты) до сих пор ни на что не
влияла: кто купит, решал одинаковый для всех бросок в дне. Теперь
помеченный, во-первых, доходит до заказа заметно чаще прочих, во-вторых,
дольше живёт и чаще возвращается. Первый рычаг — про правдоподобие:
склонность покупать и вправду свойство человека, а не свойство визита.
Второй — про видимость: метки в событии нет и не будет (кликстрим
анонимен), поэтому «постоянный покупатель» читается в данных только как
кука, которая ходит неделями и покупает не раз. Одним лифтом это
недостижимо: кука живёт меньше двух визитов за снимок, и второй покупке
негде случиться — лифт поднял бы долю кук с двумя покупками с 2% до 6% и на
том исчерпался. Рычаг жизни лежит в плане (#38), рычаг конверсии — в
воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки:
и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто
пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал
бы половину картины. Числа под каждый шаг — калибровка: помеченному 18% до
корзины и 60% из корзины в оформление против 6% и 45% у обычного
посетителя. Обычного калибровка тоже задела — его доля до корзины опущена
с 8% до 6%, и это единственное число мира, изменённое вне двух рычагов.
Довод: помеченные живут дольше и занимают уже 12% дневной аудитории вместо
5% людей когорты, поэтому при прежних 8% конверсия мира ушла бы к 2,9% —
за вилку. Рычаг метки не должен оплачиваться ростом конверсии, и границы
правки — вилки раздела 5: дневная аудитория остаётся в пределах
6–8 тыс., средний день — около 50 тыс. событий, конверсия визита —
около 2%. Расти внутри вилки аудитории
разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин.
Измерено после правки (канонический мир, 14 дней): в средний день
аудитория 7 166, визитов 9 879, событий 49 509, визитов с корзиной 765
(7,7%) и с покупкой 240 (2,4%) — все три вилки выдержаны. База до правки
была: аудитория 6 710, визитов 9 249, событий 44 436, визитов с корзиной
780 (8,4%) и с покупкой 221 (2,4%). Числа состава мира (блок #38 выше)
перемерены тем же прогоном. Уточнение к разделу 1: вольные покупки
по-прежнему решает день, но
случайность в них больше не одинакова для всех. Отклонено: *оставить
монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без
долгой жизни* — довод выше, повторных покупок он почти не прибавляет;
*покупают только помеченные* — 5% людей дают около 40 заказов в день вместо
220, вернуть их можно, лишь подняв долю покупателей до 10–30% людей, а с
ней вырастет и число двухкуковых пар.
- **Событие корзины садится на карточку товара**, событие покупки — на
страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на
карточке, а не открытием страницы корзины; при нескольких товарах в заказе
иначе его и не разложить — событий столько, со скольких карточек положили.
- **Номер заказа читаемый**: день модельного времени и порядковый номер
покупки в этом дне (`20260603-0042`). Магазины так и нумеруют, и по номеру
сразу видно день. Он же — `order_id` бэкенда: обе стороны получают один
номер, по нему соединяется сверка (мастер-спека, раздел 4). Нумеруются все
покупки, дошедшие до потока дня, в порядке событий — до всяких потерь.
Расхождения этапа 6 выбрасывают событие, когда номер уже присвоен: иначе
одна потеря перенумеровала бы чужие заказы, и мост к бэкенду разъехался бы.
- **Промокод в событии есть, скидки в сумме нет.** Часть заказов уходит с
промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и
доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта.
Класса расхождения промокод не даёт: сверка приведена к `items_total`, где
скидки и нет (мастер-спека, раздел 7). Он объясняет разрыв между клиентской
суммой и итогом заказа — одну из причин, по которым «суммы не сойдутся»
(мастер-спека, раздел 4). Таблица «код → скидка» — число мира и живёт
здесь: этап 3 берёт её готовой, а не сочиняет заново. Отклонено:
*промокода нет никогда* — колонка формата осталась бы мёртвой.
- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке — и
копейки в части цен каталога.** Генератор считает целыми копейками; цена
товара в событии — целые рубли, как у Метрики (`productPrice` там `Int64`);
дробное число одно — `purchaseRevenue` типа `Float64`. Все 180 цен каталога
кратны рублю, и это делало урок про Float64 беспредметным: округлять
нечего, копейки существуют только на словах, а расхождение сумм пришлось бы
выдумывать вероятностью. Поэтому часть цен получает копейки (1 299,90 ₽ —
обычная розница): `productPrice` округляется самим форматом, а
`purchaseRevenue` несёт точную сумму. Разрыв живёт внутри события — считать
выручку по разобранным массивам нельзя, и это настоящий урок формата, а не
придуманный.
- **Массивы `purchase*` и `product*` разной длины между собой.** Внутри своей
группы длина общая: `purchase*` — по элементу на заказ (у нас всегда один),
`product*` — по элементу на товар. Мастер-спека (раздел 1) говорит о
«параллельных массивах одной длины», и прочесть это как «все массивы
события одной длины» легко — поэтому в описании выгрузки сказано прямо.
- **Сырой `ecommerce` несёт больше, чем плоские колонки.** Лаба «сырое против
разобранного» (мастер-спека, раздел 1.2) имеет смысл, только если в сыром
лежит то, чего в массивах нет: бренд товара, его вариант, блок
`actionField` целиком. Собирает строку канонический сериализатор — руками
это второй сериализатор со своим экранированием; зависимость `orjson`,
названная рабочим выбором в разделе 4, появляется здесь, а не в #41.
- **Торговый хвост умещается в сутки резервом, а не зажимом.** Событие
покупки встаёт на несколько секунд позже просмотра страницы подтверждения,
событие корзины — позже своей карточки, а граница суток режет всё, что за
неё вышло. Визит с обещанным планом заказом уже подвинут так, чтобы
уместиться в сутки (#39), но подвинут по страницам — торгового хвоста в том
расчёте не было. Резерв расширяется на хвост: тогда правило «покупка на
секунды позже подтверждения» держится всегда, а зажим к последней секунде
ломал бы его ровно там, ради чего написан. Визит, у которого страница
подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа
не было.
- **Счётчика покупок в плане состава не заводится.** Тикет просил «счётчики
покупок и двухкуковых пар сходятся с планом», но покупок план не считает и
считать не будет: раздел 1 обещает до генерации только состав — посетителей,
приток, пары, — а торговые счётчики складываются из поведения дня. Квота на
покупки заставила бы день добирать до точного числа, и конверсия в данных
вышла бы подозрительно ровной — условность видна там, куда лаба смотрит.
Сходятся пары; покупки проверяются постфактум по сгенерированному дню и по
вилке конверсии (~2% на визит).
Решено при исполнении #40 самой реализацией:
- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило
резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это
расстояние от последней страницы, когда последним событием визита бывает
покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы
два визита в один и разошлась бы с `VisitID`. Расстояние считается от
последнего события; поймано тестом сборки визитов.
- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток
сначала считался только для визитов с обещанным планом заказом, и у
вольной покупки оставалось окно в несколько секунд: страница
подтверждения дожила до потока, а событие покупки за полночь вышло. Это
была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по
недосмотру. Теперь запас под торговый хвост есть у всякой страницы
подтверждения: либо она уходит за границу суток вместе с покупкой, либо
остаются обе. Это ровно то правило, которое блок выше называет словами
«визит, у которого страница подтверждения срезана полуночью, покупки не
даёт»; в каноническом мире числа от правки не сдвинулись — случай не
выпал ни разу за 14 дней.
- **Задержка торгового события короче самой короткой паузы между
страницами.** Тогда событие корзины не обгоняет страницу, на которой
посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите
всегда идёт страница, которая полночь пережила.
- **Номера торговых событий — из торгового подпотока.** `WatchID` строки
корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка
торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков
проверяются на непересечение — обещание уникальности держит дедупликацию
при переигровке дня, и предполагать его нельзя.
- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`,
которого в плоских колонках нет; строки живут в справочниках рядом с
устройствами и городами, а не сочиняются на месте.
Решено при исполнении #40 после приёмки (2026-08-02) — грилинг тождества
«просмотр → корзина». Приёмка нашла два точных равенства: каждое в живых
данных не встречается и потому читается аналитиком как склейка в разметке,
а не как поведение людей.
- **Корзина отвязана от страницы корзины.** Замерено до правки на всех 14
днях канонического мира: множества «визиты с событием корзины» и «визиты
со страницей `/cart`» совпадали в точности — 828 и 828 на дне 2, и так
каждый день без единого расхождения. Событие корзины
возникало только у визита, дошедшего до воронки, то есть факт добавления
идеально предсказывался более поздним просмотром страницы. Теперь
положить может любой визит, открывший карточку; страница `/cart`
остаётся шагом воронки и решается как прежде, а заказ по-прежнему
рисуется только у визита, дошедшего до подтверждения. Оговорка, без
которой появилась бы новая ложь вместо старой: у визита, дошедшего до
`/cart`, хотя бы одна позиция в корзине есть всегда — страница корзины
при пустой корзине невозможна. Брошенные корзины в мире были и до правки
(в том же дне 828 корзин против 272 покупок); правка добавляет к ним
тех, кто положил и корзину не открывал, — самый массовый сюжет магазина.
Почему это проехало при нарезке: воронка #39 записана в терминах
страниц, #40 принял решение про события («событие корзины садится на
карточку»), но вопрос «какой визит вообще кладёт» заново не задал —
страничное правило молча переехало на уровень событий.
- **Класть или нет решает и товар, а не только визит.** До правки в
корзину ложились все карточки покупающего визита: внутри воронки 100%,
вне её 0%. Привлекательность товара измерить было нечем — конверсия
«просмотр → корзина» по sku давала чистый шум, а вопрос «какие товары
кладут чаще» ответа не имел. Теперь у товара есть уровень спроса:
колонка каталога с тремя значениями (магнит, обычный, залёживается), а
вероятности трёх уровней — числа мира рядом с остальными. Отклонено:
*свой процент у каждого из 180 sku* — рукописные числа, которых никто не
обоснует, а каталог по решению #39 дописывается механически; *выводить
склонность из цены* — жёсткая связь, в которой менти найдёт ровно то,
что мы в неё вложили, и ничего сверх.
- **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний
день около 50 тыс. событий, конверсия визита около 2%. Заказ рисуется из
корзины, а корзина сузилась, поэтому доля брошенных позиций и состав
заказа перемеряются вместе с остальным: сохраняется не число, а
свойство — заказ уже корзины и однопозиционных заказов около половины.
**Измерено после правки #50** (канонический мир, 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%. Величина одна и та же, счёт разный.
Решено при исполнении #50 самой реализацией:
- **Намерение визита берётся из шага воронки, а не из метки покупателя.**
Кладёт всякий визит, открывший карточку, но дошедший до `/cart` кладёт
почти всё, что сравнивал, а прочий — редко. Метка покупателя в это
решение не заходит, и не потому, что не влияет: она уже влияет — через
воронку, где помеченному дано 18% против 6%. Взять её вторым слагаемым
значило бы посчитать одно и то же дважды и протянуть план состава сквозь
шов, по которому день отдаёт торговой половине только страницы и товары.
Плата названа: у визита без намерения добавление ни с чем в человеке не
связано — упрощение, но не ложь, потому что склонные покупать и так
преобладают среди дошедших до `/cart`.
- **Два ряда вероятностей, а не ряд с множителем.** Уровень спроса читается
дважды: ряд для визита с намерением и ряд для того, кто смотрит. Один ряд
с множителем намерения не годится: вероятности визита с намерением обязаны
быть высокими (иначе его корзина схлопывается до одной позиции, а заказ до
одного товара), а у потолка в 100% разница уровней съедается, и конверсия
по уровням перестаёт различаться. Разрыв уровней внутри рядов поэтому
разный: у смотрящего товар решает почти всё, у пришедшего покупать —
заслонён намерением.
- **Доля брошенных позиций опущена с 15% до 10%.** Корзина сузилась с 2,4
позиции до 1,7, и прежние 15% чаще выносили её целиком: спасённая позиция
возвращала заказ к одному товару, и однопозиционных заказов набиралось под
60%. Свойство сохранено, число перемерено — ровно так, как этот блок и
оговаривал.
- **Ни одному уровню спроса нельзя дать нулевую вероятность.** Ноль у
залежавшегося товара выглядел заманчиво — он дешевле всего разводил
конверсию уровней, — но по товарам этого уровня возвращал ровно то
равенство, ради которого правка и делалась: событие корзины случалось бы
только внутри визита, открывшего `/cart`, без единого исключения на
четверти каталога. Отвязка либо про весь ассортимент, либо она не отвязка.
Разводить уровни поэтому приходится не нулём внизу второго ряда, а
ослаблением залежавшегося в первом: пришедший покупать берёт его реже
ходового.
- **Уровень спроса — колонка `demand` в конце файла каталога**, значения
словами («магнит», «обычный», «залёживается»). Раздан он одной колодой на
весь каталог: доли по категориям расходятся сами (магнитов от четырёх до
девяти на тридцать строк). Раскладывать колоду поровну в каждую категорию
было бы той же неправдоподобной ровностью, какую тикет вычищал из данных.
Связь с ценой отклонена выше, и тест сторожит её отсутствие — по четвертям
цены и по медиане, потому что одни только края списка обманываются
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом
ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не
догонять правкой; манифесту нужен хеш каталога (хвост #42) тем более,
потому что теперь в файле живёт ещё и поведение.
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%
и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне
`/cart` требует своих позиций, а состав заказа требует, чтобы визит с
намерением клал почти всё сравненное. Обе границы тянут число вверх, и
опустить его — значит отпустить одну из них. Место до 9% невелико, и
следующая правка торговой половины считается против него.
- **Вилка 5–9% — ориентир, а не граница** (решение владельца, 2026-08-02).
Доля просмотров, доходящих до корзины, у живых магазинов гуляет широко:
пока число правдоподобно, само по себе оно ничего не сторожит, и
подгонять поведение под его край не надо. Настоящий предел здесь другой и
считается в другой валюте — дневной бюджет событий: события корзины
входят в те самые «около 50 тыс.», на которых стоят манифест (#42) и
порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его
причины, а не долю.
Остаётся открытым, за тикетами:
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41);
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
с манифестным решением напрашивается мини-манифест зернового мира —
форма за #42;
- паспорт мира в манифесте (зерно и версия генератора) файл каталога не
накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш
каталога — хвост для #42.