Compare commits

..
3 Commits
Author SHA1 Message Date
ddadminandClaude Opus 5 d17bd4e876 fix(generator): правки по линии ревью Кодекса — зёрна, словарь, сторожа
- Зачем:
  - третья линия ревью (Кодекс, другое семейство моделей) нашла дыру в
    стороже неизменности когорты, отставший словарь и тест, который
    сторожил не тот адрес дерева зёрен.
- Что:
  - когорта отдаётся видом на замороженный массив: флаг только для
    чтения вызывающий мог снять и испортить память, которой пользуются
    все дни окна. Граница защиты названа в докстроке — от случайности,
    не от умысла.
  - CONTEXT.md и комментарий `RETURN_TAIL_DAYS`: окно активности — от
    первого визита человека, общее на обе куки (спека это уже говорила,
    словарь отстал).
  - сторож предыстории сверял день −N с днём N, а сталкиваются −N и
    N−1; тем же классом слепоты страдали сторожа независимости состава
    и дня и различия компонентов — все три переписаны на сверку со всем
    куском адресов, куда подпоток мог бы попасть.
- Проверка:
  - make test (297), make lint, make typecheck;
  - батарея из 17 мутантов по plan/world/seeds — выживших нет; гоняется
    с PYTHONDONTWRITEBYTECODE=1: цикл правки и отката внутри одной
    секунды оставлял устаревший .pyc, и тесты шли по старому байт-коду.
- Отклонено с доводом:
  - перепроверка настаивала, что сторож неизменности не закрыт: через
    `.base` вида владелец данных размораживается. Верно фактически, но
    закрывающего состояния у находки нет — владелец памяти в numpy
    размораживается всегда, а копия когорты на каждый вызов меняет 2 мс
    на 16 МиБ копирования и убивает смысл запоминания.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:13:41 +03:00
ddadminandClaude Opus 5 2502f906a8 fix(generator): правки по двум линиям ревью плана состава
- Зачем:
  - ревью нашло два места, где код и документы говорили неправду, и
    несколько мест, где имена или комментарии вводили в заблуждение.
- Что:
  - обещание докстроки `seeds.py` подкреплено тестом: адрес в дереве
    даёт тот же подпоток, что цепочка `spawn`.
  - в тесте гарантии пар убран сторож-тавтология, вместо него проверка,
    что заказы назначены с двух разных кук.
  - `Cohort.visitors_on` — «кто пришёл в день D» спрашивается у когорты,
    а не собирается снаружи из четырёх её массивов.
  - имена: `CLIENT_ID_LIMIT`, `_RETURN_*_CUMULATIVE`, `first_of_day`,
    `WEEKLY_PROFILE_PERCENT` — профиль недели один на весь мир, по нему
    же пойдёт трафик дня-функции (#39).
  - спека: в дерево зёрен внесена ветвь предыстории; окно активности —
    от первого визита человека, общее на обе куки (иначе загляд назад
    ленивой формы удваивается); оценка накопленной аудитории больше не
    спорит с измерением.
  - CONTEXT.md: «подпоток» и «конфигурация мира».
- Проверка:
  - make test (296), make lint, make typecheck; мутации перепроверены
    после переноса среза дня в `Cohort`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:33:27 +03:00
ddadminandClaude Opus 5 abac6afe18 feat(generator): план состава мира — зерно, приток, двухкуковые пары
- Зачем:
  - тикет #38: состав мира должен быть чистой функцией зерна, а счётчики
    будущего манифеста — известны до генерации хоть одного события.
- Что:
  - `world.py` — конфигурация мира одним модулем чистых данных: приток,
    недельная волна, профиль возвратов, доли покупателей и пар, D0.
  - `seeds.py` — иерархия подпотоков на `SeedSequence`: состав мира
    (ось и предыстория) отдельно от дней и их компонентов.
  - `plan.py` — ленивый план состава: когорта дня, аудитория дня из
    окна возвратов, гарантированные заказы пар, счётчики горизонта.
    Случайность — только целыми числами.
  - спека, раздел 1: вторая кука пары рождается по затухающему профилю
    возвратов; раздел 9: измеренные числа канонического мира, оценка
    накопленной аудитории поправлена с ≈60 до 68 тыс.
  - CONTEXT.md: термин «когорта дня»; README генератора — новые модули.
- Проверка:
  - make test (295 тестов), make lint, make typecheck;
  - тесты проверены мутациями: 12 подмен в плане и конфигурации,
    каждая роняет ровно свой тест.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 12:20:48 +03:00
9 changed files with 922 additions and 21 deletions
+16 -2
View File
@@ -20,14 +20,28 @@ _Избегать_: состояние мира
Способ спросить состав мира: функция зерна, выдающая его по дням — Способ спросить состав мира: функция зерна, выдающая его по дням —
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар. когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
**Подпоток**:
Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
порядком вычислений.
**Конфигурация мира**:
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
там колонки, здесь числа.
**Когорта дня**:
Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами.
Единица плана состава: когорта — функция зерна и номера дня.
**Приток**: **Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица — Появление новых кук на всём протяжении оси модельного времени; единица —
кука (`ClientID`). Из-за притока накопленная аудитория растёт с кука (`ClientID`). Из-за притока накопленная аудитория растёт с
горизонтом и не совпадает с дневной. горизонтом и не совпадает с дневной.
**Хвост возвратов**: **Хвост возвратов**:
Окно активности куки, отсчитанное от её первого визита; дольше окна Окно активности, отсчитанное от первого визита человека и общее на обе
кука не возвращается. его куки; дольше окна кука не возвращается.
**Предыстория**: **Предыстория**:
Когорты плана с первым визитом до D0; событий не порождают. Когорты плана с первым визитом до D0; событий не порождают.
+36 -17
View File
@@ -66,9 +66,12 @@
дня D — функция (зерно, D), её случайность — подпоток состава мира, дня D — функция (зерно, D), её случайность — подпоток состава мира,
ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от
подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт
последних дней: окно активности куки — хвост возвратов (константа последних дней: окно активности — хвост возвратов (константа мира —
мира — раздел 9), отсчитанный от её первого визита; за краем окна кука раздел 9), отсчитанный от первого визита человека и общий на обе его
не возвращается, а профиль возвратов затухает к краю, поэтому обрыв в куки (уточнение при исполнении #38, 2026-08-02: окно от рождения
каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад,
которым ленивая форма и держится). За краем окна кука не
возвращается, а профиль возвратов затухает к краю, поэтому обрыв в
данных не виден. Стоимость дня не зависит от прожитого — день 500 данных не виден. Стоимость дня не зависит от прожитого — день 500
стоит как день 5, горизонт не является входом. Счётчики состава стоит как день 5, горизонт не является входом. Счётчики состава
считаются прогоном плана по дням, без генерации событий. считаются прогоном плана по дням, без генерации событий.
@@ -81,7 +84,11 @@
(доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают (доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают
вторую куку. Обе куки пары принадлежат одному человеку одной когорты; вторую куку. Обе куки пары принадлежат одному человеку одной когорты;
вторая рождается в пределах его окна активности, без фиксированного вторая рождается в пределах его окна активности, без фиксированного
зазора. Каждой паре план назначает дни гарантированных заказов: по зазора — по тому же затухающему профилю, что и возвраты (уточнение при
исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит.
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
человека — и вчетверо меньше пар, реализованных внутри снимка.
Каждой паре план назначает дни гарантированных заказов: по
одному на куку, из дней визитов этой куки, на оси от D0 и позже; одному на куку, из дней визитов этой куки, на оси от D0 и позже;
человеку предыстории, чьё окно активности таких дней не оставляет, человеку предыстории, чьё окно активности таких дней не оставляет,
пара не назначается. День-функция обязана назначенные заказы пара не назначается. День-функция обязана назначенные заказы
@@ -158,7 +165,10 @@
любой платформе — баг генератора, а не допуск. любой платформе — баг генератора, а не допуск.
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток - **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
состава мира, ветвящийся по номеру дня на когорты плана (состав состава мира, ветвящийся по номеру дня на когорты плана (состав
спрашивается по дням — раздел 1; уточнение при исполнении #38); спрашивается по дням — раздел 1; уточнение при исполнении #38).
Дни предыстории отрицательны, а позиция в дереве — неотрицательное
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
(уточнение при исполнении #38, 2026-08-02);
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
торговые события, расхождения, опоздания — в фиксированном порядке. торговые события, расхождения, опоздания — в фиксированном порядке.
По построению: параллельный прогон равен последовательному; продление По построению: параллельный прогон равен последовательному; продление
@@ -400,24 +410,33 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все
в первые 7–10 дней, тонкий хвост поздних возвратов и повторных в первые 7–10 дней, тонкий хвост поздних возвратов и повторных
покупок — до края окна (цикл повторной покупки магазина — месяцы). покупок — до края окна (цикл повторной покупки магазина — месяцы).
Хвост возвратов — окно активности куки от первого визита — и глубина Хвост возвратов — окно активности человека от его первого визита,
предыстории: 90 дней (решение владельца 2026-08-02: дольше квартала общее на обе его куки, — и глубина предыстории: 90 дней (решение
стенд никто не гоняет, а заказы старых посетителей продолжаются весь владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы
прогон; плата — чуть меньше пар, полностью реализованных внутри старых посетителей продолжаются весь прогон; плата — чуть меньше пар,
14-дневного снимка). Покупатели считаются людьми, полностью реализованных внутри 14-дневного снимка). Покупатели
не куками; доля покупателей — 5% людей когорты, а людей в когорте считаются людьми, не куками; доля покупателей — 5% людей когорты, а
почти столько же, сколько кук: вторые куки пар добавляют меньше людей в когорте почти столько же, сколько кук: вторые куки пар
процента. Доля — число плана, а не торгового тикета: без него не добавляют меньше процента. Доля — число плана, а не торгового тикета: без него не
отобрать двухкуковые пары; #40 наследует его, не переоткрывая. отобрать двухкуковые пары; #40 наследует его, не переоткрывая.
Сходимость с разделом 5: средняя кука активна ≈1,9 дня → дневная Сходимость с разделом 5: средняя кука активна ≈1,9 дня → дневная
аудитория ≈7 100 — середина вилки 6–8 тыс.; уникумов за 14 дней аудитория ≈7 100 — середина вилки 6–8 тыс.; уникумов за 14 дней
снимка ≈60 тыс. — ≈53 тыс. новыми куками плюс возвраты предыстории снимка — под 70 тыс.: новые куки плюс возвраты предыстории (точные
(не путать с ~50 тыс. событий одного дня) — рост `uniq(ClientID)` с числа — в измерении ниже; не путать с ~50 тыс. событий одного дня) —
горизонтом виден сразу; новых покупателей ~190 в день → конверсия рост `uniq(ClientID)` с горизонтом виден сразу; новых покупателей
~2% на сессию. ~190 в день → конверсия ~2% на сессию.
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке - **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели (2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
свежими; привязки к реальному календарю у констант мира всё равно нет. свежими; привязки к реальному календарю у констант мира всё равно нет.
- **Измерено на собранном плане** (#38, канонический мир, 14 дней):
приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 —
обе величины в вилках раздела 5. Накопленная аудитория за снимок —
68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории
(при нарезке возвраты предыстории оценили вдвое скромнее — отсюда
ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных
заказа попали внутрь 14 дней, — 170; остальные пары горизонт
переживают, их вторая кука приходит позже. Цифры пересчитываются
прогоном плана, событий для них не нужно.
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы: - **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
все числа мира в одном месте, написанном как приглашение любопытному все числа мира в одном месте, написанном как приглашение любопытному
менти крутить. Правка модуля — смена мира: чек манифеста честно менти крутить. Правка модуля — смена мира: чек манифеста честно
+11 -2
View File
@@ -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/` — инварианты контракта, свежесть описания и обещания плана:
чистота от зерна, приток, гарантия двухкуковых пар.
## Команды ## Команды
+287
View File
@@ -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 не трогает
дни 1N, правка одного компонента меняет только его часть снимка.
Позиция в дереве неотрицательные целые, а дни предыстории отрицательны;
поэтому у предыстории своя ветвь, а не общий ряд с осью.
"""
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
+252
View File
@@ -0,0 +1,252 @@
"""План состава: чистота, приток и обещания, данные спекой (разделы 1, 5, 9).
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
магазин на 68 тыс. посетителей»), а не при каждой правке.
"""
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)
+111
View File
@@ -0,0 +1,111 @@
"""Свойства иерархии зёрен, на которых держится детерминизм.
Проверяется не «числа такие-то» (они зависят от numpy и закреплены
`uv.lock`), а три обещания спеки, раздел 2: подпоток определяется позицией
в дереве, а не порядком вычислений; продление истории днём N+1 не трогает
дни 1N; правка одного компонента не задевает соседние.
"""
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
и N1 глубину предыстория считает от единицы, а ось дни от нуля.
Поэтому каждый день предыстории сверяется со всем началом оси.
"""
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)
)
+66
View File
@@ -0,0 +1,66 @@
"""Сторожа конфигурации мира: связность чисел, решённых спекой (раздел 9).
Крутить числа менти можно и нужно тесты сторожат не значения, а то, на чём
держатся выводы спеки: D0 понедельник (от него считается день недели),
недельный профиль в среднем даёт единицу, профиль возвратов сходится с
«в среднем 34 возврата» и «средняя кука активна 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