docs(generator): путеводитель по генератору и живой проход make demo #179
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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` полезно представлять ряд целочисленных участков:
|
||||
бросок выбирает вариант, в чей участок он попал.
|
||||
@@ -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).
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
.PHONY: lint typecheck test docs inventory
|
||||
.PHONY: lint typecheck test demo docs inventory
|
||||
|
||||
# --- Проверки ---
|
||||
|
||||
@@ -12,6 +12,11 @@ typecheck:
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# --- Пример из путеводителя ---
|
||||
|
||||
demo:
|
||||
uv run python -m clickstream_generator.demo
|
||||
|
||||
# --- Сборка из кода ---
|
||||
|
||||
docs:
|
||||
|
||||
+6
-25
@@ -13,31 +13,11 @@
|
||||
|
||||
## Как это работает
|
||||
|
||||
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
|
||||
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
|
||||
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
|
||||
|
||||
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
|
||||
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
|
||||
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
|
||||
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
|
||||
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
|
||||
история для него не нужна и на него не влияет.
|
||||
|
||||
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
|
||||
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
|
||||
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
|
||||
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
|
||||
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
||||
дня станут другими, счётчики канонического мира разойдутся с описью, и
|
||||
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
||||
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
|
||||
куки, пары и паспорта остались теми же до байта.
|
||||
|
||||
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
|
||||
распределения numpy расходятся между версиями и архитектурами, а обещано
|
||||
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
|
||||
`rng.choice` с вероятностями.
|
||||
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
|
||||
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
|
||||
а день-функция проживает один день без накопленного состояния. Код этой
|
||||
цепочки, ее схема и запуск D0 собраны в
|
||||
[путеводителе](../docs/architecture/generator/README.md).
|
||||
|
||||
## Что где лежит
|
||||
|
||||
@@ -104,6 +84,7 @@ D0 живёт предыстория, поэтому любой день соб
|
||||
- `make test` — тесты генератора;
|
||||
- `make lint` — ruff: проверка и формат;
|
||||
- `make typecheck` — ty: проверка типов;
|
||||
- `make demo` — пройти стадии канонического D0 на одном экране;
|
||||
- `make docs` — пересобрать описание выгрузки;
|
||||
- `make inventory` — пересобрать опись мира.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Короткий проход по цепочке генератора на каноническом дне D0."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import numpy as np
|
||||
|
||||
from clickstream_generator import day, plan, seeds, world
|
||||
|
||||
DAY = 0
|
||||
|
||||
|
||||
def _number(value: int) -> str:
|
||||
"""Разделяет разряды числа для чтения с экрана."""
|
||||
return f"{value:,}".replace(",", " ")
|
||||
|
||||
|
||||
def render() -> str:
|
||||
"""Собирает D0 и показывает его стадии без числового эталона."""
|
||||
seed = seeds.CANONICAL_SEED
|
||||
cohort = plan.cohort(seed, DAY)
|
||||
audience = plan.audience(seed, DAY)
|
||||
events = day.stream(seed, DAY)
|
||||
|
||||
_, rows_per_visit = np.unique(events.columns["VisitID"], return_counts=True)
|
||||
event_types = Counter(str(value) for value in events.columns["EventType"])
|
||||
smallest_visit = int(rows_per_visit.min())
|
||||
largest_visit = int(rows_per_visit.max())
|
||||
|
||||
return "\n".join(
|
||||
(
|
||||
f"Зерно: {seed}",
|
||||
"Подпотоки D0: состав мира → когорта; день → трафик, торговля, заказы",
|
||||
(
|
||||
f"Когорта D0: людей — {_number(cohort.people)}; "
|
||||
f"кук — {_number(len(cohort.client_id))}"
|
||||
),
|
||||
(
|
||||
f"Аудитория D0: кук — {_number(len(audience.client_id))}; "
|
||||
f"когорты D-{world.RETURN_TAIL_DAYS}...D0"
|
||||
),
|
||||
(
|
||||
f"Визиты по VisitID: групп — {_number(len(rows_per_visit))}; "
|
||||
f"строк в группе — {smallest_visit}–{largest_visit}"
|
||||
),
|
||||
(
|
||||
f"Строки событий: {_number(len(events))} "
|
||||
f"(pageview — {_number(event_types['pageview'])}; "
|
||||
f"add_to_cart — {_number(event_types['add_to_cart'])}; "
|
||||
f"purchase — {_number(event_types['purchase'])}); "
|
||||
f"заказов — {_number(len(events.orders))}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Печатает проход по D0."""
|
||||
print(render())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Запускаемый путеводитель остается коротким и проходит всю цепочку."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_demo_runs_and_shows_the_whole_chain() -> None:
|
||||
"""Сторожит команду и стадии, а числа мира оставляет описи."""
|
||||
finished = subprocess.run(
|
||||
["make", "--silent", "demo"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
cwd=Path(__file__).parents[1],
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert finished.returncode == 0, finished.stderr
|
||||
lines = finished.stdout.splitlines()
|
||||
assert [line.partition(":")[0] for line in lines] == [
|
||||
"Зерно",
|
||||
"Подпотоки D0",
|
||||
"Когорта D0",
|
||||
"Аудитория D0",
|
||||
"Визиты по VisitID",
|
||||
"Строки событий",
|
||||
]
|
||||
assert "когорты D-" in lines[3] and "...D0" in lines[3]
|
||||
assert "строк в группе" in lines[4]
|
||||
assert all(
|
||||
label in lines[5]
|
||||
for label in ("pageview", "add_to_cart", "purchase", "заказов")
|
||||
)
|
||||
Reference in new issue
Block a user