fix(generator): правки по двум линиям ревью плана состава
- Зачем:
- ревью нашло два места, где код и документы говорили неправду, и
несколько мест, где имена или комментарии вводили в заблуждение.
- Что:
- обещание докстроки `seeds.py` подкреплено тестом: адрес в дереве
даёт тот же подпоток, что цепочка `spawn`.
- в тесте гарантии пар убран сторож-тавтология, вместо него проверка,
что заказы назначены с двух разных кук.
- `Cohort.visitors_on` — «кто пришёл в день D» спрашивается у когорты,
а не собирается снаружи из четырёх её массивов.
- имена: `CLIENT_ID_LIMIT`, `_RETURN_*_CUMULATIVE`, `first_of_day`,
`WEEKLY_PROFILE_PERCENT` — профиль недели один на весь мир, по нему
же пойдёт трафик дня-функции (#39).
- спека: в дерево зёрен внесена ветвь предыстории; окно активности —
от первого визита человека, общее на обе куки (иначе загляд назад
ленивой формы удваивается); оценка накопленной аудитории больше не
спорит с измерением.
- CONTEXT.md: «подпоток» и «конфигурация мира».
- Проверка:
- make test (296), make lint, make typecheck; мутации перепроверены
после переноса среза дня в `Cohort`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
@@ -20,6 +20,16 @@ _Избегать_: состояние мира
|
||||
Способ спросить состав мира: функция зерна, выдающая его по дням —
|
||||
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
|
||||
|
||||
**Подпоток**:
|
||||
Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
|
||||
дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
|
||||
порядком вычислений.
|
||||
|
||||
**Конфигурация мира**:
|
||||
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
|
||||
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
|
||||
там колонки, здесь числа.
|
||||
|
||||
**Когорта дня**:
|
||||
Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами.
|
||||
Единица плана состава: когорта — функция зерна и номера дня.
|
||||
|
||||
@@ -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; остальные пары горизонт
|
||||
переживают, их вторая кука приходит позже. Цифры пересчитываются
|
||||
прогоном плана, событий для них не нужно.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,7 +25,7 @@ from enum import IntEnum
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Канонический зерно эталонного мира — константа репозитория; манифест хранит
|
||||
# Каноническое зерно эталонного мира — константа репозитория; манифест хранит
|
||||
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
|
||||
CANONICAL_SEED = 20260601
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
# Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в
|
||||
# первом же графике по дням.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user