From 2502f906a89299e29ee27897d9fcbe0f67f087e0 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 12:33:27 +0300 Subject: [PATCH] =?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():