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>
This commit is contained in:
2026-08-18 12:30:13 +03:00
co-authored by Claude Opus 5
parent 716eafe865
commit b2fcc7e593
14 changed files with 650 additions and 52 deletions
+29 -2
View File
@@ -52,19 +52,39 @@ def readable(column: NDArray[Any]) -> list[Any]:
return column.tolist()
def orders(events: day.Day) -> list[list[Any]]:
"""Заказы дня в сравнимом виде: строка на заказ, все его поля."""
theirs = events.orders
return [
[
theirs.order_id[number],
int(theirs.user_id[number]),
theirs.product[number].tolist(),
theirs.quantity[number].tolist(),
int(theirs.items_total[number]),
int(theirs.discount[number]),
int(theirs.delivery[number]),
int(theirs.total[number]),
]
for number in range(len(theirs))
]
def snapshot(events: day.Day) -> dict[str, Any]:
"""Слепок дня для сравнений: всё, что день отдал наружу.
Порядок колонок в слепке живёт отдельным списком: словари сравниваются
без оглядки на него, а порядок — часть обещания (он же порядок
контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, и
сторожить их надо тем же слепком, а не отдельной памятью.
контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, как
и вторая половина дня, заказы: сторожить их надо тем же слепком, а не
отдельной памятью.
"""
return {
"order": list(events.columns),
"values": [readable(column) for column in events.columns.values()],
"page": events.page.tolist(),
"product": events.product.tolist(),
"orders": orders(events),
}
@@ -91,6 +111,7 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
columns=dict(reversed(list(weekday.columns.items()))),
page=weekday.page,
product=weekday.product,
orders=weekday.orders,
)
assert snapshot(reordered) != original
@@ -101,6 +122,12 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
shifted[0] += 1
assert snapshot(replace(weekday, page=shifted)) != original
paid = weekday.orders.total.copy()
paid[0] += 1
assert snapshot(replace(weekday, orders=replace(weekday.orders, total=paid))) != (
original
)
def test_a_day_is_a_pure_function_of_the_seed_and_the_day():
first = snapshot(day.stream(CANONICAL_SEED, 3))
+203
View File
@@ -0,0 +1,203 @@
"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
Заказ здесь ещё без судьбы — статуса, моментов и дельты (тикет #91): всё,
что проверяется, это согласие двух источников по построению. Поэтому и
сверяется заказ не с внутренней структурой торговой половины (это было бы
сверкой кода с самим собой), а с событием, которое уехало в трекер.
Чистота заказов от зерна и дня сторожится там же, где чистота событий, —
слепком дня в `test_day.py`: заказы день отдаёт наружу наравне с потоком.
"""
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, orders, plan, schema, world
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
@pytest.fixture(scope="module")
def weekday() -> day.Day:
return day.stream(CANONICAL_SEED, WEEKDAY)
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
"""Неделя мира: назначенные пары покупают в разные дни."""
return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
def purchases_of(events: day.Day) -> dict[str, NDArray[Any]]:
"""Строки покупок дня — колонками, в порядке потока."""
here = events.columns["EventType"] == commerce.PURCHASE
return {name: value[here] for name, value in events.columns.items()}
def user_id_by_order(events: day.Day) -> dict[str, int]:
return dict(
zip(events.orders.order_id, events.orders.user_id.tolist(), strict=True)
)
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
"""Заказ — вторая проекция покупки, а не второе порождение.
У всякого заказа ровно одно событие `purchase`, и номер у них один:
сойтись двум источникам больше негде — сверка стенда соединяется
именно по нему.
"""
events = purchases_of(weekday)
numbers = [cell[0] for cell in events["purchaseID"]]
assert len(numbers) > 100
assert list(weekday.orders.order_id) == numbers
def test_the_order_repeats_the_basket_and_the_money_of_its_purchase(weekday: day.Day):
"""Корзина и деньги клиента у заказа те же — он их не пересчитывает."""
goods = catalog.catalog()
events = purchases_of(weekday)
for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0]
items = weekday.orders.product[number]
assert [goods.sku[item] for item in items.tolist()] == (
events["productID"][number].tolist()
)
assert weekday.orders.quantity[number].tolist() == (
events["productQuantity"][number].tolist()
)
# Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках.
assert weekday.orders.items_total[number] == round(
events["purchaseRevenue"][number][0] * commerce.KOPECKS
)
def test_the_discount_comes_from_the_coupon_of_the_event(weekday: day.Day):
"""Промокод в событии обязан обернуться скидкой — иначе данные соврут."""
percent = dict(world.COUPONS)
events = purchases_of(weekday)
codes = [cell[0] for cell in events["purchaseCoupon"]]
assert sum(1 for code in codes if code) > 10
for number, code in enumerate(codes):
total = int(weekday.orders.items_total[number])
expected = total * percent[code] // 100 if code else 0
assert weekday.orders.discount[number] == expected
# Скидка без кода не берётся ниоткуда, а с кодом не съедает заказ.
assert np.all(weekday.orders.discount < weekday.orders.items_total)
def test_the_money_of_an_order_adds_up(weekday: day.Day):
"""`total` = `items_total` `discount` + `delivery`, целыми копейками."""
money = weekday.orders
assert np.array_equal(
money.total, money.items_total - money.discount + money.delivery
)
for column in (money.items_total, money.discount, money.delivery, money.total):
assert np.issubdtype(column.dtype, np.integer)
assert np.all(column >= 0)
prices = {price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS}
assert set(money.delivery.tolist()) == prices
# Доставка — деньги, которых нет ни в одном событии: без неё «считаем по
# бэкенду» ничего не значило бы.
assert np.any(money.total != money.items_total - money.discount)
def test_the_order_side_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch,
):
"""Заказная сторона берёт свой подпоток и ветвится по дню рождения заказа.
Адрес в дереве зерна и есть контракт компонента (спека генератора,
раздел 2): правка заказной механики не двигает ни трафик, ни торговлю,
а слепок читает судьбу заказа из дня, в который заказ родился.
"""
asked: list[tuple[int, int, Component]] = []
honest = orders.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(orders, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.ORDERS)]
def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
"""Личность заказу даёт план, а не разбор трекера."""
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
person = dict(
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
)
events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()):
assert weekday.orders.user_id[number] == person[cookie]
assert np.all(weekday.orders.user_id > 0)
assert weekday.orders.user_id.max() < 2**53
def test_both_cookies_of_a_pair_order_as_one_user(week: list[day.Day]):
"""Мост к склейке: заказы с двух кук пары несут один `user_id`.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
users: list[dict[int, set[int]]] = []
for events in week:
by_order = user_id_by_order(events)
rows = purchases_of(events)
seen: dict[int, set[int]] = {}
for cookie, number in zip(
rows["ClientID"].tolist(),
(cell[0] for cell in rows["purchaseID"]),
strict=True,
):
seen.setdefault(cookie, set()).add(by_order[number])
users.append(seen)
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
first, second = (
users[day_number][int(cohort.client_id[cookie])]
for cookie, day_number in zip(cookies, days, strict=True)
)
assert len(first | second) == 1
assert pairs > 10
def test_the_user_id_never_reaches_the_metrica_event(weekday: day.Day):
"""Кликстрим анонимен: личность держится формой контракта, а не забывчивостью.
Проверяются числовые колонки события целиком: личность — число того же
порядка, что кука и номер события, и попасть она могла бы только в
такую. Имени для неё в контракте нет вовсе.
"""
people = set(plan.audience(CANONICAL_SEED, WEEKDAY).person_id.tolist())
assert people
names = {column.name.lower() for column in schema.COLUMNS}
assert not (names & {"userid", "user_id", "personid", "person_id"})
for name, value in weekday.columns.items():
if np.issubdtype(value.dtype, np.integer):
assert not (set(value.tolist()) & people), name
assert set(weekday.orders.user_id.tolist()) <= people
+29
View File
@@ -43,6 +43,7 @@ def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
and np.array_equal(left.pair_order_days, right.pair_order_days)
and np.array_equal(left.device, right.device)
and np.array_equal(left.city, right.city)
and np.array_equal(left.person_id, right.person_id)
)
@@ -175,6 +176,33 @@ def test_the_passport_of_a_pair_is_one_person_with_two_devices():
assert np.all(phone[cohort.device[first]] != phone[cohort.device[second]])
def test_two_cookies_of_a_pair_are_one_person():
"""Мост к склейке: у пары один `person_id`, у остальных — свой.
Значение непрозрачное и живёт ниже 2^53, как `ClientID`: выше JSON
округляет при разборе, а заказ повезёт его числом.
"""
cohort = plan.cohort(CANONICAL_SEED, 0)
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
assert cohort.pairs > 0
assert np.array_equal(cohort.person_id[first], cohort.person_id[second])
# Люди когорты — это первые куки: у них личность своя у каждого, а всего
# разных личностей в когорте ровно столько, сколько людей.
people = cohort.person_id[: cohort.people]
assert len(set(people.tolist())) == cohort.people
assert len(set(cohort.person_id.tolist())) == cohort.people
assert cohort.person_id.min() > 0
assert cohort.person_id.max() < 2**53
def test_the_day_audience_knows_the_person_behind_a_cookie():
"""День спрашивает личность у плана — больше её знать неоткуда."""
audience = plan.audience(CANONICAL_SEED, 5)
assert audience.person_id.size == audience.client_id.size
assert np.all(audience.person_id > 0)
def test_the_passport_points_into_the_directories():
cohort = plan.cohort(CANONICAL_SEED, 0)
for passport, table in (
@@ -278,5 +306,6 @@ def test_plan_arrays_are_whole_numbers():
cohort.active_day,
cohort.pair_cookies,
cohort.pair_order_days,
cohort.person_id,
):
assert np.issubdtype(array.dtype, np.integer)
+1 -1
View File
@@ -77,7 +77,7 @@ def test_day_components_do_not_share_randomness():
components = (
Component.TRAFFIC,
Component.COMMERCE,
Component.DISCREPANCIES,
Component.ORDERS,
Component.LATECOMERS,
)
assert len({int(component) for component in components}) == 4