Files
clickstream-data-platform/generator/src/clickstream_generator/plan.py
T
ddadminandClaude Opus 5 b2fcc7e593 feat(generator): заказы дня — проекция покупок с деньгами магазина
- Зачем:
  - тикет #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>
2026-08-18 12:30:13 +03:00

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