feat(generator): план состава мира — зерно, приток, двухкуковые пары
- Зачем: - тикет #38: состав мира должен быть чистой функцией зерна, а счётчики будущего манифеста — известны до генерации хоть одного события. - Что: - `world.py` — конфигурация мира одним модулем чистых данных: приток, недельная волна, профиль возвратов, доли покупателей и пар, D0. - `seeds.py` — иерархия подпотоков на `SeedSequence`: состав мира (ось и предыстория) отдельно от дней и их компонентов. - `plan.py` — ленивый план состава: когорта дня, аудитория дня из окна возвратов, гарантированные заказы пар, счётчики горизонта. Случайность — только целыми числами. - спека, раздел 1: вторая кука пары рождается по затухающему профилю возвратов; раздел 9: измеренные числа канонического мира, оценка накопленной аудитории поправлена с ≈60 до 68 тыс. - CONTEXT.md: термин «когорта дня»; README генератора — новые модули. - Проверка: - make test (295 тестов), make lint, make typecheck; - тесты проверены мутациями: 12 подмен в плане и конфигурации, каждая роняет ровно свой тест. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Иерархия зёрен: откуда любая часть мира берёт свою случайность.
|
||||
|
||||
Дерево подпотоков (спека генератора, раздел 2):
|
||||
|
||||
корневое зерно
|
||||
├── состав мира
|
||||
│ ├── ось → номер дня: когорта этого дня
|
||||
│ └── предыстория → глубина: когорта дня до D0
|
||||
└── дни
|
||||
└── номер дня → трафик, торговля, расхождения, опоздания
|
||||
|
||||
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
||||
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
||||
по документации numpy (2026-08-01) и проверено тестом: `spawn_key`, выписанный
|
||||
руками, даёт тот же подпоток, что цепочка `spawn`. На этом держатся три
|
||||
обещания: параллельный прогон равен последовательному, день N+1 не трогает
|
||||
дни 1…N, правка одного компонента меняет только его часть снимка.
|
||||
|
||||
Позиция в дереве — неотрицательные целые, а дни предыстории отрицательны;
|
||||
поэтому у предыстории своя ветвь, а не общий ряд с осью.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from enum import IntEnum
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Канонический зерно эталонного мира — константа репозитория; манифест хранит
|
||||
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
|
||||
CANONICAL_SEED = 20260601
|
||||
|
||||
|
||||
class Component(IntEnum):
|
||||
"""Подпотоки внутри дня; порядок объявления — позиция в дереве."""
|
||||
|
||||
TRAFFIC = 0
|
||||
COMMERCE = 1
|
||||
DISCREPANCIES = 2
|
||||
LATECOMERS = 3
|
||||
|
||||
|
||||
class _Branch(IntEnum):
|
||||
"""Две ветви корня: постоянный состав мира и проживание дней."""
|
||||
|
||||
COMPOSITION = 0
|
||||
DAYS = 1
|
||||
|
||||
|
||||
class _Era(IntEnum):
|
||||
"""Две ветви состава: ось событий и предыстория до D0."""
|
||||
|
||||
AXIS = 0
|
||||
PREHISTORY = 1
|
||||
|
||||
|
||||
def _stream(seed: int, position: Sequence[int]) -> np.random.Generator:
|
||||
"""Подпоток на позиции `position` дерева зерна `seed`."""
|
||||
sequence = np.random.SeedSequence(entropy=seed, spawn_key=position)
|
||||
return np.random.Generator(np.random.PCG64(sequence))
|
||||
|
||||
|
||||
def cohort_stream(seed: int, day: int) -> np.random.Generator:
|
||||
"""Случайность когорты дня `day`; отрицательный день — предыстория."""
|
||||
era, index = (_Era.AXIS, day) if day >= 0 else (_Era.PREHISTORY, -day - 1)
|
||||
return _stream(seed, (_Branch.COMPOSITION, era, index))
|
||||
|
||||
|
||||
def day_stream(seed: int, day: int, component: Component) -> np.random.Generator:
|
||||
"""Случайность одного компонента дня `day` — дня оси, не предыстории."""
|
||||
if day < 0:
|
||||
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
||||
return _stream(seed, (_Branch.DAYS, day, component))
|
||||
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user