- Зачем: - тикет #90: второй источник должен смотреть на торговую половину дня глазами бэкенда — заказ есть проекция покупки, а не второе порождение; учебный результат — два источника согласованы по построению, а не сверкой. - Что: - `commerce` отдаёт вторым выходом покупки дня: номер заказа, корзину, выручку клиента, промокод и человека за кукой; новых бросков в подпоток `COMMERCE` не добавилось, события дня не сдвинулись. - заведена заказная сторона: подпоток `ORDERS` на позиции 2 дерева зерна (прежнее мёртвое имя `DISCREPANCIES`), ветвление — по дню рождения заказа; `LATECOMERS` не тронут, его наполнит этап 6. - новый модуль `orders.py`: деньги заказа целыми копейками — `items_total` из корзины, `discount` по таблице «код → скидка» (округление вниз), `delivery` броском по таблице весов на полную длину дня, `total` = `items_total` − `discount` + `delivery`. - план состава завёл `person_id` — последним броском когорты, после паспортов: вторая кука пары повторяет ID своего человека, заказ показывает его как `user_id`, в событие Метрики он не попадает. - `world`: окно изменяемости `ORDER_WINDOW_DAYS` = 7 рядом с D0 и поясом, черновая таблица стоимости доставки. - слепок дня в тестах сторожит обе половины: заказы сравниваются наравне с потоком событий. - доки: имя подпотока заказной стороны и состав денег заказа записаны в `docs/architecture/orders`, README генератора знает про новый модуль. - Проверка: - из `generator/`: make lint, make typecheck, make test (417 тестов); в корне — make lint. - опись мира не покраснела: байты восьми дней те же, заказная сторона события не сдвинула. Closes #90 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
22 KiB
Python
399 lines
22 KiB
Python
"""План состава: способ спросить у мира, кто в нём есть в день D.
|
|
|
|
Мир — функция, не состояние. Глобального списка посетителей нет и не будет:
|
|
когорта дня — люди, впервые пришедшие именно в этот день, — чистая функция
|
|
зерна и номера дня. Аудитория дня складывается из его когорты и возвратов
|
|
когорт последних дней: дольше хвоста возвратов кука не живёт, поэтому загляд
|
|
назад ограничен окном, а не прожитой историей. День 500 стоит ровно столько
|
|
же, сколько день 5 (спека генератора, раздел 1).
|
|
|
|
Что план решает до генерации событий и чем связывает дни между собой:
|
|
|
|
- приток — кто и когда впервые появился, и сколько раз вернётся; кука
|
|
помеченного покупателя живёт дольше прочих — это один из двух рычагов
|
|
метки, второй лежит в воронке дня;
|
|
- двухкуковые пары — какой человек завёл вторую куку и в какие дни
|
|
каждая из двух кук обязана оформить заказ;
|
|
- паспорт куки — устройство и город: они у куки одни и те же во всех её
|
|
днях, а знает об этом только план (у пары один город на двоих);
|
|
- личность — `person_id` человека за кукой: у двух кук пары он один, и
|
|
заказ бэкенда показывает его как `user_id`
|
|
(docs/architecture/orders/identity.md);
|
|
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
|
|
|
|
Единица дня здесь — день активности куки, а не визит: слово «визит»
|
|
закреплено за сессией и полем `VisitID`, и визитов у куки за день бывает
|
|
несколько. Сколько именно — решает день-функция, плану это безразлично.
|
|
|
|
Случайность тянется целыми числами: диапазоны и выбор по целым весам.
|
|
Плавающие распределения системной математики не зовутся — они расходятся
|
|
между версиями 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 ids, reference, world
|
|
from clickstream_generator.seeds import cohort_stream
|
|
from clickstream_generator.weights import pick
|
|
|
|
# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то
|
|
# долю общего веса.
|
|
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
|
|
_BUYER_RETURN_COUNT_CUMULATIVE = np.cumsum(world.BUYER_RETURN_COUNT_WEIGHTS)
|
|
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
|
|
_CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES])
|
|
_DEVICE_CUMULATIVE = np.cumsum(
|
|
[profile.weight for profile in reference.DEVICE_PROFILES]
|
|
)
|
|
|
|
# Профили, разложенные надвое — телефоны и всё остальное. Вторая кука пары
|
|
# берётся из другой половины: у человека это второе устройство, а не копия
|
|
# первого. Мастер-спека (раздел 5) называет пару «телефон и ноутбук»; у нас
|
|
# к телефону встаёт десктоп или планшет — вторым устройством бывает и он, а
|
|
# запрет на планшет не дал бы ничего, кроме зауженного справочника.
|
|
_IS_PHONE = np.array(
|
|
[
|
|
profile.category == reference.PHONE_CATEGORY
|
|
for profile in reference.DEVICE_PROFILES
|
|
]
|
|
)
|
|
_DEVICE_HALVES = (np.flatnonzero(~_IS_PHONE), np.flatnonzero(_IS_PHONE))
|
|
_DEVICE_HALF_CUMULATIVE = tuple(
|
|
np.cumsum([reference.DEVICE_PROFILES[number].weight for number in half])
|
|
for half in _DEVICE_HALVES
|
|
)
|
|
|
|
|
|
@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_]
|
|
active_cookie: NDArray[np.int64]
|
|
active_day: NDArray[np.int64]
|
|
# Пары и назначенные им заказы — по строке на пару, по колонке на куку.
|
|
pair_cookies: NDArray[np.int64]
|
|
pair_order_days: NDArray[np.int64]
|
|
# Паспорт куки: номер профиля устройства и номер города в справочниках.
|
|
device: NDArray[np.int64]
|
|
city: NDArray[np.int64]
|
|
# Человек за кукой: непрозрачный ID, одинаковый у двух кук пары.
|
|
person_id: NDArray[np.uint64]
|
|
|
|
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:
|
|
"""Сколько пар получили назначенные заказы."""
|
|
return len(self.pair_cookies)
|
|
|
|
def visitors_on(self, day: int) -> DayAudience:
|
|
"""Кто из когорты пришёл в день `day` — её доля дневной аудитории.
|
|
|
|
Как дни активности и пары уложены в массивы, знает только когорта:
|
|
снаружи спрашивают день и получают готовые ряды одной длины.
|
|
"""
|
|
here = self.active_cookie[self.active_day == day]
|
|
ordering = self.pair_cookies[self.pair_order_days == day]
|
|
return DayAudience(
|
|
day=day,
|
|
client_id=self.client_id[here],
|
|
buyer=self.buyer[here],
|
|
assigned_order=np.isin(here, ordering),
|
|
device=self.device[here],
|
|
city=self.city[here],
|
|
person_id=self.person_id[here],
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DayAudience:
|
|
"""Куки, пришедшие в день `day`, — вход дня-функции."""
|
|
|
|
day: int
|
|
client_id: NDArray[np.uint64]
|
|
buyer: NDArray[np.bool_]
|
|
# Куки, которым план назначил на этот день гарантированный заказ пары.
|
|
assigned_order: NDArray[np.bool_]
|
|
# Паспорт куки: номера строк в справочниках устройств и городов.
|
|
device: NDArray[np.int64]
|
|
city: NDArray[np.int64]
|
|
# Человек за кукой: его заказ покажет этот ID как `user_id`.
|
|
person_id: NDArray[np.uint64]
|
|
|
|
|
|
@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
|
|
# Кука живёт числом ниже 2^53 — тот же потолок, что у номера события:
|
|
# выше JSON округляет при разборе. Граница не достигается.
|
|
client_id = rng.integers(1, ids.LIMIT, cookies, dtype=np.uint64)
|
|
birth_day = np.full(cookies, day, dtype=np.int64)
|
|
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
|
|
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
|
|
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
|
|
birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
|
|
|
|
# Вторая кука принадлежит покупателю — как и первая кука его пары.
|
|
buyer_cookie = np.concatenate((buyer, np.ones(paired.size, dtype=bool)))
|
|
active_cookie, active_day = _active_days(
|
|
rng, birth_day, buyer_cookie, day + world.RETURN_TAIL_DAYS
|
|
)
|
|
twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64)))
|
|
pair_cookies, pair_order_days = _assign_orders(
|
|
rng, twins, active_cookie, active_day, cookies
|
|
)
|
|
# Паспорт бросается последним — после всего, что уже измерено: тогда
|
|
# счётчики канонического мира от этой добавки не двигаются. По тому же
|
|
# правилу за ним приписана личность: этап 3 дописал её в конец, и куки,
|
|
# пары и паспорта остались теми же до байта.
|
|
device, city = _passports(rng, twins, cookies)
|
|
person_id = _persons(rng, twins, people, cookies)
|
|
return Cohort(
|
|
day=day,
|
|
people=people,
|
|
client_id=client_id,
|
|
birth_day=birth_day,
|
|
buyer=buyer_cookie,
|
|
active_cookie=active_cookie,
|
|
active_day=active_day,
|
|
pair_cookies=pair_cookies,
|
|
pair_order_days=pair_order_days,
|
|
device=device,
|
|
city=city,
|
|
person_id=person_id,
|
|
)
|
|
|
|
|
|
def audience(seed: int, day: int) -> DayAudience:
|
|
"""Кто пришёл в день `day`: его когорта плюс возвраты когорт окна."""
|
|
if day < 0:
|
|
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
|
|
|
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
|
|
parts = [
|
|
cohort(seed, born).visitors_on(day)
|
|
for born in range(day - world.RETURN_TAIL_DAYS, day + 1)
|
|
]
|
|
return DayAudience(
|
|
day=day,
|
|
client_id=np.concatenate([part.client_id for part in parts]),
|
|
buyer=np.concatenate([part.buyer for part in parts]),
|
|
assigned_order=np.concatenate([part.assigned_order for part in parts]),
|
|
device=np.concatenate([part.device for part in parts]),
|
|
city=np.concatenate([part.city for part in parts]),
|
|
person_id=np.concatenate([part.person_id for part in parts]),
|
|
)
|
|
|
|
|
|
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.active_day >= 0) & (born_cohort.active_day < days)
|
|
daily += np.bincount(born_cohort.active_day[inside], minlength=days)
|
|
seen.append(born_cohort.client_id[np.unique(born_cohort.active_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_PROFILE_PERCENT[day % 7] // 100
|
|
spread = base * world.INFLUX_JITTER_PERCENT // 100
|
|
return base + int(rng.integers(-spread, spread + 1))
|
|
|
|
|
|
def _active_days(
|
|
rng: np.random.Generator,
|
|
birth_day: NDArray[np.int64],
|
|
buyer: NDArray[np.bool_],
|
|
window_end: int,
|
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
|
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто.
|
|
|
|
Помеченный планом покупатель живёт дольше прочих: одноразовым бывает
|
|
много реже и возвращается чаще. Это первый из двух рычагов метки
|
|
(второй — воронка дня): метки в событии нет, поэтому «постоянный
|
|
покупатель» читается в данных только как кука, которая ходит неделями и
|
|
покупает не раз. Одним лифтом конверсии этого не добиться — кука живёт
|
|
меньше двух визитов за снимок, и второй покупке негде случиться (спека
|
|
генератора, раздел 9).
|
|
"""
|
|
cookies = birth_day.size
|
|
returns = np.zeros(cookies, dtype=np.int64)
|
|
one_shot = np.where(buyer, world.BUYER_ONE_SHOT_PERCENT, world.ONE_SHOT_PERCENT)
|
|
returning = rng.integers(0, 100, cookies) >= one_shot
|
|
for here, weights in (
|
|
(returning & ~buyer, _RETURN_COUNT_CUMULATIVE),
|
|
(returning & buyer, _BUYER_RETURN_COUNT_CUMULATIVE),
|
|
):
|
|
returns[here] = 1 + pick(rng, weights, int(here.sum()))
|
|
|
|
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
|
|
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))
|
|
|
|
# Вторая кука пары рождается посреди окна, и её возвраты за край не идут.
|
|
inside = when <= window_end
|
|
cookie, when = cookie[inside], when[inside]
|
|
order = np.lexsort((when, cookie))
|
|
cookie, when = cookie[order], when[order]
|
|
|
|
# Два возврата в один день — один день активности: он у куки бывает один.
|
|
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(
|
|
rng: np.random.Generator,
|
|
pair_cookies: NDArray[np.int64],
|
|
active_cookie: NDArray[np.int64],
|
|
active_day: NDArray[np.int64],
|
|
cookies: int,
|
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
|
"""Дни гарантированных заказов пары: по дню каждой из двух кук, от D0.
|
|
|
|
Человеку предыстории, у чьей куки дней на оси не осталось, пара не
|
|
назначается: обещать заказ, которого никто не увидит, нечестно. Дни
|
|
берутся той же случайностью, что и всё остальное, — в данных условность
|
|
не видна.
|
|
"""
|
|
on_axis = active_day >= 0
|
|
counts = np.bincount(active_cookie[on_axis], minlength=cookies)
|
|
# Дни отсортированы по куке, а внутри куки — по возрастанию, и дни от D0
|
|
# идут последними. Значит, дни на оси у куки — хвост её блока: от конца
|
|
# блока назад ровно `counts` дней.
|
|
first_on_axis = np.searchsorted(active_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, active_day[chosen]
|
|
|
|
|
|
def _passports(
|
|
rng: np.random.Generator, twins: NDArray[np.int64], cookies: int
|
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
|
"""Устройство и город каждой куки; у пары город один, устройства разные.
|
|
|
|
Выводить паспорт арифметикой из `ClientID` было бы дешевле, но про
|
|
двухкуковые пары знает только план, а два города у одного человека —
|
|
ложь в данных (спека генератора, раздел 9).
|
|
"""
|
|
device = pick(rng, _DEVICE_CUMULATIVE, cookies)
|
|
city = pick(rng, _CITY_CUMULATIVE, cookies)
|
|
|
|
first, second = twins[:, 0], twins[:, 1]
|
|
city[second] = city[first]
|
|
# Половина справочника выбирается по первой куке, строка в ней — броском.
|
|
other_half = np.where(_IS_PHONE[device[first]], 0, 1)
|
|
for half in (0, 1):
|
|
here = second[other_half == half]
|
|
device[here] = _DEVICE_HALVES[half][
|
|
pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size)
|
|
]
|
|
return device, city
|
|
|
|
|
|
def _persons(
|
|
rng: np.random.Generator, twins: NDArray[np.int64], people: int, cookies: int
|
|
) -> NDArray[np.uint64]:
|
|
"""Человек за каждой кукой: у двух кук пары ID один и тот же.
|
|
|
|
Личность — минимальная форма отношения «кука принадлежит человеку», и
|
|
знает его только план: заказ спрашивает и показывает то же число как
|
|
`user_id`, а в кликстрим оно не попадает вовсе
|
|
(docs/architecture/orders/identity.md). Значение непрозрачное и живёт
|
|
ниже 2^53 — тот же потолок, что у куки: выше JSON округляет при разборе.
|
|
"""
|
|
person_id = np.empty(cookies, dtype=np.uint64)
|
|
person_id[:people] = rng.integers(1, ids.LIMIT, people, dtype=np.uint64)
|
|
# Вторая кука пары повторяет ID своего человека: заказы с двух кук —
|
|
# это и есть склейка, ради которой пары заведены.
|
|
person_id[twins[:, 1]] = person_id[twins[:, 0]]
|
|
return person_id
|