feat(generator): план состава мира — зерно, приток, двухкуковые пары #46
+32
-3
@@ -12,11 +12,40 @@
|
|||||||
_Избегать_: склад, склад данных
|
_Избегать_: склад, склад данных
|
||||||
|
|
||||||
**Состав мира**:
|
**Состав мира**:
|
||||||
Постоянная часть мира генератора — популяция посетителей с календарём их
|
Постоянная часть мира генератора — популяция посетителей, их привычки,
|
||||||
появления, привычки, календарь двухкуковых пар. Чистая функция зерна:
|
двухкуковые пары. По дням его выдаёт план состава.
|
||||||
вычисляется при старте любого процесса, между прогонами не хранится.
|
|
||||||
_Избегать_: состояние мира
|
_Избегать_: состояние мира
|
||||||
|
|
||||||
|
**План состава**:
|
||||||
|
Способ спросить состав мира: функция зерна, выдающая его по дням —
|
||||||
|
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
|
||||||
|
|
||||||
|
**Подпоток**:
|
||||||
|
Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
|
||||||
|
дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
|
||||||
|
порядком вычислений.
|
||||||
|
|
||||||
|
**Конфигурация мира**:
|
||||||
|
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
|
||||||
|
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
|
||||||
|
там колонки, здесь числа.
|
||||||
|
|
||||||
|
**Когорта дня**:
|
||||||
|
Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами.
|
||||||
|
Единица плана состава: когорта — функция зерна и номера дня.
|
||||||
|
|
||||||
|
**Приток**:
|
||||||
|
Появление новых кук на всём протяжении оси модельного времени; единица —
|
||||||
|
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
|
||||||
|
горизонтом и не совпадает с дневной.
|
||||||
|
|
||||||
|
**Хвост возвратов**:
|
||||||
|
Окно активности, отсчитанное от первого визита человека и общее на обе
|
||||||
|
его куки; дольше окна кука не возвращается.
|
||||||
|
|
||||||
|
**Предыстория**:
|
||||||
|
Когорты плана с первым визитом до D0; событий не порождают.
|
||||||
|
|
||||||
**Ось модельного времени**:
|
**Ось модельного времени**:
|
||||||
Собственный календарь мира генератора. Дни считаются от фиксированного
|
Собственный календарь мира генератора. Дни считаются от фиксированного
|
||||||
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
|
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
|
||||||
|
|
||||||
Статус: Proposed — ждёт приёмки владельцем (тикет #33).
|
Статус: Accepted — принята владельцем 2026-08-01 (PR #35).
|
||||||
Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
|
Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
|
||||||
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
|
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
|
||||||
(#29), производительность (#30); исследование скорости (#31).
|
(#29), производительность (#30); исследование скорости (#31).
|
||||||
@@ -46,25 +46,62 @@
|
|||||||
|
|
||||||
- **Мир функциональный, ничего не мутирует.** Постоянный состав мира —
|
- **Мир функциональный, ничего не мутирует.** Постоянный состав мира —
|
||||||
популяция посетителей, их привычки, календарь двухкуковых пар — чистая
|
популяция посетителей, их привычки, календарь двухкуковых пар — чистая
|
||||||
функция зерна, вычисляется при старте любого процесса. День D — функция
|
функция зерна; целиком не вычисляется и не хранится, спрашивается по
|
||||||
(зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из
|
дням (форма плана — ниже). День D — функция (зерно, D); межднёвные
|
||||||
плана состава, а не копятся в состоянии. Глобальные инварианты («каждый
|
связи (окно заказов K, опоздания) выводятся из плана состава, а не
|
||||||
двухкуковый покупатель заказал с обеих кук») гарантируются планом —
|
копятся в состоянии. Глобальные инварианты («каждый двухкуковый
|
||||||
счётчики манифеста известны до генерации событий.
|
покупатель заказал с обеих кук») гарантируются планом; счётчики
|
||||||
|
состава — посетители, пары, приток — известны до генерации событий,
|
||||||
|
торговые счётчики сложатся, когда торговое поведение определит #40.
|
||||||
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
||||||
задаёт календарь появления — у каждого посетителя есть дата первого
|
задаёт календарь появления — у каждого посетителя есть дата первого
|
||||||
визита и профиль возвратов, включая затухание: заметная доля кук
|
визита и профиль возвратов, включая затухание: заметная доля кук
|
||||||
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
||||||
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
||||||
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
||||||
счётчики манифеста по-прежнему известны до генерации (уточнение по
|
счётчики состава по-прежнему известны до генерации (уточнение по
|
||||||
вычитке владельца, 2026-08-01); его числа — раздел 9.
|
вычитке владельца, 2026-08-01); его числа — раздел 9.
|
||||||
- **Своя ось модельного времени.** Мир рождается в фиксированный день D0
|
- **Форма плана — ленивая, по когортам дня** (уточнение при исполнении
|
||||||
(понедельник — см. раздел 5); реальный календарь в модели не участвует.
|
#38, 2026-08-02). Глобальный список посетителей не строится: когорта
|
||||||
В `EventDate`/`UTCEventTime` дни оси ложатся конкретными датами, но это
|
дня D — функция (зерно, D), её случайность — подпоток состава мира,
|
||||||
константа мира, от даты запуска не зависящая (значение — раздел 9).
|
ветвящийся по номеру дня (позиция в дереве — раздел 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). Между прогонами живут только зерно и
|
||||||
|
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
|
||||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||||||
@@ -89,6 +126,27 @@
|
|||||||
даты эталонного мира зависимыми от даты запуска, манифест теряет
|
даты эталонного мира зависимыми от даты запуска, манифест теряет
|
||||||
воспроизводимость.
|
воспроизводимость.
|
||||||
|
|
||||||
|
Отклонено при исполнении #38 (2026-08-02):
|
||||||
|
|
||||||
|
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
|
||||||
|
зерновой мир и половина снимка оказались бы на разгоне, недельная лаба
|
||||||
|
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
|
||||||
|
быть средним — пришлось бы двигать принятые числа раздела 5.
|
||||||
|
- *Материализованный план на горизонт*: горизонт становится обязательным
|
||||||
|
входом каждого запуска, стоимость старта растёт с прожитым; префиксную
|
||||||
|
устойчивость даёт и ленивая форма — даром, через подпотоки по номеру дня.
|
||||||
|
- *Замкнутый пул кук со сменой поколений*: постоянная память ценой фальшивой
|
||||||
|
константы — потолка одновременно живущих кук, которого в жизни нет и
|
||||||
|
который менти нечем объяснить.
|
||||||
|
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
||||||
|
гарантия — однажды манифест покраснеет, а чинить нечем, кроме смены
|
||||||
|
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
||||||
|
так что реализм этой лотереи невидим.
|
||||||
|
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
||||||
|
«все пары покупают в первый день» виден в данных ровно там, куда лаба
|
||||||
|
склейки смотрит пристальнее всего. Условность допустима, пока она не
|
||||||
|
видна в данных.
|
||||||
|
|
||||||
## 2. Детерминизм от зерна
|
## 2. Детерминизм от зерна
|
||||||
|
|
||||||
Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28).
|
Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28).
|
||||||
@@ -105,7 +163,12 @@
|
|||||||
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
||||||
скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на
|
скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на
|
||||||
любой платформе — баг генератора, а не допуск.
|
любой платформе — баг генератора, а не допуск.
|
||||||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → состав мира;
|
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
||||||
|
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
||||||
|
спрашивается по дням — раздел 1; уточнение при исполнении #38).
|
||||||
|
Дни предыстории отрицательны, а позиция в дереве — неотрицательное
|
||||||
|
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
||||||
|
(уточнение при исполнении #38, 2026-08-02);
|
||||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||||||
торговые события, расхождения, опоздания — в фиксированном порядке.
|
торговые события, расхождения, опоздания — в фиксированном порядке.
|
||||||
По построению: параллельный прогон равен последовательному; продление
|
По построению: параллельный прогон равен последовательному; продление
|
||||||
@@ -334,18 +397,61 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||||||
готовая демонстрация идемпотентности конвейера.
|
готовая демонстрация идемпотентности конвейера.
|
||||||
|
|
||||||
## 9. Решается при нарезке этапа 2 (#34)
|
## 9. Вопросы нарезки этапа 2 (#34): решения и остатки
|
||||||
|
|
||||||
Осталось из тумана карты — вопросы уровня тикетов, не развилок:
|
Туман карты разложен нарезкой по тикетам; решения фиксируются здесь по
|
||||||
|
мере исполнения.
|
||||||
|
|
||||||
|
Решено при исполнении #38 (2026-08-02):
|
||||||
|
|
||||||
|
- **Числа притока и состава.** Приток — ~3 800 новых кук в средний день,
|
||||||
|
модулируется тем же недельным профилем, что трафик (иначе доля
|
||||||
|
новичков скакала бы по дням недели). Доля одноразовых кук — 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). Дата недавняя, чтобы данные первые месяцы выглядели
|
||||||
|
свежими; привязки к реальному календарю у констант мира всё равно нет.
|
||||||
|
- **Измерено на собранном плане** (#38, канонический мир, 14 дней):
|
||||||
|
приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 —
|
||||||
|
обе величины в вилках раздела 5. Накопленная аудитория за снимок —
|
||||||
|
68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории
|
||||||
|
(при нарезке возвраты предыстории оценили вдвое скромнее — отсюда
|
||||||
|
ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных
|
||||||
|
заказа попали внутрь 14 дней, — 170; остальные пары горизонт
|
||||||
|
переживают, их вторая кука приходит позже. Цифры пересчитываются
|
||||||
|
прогоном плана, событий для них не нужно.
|
||||||
|
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
|
||||||
|
все числа мира в одном месте, написанном как приглашение любопытному
|
||||||
|
менти крутить. Правка модуля — смена мира: чек манифеста честно
|
||||||
|
краснеет, манифест сторожит только канон. Вне модуля — лишь то, что
|
||||||
|
мира не меняет: своё зерно и транспортные флаги проигрывателя.
|
||||||
|
Отклонено: внешний конфиг и env-переопределения — переменная мира,
|
||||||
|
которую паспорт манифеста не видит; файл-конфиг в репозитории — по
|
||||||
|
смыслу равен модулю, но платит загрузчиком и валидацией (довод
|
||||||
|
раздела 3 против YAML).
|
||||||
|
|
||||||
|
Остаётся открытым, за тикетами:
|
||||||
|
|
||||||
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
|
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
|
||||||
проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из
|
проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из
|
||||||
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт;
|
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41);
|
||||||
- числа притока посетителей: доля одноразовых кук и темп появления новых
|
|
||||||
(раздел 1);
|
|
||||||
- календарная дата-константа D0: каким числом дни оси ложатся в
|
|
||||||
`EventDate`/`UTCEventTime`;
|
|
||||||
- формат описания мира и конфигурации (что константа кода, что параметр);
|
|
||||||
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
|
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
|
||||||
с манифестным решением напрашивается мини-манифест зернового мира — форму
|
с манифестным решением напрашивается мини-манифест зернового мира —
|
||||||
выбрать при нарезке.
|
форма за #42.
|
||||||
|
|||||||
+11
-2
@@ -4,17 +4,26 @@
|
|||||||
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
|
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
|
||||||
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
||||||
|
|
||||||
Пока здесь каркас проекта и его сердце — контракт схемы события.
|
Пока здесь контракт схемы события и план состава мира — событий генератор ещё
|
||||||
|
не порождает.
|
||||||
|
|
||||||
## Что где лежит
|
## Что где лежит
|
||||||
|
|
||||||
|
- `src/clickstream_generator/world.py` — конфигурация мира: все его числа
|
||||||
|
одним местом. Правка любого — смена мира; крутить их и предлагается.
|
||||||
|
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
||||||
|
подпотока берёт случайность. На ней держится весь детерминизм.
|
||||||
|
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
||||||
|
день D. Когорты, приток, двухкуковые пары и счётчики — до генерации
|
||||||
|
событий.
|
||||||
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
||||||
колонках выгрузки. Собственность генератора; из него выводятся сам
|
колонках выгрузки. Собственность генератора; из него выводятся сам
|
||||||
генератор, его валидация и описание выгрузки в доках.
|
генератор, его валидация и описание выгрузки в доках.
|
||||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
||||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
||||||
из контракта. Документ руками не правят — пересобирают.
|
из контракта. Документ руками не правят — пересобирают.
|
||||||
- `tests/` — инварианты контракта и свежесть описания.
|
- `tests/` — инварианты контракта, свежесть описания и обещания плана:
|
||||||
|
чистота от зерна, приток, гарантия двухкуковых пар.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""План состава: способ спросить у мира, кто в нём есть в день D.
|
||||||
|
|
||||||
|
Мир — функция, не состояние. Глобального списка посетителей нет и не будет:
|
||||||
|
когорта дня — люди, впервые пришедшие именно в этот день, — чистая функция
|
||||||
|
зерна и номера дня. Аудитория дня складывается из его когорты и возвратов
|
||||||
|
когорт последних дней: дольше хвоста возвратов кука не живёт, поэтому загляд
|
||||||
|
назад ограничен окном, а не прожитой историей. День 500 стоит ровно столько
|
||||||
|
же, сколько день 5 (спека генератора, раздел 1).
|
||||||
|
|
||||||
|
Что план решает до генерации событий и чем связывает дни между собой:
|
||||||
|
|
||||||
|
- приток — кто и когда впервые появился, и сколько раз вернётся;
|
||||||
|
- двухкуковые пары — какой человек завёл вторую куку и в какие дни
|
||||||
|
каждая из двух кук обязана оформить заказ;
|
||||||
|
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
|
||||||
|
|
||||||
|
Случайность тянется целыми числами: диапазоны и выбор по целым весам.
|
||||||
|
Плавающие распределения системной математики не зовутся — они расходятся
|
||||||
|
между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение
|
||||||
|
(спека, раздел 2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import world
|
||||||
|
from clickstream_generator.seeds import cohort_stream
|
||||||
|
|
||||||
|
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
|
||||||
|
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
|
||||||
|
CLIENT_ID_LIMIT = 2**53
|
||||||
|
|
||||||
|
# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то
|
||||||
|
# долю общего веса.
|
||||||
|
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
|
||||||
|
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Cohort:
|
||||||
|
"""Люди, впервые пришедшие в мир в день `day`, с их куками и визитами.
|
||||||
|
|
||||||
|
Куки лежат одним рядом: сначала первые куки людей — по одной на человека,
|
||||||
|
индексы 0…`people`−1, — затем вторые куки двухкуковых пар. Кука без пары
|
||||||
|
и есть человек целиком.
|
||||||
|
|
||||||
|
Визиты — плоская таблица «кука — день», отсортированная и без повторов:
|
||||||
|
на день у куки приходится не больше одного визита. Все дни лежат в окне
|
||||||
|
активности человека: от `day` до `day` + хвост возвратов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
day: int
|
||||||
|
people: int
|
||||||
|
client_id: NDArray[np.uint64]
|
||||||
|
birth_day: NDArray[np.int64]
|
||||||
|
buyer: NDArray[np.bool_]
|
||||||
|
visit_cookie: NDArray[np.int64]
|
||||||
|
visit_day: NDArray[np.int64]
|
||||||
|
# Пары и назначенные им заказы — по строке на пару, по колонке на куку.
|
||||||
|
pair_cookies: NDArray[np.int64]
|
||||||
|
pair_order_days: NDArray[np.int64]
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
||||||
|
|
||||||
|
Не флагом на самом массиве, а видом на замороженный: флаг
|
||||||
|
вызывающий снял бы и сам, а испорченную когорту получили бы потом
|
||||||
|
все дни окна. Вид данных не копирует — платы за это нет.
|
||||||
|
|
||||||
|
Граница у защиты честная: полной неприкосновенности numpy не даёт —
|
||||||
|
добравшись до владельца данных через `.base`, разморозить можно
|
||||||
|
что угодно. Это защита от случайной записи и короткого пути, но не
|
||||||
|
от умысла; умысел закрывался бы копией когорты на каждый вызов —
|
||||||
|
16 МиБ на день вместо двух миллисекунд.
|
||||||
|
"""
|
||||||
|
for field in fields(self):
|
||||||
|
value = getattr(self, field.name)
|
||||||
|
if isinstance(value, np.ndarray):
|
||||||
|
value.flags.writeable = False
|
||||||
|
object.__setattr__(self, field.name, value[...])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pairs(self) -> int:
|
||||||
|
"""Сколько пар получили назначенные заказы."""
|
||||||
|
return len(self.pair_cookies)
|
||||||
|
|
||||||
|
def visitors_on(
|
||||||
|
self, day: int
|
||||||
|
) -> tuple[NDArray[np.uint64], NDArray[np.bool_], NDArray[np.bool_]]:
|
||||||
|
"""Кто из когорты пришёл в день `day`: куки, покупатели, заказы пар.
|
||||||
|
|
||||||
|
Как визиты и пары уложены в массивы, знает только когорта: снаружи
|
||||||
|
спрашивают день и получают три ряда одной длины.
|
||||||
|
"""
|
||||||
|
here = self.visit_cookie[self.visit_day == day]
|
||||||
|
ordering = self.pair_cookies[self.pair_order_days == day]
|
||||||
|
return self.client_id[here], self.buyer[here], np.isin(here, ordering)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DayAudience:
|
||||||
|
"""Куки, пришедшие в день `day`, — вход для будущей дня-функции."""
|
||||||
|
|
||||||
|
day: int
|
||||||
|
client_id: NDArray[np.uint64]
|
||||||
|
buyer: NDArray[np.bool_]
|
||||||
|
# Куки, которым план назначил на этот день гарантированный заказ пары.
|
||||||
|
assigned_order: NDArray[np.bool_]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PlanCounters:
|
||||||
|
"""Счётчики горизонта `days`, известные до генерации хоть одного события."""
|
||||||
|
|
||||||
|
days: int
|
||||||
|
new_cookies: tuple[int, ...]
|
||||||
|
audience: tuple[int, ...]
|
||||||
|
# Накопленная аудитория: uniq(ClientID) за весь горизонт. С дневной не
|
||||||
|
# сходится и растёт с горизонтом — это и есть приток, видимый на плане.
|
||||||
|
visitors: int
|
||||||
|
# Пары, реализованные внутри горизонта: оба назначенных заказа в нём.
|
||||||
|
pairs: int
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=world.RETURN_TAIL_DAYS + 8)
|
||||||
|
def cohort(seed: int, day: int) -> Cohort:
|
||||||
|
"""Когорта дня `day`; отрицательный день — предыстория, событий не даёт.
|
||||||
|
|
||||||
|
Функция чистая, а запоминание — только чтобы соседние дни не считали одни
|
||||||
|
и те же когорты заново: окно возвратов у них общее.
|
||||||
|
"""
|
||||||
|
if day < -world.RETURN_TAIL_DAYS:
|
||||||
|
raise ValueError(f"предыстория мира не глубже {world.RETURN_TAIL_DAYS} дней")
|
||||||
|
|
||||||
|
rng = cohort_stream(seed, day)
|
||||||
|
people = _influx(rng, day)
|
||||||
|
buyer = rng.integers(0, 100, people) < world.BUYER_PERCENT
|
||||||
|
paired = np.flatnonzero(buyer)[
|
||||||
|
rng.integers(0, 100, int(buyer.sum())) < world.PAIRED_BUYER_PERCENT
|
||||||
|
]
|
||||||
|
|
||||||
|
cookies = people + paired.size
|
||||||
|
client_id = rng.integers(1, CLIENT_ID_LIMIT, cookies, dtype=np.uint64)
|
||||||
|
birth_day = np.full(cookies, day, dtype=np.int64)
|
||||||
|
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
|
||||||
|
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
|
||||||
|
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
|
||||||
|
birth_day[people:] += 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
|
||||||
|
|
||||||
|
visit_cookie, visit_day = _visits(rng, birth_day, day + world.RETURN_TAIL_DAYS)
|
||||||
|
pair_cookies, pair_order_days = _assign_orders(
|
||||||
|
rng,
|
||||||
|
np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))),
|
||||||
|
visit_cookie,
|
||||||
|
visit_day,
|
||||||
|
cookies,
|
||||||
|
)
|
||||||
|
return Cohort(
|
||||||
|
day=day,
|
||||||
|
people=people,
|
||||||
|
client_id=client_id,
|
||||||
|
birth_day=birth_day,
|
||||||
|
# Вторая кука принадлежит покупателю — как и первая кука его пары.
|
||||||
|
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
|
||||||
|
visit_cookie=visit_cookie,
|
||||||
|
visit_day=visit_day,
|
||||||
|
pair_cookies=pair_cookies,
|
||||||
|
pair_order_days=pair_order_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audience(seed: int, day: int) -> DayAudience:
|
||||||
|
"""Кто пришёл в день `day`: его когорта плюс возвраты когорт окна."""
|
||||||
|
if day < 0:
|
||||||
|
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
||||||
|
|
||||||
|
client_id, buyer, assigned = [], [], []
|
||||||
|
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
|
||||||
|
for born in range(day - world.RETURN_TAIL_DAYS, day + 1):
|
||||||
|
came, bought, ordered = cohort(seed, born).visitors_on(day)
|
||||||
|
client_id.append(came)
|
||||||
|
buyer.append(bought)
|
||||||
|
assigned.append(ordered)
|
||||||
|
return DayAudience(
|
||||||
|
day=day,
|
||||||
|
client_id=np.concatenate(client_id),
|
||||||
|
buyer=np.concatenate(buyer),
|
||||||
|
assigned_order=np.concatenate(assigned),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def counters(seed: int, days: int) -> PlanCounters:
|
||||||
|
"""Счётчики плана на горизонте `days` — прогон плана по дням, без событий."""
|
||||||
|
new_cookies = np.zeros(days, dtype=np.int64)
|
||||||
|
daily = np.zeros(days, dtype=np.int64)
|
||||||
|
seen: list[NDArray[np.uint64]] = []
|
||||||
|
pairs = 0
|
||||||
|
|
||||||
|
# Ниже — вся предыстория: её когорты ещё возвращаются в горизонт.
|
||||||
|
for born in range(-world.RETURN_TAIL_DAYS, days):
|
||||||
|
born_cohort = cohort(seed, born)
|
||||||
|
inside = (born_cohort.visit_day >= 0) & (born_cohort.visit_day < days)
|
||||||
|
daily += np.bincount(born_cohort.visit_day[inside], minlength=days)
|
||||||
|
seen.append(born_cohort.client_id[np.unique(born_cohort.visit_cookie[inside])])
|
||||||
|
appeared = born_cohort.birth_day[
|
||||||
|
(born_cohort.birth_day >= 0) & (born_cohort.birth_day < days)
|
||||||
|
]
|
||||||
|
new_cookies += np.bincount(appeared, minlength=days)
|
||||||
|
pairs += int(np.all(born_cohort.pair_order_days < days, axis=1).sum())
|
||||||
|
|
||||||
|
return PlanCounters(
|
||||||
|
days=days,
|
||||||
|
new_cookies=tuple(new_cookies.tolist()),
|
||||||
|
audience=tuple(daily.tolist()),
|
||||||
|
visitors=int(np.unique(np.concatenate(seen)).size),
|
||||||
|
pairs=pairs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _influx(rng: np.random.Generator, day: int) -> int:
|
||||||
|
"""Сколько новых людей приходит в этот день: число мира по недельной волне."""
|
||||||
|
base = world.DAILY_INFLUX * world.WEEKLY_PROFILE_PERCENT[day % 7] // 100
|
||||||
|
spread = base * world.INFLUX_JITTER_PERCENT // 100
|
||||||
|
return base + int(rng.integers(-spread, spread + 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _visits(
|
||||||
|
rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
|
||||||
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
|
"""Дни визитов каждой куки: день рождения и возвраты, пока окно открыто."""
|
||||||
|
cookies = birth_day.size
|
||||||
|
returns = np.zeros(cookies, dtype=np.int64)
|
||||||
|
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT
|
||||||
|
returns[returning] = 1 + _pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum()))
|
||||||
|
|
||||||
|
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
|
||||||
|
delay = 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
|
||||||
|
cookie = np.concatenate((np.arange(cookies, dtype=np.int64), owner))
|
||||||
|
when = np.concatenate((birth_day, birth_day[owner] + delay))
|
||||||
|
|
||||||
|
# Вторая кука пары рождается посреди окна, и её возвраты за край не идут.
|
||||||
|
inside = when <= window_end
|
||||||
|
cookie, when = cookie[inside], when[inside]
|
||||||
|
order = np.lexsort((when, cookie))
|
||||||
|
cookie, when = cookie[order], when[order]
|
||||||
|
|
||||||
|
# Два возврата в один день — один визит: день у куки бывает только один.
|
||||||
|
first_of_day = np.ones(cookie.size, dtype=bool)
|
||||||
|
first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1])
|
||||||
|
return cookie[first_of_day], when[first_of_day]
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_orders(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
pair_cookies: NDArray[np.int64],
|
||||||
|
visit_cookie: NDArray[np.int64],
|
||||||
|
visit_day: NDArray[np.int64],
|
||||||
|
cookies: int,
|
||||||
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
|
"""Дни гарантированных заказов пары: по визиту каждой из двух кук, от D0.
|
||||||
|
|
||||||
|
Человеку предыстории, у чьей куки визитов на оси не осталось, пара не
|
||||||
|
назначается: обещать заказ, которого никто не увидит, нечестно. Дни
|
||||||
|
берутся той же случайностью, что и всё остальное, — в данных условность
|
||||||
|
не видна.
|
||||||
|
"""
|
||||||
|
on_axis = visit_day >= 0
|
||||||
|
counts = np.bincount(visit_cookie[on_axis], minlength=cookies)
|
||||||
|
# Визиты отсортированы по куке, а внутри куки — по дню, и дни от D0 идут
|
||||||
|
# последними. Значит, дни на оси у куки — хвост её блока: от конца блока
|
||||||
|
# назад ровно `counts` визитов.
|
||||||
|
first_on_axis = np.searchsorted(visit_cookie, np.arange(cookies), "right") - counts
|
||||||
|
|
||||||
|
assigned = np.all(counts[pair_cookies] > 0, axis=1)
|
||||||
|
pairs = pair_cookies[assigned]
|
||||||
|
chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs])
|
||||||
|
return pairs, visit_day[chosen]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(
|
||||||
|
rng: np.random.Generator, cumulative: NDArray[np.int64], size: int
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Выбор по целым весам: куда попал бросок в общий вес, тот вариант и вышел."""
|
||||||
|
return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Иерархия зёрен: откуда любая часть мира берёт свою случайность.
|
||||||
|
|
||||||
|
Дерево подпотоков (спека генератора, раздел 2):
|
||||||
|
|
||||||
|
корневое зерно
|
||||||
|
├── состав мира
|
||||||
|
│ ├── ось → номер дня: когорта этого дня
|
||||||
|
│ └── предыстория → глубина: когорта дня до D0
|
||||||
|
└── дни
|
||||||
|
└── номер дня → трафик, торговля, расхождения, опоздания
|
||||||
|
|
||||||
|
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
||||||
|
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
||||||
|
по документации numpy (2026-08-01) и проверено тестом: `spawn_key`, выписанный
|
||||||
|
руками, даёт тот же подпоток, что цепочка `spawn`. На этом держатся три
|
||||||
|
обещания: параллельный прогон равен последовательному, день N+1 не трогает
|
||||||
|
дни 1…N, правка одного компонента меняет только его часть снимка.
|
||||||
|
|
||||||
|
Позиция в дереве — неотрицательные целые, а дни предыстории отрицательны;
|
||||||
|
поэтому у предыстории своя ветвь, а не общий ряд с осью.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Каноническое зерно эталонного мира — константа репозитория; манифест хранит
|
||||||
|
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
|
||||||
|
CANONICAL_SEED = 20260601
|
||||||
|
|
||||||
|
|
||||||
|
class Component(IntEnum):
|
||||||
|
"""Подпотоки внутри дня; порядок объявления — позиция в дереве."""
|
||||||
|
|
||||||
|
TRAFFIC = 0
|
||||||
|
COMMERCE = 1
|
||||||
|
DISCREPANCIES = 2
|
||||||
|
LATECOMERS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class _Branch(IntEnum):
|
||||||
|
"""Две ветви корня: постоянный состав мира и проживание дней."""
|
||||||
|
|
||||||
|
COMPOSITION = 0
|
||||||
|
DAYS = 1
|
||||||
|
|
||||||
|
|
||||||
|
class _Era(IntEnum):
|
||||||
|
"""Две ветви состава: ось событий и предыстория до D0."""
|
||||||
|
|
||||||
|
AXIS = 0
|
||||||
|
PREHISTORY = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _stream(seed: int, position: Sequence[int]) -> np.random.Generator:
|
||||||
|
"""Подпоток на позиции `position` дерева зерна `seed`."""
|
||||||
|
sequence = np.random.SeedSequence(entropy=seed, spawn_key=position)
|
||||||
|
return np.random.Generator(np.random.PCG64(sequence))
|
||||||
|
|
||||||
|
|
||||||
|
def cohort_stream(seed: int, day: int) -> np.random.Generator:
|
||||||
|
"""Случайность когорты дня `day`; отрицательный день — предыстория."""
|
||||||
|
era, index = (_Era.AXIS, day) if day >= 0 else (_Era.PREHISTORY, -day - 1)
|
||||||
|
return _stream(seed, (_Branch.COMPOSITION, era, index))
|
||||||
|
|
||||||
|
|
||||||
|
def day_stream(seed: int, day: int, component: Component) -> np.random.Generator:
|
||||||
|
"""Случайность одного компонента дня `day` — дня оси, не предыстории."""
|
||||||
|
if day < 0:
|
||||||
|
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
||||||
|
return _stream(seed, (_Branch.DAYS, day, component))
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Конфигурация мира: все числа, которыми задан модельный магазин.
|
||||||
|
|
||||||
|
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
|
||||||
|
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
|
||||||
|
поэтому чек манифеста честно покраснеет: манифест сторожит только канонический
|
||||||
|
мир, свои миры менти собирает без его гарантий (спека генератора, раздел 9).
|
||||||
|
|
||||||
|
Числа решены спекой и связаны между собой; связки сторожат тесты
|
||||||
|
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
|
||||||
|
Здесь только числа — как в контракте схемы, никакого поведения; длинные
|
||||||
|
таблицы записаны коротко, но остаются таблицами.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
||||||
|
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
||||||
|
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
||||||
|
# зависит — иначе манифест перестал бы быть воспроизводимым.
|
||||||
|
ORIGIN = date(2026, 6, 1)
|
||||||
|
|
||||||
|
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
|
||||||
|
# свою куку, поэтому число это же — приток кук; вторые куки двухкуковых пар
|
||||||
|
# добавляют к нему меньше процента.
|
||||||
|
DAILY_INFLUX = 3_800
|
||||||
|
|
||||||
|
# Недельная волна мира, проценты от среднего: понедельник … воскресенье.
|
||||||
|
# Профиль один на весь мир: по нему идёт приток, по нему же пойдёт трафик
|
||||||
|
# дня-функции — иначе доля новичков скакала бы по дням недели. В сумме ровно
|
||||||
|
# 700: за неделю средний день остаётся средним.
|
||||||
|
WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92)
|
||||||
|
|
||||||
|
# Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в
|
||||||
|
# первом же графике по дням.
|
||||||
|
INFLUX_JITTER_PERCENT = 5
|
||||||
|
|
||||||
|
# Доля одноразовых кук: пришли раз и не вернулись — как в живом трафике.
|
||||||
|
ONE_SHOT_PERCENT = 75
|
||||||
|
|
||||||
|
# Сколько раз возвращается кука, которая вернулась хоть раз: веса для 1, 2,
|
||||||
|
# 3 … возвратов. В среднем выходит 3–4 возврата; вместе с одноразовыми это
|
||||||
|
# ≈1,9 активного дня на куку — отсюда дневная аудитория 6–8 тыс. при
|
||||||
|
# притоке 3 800 (спека, разделы 5 и 9).
|
||||||
|
RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1)
|
||||||
|
|
||||||
|
# Хвост возвратов: окно активности человека от его первого визита, общее на
|
||||||
|
# обе его куки. За краем окна кука не возвращается. Оно же — глубина
|
||||||
|
# предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на
|
||||||
|
# полке с самого первого дня.
|
||||||
|
RETURN_TAIL_DAYS = 90
|
||||||
|
|
||||||
|
# Профиль возвратов по дням от первого визита: почти всё в первую неделю,
|
||||||
|
# дальше тонкий хвост до края окна — повторные покупки в магазине случаются
|
||||||
|
# и через месяцы. Профиль затухает к краю, поэтому обрыв на нём в данных не
|
||||||
|
# виден. Читается по парам «сколько дней — с каким весом»; дней в сумме
|
||||||
|
# ровно `RETURN_TAIL_DAYS`.
|
||||||
|
RETURN_DELAY_WEIGHTS = tuple(
|
||||||
|
weight
|
||||||
|
for days, weight in ((3, 100), (7, 40), (20, 8), (60, 1))
|
||||||
|
for _ in range(days)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Доля покупателей среди людей когорты. Считается людьми, не куками: человек
|
||||||
|
# с двумя куками — один покупатель. Число плана, а не торгового поведения:
|
||||||
|
# без него не отобрать двухкуковые пары.
|
||||||
|
BUYER_PERCENT = 5
|
||||||
|
|
||||||
|
# Доля покупателей, у которых заведётся вторая кука (мастер-спека, раздел 5).
|
||||||
|
# Такой паре план назначает по гарантированному заказу с каждой куки — на
|
||||||
|
# этом стоит лаба про склейку личности.
|
||||||
|
PAIRED_BUYER_PERCENT = 15
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""План состава: чистота, приток и обещания, данные спекой (разделы 1, 5, 9).
|
||||||
|
|
||||||
|
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
||||||
|
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
|
||||||
|
магазин на 6–8 тыс. посетителей»), а не при каждой правке.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from clickstream_generator import plan, world
|
||||||
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
|
# Горизонт эталонного снимка — две недели (спека, раздел 5).
|
||||||
|
SNAPSHOT_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_memo():
|
||||||
|
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
|
||||||
|
plan.cohort.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def visits_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
|
||||||
|
"""Таблица визитов парами «кука — день», как её видит день-функция."""
|
||||||
|
cookies, days = cohort.visit_cookie.tolist(), cohort.visit_day.tolist()
|
||||||
|
return list(zip(cookies, days, strict=True))
|
||||||
|
|
||||||
|
|
||||||
|
def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
|
||||||
|
return (
|
||||||
|
left.day == right.day
|
||||||
|
and left.people == right.people
|
||||||
|
and np.array_equal(left.client_id, right.client_id)
|
||||||
|
and np.array_equal(left.buyer, right.buyer)
|
||||||
|
and np.array_equal(left.birth_day, right.birth_day)
|
||||||
|
and np.array_equal(left.visit_cookie, right.visit_cookie)
|
||||||
|
and np.array_equal(left.visit_day, right.visit_day)
|
||||||
|
and np.array_equal(left.pair_cookies, right.pair_cookies)
|
||||||
|
and np.array_equal(left.pair_order_days, right.pair_order_days)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_plan_is_a_pure_function_of_the_seed():
|
||||||
|
first = plan.cohort(CANONICAL_SEED, 3)
|
||||||
|
plan.cohort.cache_clear()
|
||||||
|
assert same_cohort(first, plan.cohort(CANONICAL_SEED, 3))
|
||||||
|
|
||||||
|
|
||||||
|
def test_counters_are_a_pure_function_of_the_seed():
|
||||||
|
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
plan.cohort.cache_clear()
|
||||||
|
assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_remembered_cohort_cannot_be_spoiled_from_outside():
|
||||||
|
"""Когорту помнят и раздают всем дням окна: править её нельзя никак."""
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cohort.client_id[0] = 42
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
cohort.client_id.flags.writeable = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_seed_is_another_world():
|
||||||
|
ours = plan.cohort(CANONICAL_SEED, 3)
|
||||||
|
theirs = plan.cohort(CANONICAL_SEED + 1, 3)
|
||||||
|
assert not same_cohort(ours, theirs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_day_costs_the_same_however_far_it_lies():
|
||||||
|
"""Горизонт не вход: день 500 стоит ровно столько же когорт, что день 5."""
|
||||||
|
plan.audience(CANONICAL_SEED, 5)
|
||||||
|
near = plan.cohort.cache_info().misses
|
||||||
|
plan.cohort.cache_clear()
|
||||||
|
plan.audience(CANONICAL_SEED, 500)
|
||||||
|
assert plan.cohort.cache_info().misses == near
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_world_has_a_prehistory_but_no_bottom_under_it():
|
||||||
|
assert plan.cohort(CANONICAL_SEED, -world.RETURN_TAIL_DAYS).people > 0
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
plan.cohort(CANONICAL_SEED, -world.RETURN_TAIL_DAYS - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_do_not_start_before_the_origin():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
plan.audience(CANONICAL_SEED, -1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -1, 0, 6, 13])
|
||||||
|
def test_visits_stay_inside_the_activity_window(day: int):
|
||||||
|
"""Окно активности — хвост возвратов от первого визита человека."""
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, day)
|
||||||
|
assert cohort.visit_day.min() == day
|
||||||
|
assert cohort.visit_day.max() <= day + world.RETURN_TAIL_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_cookie_visits_on_the_day_it_was_born():
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
cookies = range(cohort.client_id.size)
|
||||||
|
born = zip(cookies, cohort.birth_day.tolist(), strict=True)
|
||||||
|
assert set(born) <= set(visits_of(cohort))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_cookie_visits_a_day_once():
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
visits = visits_of(cohort)
|
||||||
|
assert visits == sorted(visits)
|
||||||
|
assert len(set(visits)) == len(visits)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ids_are_unique_and_survive_json():
|
||||||
|
"""Числа выше 2^53 в JSON округляются — куке столько не нужно."""
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
assert len(set(cohort.client_id.tolist())) == cohort.client_id.size
|
||||||
|
assert cohort.client_id.max() < 2**53
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pair_is_one_buyer_with_two_cookies():
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
assert cohort.pairs > 0
|
||||||
|
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
|
||||||
|
assert np.all(first < cohort.people)
|
||||||
|
assert np.all(second >= cohort.people)
|
||||||
|
assert np.all(cohort.buyer[cohort.pair_cookies])
|
||||||
|
born_apart = cohort.birth_day[second] - cohort.birth_day[first]
|
||||||
|
assert np.all(born_apart > 0)
|
||||||
|
assert np.all(born_apart <= world.RETURN_TAIL_DAYS)
|
||||||
|
# Вторая кука заводится, пока человек ещё ходит: обычно в первые дни.
|
||||||
|
assert np.median(born_apart) < 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairs_are_the_agreed_share_of_buyers():
|
||||||
|
"""15% покупателей (мастер-спека, раздел 5) — доля решена, не переоткрыта."""
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
buyers = int(cohort.buyer[: cohort.people].sum())
|
||||||
|
assert 0.10 < cohort.pairs / buyers < 0.20
|
||||||
|
assert 0.03 < buyers / cohort.people < 0.07
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5])
|
||||||
|
def test_every_pair_orders_from_both_cookies_on_the_axis(day: int):
|
||||||
|
"""Гарантия двухкуковых: заказ назначен на день визита куки, не раньше D0."""
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, day)
|
||||||
|
visits = set(visits_of(cohort))
|
||||||
|
for cookies, days in zip(
|
||||||
|
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
|
||||||
|
):
|
||||||
|
assert cookies[0] != cookies[1], "заказы пары — с двух разных кук"
|
||||||
|
for cookie, order_day in zip(cookies, days, strict=True):
|
||||||
|
assert order_day >= 0
|
||||||
|
assert (cookie, order_day) in visits
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_daily_audience_matches_the_spec_band():
|
||||||
|
"""6–8 тыс. посетителей в день — правдоподобный средний магазин."""
|
||||||
|
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
assert all(6_000 <= size <= 8_000 for size in counters.audience)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_influx_holds_its_daily_number():
|
||||||
|
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
average = sum(counters.new_cookies) / len(counters.new_cookies)
|
||||||
|
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_influx_breathes_with_the_week():
|
||||||
|
"""Приток модулируется тем же недельным профилем, что трафик."""
|
||||||
|
|
||||||
|
def mean(numbers: tuple[int, ...] | list[int]) -> float:
|
||||||
|
return sum(numbers) / len(numbers)
|
||||||
|
|
||||||
|
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5]
|
||||||
|
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
|
||||||
|
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean(
|
||||||
|
world.WEEKLY_PROFILE_PERCENT[:5]
|
||||||
|
)
|
||||||
|
assert abs(mean(weekend) / mean(weekdays) - expected) < 0.03
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_influx_differs_even_on_the_same_weekday():
|
||||||
|
"""Ровный приток выдал бы себя в первом же графике по дням."""
|
||||||
|
mondays = {plan.cohort(CANONICAL_SEED, day).people for day in (0, 7, 14, 21)}
|
||||||
|
assert len(mondays) > 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_influx_is_visible_on_the_plan():
|
||||||
|
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
|
||||||
|
week = plan.counters(CANONICAL_SEED, 7)
|
||||||
|
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
assert week.visitors < fortnight.visitors
|
||||||
|
assert fortnight.visitors > 2 * max(fortnight.audience)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_horizon_is_a_prefix_not_another_world():
|
||||||
|
"""Продление истории днём N+1 не трогает дни 1…N."""
|
||||||
|
week = plan.counters(CANONICAL_SEED, 7)
|
||||||
|
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
assert fortnight.audience[:7] == week.audience
|
||||||
|
assert fortnight.new_cookies[:7] == week.new_cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_counters_count_the_same_audience_that_the_day_gets():
|
||||||
|
"""Счётчики и дня-функция спрашивают один план — расходиться им негде."""
|
||||||
|
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
for day in (0, 7, SNAPSHOT_DAYS - 1):
|
||||||
|
actual = plan.audience(CANONICAL_SEED, day).client_id.size
|
||||||
|
assert actual == counters.audience[day]
|
||||||
|
|
||||||
|
|
||||||
|
def test_counters_know_the_pairs_before_a_single_event():
|
||||||
|
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||||
|
assert counters.pairs > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_audience_of_a_day_carries_its_assigned_orders():
|
||||||
|
audience = plan.audience(CANONICAL_SEED, 5)
|
||||||
|
assert audience.client_id.size == audience.buyer.size
|
||||||
|
assert audience.assigned_order.sum() > 0
|
||||||
|
assert np.all(audience.buyer[audience.assigned_order])
|
||||||
|
assert len(set(audience.client_id.tolist())) == audience.client_id.size
|
||||||
|
|
||||||
|
|
||||||
|
def test_randomness_is_drawn_in_whole_numbers():
|
||||||
|
"""Дисциплина спеки (раздел 2): целые числа, никакой системной математики.
|
||||||
|
|
||||||
|
Сторож — разрешительный: плавающие распределения numpy расходятся между
|
||||||
|
архитектурами и версиями (NEP 19), поэтому в план пускается только выбор
|
||||||
|
целого.
|
||||||
|
"""
|
||||||
|
for module in (plan, world):
|
||||||
|
source = inspect.getsource(module)
|
||||||
|
assert set(re.findall(r"\brng\.(\w+)", source)) <= {"integers"}
|
||||||
|
assert not re.search(r"^\s*import (random|math)\b", source, re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_arrays_are_whole_numbers():
|
||||||
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
|
for array in (
|
||||||
|
cohort.client_id,
|
||||||
|
cohort.birth_day,
|
||||||
|
cohort.visit_cookie,
|
||||||
|
cohort.visit_day,
|
||||||
|
cohort.pair_cookies,
|
||||||
|
cohort.pair_order_days,
|
||||||
|
):
|
||||||
|
assert np.issubdtype(array.dtype, np.integer)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Свойства иерархии зёрен, на которых держится детерминизм.
|
||||||
|
|
||||||
|
Проверяется не «числа такие-то» (они зависят от numpy и закреплены
|
||||||
|
`uv.lock`), а три обещания спеки, раздел 2: подпоток определяется позицией
|
||||||
|
в дереве, а не порядком вычислений; продление истории днём N+1 не трогает
|
||||||
|
дни 1…N; правка одного компонента не задевает соседние.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from clickstream_generator.seeds import (
|
||||||
|
CANONICAL_SEED,
|
||||||
|
Component,
|
||||||
|
cohort_stream,
|
||||||
|
day_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def first_draws(stream: np.random.Generator) -> list[int]:
|
||||||
|
"""Отпечаток подпотока: первые броски целыми."""
|
||||||
|
return stream.integers(0, 2**32, 8).tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_seed_is_a_repository_constant():
|
||||||
|
assert isinstance(CANONICAL_SEED, int)
|
||||||
|
|
||||||
|
|
||||||
|
def test_addressing_a_subtree_equals_spawning_down_to_it():
|
||||||
|
"""Свойство numpy, на котором стоит вся раздача зерна.
|
||||||
|
|
||||||
|
Потомок определяется парой (зерно, позиция в дереве): выписанный руками
|
||||||
|
`spawn_key` даёт тот же подпоток, что цепочка `spawn`. Иначе результат
|
||||||
|
зависел бы от порядка вычислений, и «параллельно равно последовательно»
|
||||||
|
не выполнялось бы.
|
||||||
|
"""
|
||||||
|
chained = np.random.SeedSequence(CANONICAL_SEED).spawn(1)[0].spawn(4)[3]
|
||||||
|
addressed = np.random.SeedSequence(CANONICAL_SEED, spawn_key=(0, 3))
|
||||||
|
assert chained.spawn_key == addressed.spawn_key
|
||||||
|
assert first_draws(np.random.Generator(np.random.PCG64(chained))) == first_draws(
|
||||||
|
np.random.Generator(np.random.PCG64(addressed))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_is_a_position_in_the_tree_not_an_order_of_calls():
|
||||||
|
straight = first_draws(cohort_stream(CANONICAL_SEED, 5))
|
||||||
|
cohort_stream(CANONICAL_SEED, 0)
|
||||||
|
day_stream(CANONICAL_SEED, 3, Component.TRAFFIC)
|
||||||
|
detoured = first_draws(cohort_stream(CANONICAL_SEED, 5))
|
||||||
|
assert straight == detoured
|
||||||
|
|
||||||
|
|
||||||
|
def test_cohorts_of_different_days_are_independent():
|
||||||
|
draws = [first_draws(cohort_stream(CANONICAL_SEED, day)) for day in range(5)]
|
||||||
|
assert len({tuple(draw) for draw in draws}) == len(draws)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prehistory_does_not_collide_with_the_axis():
|
||||||
|
"""Дни до D0 отрицательны, позиция в дереве — нет: своя ветвь.
|
||||||
|
|
||||||
|
Сравнивать день −N с днём N мало: слейся эти ветви, столкнулись бы −N
|
||||||
|
и N−1 — глубину предыстория считает от единицы, а ось дни от нуля.
|
||||||
|
Поэтому каждый день предыстории сверяется со всем началом оси.
|
||||||
|
"""
|
||||||
|
axis = {tuple(first_draws(cohort_stream(CANONICAL_SEED, day))) for day in range(6)}
|
||||||
|
for depth in range(1, 6):
|
||||||
|
prehistoric = tuple(first_draws(cohort_stream(CANONICAL_SEED, -depth)))
|
||||||
|
assert prehistoric not in axis
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_components_do_not_share_randomness():
|
||||||
|
"""Четыре подпотока дня из спеки — и они четыре разных.
|
||||||
|
|
||||||
|
Компоненты перечислены поимённо, а не обходом `Component`: слейся два
|
||||||
|
имени в одно значение, обход молча стал бы короче, и тест сверял бы
|
||||||
|
сам себя.
|
||||||
|
"""
|
||||||
|
components = (
|
||||||
|
Component.TRAFFIC,
|
||||||
|
Component.COMMERCE,
|
||||||
|
Component.DISCREPANCIES,
|
||||||
|
Component.LATECOMERS,
|
||||||
|
)
|
||||||
|
assert len({int(component) for component in components}) == 4
|
||||||
|
draws = {
|
||||||
|
tuple(first_draws(day_stream(CANONICAL_SEED, 3, component)))
|
||||||
|
for component in components
|
||||||
|
}
|
||||||
|
assert len(draws) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_composition_and_day_never_share_a_stream():
|
||||||
|
"""Состав мира ветвится сам по себе — день-функция его не сдвигает.
|
||||||
|
|
||||||
|
Сверять день N с составом дня N мало: слейся эти ветви, столкнулись бы
|
||||||
|
состав дня K и K-й компонент дня 0 — номер дня в одном адресе стоит
|
||||||
|
там же, где номер компонента в другом. Поэтому каждый компонент
|
||||||
|
сверяется со всем куском состава, куда он мог бы попасть.
|
||||||
|
"""
|
||||||
|
composition = {
|
||||||
|
tuple(first_draws(cohort_stream(CANONICAL_SEED, day))) for day in range(-5, 6)
|
||||||
|
}
|
||||||
|
for day in range(5):
|
||||||
|
for component in Component:
|
||||||
|
stream = first_draws(day_stream(CANONICAL_SEED, day, component))
|
||||||
|
assert tuple(stream) not in composition
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_seed_is_another_world():
|
||||||
|
assert first_draws(cohort_stream(CANONICAL_SEED, 3)) != first_draws(
|
||||||
|
cohort_stream(CANONICAL_SEED + 1, 3)
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Сторожа конфигурации мира: связность чисел, решённых спекой (раздел 9).
|
||||||
|
|
||||||
|
Крутить числа менти можно и нужно — тесты сторожат не значения, а то, на чём
|
||||||
|
держатся выводы спеки: D0 — понедельник (от него считается день недели),
|
||||||
|
недельный профиль в среднем даёт единицу, профиль возвратов сходится с
|
||||||
|
«в среднем 3–4 возврата» и «средняя кука активна ≈1,9 дня».
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clickstream_generator import world
|
||||||
|
|
||||||
|
|
||||||
|
def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
|
||||||
|
pairs = zip(values, weights, strict=True)
|
||||||
|
weighted = sum(value * weight for value, weight in pairs)
|
||||||
|
return weighted / sum(weights)
|
||||||
|
|
||||||
|
|
||||||
|
def test_origin_is_a_monday():
|
||||||
|
"""День недели считается как остаток номера дня — это верно от понедельника."""
|
||||||
|
assert world.ORIGIN.weekday() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_profile_covers_a_week_and_averages_to_one():
|
||||||
|
assert len(world.WEEKLY_PROFILE_PERCENT) == 7
|
||||||
|
assert sum(world.WEEKLY_PROFILE_PERCENT) == 700
|
||||||
|
|
||||||
|
|
||||||
|
def test_returning_share_matches_the_spec():
|
||||||
|
assert world.ONE_SHOT_PERCENT == 75
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_average_three_to_four():
|
||||||
|
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
|
||||||
|
assert 3 <= mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS) <= 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_average_cookie_is_active_about_two_days():
|
||||||
|
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
|
||||||
|
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
|
||||||
|
returns = mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS)
|
||||||
|
active_days = 1 + (100 - world.ONE_SHOT_PERCENT) / 100 * returns
|
||||||
|
assert 1.8 <= active_days <= 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_delays_cover_the_whole_activity_window():
|
||||||
|
delays = world.RETURN_DELAY_WEIGHTS
|
||||||
|
assert len(delays) == world.RETURN_TAIL_DAYS
|
||||||
|
assert all(weight > 0 for weight in delays)
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_profile_decays_towards_the_edge():
|
||||||
|
"""Затухание — чтобы обрыв на краю окна в данных был не виден."""
|
||||||
|
delays = world.RETURN_DELAY_WEIGHTS
|
||||||
|
steps = zip(delays, delays[1:], strict=False)
|
||||||
|
assert all(later <= earlier for earlier, later in steps)
|
||||||
|
assert delays[-1] * 10 < delays[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_most_returns_land_in_the_first_days():
|
||||||
|
delays = world.RETURN_DELAY_WEIGHTS
|
||||||
|
assert sum(delays[:10]) > sum(delays[10:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairs_are_a_small_part_of_the_cohort():
|
||||||
|
"""Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9)."""
|
||||||
|
assert world.BUYER_PERCENT * world.PAIRED_BUYER_PERCENT < 100
|
||||||
Reference in New Issue
Block a user