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:
2026-08-02 12:20:48 +03:00
co-authored by Claude Opus 5
parent 68a35bfdcb
commit abac6afe18
9 changed files with 807 additions and 3 deletions
+4
View File
@@ -20,6 +20,10 @@ _Избегать_: состояние мира
Способ спросить состав мира: функция зерна, выдающая его по дням —
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
**Когорта дня**:
Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами.
Единица плана состава: когорта — функция зерна и номера дня.
**Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица —
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
+13 -1
View File
@@ -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; остальные пары горизонт
переживают, их вторая кука приходит позже. Цифры пересчитываются
прогоном плана, событий для них не нужно.
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
все числа мира в одном месте, написанном как приглашение любопытному
менти крутить. Правка модуля — смена мира: чек манифеста честно
+11 -2
View File
@@ -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/` — инварианты контракта, свежесть описания и обещания плана:
чистота от зерна, приток, гарантия двухкуковых пар.
## Команды
+264
View File
@@ -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 не трогает
дни 1N, правка одного компонента меняет только его часть снимка.
Позиция в дереве неотрицательные целые, а дни предыстории отрицательны;
поэтому у предыстории своя ветвь, а не общий ряд с осью.
"""
from collections.abc import Sequence
from enum import IntEnum
import numpy as np
# Канонический зерно эталонного мира — константа репозитория; манифест хранит
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
CANONICAL_SEED = 20260601
class Component(IntEnum):
"""Подпотоки внутри дня; порядок объявления — позиция в дереве."""
TRAFFIC = 0
COMMERCE = 1
DISCREPANCIES = 2
LATECOMERS = 3
class _Branch(IntEnum):
"""Две ветви корня: постоянный состав мира и проживание дней."""
COMPOSITION = 0
DAYS = 1
class _Era(IntEnum):
"""Две ветви состава: ось событий и предыстория до D0."""
AXIS = 0
PREHISTORY = 1
def _stream(seed: int, position: Sequence[int]) -> np.random.Generator:
"""Подпоток на позиции `position` дерева зерна `seed`."""
sequence = np.random.SeedSequence(entropy=seed, spawn_key=position)
return np.random.Generator(np.random.PCG64(sequence))
def cohort_stream(seed: int, day: int) -> np.random.Generator:
"""Случайность когорты дня `day`; отрицательный день — предыстория."""
era, index = (_Era.AXIS, day) if day >= 0 else (_Era.PREHISTORY, -day - 1)
return _stream(seed, (_Branch.COMPOSITION, era, index))
def day_stream(seed: int, day: int, component: Component) -> np.random.Generator:
"""Случайность одного компонента дня `day` — дня оси, не предыстории."""
if day < 0:
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
return _stream(seed, (_Branch.DAYS, day, component))
@@ -0,0 +1,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
+243
View File
@@ -0,0 +1,243 @@
"""План состава: чистота, приток и обещания, данные спекой (разделы 1, 5, 9).
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
магазин на 68 тыс. посетителей»), а не при каждой правке.
"""
import inspect
import re
import numpy as np
import pytest
from clickstream_generator import plan, world
from clickstream_generator.seeds import CANONICAL_SEED
# Горизонт эталонного снимка — две недели (спека, раздел 5).
SNAPSHOT_DAYS = 14
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
def visits_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
"""Таблица визитов парами «кука — день», как её видит день-функция."""
cookies, days = cohort.visit_cookie.tolist(), cohort.visit_day.tolist()
return list(zip(cookies, days, strict=True))
def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
return (
left.day == right.day
and left.people == right.people
and np.array_equal(left.client_id, right.client_id)
and np.array_equal(left.buyer, right.buyer)
and np.array_equal(left.birth_day, right.birth_day)
and np.array_equal(left.visit_cookie, right.visit_cookie)
and np.array_equal(left.visit_day, right.visit_day)
and np.array_equal(left.pair_cookies, right.pair_cookies)
and np.array_equal(left.pair_order_days, right.pair_order_days)
)
def test_the_plan_is_a_pure_function_of_the_seed():
first = plan.cohort(CANONICAL_SEED, 3)
plan.cohort.cache_clear()
assert same_cohort(first, plan.cohort(CANONICAL_SEED, 3))
def test_counters_are_a_pure_function_of_the_seed():
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
plan.cohort.cache_clear()
assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
def test_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)
+66
View File
@@ -0,0 +1,66 @@
"""Свойства иерархии зёрен, на которых держится детерминизм.
Проверяется не «числа такие-то» (они зависят от numpy и закреплены
`uv.lock`), а три обещания спеки, раздел 2: подпоток определяется позицией
в дереве, а не порядком вычислений; продление истории днём N+1 не трогает
дни 1N; правка одного компонента не задевает соседние.
"""
import numpy as np
from clickstream_generator.seeds import (
CANONICAL_SEED,
Component,
cohort_stream,
day_stream,
)
def first_draws(stream: np.random.Generator) -> list[int]:
"""Отпечаток подпотока: первые броски целыми."""
return stream.integers(0, 2**32, 8).tolist()
def test_canonical_seed_is_a_repository_constant():
assert isinstance(CANONICAL_SEED, int)
def test_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)
)
+66
View File
@@ -0,0 +1,66 @@
"""Сторожа конфигурации мира: связность чисел, решённых спекой (раздел 9).
Крутить числа менти можно и нужно тесты сторожат не значения, а то, на чём
держатся выводы спеки: D0 понедельник (от него считается день недели),
недельный профиль в среднем даёт единицу, профиль возвратов сходится с
«в среднем 34 возврата» и «средняя кука активна 1,9 дня».
"""
from clickstream_generator import world
def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
pairs = zip(values, weights, strict=True)
weighted = sum(value * weight for value, weight in pairs)
return weighted / sum(weights)
def test_origin_is_a_monday():
"""День недели считается как остаток номера дня — это верно от понедельника."""
assert world.ORIGIN.weekday() == 0
def test_weekly_profile_covers_a_week_and_averages_to_one():
assert len(world.WEEKLY_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