From 78ddb8681719a4094f70a223f7c82c0c86e5a92c Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 11:47:59 +0300 Subject: [PATCH 1/4] =?UTF-8?q?docs(specs):=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20#38=20=E2=80=94=20=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=B0,=20=D1=87=D0=B8=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=D1=82=D0=BE=D0=BA=D0=B0,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - тикет #38 требует решить и внести в спеку генератора числа притока, D0 и формат конфигурации мира до реализации плана состава. - Что: - раздел 1 спеки: ленивый план по когортам дня, предыстория с полкой от D0, гарантия двухкуковых пар назначенными заказами (единица — человек), пять отклонённых вариантов с доводами. - раздел 9: приток 3 800 кук/день, окно активности 90 дней (решение владельца), доля покупателей 5% людей, D0 = 2026-06-01, конфигурация мира — модуль чистых данных; шапка Proposed → Accepted. - CONTEXT.md: термины «план состава», «приток», «хвост возвратов», «предыстория»; «состав мира» уточнён, словарь очищен от решений. - Проверка: - двойное слепое ревью правок (Codex + Claude), все 20 находок закрыты; арифметика чисел пересчитана ревьюерами независимо. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 21 ++++- docs/specs/2026-08-01-generator.md | 135 ++++++++++++++++++++++++----- 2 files changed, 129 insertions(+), 27 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 081af02..cf7278a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,11 +12,26 @@ _Избегать_: склад, склад данных **Состав мира**: -Постоянная часть мира генератора — популяция посетителей с календарём их -появления, привычки, календарь двухкуковых пар. Чистая функция зерна: -вычисляется при старте любого процесса, между прогонами не хранится. +Постоянная часть мира генератора — популяция посетителей, их привычки, +двухкуковые пары. По дням его выдаёт план состава. _Избегать_: состояние мира +**План состава**: +Способ спросить состав мира: функция зерна, выдающая его по дням — +когорту новых кук, их возвраты, назначенные заказы двухкуковых пар. + +**Приток**: +Появление новых кук на всём протяжении оси модельного времени; единица — +кука (`ClientID`). Из-за притока накопленная аудитория растёт с +горизонтом и не совпадает с дневной. + +**Хвост возвратов**: +Окно активности куки, отсчитанное от её первого визита; дольше окна +кука не возвращается. + +**Предыстория**: +Когорты плана с первым визитом до D0; событий не порождают. + **Ось модельного времени**: Собственный календарь мира генератора. Дни считаются от фиксированного первого дня D0; реальный календарь в модели не участвует. Между прогонами diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 66367f6..18a2c74 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -1,6 +1,6 @@ # Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости -Статус: Proposed — ждёт приёмки владельцем (тикет #33). +Статус: Accepted — принята владельцем 2026-08-01 (PR #35). Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26. Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода (#29), производительность (#30); исследование скорости (#31). @@ -46,25 +46,55 @@ - **Мир функциональный, ничего не мутирует.** Постоянный состав мира — популяция посетителей, их привычки, календарь двухкуковых пар — чистая - функция зерна, вычисляется при старте любого процесса. День D — функция - (зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из - плана состава, а не копятся в состоянии. Глобальные инварианты («каждый - двухкуковый покупатель заказал с обеих кук») гарантируются планом — - счётчики манифеста известны до генерации событий. + функция зерна; целиком не вычисляется и не хранится, спрашивается по + дням (форма плана — ниже). День D — функция (зерно, D); межднёвные + связи (окно заказов K, опоздания) выводятся из плана состава, а не + копятся в состоянии. Глобальные инварианты («каждый двухкуковый + покупатель заказал с обеих кук») гарантируются планом; счётчики + состава — посетители, пары, приток — известны до генерации событий, + торговые счётчики сложатся, когда торговое поведение определит #40. - **Состав не замкнут: посетители появляются и затухают.** План состава задаёт календарь появления — у каждого посетителя есть дата первого визита и профиль возвратов, включая затухание: заметная доля кук одноразовая, как в живом трафике. Новые посетители появляются на всём протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная аудитории не сходятся в одно число. Приток — часть плана, а не мутация: - счётчики манифеста по-прежнему известны до генерации (уточнение по + счётчики состава по-прежнему известны до генерации (уточнение по вычитке владельца, 2026-08-01); его числа — раздел 9. -- **Своя ось модельного времени.** Мир рождается в фиксированный день D0 - (понедельник — см. раздел 5); реальный календарь в модели не участвует. - В `EventDate`/`UTCEventTime` дни оси ложатся конкретными датами, но это - константа мира, от даты запуска не зависящая (значение — раздел 9). - Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — - мир замер, потом продолжил. +- **Форма плана — ленивая, по когортам дня** (уточнение при исполнении + #38, 2026-08-02). Глобальный список посетителей не строится: когорта + дня D — функция (зерно, D), её случайность — подпоток состава мира, + ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от + подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт + последних дней: окно активности куки — хвост возвратов (константа + мира — раздел 9), отсчитанный от её первого визита; за краем окна кука + не возвращается, а профиль возвратов затухает к краю, поэтому обрыв в + данных не виден. Стоимость дня не зависит от прожитого — день 500 + стоит как день 5, горизонт не является входом. Счётчики состава + считаются прогоном плана по дням, без генерации событий. +- **Предыстория: полка с первого дня.** Когорты существуют и до D0 — на + глубину хвоста возвратов. Событий они не порождают (ось событий + начинается в D0) — только дают, кому возвращаться в первые дни: дневная + аудитория на полке с самого D0, разгона «пустого магазина» нет. +- **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица + здесь — человек, не кука: план помечает часть людей покупателями + (доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают + вторую куку. Обе куки пары принадлежат одному человеку одной когорты; + вторая рождается в пределах его окна активности, без фиксированного + зазора. Каждой паре план назначает дни гарантированных заказов: по + одному на куку, из дней визитов этой куки, на оси от D0 и позже; + человеку предыстории, чьё окно активности таких дней не оставляет, + пара не назначается. День-функция обязана назначенные заказы + реализовать; остальные покупки — вольные, их решает генератор торговых + событий (#40). Манифест считает пары, реализованные в горизонте + снимка. Условность в данных не видна: дни назначены той же + случайностью, просто брошенной планом один раз. +- **Своя ось модельного времени.** Ось событий начинается в + фиксированный день D0 (понедельник — см. раздел 5); реальный + календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси + ложатся конкретными датами, но это константа мира, от даты запуска не + зависящая (значение — раздел 9). Между прогонами живут только зерно и + позиция на оси: выключенный ноутбук — мир замер, потом продолжил. - **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг @@ -89,6 +119,27 @@ даты эталонного мира зависимыми от даты запуска, манифест теряет воспроизводимость. +Отклонено при исполнении #38 (2026-08-02): + +- *Разгон вместо предыстории* («первые дни малы — магазин запустился»): + зерновой мир и половина снимка оказались бы на разгоне, недельная лаба + сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы + быть средним — пришлось бы двигать принятые числа раздела 5. +- *Материализованный план на горизонт*: горизонт становится обязательным + входом каждого запуска, стоимость старта растёт с прожитым; префиксную + устойчивость даёт и ленивая форма — даром, через подпотоки по номеру дня. +- *Замкнутый пул кук со сменой поколений*: постоянная память ценой фальшивой + константы — потолка одновременно живущих кук, которого в жизни нет и + который менти нечем объяснить. +- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не + гарантия — однажды манифест покраснеет, а чинить нечем, кроме смены + зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев, + так что реализм этой лотереи невидим. +- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор + «все пары покупают в первый день» виден в данных ровно там, куда лаба + склейки смотрит пристальнее всего. Условность допустима, пока она не + видна в данных. + ## 2. Детерминизм от зерна Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28). @@ -105,7 +156,9 @@ тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на любой платформе — баг генератора, а не допуск. -- **Раздача зерна — иерархией подпотоков.** Корневое зерно → состав мира; +- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток + состава мира, ветвящийся по номеру дня на когорты плана (состав + спрашивается по дням — раздел 1; уточнение при исполнении #38); (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, торговые события, расхождения, опоздания — в фиксированном порядке. По построению: параллельный прогон равен последовательному; продление @@ -334,18 +387,52 @@ pytest-тест с маркером `perf` и таймаутом-обрубан - **Будущие лабы**: перезаливка дня 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 дней + снимка ≈60 тыс. — ≈53 тыс. новыми куками плюс возвраты предыстории + (не путать с ~50 тыс. событий одного дня) — рост `uniq(ClientID)` с + горизонтом виден сразу; новых покупателей ~190 в день → конверсия + ~2% на сессию. +- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке + (2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели + свежими; привязки к реальному календарю у констант мира всё равно нет. +- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы: + все числа мира в одном месте, написанном как приглашение любопытному + менти крутить. Правка модуля — смена мира: чек манифеста честно + краснеет, манифест сторожит только канон. Вне модуля — лишь то, что + мира не меняет: своё зерно и транспортные флаги проигрывателя. + Отклонено: внешний конфиг и env-переопределения — переменная мира, + которую паспорт манифеста не видит; файл-конфиг в репозитории — по + смыслу равен модулю, но платит загрузчиком и валидацией (довод + раздела 3 против YAML). + +Остаётся открытым, за тикетами: - интерфейс запуска генератора (CLI / цели make) и как он делит режимы проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из - оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт; -- числа притока посетителей: доля одноразовых кук и темп появления новых - (раздел 1); -- календарная дата-константа D0: каким числом дни оси ложатся в - `EventDate`/`UTCEventTime`; -- формат описания мира и конфигурации (что константа кода, что параметр); + оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41); - как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки): - с манифестным решением напрашивается мини-манифест зернового мира — форму - выбрать при нарезке. + с манифестным решением напрашивается мини-манифест зернового мира — + форма за #42. -- 2.54.0 From abac6afe18096c83af0ab7629662585f7aec0b77 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 12:20:48 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(generator):=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=20=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B0=20=D0=BC=D0=B8?= =?UTF-8?q?=D1=80=D0=B0=20=E2=80=94=20=D0=B7=D0=B5=D1=80=D0=BD=D0=BE,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D1=82=D0=BE=D0=BA,=20=D0=B4=D0=B2=D1=83?= =?UTF-8?q?=D1=85=D0=BA=D1=83=D0=BA=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - тикет #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) --- CONTEXT.md | 4 + docs/specs/2026-08-01-generator.md | 14 +- generator/README.md | 13 +- generator/src/clickstream_generator/plan.py | 264 +++++++++++++++++++ generator/src/clickstream_generator/seeds.py | 72 +++++ generator/src/clickstream_generator/world.py | 68 +++++ generator/tests/test_plan.py | 243 +++++++++++++++++ generator/tests/test_seeds.py | 66 +++++ generator/tests/test_world.py | 66 +++++ 9 files changed, 807 insertions(+), 3 deletions(-) create mode 100644 generator/src/clickstream_generator/plan.py create mode 100644 generator/src/clickstream_generator/seeds.py create mode 100644 generator/src/clickstream_generator/world.py create mode 100644 generator/tests/test_plan.py create mode 100644 generator/tests/test_seeds.py create mode 100644 generator/tests/test_world.py diff --git a/CONTEXT.md b/CONTEXT.md index cf7278a..3942008 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,10 @@ _Избегать_: состояние мира Способ спросить состав мира: функция зерна, выдающая его по дням — когорту новых кук, их возвраты, назначенные заказы двухкуковых пар. +**Когорта дня**: +Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами. +Единица плана состава: когорта — функция зерна и номера дня. + **Приток**: Появление новых кук на всём протяжении оси модельного времени; единица — кука (`ClientID`). Из-за притока накопленная аудитория растёт с diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 18a2c74..77e1ec5 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -81,7 +81,10 @@ (доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку. Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в пределах его окна активности, без фиксированного - зазора. Каждой паре план назначает дни гарантированных заказов: по + зазора — по тому же затухающему профилю, что и возвраты (уточнение при + исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит. + Равные шансы по всему окну означали бы вторую куку у давно ушедшего + человека — и вчетверо меньше пар, реализованных внутри снимка. Каждой паре план назначает дни гарантированных заказов: по одному на куку, из дней визитов этой куки, на оси от D0 и позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара не назначается. День-функция обязана назначенные заказы @@ -418,6 +421,15 @@ pytest-тест с маркером `perf` и таймаутом-обрубан - **D0 = 2026-06-01, понедельник** — решение владельца при нарезке (2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели свежими; привязки к реальному календарю у констант мира всё равно нет. +- **Измерено на собранном плане** (#38, канонический мир, 14 дней): + приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 — + обе величины в вилках раздела 5. Накопленная аудитория за снимок — + 68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории, + а не ≈60 тыс., как оценивалось выше, — возвраты предыстории при + оценке посчитали вдвое скромнее. Пар, у которых оба назначенных + заказа попали внутрь 14 дней, — 170; остальные пары горизонт + переживают, их вторая кука приходит позже. Цифры пересчитываются + прогоном плана, событий для них не нужно. - **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы: все числа мира в одном месте, написанном как приглашение любопытному менти крутить. Правка модуля — смена мира: чек манифеста честно diff --git a/generator/README.md b/generator/README.md index 57356dc..f108d93 100644 --- a/generator/README.md +++ b/generator/README.md @@ -4,17 +4,26 @@ образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения — спека [«Генератор (этап 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_doc.py` — сборка «описания выгрузки» ([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md)) из контракта. Документ руками не правят — пересобирают. -- `tests/` — инварианты контракта и свежесть описания. +- `tests/` — инварианты контракта, свежесть описания и обещания плана: + чистота от зерна, приток, гарантия двухкуковых пар. ## Команды diff --git a/generator/src/clickstream_generator/plan.py b/generator/src/clickstream_generator/plan.py new file mode 100644 index 0000000..09ad7e1 --- /dev/null +++ b/generator/src/clickstream_generator/plan.py @@ -0,0 +1,264 @@ +"""План состава: способ спросить у мира, кто в нём есть в день 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` в контракте схемы. +MAX_CLIENT_ID = 2**53 + +# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то +# долю общего веса. +_RETURN_COUNTS = np.cumsum(world.RETURN_COUNT_WEIGHTS) +_RETURN_DELAYS = 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: + """Когорта запоминается, поэтому массивы отдаются только на чтение.""" + for field in fields(self): + value = getattr(self, field.name) + if isinstance(value, np.ndarray): + value.flags.writeable = False + + @property + def pairs(self) -> int: + """Сколько пар получили назначенные заказы.""" + return len(self.pair_cookies) + + +@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, MAX_CLIENT_ID, cookies, dtype=np.uint64) + birth_day = np.full(cookies, day, dtype=np.int64) + # Вторая кука рождается, пока человек ещё ходит: тем же затухающим + # профилем, что и возвраты, — обычно через дни, изредка через месяцы. + # Фиксированного зазора нет, иначе пары в данных узнавались бы по нему. + birth_day[people:] += 1 + _pick(rng, _RETURN_DELAYS, 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): + born_cohort = cohort(seed, born) + here = born_cohort.visit_cookie[born_cohort.visit_day == day] + client_id.append(born_cohort.client_id[here]) + buyer.append(born_cohort.buyer[here]) + ordering = born_cohort.pair_cookies[born_cohort.pair_order_days == day] + assigned.append(np.isin(here, ordering)) + 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_INFLUX_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_COUNTS, int(returning.sum())) + + owner = np.repeat(np.arange(cookies, dtype=np.int64), returns) + delay = 1 + _pick(rng, _RETURN_DELAYS, 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] + + # Два возврата в один день — один визит: день у куки бывает только один. + once = np.ones(cookie.size, dtype=bool) + once[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1]) + return cookie[once], when[once] + + +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") diff --git a/generator/src/clickstream_generator/seeds.py b/generator/src/clickstream_generator/seeds.py new file mode 100644 index 0000000..c8bf55d --- /dev/null +++ b/generator/src/clickstream_generator/seeds.py @@ -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)) diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py new file mode 100644 index 0000000..52972ae --- /dev/null +++ b/generator/src/clickstream_generator/world.py @@ -0,0 +1,68 @@ +"""Конфигурация мира: все числа, которыми задан модельный магазин. + +Модуль — приглашение крутить: поменяйте число, пересоберите снимок и +посмотрите, что стало с данными. Правка любой константы здесь — смена мира, +поэтому чек манифеста честно покраснеет: манифест сторожит только канонический +мир, свои миры менти собирает без его гарантий (спека генератора, раздел 9). + +Числа решены спекой и связаны между собой; связки сторожат тесты +`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего. +Здесь только чистые данные — как в контракте схемы, никакой логики. +""" + +from datetime import date + +# D0 — первый день оси модельного времени, понедельник. Реальный календарь в +# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в +# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не +# зависит — иначе манифест перестал бы быть воспроизводимым. +ORIGIN = date(2026, 6, 1) + +# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит +# свою куку, поэтому число это же — приток кук; вторые куки двухкуковых пар +# добавляют к нему меньше процента. +DAILY_INFLUX = 3_800 + +# Недельная волна притока, проценты от среднего: понедельник … воскресенье. +# Модулируется тем же профилем, что трафик, — иначе доля новичков скакала бы +# по дням недели. В сумме ровно 700: за неделю средний день остаётся средним. +WEEKLY_INFLUX_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 diff --git a/generator/tests/test_plan.py b/generator/tests/test_plan.py new file mode 100644 index 0000000..929f489 --- /dev/null +++ b/generator/tests/test_plan.py @@ -0,0 +1,243 @@ +"""План состава: чистота, приток и обещания, данные спекой (разделы 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_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 len(set(days)) == len(days) or 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_INFLUX_PERCENT[5:]) / mean( + world.WEEKLY_INFLUX_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) diff --git a/generator/tests/test_seeds.py b/generator/tests/test_seeds.py new file mode 100644 index 0000000..b285511 --- /dev/null +++ b/generator/tests/test_seeds.py @@ -0,0 +1,66 @@ +"""Свойства иерархии зёрен, на которых держится детерминизм. + +Проверяется не «числа такие-то» (они зависят от 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_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 отрицательны, позиция в дереве — нет: своя ветвь.""" + for depth in range(1, 5): + assert first_draws(cohort_stream(CANONICAL_SEED, -depth)) != first_draws( + cohort_stream(CANONICAL_SEED, depth) + ) + + +def test_day_components_do_not_share_randomness(): + draws = [ + first_draws(day_stream(CANONICAL_SEED, 3, component)) for component in Component + ] + assert len({tuple(draw) for draw in draws}) == len(Component) + + +def test_composition_and_day_are_separate_streams(): + """Состав мира ветвится сам по себе — день-функция его не сдвигает.""" + assert first_draws(cohort_stream(CANONICAL_SEED, 3)) != first_draws( + day_stream(CANONICAL_SEED, 3, Component.TRAFFIC) + ) + + +def test_another_seed_is_another_world(): + assert first_draws(cohort_stream(CANONICAL_SEED, 3)) != first_draws( + cohort_stream(CANONICAL_SEED + 1, 3) + ) diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py new file mode 100644 index 0000000..de70c72 --- /dev/null +++ b/generator/tests/test_world.py @@ -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_INFLUX_PERCENT) == 7 + assert sum(world.WEEKLY_INFLUX_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 -- 2.54.0 From 2502f906a89299e29ee27897d9fcbe0f67f087e0 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 12:33:27 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(generator):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=B4=D0=B2=D1=83=D0=BC=20=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=8F=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - ревью нашло два места, где код и документы говорили неправду, и несколько мест, где имена или комментарии вводили в заблуждение. - Что: - обещание докстроки `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) --- CONTEXT.md | 10 ++++ docs/specs/2026-08-01-generator.md | 47 +++++++++-------- generator/src/clickstream_generator/plan.py | 53 ++++++++++++-------- generator/src/clickstream_generator/seeds.py | 2 +- generator/src/clickstream_generator/world.py | 12 +++-- generator/tests/test_plan.py | 6 +-- generator/tests/test_seeds.py | 16 ++++++ generator/tests/test_world.py | 4 +- 8 files changed, 98 insertions(+), 52 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3942008..2c056fe 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,16 @@ _Избегать_: состояние мира Способ спросить состав мира: функция зерна, выдающая его по дням — когорту новых кук, их возвраты, назначенные заказы двухкуковых пар. +**Подпоток**: +Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого +дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не +порядком вычислений. + +**Конфигурация мира**: +Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли +покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы: +там колонки, здесь числа. + **Когорта дня**: Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами. Единица плана состава: когорта — функция зерна и номера дня. diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 77e1ec5..166a799 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -66,9 +66,12 @@ дня D — функция (зерно, D), её случайность — подпоток состава мира, ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт - последних дней: окно активности куки — хвост возвратов (константа - мира — раздел 9), отсчитанный от её первого визита; за краем окна кука - не возвращается, а профиль возвратов затухает к краю, поэтому обрыв в + последних дней: окно активности — хвост возвратов (константа мира — + раздел 9), отсчитанный от первого визита человека и общий на обе его + куки (уточнение при исполнении #38, 2026-08-02: окно от рождения + каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад, + которым ленивая форма и держится). За краем окна кука не + возвращается, а профиль возвратов затухает к краю, поэтому обрыв в данных не виден. Стоимость дня не зависит от прожитого — день 500 стоит как день 5, горизонт не является входом. Счётчики состава считаются прогоном плана по дням, без генерации событий. @@ -84,7 +87,8 @@ зазора — по тому же затухающему профилю, что и возвраты (уточнение при исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну означали бы вторую куку у давно ушедшего - человека — и вчетверо меньше пар, реализованных внутри снимка. Каждой паре план назначает дни гарантированных заказов: по + человека — и вчетверо меньше пар, реализованных внутри снимка. + Каждой паре план назначает дни гарантированных заказов: по одному на куку, из дней визитов этой куки, на оси от D0 и позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара не назначается. День-функция обязана назначенные заказы @@ -161,7 +165,10 @@ любой платформе — баг генератора, а не допуск. - **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток состава мира, ветвящийся по номеру дня на когорты плана (состав - спрашивается по дням — раздел 1; уточнение при исполнении #38); + спрашивается по дням — раздел 1; уточнение при исполнении #38). + Дни предыстории отрицательны, а позиция в дереве — неотрицательное + целое, поэтому у предыстории своя ветвь состава, отдельная от оси + (уточнение при исполнении #38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, торговые события, расхождения, опоздания — в фиксированном порядке. По построению: параллельный прогон равен последовательному; продление @@ -403,30 +410,30 @@ pytest-тест с маркером `perf` и таймаутом-обрубан возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все в первые 7–10 дней, тонкий хвост поздних возвратов и повторных покупок — до края окна (цикл повторной покупки магазина — месяцы). - Хвост возвратов — окно активности куки от первого визита — и глубина - предыстории: 90 дней (решение владельца 2026-08-02: дольше квартала - стенд никто не гоняет, а заказы старых посетителей продолжаются весь - прогон; плата — чуть меньше пар, полностью реализованных внутри - 14-дневного снимка). Покупатели считаются людьми, - не куками; доля покупателей — 5% людей когорты, а людей в когорте - почти столько же, сколько кук: вторые куки пар добавляют меньше - процента. Доля — число плана, а не торгового тикета: без него не + Хвост возвратов — окно активности человека от его первого визита, + общее на обе его куки, — и глубина предыстории: 90 дней (решение + владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы + старых посетителей продолжаются весь прогон; плата — чуть меньше пар, + полностью реализованных внутри 14-дневного снимка). Покупатели + считаются людьми, не куками; доля покупателей — 5% людей когорты, а + людей в когорте почти столько же, сколько кук: вторые куки пар + добавляют меньше процента. Доля — число плана, а не торгового тикета: без него не отобрать двухкуковые пары; #40 наследует его, не переоткрывая. Сходимость с разделом 5: средняя кука активна ≈1,9 дня → дневная аудитория ≈7 100 — середина вилки 6–8 тыс.; уникумов за 14 дней - снимка ≈60 тыс. — ≈53 тыс. новыми куками плюс возвраты предыстории - (не путать с ~50 тыс. событий одного дня) — рост `uniq(ClientID)` с - горизонтом виден сразу; новых покупателей ~190 в день → конверсия - ~2% на сессию. + снимка — под 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 тыс., как оценивалось выше, — возвраты предыстории при - оценке посчитали вдвое скромнее. Пар, у которых оба назначенных + 68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории + (при нарезке возвраты предыстории оценили вдвое скромнее — отсюда + ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных заказа попали внутрь 14 дней, — 170; остальные пары горизонт переживают, их вторая кука приходит позже. Цифры пересчитываются прогоном плана, событий для них не нужно. diff --git a/generator/src/clickstream_generator/plan.py b/generator/src/clickstream_generator/plan.py index 09ad7e1..ff31e08 100644 --- a/generator/src/clickstream_generator/plan.py +++ b/generator/src/clickstream_generator/plan.py @@ -30,13 +30,13 @@ from clickstream_generator import world from clickstream_generator.seeds import cohort_stream # Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у -# `WatchID` в контракте схемы. -MAX_CLIENT_ID = 2**53 +# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней. +CLIENT_ID_LIMIT = 2**53 # Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то # долю общего веса. -_RETURN_COUNTS = np.cumsum(world.RETURN_COUNT_WEIGHTS) -_RETURN_DELAYS = np.cumsum(world.RETURN_DELAY_WEIGHTS) +_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS) +_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS) @dataclass(frozen=True, slots=True) @@ -75,6 +75,18 @@ class Cohort: """Сколько пар получили назначенные заказы.""" 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: @@ -119,12 +131,12 @@ def cohort(seed: int, day: int) -> Cohort: ] cookies = people + paired.size - client_id = rng.integers(1, MAX_CLIENT_ID, cookies, dtype=np.uint64) + 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_DELAYS, paired.size) + 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( @@ -156,12 +168,10 @@ def audience(seed: int, day: int) -> DayAudience: client_id, buyer, assigned = [], [], [] # Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси. for born in range(day - world.RETURN_TAIL_DAYS, day + 1): - born_cohort = cohort(seed, born) - here = born_cohort.visit_cookie[born_cohort.visit_day == day] - client_id.append(born_cohort.client_id[here]) - buyer.append(born_cohort.buyer[here]) - ordering = born_cohort.pair_cookies[born_cohort.pair_order_days == day] - assigned.append(np.isin(here, ordering)) + 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), @@ -177,7 +187,7 @@ def counters(seed: int, days: int) -> PlanCounters: 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) @@ -200,7 +210,7 @@ def counters(seed: int, days: int) -> PlanCounters: def _influx(rng: np.random.Generator, day: int) -> int: """Сколько новых людей приходит в этот день: число мира по недельной волне.""" - base = world.DAILY_INFLUX * world.WEEKLY_INFLUX_PERCENT[day % 7] // 100 + 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)) @@ -212,10 +222,10 @@ def _visits( 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_COUNTS, int(returning.sum())) + 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_DELAYS, owner.size) + 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)) @@ -226,9 +236,9 @@ def _visits( cookie, when = cookie[order], when[order] # Два возврата в один день — один визит: день у куки бывает только один. - once = np.ones(cookie.size, dtype=bool) - once[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1]) - return cookie[once], when[once] + 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( @@ -247,8 +257,9 @@ def _assign_orders( """ on_axis = visit_day >= 0 counts = np.bincount(visit_cookie[on_axis], minlength=cookies) - # Визиты куки идут подряд и по возрастанию дня, поэтому дни от D0 — хвост - # её блока: до конца блока ровно `counts` визитов. + # Визиты отсортированы по куке, а внутри куки — по дню, и дни от D0 идут + # последними. Значит, дни на оси у куки — хвост её блока: от конца блока + # назад ровно `counts` визитов. first_on_axis = np.searchsorted(visit_cookie, np.arange(cookies), "right") - counts assigned = np.all(counts[pair_cookies] > 0, axis=1) diff --git a/generator/src/clickstream_generator/seeds.py b/generator/src/clickstream_generator/seeds.py index c8bf55d..0da6d53 100644 --- a/generator/src/clickstream_generator/seeds.py +++ b/generator/src/clickstream_generator/seeds.py @@ -25,7 +25,7 @@ from enum import IntEnum import numpy as np -# Канонический зерно эталонного мира — константа репозитория; манифест хранит +# Каноническое зерно эталонного мира — константа репозитория; манифест хранит # его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста. CANONICAL_SEED = 20260601 diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 52972ae..f74afbc 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -7,7 +7,8 @@ Числа решены спекой и связаны между собой; связки сторожат тесты `test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего. -Здесь только чистые данные — как в контракте схемы, никакой логики. +Здесь только числа — как в контракте схемы, никакого поведения; длинные +таблицы записаны коротко, но остаются таблицами. """ from datetime import date @@ -23,10 +24,11 @@ ORIGIN = date(2026, 6, 1) # добавляют к нему меньше процента. DAILY_INFLUX = 3_800 -# Недельная волна притока, проценты от среднего: понедельник … воскресенье. -# Модулируется тем же профилем, что трафик, — иначе доля новичков скакала бы -# по дням недели. В сумме ровно 700: за неделю средний день остаётся средним. -WEEKLY_INFLUX_PERCENT = (105, 108, 107, 105, 95, 88, 92) +# Недельная волна мира, проценты от среднего: понедельник … воскресенье. +# Профиль один на весь мир: по нему идёт приток, по нему же пойдёт трафик +# дня-функции — иначе доля новичков скакала бы по дням недели. В сумме ровно +# 700: за неделю средний день остаётся средним. +WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92) # Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в # первом же графике по дням. diff --git a/generator/tests/test_plan.py b/generator/tests/test_plan.py index 929f489..7dc1671 100644 --- a/generator/tests/test_plan.py +++ b/generator/tests/test_plan.py @@ -141,7 +141,7 @@ def test_every_pair_orders_from_both_cookies_on_the_axis(day: int): for cookies, days in zip( cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True ): - assert len(set(days)) == len(days) or cookies[0] != cookies[1] + 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 @@ -168,8 +168,8 @@ def test_the_influx_breathes_with_the_week(): 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_INFLUX_PERCENT[5:]) / mean( - world.WEEKLY_INFLUX_PERCENT[:5] + expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean( + world.WEEKLY_PROFILE_PERCENT[:5] ) assert abs(mean(weekend) / mean(weekdays) - expected) < 0.03 diff --git a/generator/tests/test_seeds.py b/generator/tests/test_seeds.py index b285511..55eb390 100644 --- a/generator/tests/test_seeds.py +++ b/generator/tests/test_seeds.py @@ -25,6 +25,22 @@ 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) diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py index de70c72..b953763 100644 --- a/generator/tests/test_world.py +++ b/generator/tests/test_world.py @@ -21,8 +21,8 @@ def test_origin_is_a_monday(): def test_weekly_profile_covers_a_week_and_averages_to_one(): - assert len(world.WEEKLY_INFLUX_PERCENT) == 7 - assert sum(world.WEEKLY_INFLUX_PERCENT) == 700 + assert len(world.WEEKLY_PROFILE_PERCENT) == 7 + assert sum(world.WEEKLY_PROFILE_PERCENT) == 700 def test_returning_share_matches_the_spec(): -- 2.54.0 From d17bd4e876e774b9a51a1f08270ff1b08d8e6353 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 13:13:41 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(generator):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=9A=D0=BE=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D0=B0=20=E2=80=94=20=D0=B7=D1=91=D1=80=D0=BD=D0=B0,=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8C,=20=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B6=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - третья линия ревью (Кодекс, другое семейство моделей) нашла дыру в стороже неизменности когорты, отставший словарь и тест, который сторожил не тот адрес дерева зёрен. - Что: - когорта отдаётся видом на замороженный массив: флаг только для чтения вызывающий мог снять и испортить память, которой пользуются все дни окна. Граница защиты названа в докстроке — от случайности, не от умысла. - 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) --- CONTEXT.md | 4 +- generator/src/clickstream_generator/plan.py | 14 ++++- generator/src/clickstream_generator/world.py | 7 +-- generator/tests/test_plan.py | 9 ++++ generator/tests/test_seeds.py | 57 +++++++++++++++----- 5 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 2c056fe..18a786c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,8 +40,8 @@ _Избегать_: состояние мира горизонтом и не совпадает с дневной. **Хвост возвратов**: -Окно активности куки, отсчитанное от её первого визита; дольше окна -кука не возвращается. +Окно активности, отсчитанное от первого визита человека и общее на обе +его куки; дольше окна кука не возвращается. **Предыстория**: Когорты плана с первым визитом до D0; событий не порождают. diff --git a/generator/src/clickstream_generator/plan.py b/generator/src/clickstream_generator/plan.py index ff31e08..d66d5dd 100644 --- a/generator/src/clickstream_generator/plan.py +++ b/generator/src/clickstream_generator/plan.py @@ -64,11 +64,23 @@ class Cohort: 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: diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index f74afbc..584f04a 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -43,9 +43,10 @@ ONE_SHOT_PERCENT = 75 # притоке 3 800 (спека, разделы 5 и 9). RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1) -# Хвост возвратов: окно активности куки от её первого визита. За краем окна -# кука не возвращается. Оно же — глубина предыстории: столько когорт живёт -# до D0, чтобы дневная аудитория была на полке с самого первого дня. +# Хвост возвратов: окно активности человека от его первого визита, общее на +# обе его куки. За краем окна кука не возвращается. Оно же — глубина +# предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на +# полке с самого первого дня. RETURN_TAIL_DAYS = 90 # Профиль возвратов по дням от первого визита: почти всё в первую неделю, diff --git a/generator/tests/test_plan.py b/generator/tests/test_plan.py index 7dc1671..f25ba79 100644 --- a/generator/tests/test_plan.py +++ b/generator/tests/test_plan.py @@ -56,6 +56,15 @@ def test_counters_are_a_pure_function_of_the_seed(): 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) diff --git a/generator/tests/test_seeds.py b/generator/tests/test_seeds.py index 55eb390..2cd8be6 100644 --- a/generator/tests/test_seeds.py +++ b/generator/tests/test_seeds.py @@ -55,25 +55,54 @@ def test_cohorts_of_different_days_are_independent(): def test_prehistory_does_not_collide_with_the_axis(): - """Дни до D0 отрицательны, позиция в дереве — нет: своя ветвь.""" - for depth in range(1, 5): - assert first_draws(cohort_stream(CANONICAL_SEED, -depth)) != first_draws( - cohort_stream(CANONICAL_SEED, depth) - ) + """Дни до 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(): - draws = [ - first_draws(day_stream(CANONICAL_SEED, 3, component)) for component in Component - ] - assert len({tuple(draw) for draw in draws}) == len(Component) + """Четыре подпотока дня из спеки — и они четыре разных. - -def test_composition_and_day_are_separate_streams(): - """Состав мира ветвится сам по себе — день-функция его не сдвигает.""" - assert first_draws(cohort_stream(CANONICAL_SEED, 3)) != first_draws( - day_stream(CANONICAL_SEED, 3, Component.TRAFFIC) + Компоненты перечислены поимённо, а не обходом `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(): -- 2.54.0