diff --git a/docs/architecture/generator/README.md b/docs/architecture/generator/README.md new file mode 100644 index 0000000..6a762c0 --- /dev/null +++ b/docs/architecture/generator/README.md @@ -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["Заказы дня
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): +он отвечает на другой вопрос — как пройти принятое устройство по коду. diff --git a/docs/architecture/generator/commerce-seam.md b/docs/architecture/generator/commerce-seam.md new file mode 100644 index 0000000..8ee2407 --- /dev/null +++ b/docs/architecture/generator/commerce-seam.md @@ -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). Судьба, +слепки и деньги бэкенда описаны там. diff --git a/docs/architecture/generator/cookie-passport.md b/docs/architecture/generator/cookie-passport.md new file mode 100644 index 0000000..87019a5 --- /dev/null +++ b/docs/architecture/generator/cookie-passport.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. diff --git a/docs/architecture/generator/counter-time.md b/docs/architecture/generator/counter-time.md new file mode 100644 index 0000000..af13a5e --- /dev/null +++ b/docs/architecture/generator/counter-time.md @@ -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. diff --git a/docs/architecture/generator/lazy-world.md b/docs/architecture/generator/lazy-world.md new file mode 100644 index 0000000..7c6ab01 --- /dev/null +++ b/docs/architecture/generator/lazy-world.md @@ -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. diff --git a/docs/architecture/generator/randomness.md b/docs/architecture/generator/randomness.md new file mode 100644 index 0000000..2a8307b --- /dev/null +++ b/docs/architecture/generator/randomness.md @@ -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` полезно представлять ряд целочисленных участков: +бросок выбирает вариант, в чей участок он попал. diff --git a/docs/architecture/generator/seeds.md b/docs/architecture/generator/seeds.md new file mode 100644 index 0000000..46eaccb --- /dev/null +++ b/docs/architecture/generator/seeds.md @@ -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). diff --git a/generator/Makefile b/generator/Makefile index 4097a78..d17ba7a 100644 --- a/generator/Makefile +++ b/generator/Makefile @@ -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: diff --git a/generator/README.md b/generator/README.md index b007f43..a5bba80 100644 --- a/generator/README.md +++ b/generator/README.md @@ -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` — пересобрать опись мира. diff --git a/generator/src/clickstream_generator/demo.py b/generator/src/clickstream_generator/demo.py new file mode 100644 index 0000000..93b6342 --- /dev/null +++ b/generator/src/clickstream_generator/demo.py @@ -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() diff --git a/generator/tests/test_demo.py b/generator/tests/test_demo.py new file mode 100644 index 0000000..604310d --- /dev/null +++ b/generator/tests/test_demo.py @@ -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", "заказов") + )