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:
1 parent
33aef73d0e
commit
0c466c082e
11 files changed
+392
-26
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:
|
test:
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
|
||||||
|
# --- Пример из путеводителя ---
|
||||||
|
|
||||||
|
demo:
|
||||||
|
uv run python -m clickstream_generator.demo
|
||||||
|
|
||||||
# --- Сборка из кода ---
|
# --- Сборка из кода ---
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
|
|||||||
+6
-25
@@ -13,31 +13,11 @@
|
|||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
|
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
|
||||||
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
|
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
|
||||||
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
|
а день-функция проживает один день без накопленного состояния. Код этой
|
||||||
|
цепочки, ее схема и запуск D0 собраны в
|
||||||
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
|
[путеводителе](../docs/architecture/generator/README.md).
|
||||||
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
|
|
||||||
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
|
|
||||||
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
|
|
||||||
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
|
|
||||||
история для него не нужна и на него не влияет.
|
|
||||||
|
|
||||||
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
|
|
||||||
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
|
|
||||||
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
|
|
||||||
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
|
|
||||||
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
|
||||||
дня станут другими, счётчики канонического мира разойдутся с описью, и
|
|
||||||
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
|
||||||
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
|
|
||||||
куки, пары и паспорта остались теми же до байта.
|
|
||||||
|
|
||||||
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
|
|
||||||
распределения numpy расходятся между версиями и архитектурами, а обещано
|
|
||||||
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
|
|
||||||
`rng.choice` с вероятностями.
|
|
||||||
|
|
||||||
## Что где лежит
|
## Что где лежит
|
||||||
|
|
||||||
@@ -104,6 +84,7 @@ D0 живёт предыстория, поэтому любой день соб
|
|||||||
- `make test` — тесты генератора;
|
- `make test` — тесты генератора;
|
||||||
- `make lint` — ruff: проверка и формат;
|
- `make lint` — ruff: проверка и формат;
|
||||||
- `make typecheck` — ty: проверка типов;
|
- `make typecheck` — ty: проверка типов;
|
||||||
|
- `make demo` — пройти стадии канонического D0 на одном экране;
|
||||||
- `make docs` — пересобрать описание выгрузки;
|
- `make docs` — пересобрать описание выгрузки;
|
||||||
- `make inventory` — пересобрать опись мира.
|
- `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