docs(generator): путеводитель по генератору и живой проход make demo

- Зачем:
  - спека отвечает «почему», а жанра «как пройти устройство по коду»
    не было (#48); черновик в generator/README.md ждал выверки.
- Что:
  - новый набор docs/architecture/generator/: индекс с целевой картиной
    и mermaid-схемой плюс шесть поддокументов по темам тикета.
  - демо-модуль и цель make demo: канонический D0, все стадии цепочки
    на одном экране; тест-сторож проверяет стадии без прибитых чисел.
  - раздел «Как это работает» в generator/README.md сжат до ссылки
    на путеводитель, черновая врезка снята.
- Проверка:
  - из generator/: make lint, make typecheck, make test (430 passed),
    make demo; два слепых ревью и узкая перепроверка — находки закрыты.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Fable 5 committed 2026-08-28 06:00:36 -04:00
1 parent 33aef73d0e
commit 0c466c082e
11 files changed
+392 -26

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
# Генератор кликстрима
Путеводитель показывает, как пройти генератор от корневого зерна до двух
выходов дня, не пересказывая код и спеки.
## Целевая картина одним взглядом
- **Подпоток определяется местом в дереве, а мир внутри подпотока — порядком
бросков** — [дерево зерен и порядок бросков](seeds.md).
- **Мир поднимает только когорты окна, нужные запрошенному дню** —
[ленивость мира](lazy-world.md).
- **Устройство и город принадлежат куке на всю жизнь и потому решаются
планом состава** — [паспорт куки](cookie-passport.md).
- **Выбор по весам сведен к целым броскам и арифметике интервалов** —
[целочисленная случайность](randomness.md).
- **Торговля получает готовые строки страниц и возвращает поток событий
вместе с покупками для бэкенда** — [шов торговых событий](commerce-seam.md).
- **Сутки принадлежат поясу счетчика, а `UTCEventTime` остается абсолютной
меткой** — [время модельного дня](counter-time.md).
```mermaid
flowchart LR
seed["Корневое зерно"] --> streams["Подпотоки"]
streams --> cohorts["Когорты"]
cohorts --> audience["Аудитория дня"]
audience --> visits["Визиты"]
visits --> output["Выход day.stream"]
output --> events["Поток событий"]
output --> backend["Заказы дня<br/>orders.of_day"]
```
За правой ветвью начинается отдельный набор документов —
[устройство заказов](../orders/README.md).
## Живой проход
Из каталога `generator/` одна команда собирает канонический D0 и показывает
все стадии на одном экране:
```sh
make demo
```
Когорту и аудиторию пример получает через открытый интерфейс плана, а визиты
считает группировкой выходных строк по `VisitID`. Код прохода —
[`demo.main`](../../../generator/src/clickstream_generator/demo.py).
## Родословная
Устройство генератора и основания решений записаны в [спеке этапа
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md).
Путеводитель появился по тикету
[#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
он отвечает на другой вопрос — как пройти принятое устройство по коду.
@@ -0,0 +1,34 @@
# Шов торговых событий
Этот документ отвечает на вопрос, что день передает торговой части и что
получает от нее назад.
[`day.stream`](../../../generator/src/clickstream_generator/day.py) сначала
собирает выровненные по строкам ряды трафика:
- `columns` — готовые колонки просмотров страниц;
- `page` — вид страницы магазина;
- `product` — номер товара у карточки;
- `person` — человек за кукой строки.
`page` указывает, куда может сесть торговое событие, а `product` — какой товар
посетитель действительно открыл. `person` в кликстрим не выходит: он нужен
только для второй проекции покупки — заказа бэкенда.
[`commerce.weave`](../../../generator/src/clickstream_generator/commerce.py)
принимает эти ряды, вплетает `add_to_cart` и `purchase`, снова упорядочивает
поток и возвращает обновленные `columns`, `page`, `product` вместе с
[`commerce.Purchases`](../../../generator/src/clickstream_generator/commerce.py).
`Purchases` — узкая передача следующей стороне. В ней лежат номер заказа,
купленные товары и количества, клиентская выручка, промокод, момент события и
человек. Все они получены из тех же покупок, что уехали в поток, а не разобраны
назад из JSON.
[`orders.of_day`](../../../generator/src/clickstream_generator/orders.py)
принимает `Purchases`, а [`day.Day`](../../../generator/src/clickstream_generator/day.py)
отдает заказы рядом с колонками событий. Здесь поток дня расходится на две
ветви, показанные в индексе.
За этой границей начинается [устройство заказов](../orders/README.md). Судьба,
слепки и деньги бэкенда описаны там.
@@ -0,0 +1,29 @@
# Паспорт куки
Этот документ отвечает на вопрос, почему паспорт куки решает план состава, а
день только разворачивает его в события.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) получает
случайность через
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и вызывает [`plan._passports`](../../../generator/src/clickstream_generator/plan.py)
в ветви «состав мира». Это не подпоток отдельного дня.
`plan._passports` выбирает строки устройства и города из справочников. У
двухкуковой пары город первой куки переносится во вторую, а устройство
выбирается из другой половины профилей. Сведения о парах есть только у плана.
Если независимо вывести паспорт каждой куки из `ClientID`, один человек может
оказаться в двух городах — это будет ложью в данных.
[`plan.Cohort`](../../../generator/src/clickstream_generator/plan.py) хранит
номера устройства и города. `Cohort.visitors_on` переносит их в
[`plan.DayAudience`](../../../generator/src/clickstream_generator/plan.py), а
[`day.stream`](../../../generator/src/clickstream_generator/day.py) разворачивает
профили в колонки событий. День не бросает паспорт заново, поэтому кука
сохраняет его на всю жизнь.
Броски паспорта стоят после прежнего состава когорты. Правило такого
продолжения подпотока описано в [дереве зерен](seeds.md), а основания выбора
самого паспорта — в [спеке генератора](../../specs/2026-08-01-generator.md),
раздел 9. Зачем стенду нужна двухкуковая пара одного человека, фиксирует
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
@@ -0,0 +1,40 @@
# Время модельного дня
Этот документ отвечает на вопрос, почему дата модельных суток не обязана
совпадать с UTC-датой события.
В коде встречаются три координаты времени:
- D — номер дня на оси модельного времени;
- `EventDate` — дата D в поясе счетчика;
- `UTCEventTime` — абсолютная метка события.
Местное время гостя задает форму активности внутри суток, а границу дня задает
пояс счетчика.
[`world.ORIGIN`](../../../generator/src/clickstream_generator/world.py) — D0
календарной оси: он переводит номер D в конкретную дату. Это не механизм
часового пояса. Механизм —
[`world.COUNTER_TIMEZONE_MINUTES`](../../../generator/src/clickstream_generator/world.py),
смещение пояса счетчика от UTC.
В [`day._columns`](../../../generator/src/clickstream_generator/day.py)
`EventDate` получается как `ORIGIN + D`. Чтобы получить абсолютную полночь
тех же суток, код вычитает смещение счетчика, а затем прибавляет секунды
события внутри дня. Поэтому сутки `2026-06-01` в UTC+4 начинаются в
`2026-05-31 20:00:00Z`: у первых четырех часов
`toDate(UTCEventTime) != EventDate`.
[`day._starts`](../../../generator/src/clickstream_generator/day.py) сначала
выбирает час из городской строки весов, собранной
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py),
а затем равномерно выбирает секунду внутри часа. Строка поворачивает суточную
волну на разницу между поясом гостя и поясом счетчика. Так вечерний пик гостя
попадает в нужные часы внутри дня, но граница суток и `EventDate` остаются
счетчиковыми.
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
полей записаны в [описании события](../../formats/clickstream-event.md).
Основание модели времени записано в [спеке
генератора](../../specs/2026-08-01-generator.md), раздел 9.
+26
View File
@@ -0,0 +1,26 @@
# Ленивость мира
Этот документ отвечает на вопрос, как любой день собирается без прожитой
истории и без глобального списка посетителей.
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) строит
одну когорту по зерну и дню ее появления. Отрицательные дни обозначают
предысторию; ее глубину ограничивает
[`world.RETURN_TAIL_DAYS`](../../../generator/src/clickstream_generator/world.py).
Запоминание уже рассчитанных когорт ускоряет соседние вызовы, но не становится
состоянием мира: очистка кеша не меняет результат.
[`plan.audience`](../../../generator/src/clickstream_generator/plan.py) для
дня D спрашивает `Cohort.visitors_on(D)` у когорт от
`D - RETURN_TAIL_DAYS` до D и соединяет полученные части. Поэтому вызов для
D0 поднимает всю предысторию в окне — 91 когорту от D−90 до D0.
Когорты предыстории дают D0 вернувшихся посетителей, но собственных событий
не порождают. Ось событий начинается в D0. Окно активности отсчитывается от
первого дня человека и остается общим для обеих его кук; за краем окна
когорта больше не может попасть в аудиторию.
Отсюда самостоятельность дня: D500 и D5 заглядывают назад на одинаковое число
когорт. Ни плану состава, ни день-функции горизонт мира не нужен.
Зачем приняты предыстория и ограниченное окно, объясняет [спека
генератора](../../specs/2026-08-01-generator.md), раздел 1.
+41
View File
@@ -0,0 +1,41 @@
# Целочисленная случайность
Этот документ отвечает на вопрос, как читать целочисленный выбор по весам и
зачем генератору свой маленький `weights.py`.
За `PCG64` и целые броски `Generator.integers` отвечает NumPy.
Генератор стенда не пользуется готовым выбором по вещественным вероятностям и
плавающими распределениями: их алгоритмы и системная математика могут
различаться между версиями и архитектурами. Зависимости закреплены, а броски
остаются целыми — так выполняется побайтовое обещание, записанное в [спеке
генератора](../../specs/2026-08-01-generator.md), раздел 2.
## Одна таблица весов
[`weights.pick`](../../../generator/src/clickstream_generator/weights.py)
сначала получает накопленные веса. Для весов `2, 3, 1` это границы
`2, 5, 6`. Затем он бросает целое число из полуинтервала `[0, 6)` и находит
первую границу справа:
- броски `0–1` выбирают вариант 0;
- броски `2–4` выбирают вариант 1;
- бросок `5` выбирает вариант 2.
Размер участка и есть вес варианта. Нормировать веса до долей единицы не
нужно.
## Своя таблица для каждой строки
[`weights.pick_row`](../../../generator/src/clickstream_generator/weights.py)
работает так же, когда у каждого броска свой ряд весов. Ряды с одинаковой
суммой укладываются друг за другом. Номер ряда смещает бросок на его участок,
единый поиск находит ячейку, а обратное смещение возвращает номер варианта.
Конкретный проход виден в
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py)
и [`day._starts`](../../../generator/src/clickstream_generator/day.py): для
каждого города суточная волна повернута в свой ряд, но сумма весов от поворота
не меняется.
При чтении вызова `pick` полезно представлять ряд целочисленных участков:
бросок выбирает вариант, в чей участок он попал.
+61
View File
@@ -0,0 +1,61 @@
# Дерево зерен и порядок бросков
Этот документ отвечает на вопрос, почему место подпотока стабильно, а порядок
бросков внутри него менять нельзя.
## Два порядка
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
и [`seeds.day_stream`](../../../generator/src/clickstream_generator/seeds.py)
находят ветвь по позиции `SeedSequence(entropy, spawn_key)`. Поэтому результат
ветви не зависит от того, какую соседнюю ветвь вычислили раньше.
Внутри найденной ветви живет один `Generator`. Каждый его вызов забирает
следующие значения. Поэтому порядок вызовов меняет результат: прежний
пятый бросок после вставки становится шестым и достается другому свойству
мира.
Дерево целиком описано в модуле
[`seeds`](../../../generator/src/clickstream_generator/seeds.py), а
[`seeds.Component`](../../../generator/src/clickstream_generator/seeds.py)
задает его нижний уровень — четыре подпотока дня:
```text
корневое зерно
├── состав мира
│ ├── ось событий → номер дня → когорта
│ └── предыстория → глубина → когорта до D0
└── дни
└── номер дня
├── TRAFFIC
├── COMMERCE
├── ORDERS
└── LATECOMERS — место будущей событийной стороны
```
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
получила отдельную ветвь. Компоненты дня тоже занимают позиции, а не получают
зерна в порядке запуска.
## Что ломает вставка
Новый бросок в середине функции сдвигает все последующие значения этого
подпотока. Код продолжает работать, но тем же свойствам достается другая
случайность. Соседние подпотоки остаются прежними, а измененная часть
канонического мира проявляется в описи и побайтовом сравнении.
Какое обещание воспроизводимости это защищает, записано в
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
различать два свойства: `SeedSequence` стабилизирует адрес ветви, но порядок
бросков внутри `Generator` стабилизирует только сам код.
## Броски последними
Новое свойство продолжает существующий подпоток после прежних бросков. Такой
бросок не отнимает значения у уже рассчитанных свойств. В
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) паспорт
куки был дописан после состава и назначенных заказов, а личность человека —
после паспорта. Поэтому прежние ряды остались прежними.
Почему паспорт вообще принадлежит этой ветви, разобрано отдельно в
[документе о паспорте куки](cookie-passport.md).