Зачем: модельный день прогоняется только руками, и владельцу нечем проверять процессы стенда вживую. Этап 3 без дага не существует вовсе (ADR 0008), поэтому форму пульта и способ вызова генератора надо решить раньше кода. Что: ADR 0009 — работники без расписания (`world_next_day` пачкой, `world_live_day` в темпе) и выключатель `world_live` без своей работы, ждущий дочерний прогон триггером, а не сенсором; генератор зовётся `DockerOperator` в каноническом контейнере, потому что в образе Airflow ему не жить (Python 3.14 против 3.13); позиция ставится как «последний сыгранный + 1» — правило вместо сторожа, и его мягкий худший исход назван. Плата за сокет докера снята замером: доступ открывается добавлением GID в контейнер, правка прав на хосте не нужна, а сам GID — локальная настройка в `.env`. Пакетная автоматика отложена, не отвергнута. Расхождения внесены в спеку генератора: позицию ставит сыгравший, у живого дня появился выключатель. В словарь добавлен «пульт мира». Проверка: `make config-test` зелёный; API Airflow 3.3 (пауза и ручной запуск, ожидание триггера по идентификатору прогона, привязка сенсора к логической дате, `DockerOperator`) сверено через MCP Context7; версия Python в образе и права сокета — замером. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1022 lines
106 KiB
Markdown
1022 lines
106 KiB
Markdown
# Генератор (этап 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, 9–11; заметка
|
||
[«Скорость батчевой генерации в Python»](../research/2026-08-01-python-batch-generation-speed.md).
|
||
|
||
## Зачем
|
||
|
||
Мастер-спека решила, **что** генерирует стенд: широкое событие в 47 колонок,
|
||
таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре
|
||
развилки о том, **как** генератор устроен, она отложила: модель мира,
|
||
границы детерминизма, источник истины схемы с разделением потока и пакета,
|
||
числовые требования скорости. Карта #26 эти развилки прошла; спека собирает
|
||
решения в одну картину. По ней этап 2 режется на тикеты (#34).
|
||
|
||
Здесь не переоткрывается решённое мастер-спекой: модель данных события,
|
||
таксономия, анонимность, механика заказов (границы мандата #14). Генератор
|
||
слепков заказов — этап 3: эта спека лишь не должна ему мешать. Один
|
||
осознанный выход за границы: решение о хранении снимка (раздел 5) формально
|
||
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
||
«Производительности».
|
||
|
||
## Целевая картина одним взглядом
|
||
|
||
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
||
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
||
и позиция на оси времени.
|
||
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
||
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
||
хранилище строится по рендеренной документации, границу сторожит строгий
|
||
приём на стороне хранилища.
|
||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
|
||
в git — только опись мира; автоматических порогов по времени нет.
|
||
|
||
## 1. Модель мира
|
||
|
||
Резолюция развилки [«Модель мира»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/27).
|
||
|
||
- **Мир функциональный, ничего не мутирует.** Постоянный состав мира —
|
||
популяция посетителей, их привычки, календарь двухкуковых пар — чистая
|
||
функция зерна; целиком не вычисляется и не хранится, спрашивается по
|
||
дням (форма плана — ниже). День D — функция (зерно, D); межднёвные
|
||
связи (окно заказов K, опоздания) выводятся из плана состава, а не
|
||
копятся в состоянии. Глобальные инварианты («каждый двухкуковый
|
||
покупатель заказал с обеих кук») гарантируются планом; счётчики
|
||
состава — посетители, пары, приток — известны до генерации событий,
|
||
торговые счётчики сложатся, когда торговое поведение определит #40.
|
||
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
||
задаёт календарь появления — у каждого посетителя есть дата первого
|
||
дня и профиль возвратов, включая затухание: заметная доля кук
|
||
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
||
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
||
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
||
счётчики состава по-прежнему известны до генерации (уточнение по
|
||
вычитке владельца, 2026-08-01); его числа — раздел 9.
|
||
- **Форма плана — ленивая, по когортам дня** (уточнение при исполнении
|
||
#38, 2026-08-02). Глобальный список посетителей не строится: когорта
|
||
дня D — функция (зерно, D), её случайность — подпоток состава мира,
|
||
ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от
|
||
подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт
|
||
последних дней: окно активности — хвост возвратов (константа мира —
|
||
раздел 9), отсчитанный от первого дня человека и общий на обе его
|
||
куки (уточнение при исполнении #38, 2026-08-02: окно от рождения
|
||
каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад,
|
||
которым ленивая форма и держится). За краем окна кука не
|
||
возвращается, а профиль возвратов затухает к краю, поэтому обрыв в
|
||
данных не виден. Стоимость дня не зависит от прожитого — день 500
|
||
стоит как день 5, горизонт не является входом. Счётчики состава
|
||
считаются прогоном плана по дням, без генерации событий.
|
||
- **Предыстория: полка с первого дня.** Когорты существуют и до D0 — на
|
||
глубину хвоста возвратов. Событий они не порождают (ось событий
|
||
начинается в D0) — только дают, кому возвращаться в первые дни: дневная
|
||
аудитория на полке с самого D0, разгона «пустого магазина» нет.
|
||
- **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица
|
||
здесь — человек, не кука: план помечает часть людей покупателями
|
||
(доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают
|
||
вторую куку. Обе куки пары принадлежат одному человеку одной когорты;
|
||
вторая рождается в пределах его окна активности, без фиксированного
|
||
зазора — по тому же затухающему профилю, что и возвраты (уточнение при
|
||
исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит.
|
||
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
|
||
человека — и вчетверо меньше пар, реализованных внутри снимка.
|
||
Каждой паре план назначает дни гарантированных заказов: по
|
||
одному на куку, из дней активности этой куки, на оси от D0 и позже;
|
||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
||
пара не назначается. День-функция обязана назначенные заказы
|
||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
||
событий (#40). Опись считает пары, реализованные в горизонте
|
||
снимка. Условность в данных не видна: дни назначены той же
|
||
случайностью, просто брошенной планом один раз.
|
||
- **Своя ось модельного времени.** Ось событий начинается в
|
||
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
||
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
||
ложатся конкретными датами, но это константа мира, от даты запуска не
|
||
зависящая (значение — раздел 9). Между прогонами живут только зерно и
|
||
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
|
||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||
«дышат»; включается по требованию — постоянным фоном идёт, только пока
|
||
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
|
||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||
переигрывается (раздел 4).
|
||
- **Поток и пакет совместимы по построению.** День-функция выдаёт один
|
||
упорядоченный поток событий; режимы отличаются только способом
|
||
проигрывания — пачкой или с темпом.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
||
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум;
|
||
ни один урок стенда на старении не стоит.
|
||
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
||
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
||
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
||
даты эталонного мира зависимыми от даты запуска, опись теряет
|
||
воспроизводимость.
|
||
|
||
Отклонено при исполнении #38 (2026-08-02):
|
||
|
||
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
|
||
стартовый мир и половина снимка оказались бы на разгоне, недельная лаба
|
||
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
|
||
быть средним — пришлось бы двигать принятые числа раздела 5.
|
||
- *Материализованный план на горизонт*: горизонт становится обязательным
|
||
входом каждого запуска, стоимость старта растёт с прожитым; префиксную
|
||
устойчивость даёт и ленивая форма — даром, через подпотоки по номеру дня.
|
||
- *Замкнутый пул кук со сменой поколений*: постоянная память ценой фальшивой
|
||
константы — потолка одновременно живущих кук, которого в жизни нет и
|
||
который менти нечем объяснить.
|
||
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
||
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены
|
||
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
||
так что реализм этой лотереи невидим.
|
||
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
||
«все пары покупают в первый день» виден в данных ровно там, куда лаба
|
||
склейки смотрит пристальнее всего. Условность допустима, пока она не
|
||
видна в данных.
|
||
|
||
## 2. Детерминизм от зерна
|
||
|
||
Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28).
|
||
|
||
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
||
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
||
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
||
строк; сверка — по хешам описи, а два локально пересобранных снимка
|
||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
||
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
||
`_load_ts`, темп живого дня.
|
||
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
||
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
||
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на
|
||
любой платформе — баг генератора, а не допуск.
|
||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
||
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
||
спрашивается по дням — раздел 1; уточнение при исполнении #38).
|
||
Дни предыстории отрицательны, а позиция в дереве — неотрицательное
|
||
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
||
(уточнение при исполнении #38, 2026-08-02);
|
||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||
торговые события, расхождения, опоздания — в фиксированном порядке.
|
||
По построению: параллельный прогон равен последовательному; продление
|
||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
||
только его часть снимка — в описи меняются хеши только затронутых
|
||
дней, дифф двух локальных пересборок читаем.
|
||
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
||
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
||
расширением `spawn_key`, потомок полностью определяется парой
|
||
(entropy, spawn_key) — позицией в дереве, а не порядком вычислений. Это
|
||
ровно то свойство, на котором держатся три гарантии предыдущего пункта.
|
||
Каждый подпоток кормит `PCG64` — генератор, рекомендованный numpy.
|
||
- **Дисциплина целочисленной случайности.** Случайность тянется целыми
|
||
числами: диапазоны, выбор из таблиц. Плавающие распределения из системной
|
||
математики не используются — это снимает межархитектурные расхождения
|
||
amd64/arm64. Деньги считаются в целых копейках; Float64 — только
|
||
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
||
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
||
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
||
гарантий описи. Опись хранит паспорт мира — зерно и версию
|
||
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
||
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
||
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
||
пики — до ~2× среднего. С детерминизмом профиль совместим: это часть
|
||
функции дня, а не внешний шум.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Воспроизводимы только состав мира и счётчики*: ломает доигрывание дня
|
||
через дедуп (другие `WatchID` — дубли вместо склейки) и воспроизводимую
|
||
отладку.
|
||
- *События те же, байты не обещаем*: экономия копеечная, а честный diff
|
||
снимка и тесты «хеш совпал» теряются.
|
||
- *Общий RNG-поток на все дни*: порядок исполнения менял бы результат —
|
||
«параллельно равно последовательно» недостижимо.
|
||
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
||
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
||
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
||
пересборка описи одним PR.
|
||
|
||
## 3. Контракт схемы
|
||
|
||
Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть первая.
|
||
|
||
- **Граница вывода — по шву «трекер | хранилище», как data contract.**
|
||
Контракт схемы — собственность генератора, как формат выгрузки —
|
||
собственность Метрики. Из контракта выводятся: сам генератор, его
|
||
валидация и публичное «описание выгрузки» в доках — рендеренная таблица
|
||
колонок, аналог документации Метрики.
|
||
- **Форма контракта — импортируемый python-модуль с чистыми данными**:
|
||
описатели колонок (имя Метрики, тип ClickHouse, тип numpy, нормализованное
|
||
snake_case-имя, группа полей, порядок), никакой логики. Читаемость для
|
||
менти несёт рендеренная таблица в доках, не модуль. Уточнение при
|
||
исполнении (#36): нормализованное имя — имя источника, приведённое к
|
||
нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою
|
||
модель и называет атрибуты по ней; `dds.event_v` эти имена берёт (раздел 7
|
||
мастер-спеки), но контракт их не диктует и тестами не сторожит.
|
||
- **Сторона хранилища пишется по документации, не генерируется.** DDL
|
||
`ods.event`, SELECT матвью, `dds.event_v`, трансформации — работа
|
||
следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется
|
||
к источнику. Границу сторожит боевой механизм — строгий приём
|
||
(`Nullable`-разбор со сверкой набора ключей, таблицы `*_errors` — раздел 6
|
||
мастер-спеки). Сверка объявлений (`system.columns` поднятого стенда против
|
||
контракта) здесь стояла вторым механизмом и снята при исполнении #43:
|
||
сверх строгого приёма она ловила ровно одно — смену типа колонки, — а её
|
||
ловит и сверка разобранного события, причём на живых данных, а не на
|
||
объявлениях.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Автогенерация DDL хранилища из контракта* (буква раздела 1.4
|
||
мастер-спеки до правки): пересекает границу ответственности компонент —
|
||
в бою хранилище адаптируется к источнику руками, менти пришлось бы
|
||
объяснять приём, которого в жизни нет. Data contract даёт тот же щит от
|
||
дрейфа без этой условности.
|
||
- *YAML как форма контракта*: красота ценой загрузчика и «схемы для схемы»;
|
||
потребителя вне Python нет — хранилище читает рендеренную документацию,
|
||
не машинный файл.
|
||
|
||
## 4. Поток и пакет: сериализатор, проигрыватель, приёмники
|
||
|
||
Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть вторая.
|
||
|
||
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
||
упорядоченный поток канонических байтов — единственное место, где событие
|
||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
||
для пересборки и проверок описи), Kafka пачкой — пакетный режим,
|
||
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
||
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
||
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
||
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
|
||
хранилища читает топик байтами и кладёт сообщение строкой
|
||
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
|
||
событий в одно сообщение сломала бы разбор целиком.
|
||
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
|
||
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь
|
||
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и
|
||
разбирает событие глазами, а число эпохи этот урок убивает. Колонка
|
||
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
|
||
Метрика.
|
||
|
||
Оговорка про цену разбора, вписанная сюда 6 августа и оказавшаяся неверной:
|
||
здесь стояло, что `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')`
|
||
«принимает ISO без плясок». Не принимает — на строке с суффиксом `Z` он
|
||
отдаёт NULL, и при исполнении #43 это увело бы в брак все события до
|
||
единого. Измерено на стенде 7 августа 2026 года; форма на проводе от этого
|
||
не меняется, меняется выражение разбора на стороне хранилища — `parseDateTime`
|
||
по буквально названному формату вместо `JSONExtract`
|
||
([ADR 0005](../adr/0005-event-ingestion.md)). То, что форма на проводе одна и
|
||
каноническая, здесь работает на хранилище: раз запись ровно одна, разбирать
|
||
её можно строго, не принимая заодно десяток чужих записей.
|
||
|
||
Форму реализует сериализатор (#41), хранилище (#43) читает то, что он
|
||
положил: порядок тикетов развёрнут 6 августа 2026 года, и отправитель идёт
|
||
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
|
||
разойдётся это уже после приёмки.
|
||
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
||
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
||
Смена библиотеки меняет канонические байты, поэтому проходит как
|
||
обновление зависимости: осознанная пересборка описи одним PR.
|
||
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
||
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
||
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
||
повторы: `WatchID` детерминированы, повтор — та же строка для
|
||
ReplacingMergeTree.
|
||
- **Эталонный снимок при старте стенда — через Kafka, пакетным режимом
|
||
проигрывателя.** Отдельный механизм заливки не строится: каждый `make up`
|
||
бесплатно прогоняет весь конвейер на настоящих данных, и строгий приём
|
||
хранилища проверяет контракт тем же прогоном.
|
||
Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой
|
||
загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий —
|
||
расчётно минута-две, запас есть.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
||
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
||
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||
мастер-спеки).
|
||
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
||
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
||
остаётся только там, где нужен кэш.
|
||
- *Прямая загрузка снимка в ClickHouse* (и гибрид с ручным заполнением
|
||
сырого слоя): второй путь приёма, пустой либо поддельный `stg.hits_raw` —
|
||
теряются переобработка дня X по `event_date` и урок виртуальных колонок
|
||
«какая нода читала топик».
|
||
|
||
## 5. Числа: объёмы, режимы, бюджеты
|
||
|
||
Резолюция развилки [«Производительность»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/30);
|
||
порядки величин — [исследование #31](../research/2026-08-01-python-batch-generation-speed.md).
|
||
|
||
- **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до
|
||
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
||
правдоподобный средний магазин.
|
||
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
|
||
понедельник. Самая короткая длина, при которой есть замороженная зона за
|
||
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
|
||
месяца — дешёвый ход (пересборка описи), если понадобится.
|
||
- **В git — только опись, снимок не хранится.** Снимок генерируется при
|
||
`make up` и при проверках: артефакт — кэш чистой функции, кэш в git не
|
||
хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
|
||
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней.
|
||
Каждый `make up` — живая демонстрация детерминизма. Честная потеря —
|
||
страховка на случай платформенного бага: раньше менти с расходящимися
|
||
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
||
описи; смягчение — CI гоняет генерацию на amd64 и arm64.
|
||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||
в пиковые часы сильных дней — до ~100. Число —
|
||
значение по умолчанию, переопределяется флагом проигрывателя: ускорение —
|
||
свойство транспорта, вне обещания воспроизводимости, константой мира не
|
||
делается.
|
||
|
||
### Бюджеты и способ замера
|
||
|
||
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
|
||
железе кончаются ритуальным удалением проверки.
|
||
|
||
Ориентиры на референсной машине — в спеке, без автоматики. Где стоит замер,
|
||
там он с датой; остальное — порядок величины, пока не мерили:
|
||
|
||
| Операция | Ориентир |
|
||
|---|---|
|
||
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
||
| Пересборка эталонного мира (14 дней + опись, без транспорта) | до минуты |
|
||
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
||
| Лаг живого дня | секунды |
|
||
|
||
**Автоматических порогов нет ни одного** — решение владельца 7 августа
|
||
2026 года при исполнении #42. Здесь стоял единственный: «полный день
|
||
генерируется ≤ 30 с». Назначен он был до того, как генератор написали, а
|
||
первый замер настоящего кода дал **1,7 секунды на день в 50 626 событий**
|
||
(машина стенда, 7 августа 2026 года). Порог оказался в восемнадцать раз выше
|
||
факта: зелен при любой правдоподобной регрессии, то есть не сторож, а
|
||
украшение. Как здесь говорят о времени, к тому дню уже решила [карта
|
||
целей](../architecture/testing.md): цена — замеренное число с датой, а не
|
||
назначенный предел.
|
||
|
||
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
|
||
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
|
||
логе, а `make up` после #42 гоняет генератор по восемь дней при каждом
|
||
подъёме стенда: замедлись он на порядок — подъём стенда встанет колом, и
|
||
заметит это первый же человек, который его поднял.
|
||
|
||
Отклонено с доводами:
|
||
|
||
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
|
||
впритык — повторение истории с памятью (ADR 0004). Оставленный было один
|
||
грубый порог снят при исполнении #42 (выше).
|
||
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
|
||
медленный генератор — съедено детерминизмом и скоростью; остаётся только
|
||
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
|
||
раздутия, но лишняя машинерия и вторая правда.
|
||
- *Снимок 7 дней*: ни одного замороженного дня, сезонность без сравнения.
|
||
*30 дней*: нового урока не даёт — отложено как дешёвое удлинение.
|
||
- *×120 / ×1440*: волна смазывается в перемотку либо превращается в пакет с
|
||
анимацией — живой режим теряет смысл.
|
||
|
||
## 6. Правила кода этапа 2
|
||
|
||
Хвосты резолюций, обязательные для реализации:
|
||
|
||
- **Целочисленная случайность** — правило кода, а не пожелание: диапазоны и
|
||
выбор из таблиц целыми, плавающие распределения системной математики не
|
||
звать; деньги — в целых копейках.
|
||
- **Случайные значения — векторно из numpy (PCG64)**; посточный цикл — лишь
|
||
там, где логика действительно посточная (цепочки сессий).
|
||
- **Посточные фейкеры (Faker, mimesis) — только для справочников** (каталог
|
||
и прочие справочные строки), не в горячем цикле: они медленнее numpy на
|
||
2–3 порядка. Выбор библиотеки — этапу 2: справочники генерируются один
|
||
раз, скорость безразлична; возможно, хватит таблиц-литералов в коде и
|
||
фейкер не понадобится вовсе.
|
||
- **Сериализация — через единственный канонический сериализатор** (orjson);
|
||
прямых `json.dumps` по коду нет.
|
||
- **Распараллеливание — multiprocessing по модельным дням**; внутри дня —
|
||
однопоточно, единица работы и так крупная.
|
||
|
||
## 7. Расхождения с мастер-спекой
|
||
|
||
Внесены в мастер-спеку тем же коммитом, что и эта спека:
|
||
|
||
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
||
contract — хранилище пишется по документации, границу сторожит строгий
|
||
приём (раздел 3 здесь).
|
||
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью;
|
||
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
||
версионирования артефакта» закрыт этим же ходом: версионируется опись.
|
||
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
||
производительности» закрыт числами раздела 5.
|
||
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
||
переобработки при исчерпании retention Kafka (раздел 7), формулировка
|
||
этапа 7 (раздел 9).
|
||
|
||
Внесены при исполнении #40 (2026-08-02):
|
||
|
||
- **Раздел 1**: «в параллельных массивах одной длины» уточнено — длина общая
|
||
внутри группы, а `purchase*` и `product*` между собой разной длины.
|
||
- **Разделы 4 и 7**: механика класса `amount_delta` названа честнее.
|
||
Округления `Float64` сами по себе расхождения не дают: деньги считаются
|
||
целыми копейками, и дробное число в событии одно. Класс создаёт генератор
|
||
намеренно — так мастер-спека и допускала вторым вариантом.
|
||
|
||
## 8. Хвосты следующим этапам
|
||
|
||
- **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит
|
||
промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе
|
||
стороны берут один и тот же (раздел 9).
|
||
- **Этап 4 (сверка)**: копейки в ценах каталога дают разрыв внутри события —
|
||
`productPrice` округлён форматом, `purchaseRevenue` точна. В сверку он сам
|
||
не попадает: клиент шлёт точную сумму, и она сходится с `items_total`
|
||
бэкенда. Класс `amount_delta` этап 4 всё равно создаёт намеренно —
|
||
мастер-спека (раздел 4) это и допускает; материал для разговора про Float64
|
||
теперь есть.
|
||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||
она переменной Airflow: отсутствие переменной означает мир в стартовом
|
||
состоянии, а ставит её тот даг пульта, который сыграл день, — значением
|
||
«последний сыгранный + 1» и только по успеху.
|
||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
||
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
||
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
||
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
||
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
||
- **Этап 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 получает готовый.
|
||
Решена форма, а не длина: артикул — четыре буквы категории и четыре
|
||
цифры, цена — целые копейки, категорий шесть. Строки дописываются
|
||
механически, поэтому ни код, ни тесты их не считают, а товар для карточки
|
||
выбирается равномерно внутри категории.
|
||
- **Ассортимент — непродовольственная розница** (товары для дома, текстиль,
|
||
посуда, мелкая бытовая техника, детское, одежда и обувь), ~150–200 sku.
|
||
Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной
|
||
покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит.
|
||
Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38.
|
||
- **География — один регион присутствия**: Поволжье с центром в Самаре,
|
||
города своего региона и тонкий хвост остальной страны. «Топ городов
|
||
России» дал бы магазину с одним складом карту, которой у него не бывает.
|
||
- **Модельные сутки считаются в часовом поясе счётчика** (UTC+4), как в
|
||
выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` —
|
||
абсолютная метка. Шов суток приходится на местную полночь и не режет
|
||
утренние сессии, а регион мира выбирается свободно. Суточная волна задана
|
||
в местном времени посетителя: гостю из другого пояса профиль
|
||
поворачивается на разницу, и пик слегка размазывается — как в жизни.
|
||
Следствие для соседних этапов: `toDate(UTCEventTime)` ≠ `EventDate`; оно
|
||
записано в описание выгрузки, иначе сторона хранилища выведет дату сама и
|
||
разойдётся на несколько часов данных.
|
||
- **Паспорт куки — постоянная часть мира, а не поведение дня.** Браузер, ОС,
|
||
устройство, экран и город у куки одни во всех её днях: кука — это браузер
|
||
на устройстве. Держит их план состава (`Cohort` и `DayAudience` прирастают
|
||
двумя массивами), разворачивает в поля события день-функция. Выводить
|
||
паспорт арифметикой из `ClientID` дешевле, но про двухкуковые пары знает
|
||
только план, а два города у одного человека — ложь в данных; пара
|
||
получает один город и разные устройства («телефон и ноутбук»,
|
||
мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому
|
||
измеренные числа канонического мира не сдвинулись: прогон счётчиков до и
|
||
после дал те же 3 828 / 6 235–7 124 / 68 119 / 170.
|
||
- **Справочники — таблицы-литералы** рядом с конфигурацией мира; Faker и
|
||
mimesis не подключаются. Нужны не случайные строки, а связки (город → id
|
||
региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их
|
||
фейкер не даёт, таблицу пришлось бы написать всё равно, а новая
|
||
зависимость молча меняла бы мир при обновлении своих словарей.
|
||
- **Гео-id — настоящие числа геобазы Яндекса.** Проверка 2026-08-02: id
|
||
подтверждались обращением к живым сервисам Яндекса по тому же номеру
|
||
(`yandex.ru/pogoda/<id>`, `yandex.ru/maps/225/russia/`) — страница
|
||
открывает ожидаемое место. Оговорка стоит в коде: опубликованной таблицы
|
||
геобазы найти не удалось, а что `RegionCityID` Метрики нумерует регионы
|
||
той же геобазой — обоснованное допущение, не подтверждённый источником
|
||
факт. Выдуманные по памяти числа не годятся ни в каком случае.
|
||
- **IP-адреса — нероутируемые диапазоны**: документационные сети RFC 5737 и
|
||
benchmark-сеть 198.18/15 по ломтю на город, телефонам — CGNAT 100.64/10,
|
||
как у настоящих операторов. Правдоподобные публичные адреса принадлежат
|
||
живым организациям, и в учебных данных им не место.
|
||
- **Словарь: «визит» остаётся за сессией** и полем `VisitID`. Одноимённое
|
||
понятие плана состава переименовано в «день активности»: у куки он один на
|
||
день, а визитов внутри дня бывает несколько.
|
||
|
||
Решено при исполнении #39 самой реализацией:
|
||
|
||
- **Правила резки визитов** (мастер-спека, раздел 1.2 требовала их
|
||
задокументировать): визит принадлежит одной куке; пауза дольше 30 минут
|
||
рвёт визит надвое; граница модельных суток режет визит, события за
|
||
полночь в день не попадают. Единственное исключение — визит с заказом,
|
||
обещанным планом двухкуковых пар: его старт сдвигается назад, чтобы
|
||
воронка уместилась в сутки. Это принятое ограничение модели, а не
|
||
недосмотр: обещание плана — гарантия, на которой стоит лаба склейки, и
|
||
ради неё мы сужаем свободу старта. Цена названа и мала: около 24 визитов
|
||
в день никогда не начинаются в последние минуты суток (из ~9,5 тыс.).
|
||
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
|
||
заново, сверяя сборку с `VisitID`.
|
||
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
|
||
визит. Воронка при исполнении #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). Упрётся
|
||
будущая правка — двигать надо бюджет и его причины, а не долю.
|
||
|
||
Решено при исполнении #41 (2026-08-07). Первые два пункта — решения владельца
|
||
грилингом 7 августа 2026 года, внесённые как есть; остальные приняты при
|
||
исполнении.
|
||
|
||
- **Генератор живёт в своём контейнере**, и на этапе 5 даг будет запускать
|
||
контейнер, а не импортировать пакет в процессе воркера. Довод учебный: так
|
||
граница между оркестратором и задачей видна глазами — у задачи своё
|
||
окружение, свой образ, свой процесс, а оркестратор передаёт параметры и
|
||
читает результат. Это та же ступенька, с которой в бою вырастает
|
||
`KubernetesPodOperator`. Побочно это единственный вариант, при котором
|
||
«генератор — отдельная и заменяемая сущность» перестаёт быть словами: его
|
||
зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для
|
||
целей генератора — `make test`, `make lint` и `make typecheck` из каталога
|
||
`generator/`. Цена названа и принимается:
|
||
чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это
|
||
доступ, равный root на хосте; на учебном стенде размен допустим, но
|
||
записывается уроком — в бою так не делают. Отклонено: *генератор в образе
|
||
Airflow, даг импортирует пакет* (так у стенда v1) — механика проще, но менти
|
||
видит лишь «даг позвал функцию», а платить пришлось бы сменой базового
|
||
образа всего стенда, переездом контекста сборки и смешением зависимостей;
|
||
*`ExternalPythonOperator` — задача в отдельном venv внутри образа Airflow*:
|
||
изоляцию даёт и сокета не требует, но границу видно в конфигурации, а не в
|
||
`docker ps` — остаётся запасным путём, если проброс сокета однажды окажется
|
||
неприемлемым.
|
||
- **Клиент Kafka — `confluent-kafka`.** Он уже стоит в образе Airflow, на нём
|
||
написан пробник `dags/test_kafka.py`, и его же использует официальный
|
||
провайдер Kafka для Airflow: один клиент на стенде вместо двух. Объявлен
|
||
необязательной группой зависимостей генератора (`[kafka]`), а образ ставит
|
||
её из лока: приёмник импортирует клиента внутри функции, поэтому пакет без
|
||
этой группы остаётся живым и переносимым — с файловым приёмником.
|
||
- **Интерфейс запуска — `python -m clickstream_generator batch|live`.**
|
||
Зовущий — контейнер: параметры приходят аргументами или переменными
|
||
окружения (`GENERATOR_SEED`, `GENERATOR_DAY`, `GENERATOR_DAYS`,
|
||
`GENERATOR_LIMIT`, `GENERATOR_SPEED`, `GENERATOR_FILE`,
|
||
`KAFKA_BOOTSTRAP_SERVERS`, `KAFKA_TOPIC`), логи идут в стандартный вывод,
|
||
итог виден кодом возврата. Общее у режимов: `--seed`, `--day`, `--days` и
|
||
приёмник — `--file` либо `--brokers` с `--topic`. Состояния нет: позицию на
|
||
оси ведёт зовущий (хвост этапу 5 — раздел 8). `--days` играет несколько
|
||
дней подряд одним запуском — этим зальётся стартовый мир (#42), восемь
|
||
запусков службы внутри `make up` были бы плохим ответом. Отклонено:
|
||
*режимы флагом `--speed 0`* — различие тогда прячется за числом, тогда как
|
||
у режимов разное устройство: у пакетного есть пачка и нет ожидания, у
|
||
живого наоборот; *приёмник по умолчанию* — молчание однажды напишет в файл
|
||
то, чего ждали в Kafka, поэтому не назван приёмник — ошибка, названы оба —
|
||
тоже.
|
||
- **Умолчание есть только у того, что описывает мир** (решение владельца при
|
||
разборе ревью #41, 2026-08-07). Зерно (каноническое), число дней (1) и темп
|
||
живого дня (×60) — свойства модели: они заданы спекой и этим тикетом, и
|
||
умолчание здесь — тот же самый ответ. Позиция на оси (`--day`), адрес
|
||
брокера, имя топика и путь файла — факты стенда, на котором генератор
|
||
запущен; своего умолчания у них нет, и без них прогон падает с ненулевым
|
||
кодом до первой сгенерированной строки. Довод: генератор — отдельная и
|
||
переносимая сущность, факты про *этот* стенд обязан приносить зовущий
|
||
(служба compose, цель `make`, даг этапа 5). Умолчание подменило бы забытый
|
||
параметр чужим фактом и выдало бы неверный ответ за успех — даг `next_day`,
|
||
не передавший день, переиграл бы нулевой, вышел бы с нулём, был бы записан
|
||
удачей и подвинул позицию: мир и позиция разъехались бы молча. Отличие от
|
||
`--days 1` названо прямо: единица измерения — минимальный *верный* ответ, а
|
||
`0` в позиции — конкретный *неверный*, поданный тихо. Переменные окружения
|
||
(`GENERATOR_DAY`, `KAFKA_TOPIC`) умолчаниями не считаются: это тот же
|
||
зовущий, только другим каналом. Следствие для обвязки: имя топика и день
|
||
служба compose и цели `make` передают явно.
|
||
- **Ограниченная пачка — флаг `--limit N` пакетного режима**: потолок событий
|
||
на весь прогон, а не на каждый день. Довод от менти: тот, кто хочет
|
||
посмотреть на конвейер, не должен ждать полсотни тысяч событий. Сочетается
|
||
с файловым приёмником, даёт взять срез, ещё не приезжавший в ODS, а число
|
||
отправленного печатается всегда — счёт нужен обеим сторонам (на нём стоят
|
||
проверки #43). На живой день пачка не распространяется: ожидание там
|
||
снимает ускорение.
|
||
- **Ключа у сообщения Kafka нет.** `WatchID` ключом был бы ключом только на
|
||
вид: обещание Kafka про ключ — «сообщения одного ключа лежат в одном
|
||
разделе и сохраняют порядок», а у ряда, где ключи не повторяются, обещать
|
||
нечего; читатель же прочёл бы в нём смысл, которого нет. Осмысленный ключ
|
||
здесь — `ClientID`, но раскладку по разделам стенд намеренно оставляет
|
||
транспорту (раздел 2), и лаба «какая нода читала топик» живёт как раз тем,
|
||
что раскладка не предрешена. Проверено 2026-08-07 (Context7 по
|
||
`CONFIGURATION.md` librdkafka и снятие умолчаний с самой библиотеки,
|
||
поставленной `confluent-kafka` 2.15.0, через `rd_kafka_conf_dump`):
|
||
умолчание топикового `partitioner` — `consistent_random`, то есть у
|
||
сообщения без ключа раздел случайный; `sticky.partitioning.linger.ms` по
|
||
умолчанию 10 — окно, в котором подряд идущие сообщения без ключа липнут к
|
||
одному разделу. Документация умолчаний не называет, поэтому оба числа взяты
|
||
у библиотеки. Следствие: день целиком идёт секунды и ложится в оба раздела,
|
||
а короткая пачка укладывается в одно окно и уезжает в один — поэтому «топик
|
||
прочитан обеими нодами» проверяется днём и разово.
|
||
- **Приёмников два, а режимов три.** «Kafka пачкой» и «Kafka с темпом ×60» —
|
||
один и тот же приёмник у разных зовущих: темп держит проигрыватель, знающий
|
||
время события. Приёмник, умеющий ускорение, снова знал бы о содержимом —
|
||
ровно то, чего правило «глупых приёмников» (раздел 4) не хочет.
|
||
- **Раскладка образа повторяет раскладку репозитория**: `/app` — корень,
|
||
пакет лежит исходником в `/app/generator/src` и виден через `PYTHONPATH`,
|
||
каталог товаров — в `/app/data`. Причина не в красоте: путь к каталогу
|
||
считается от модуля вверх по дереву (`catalog.py`, `parents[3]`), а
|
||
поставленный не editable пакет переехал бы в `site-packages` — и те же
|
||
`parents[3]` указали бы внутрь `.venv`. Тогда каталог в образе есть, а
|
||
модуль его не находит. Проверено запуском в контейнере; заодно день,
|
||
сыгранный в образе, совпал побайтово с днём, сыгранным на машине.
|
||
|
||
Решено при исполнении #42 (2026-08-07):
|
||
|
||
- **Опись мира одна, и она же растёт до эталонной.** Стартовый мир конца
|
||
этапа 2 — первые восемь дней оси, понедельник по понедельник (решение
|
||
владельца при нарезке, 2026-08-01): полная неделя с выходными и первый
|
||
замкнутый цикл окна K = 7. Отдельного «младшего» файла для них не
|
||
заводится: `data/world-inventory.json` — та самая опись, которую раздел 5
|
||
обещает эталонному миру, пока короткая. Этап 7 продлит её до четырнадцати
|
||
дней, а не заведёт вторую. Форма — JSON: паспорт мира (зерно, версия
|
||
генератора, хеш каталога) и по строке на день с датой, числом событий и
|
||
хешем. Собирается целью `make inventory`, свежесть сторожит тест
|
||
`test_inventory.py` — тем же способом, что свежесть «описания выгрузки».
|
||
Отклонено: *два файла, «мини» и полный* — две правды об одном мире и
|
||
лишнее слово в словаре.
|
||
- **Хеш каталога — в паспорте, и работа у него объяснительная.** Правка
|
||
цены в `data/catalog/products.csv` меняет мир так же молча, как правка
|
||
кода, но поймают её и без хеша: хеши дней разойдутся. Хеш каталога
|
||
отвечает на следующий вопрос — **что** правили: разошлись дни и каталог —
|
||
CSV; разошлись только дни — код.
|
||
- **Слова.** «Манифест» по всей спеке переименован в **опись мира**, а
|
||
«зерновой мир» — в **стартовый мир** (решение владельца 2026-08-07).
|
||
Довод — правило языка репозитория: есть обычное русское слово — берётся
|
||
оно. Заодно исчезла пара «манифест / мини-манифест», в которой читателю
|
||
пришлось бы различать две сущности там, где вещь одна.
|
||
|
||
Открытых вопросов за разделом не осталось: интерфейс запуска закрыт #41,
|
||
форма описи и хеш каталога — здесь.
|