Files
clickstream-data-platform/generator/src/clickstream_generator/world.py
T
ddadmin 7c9eeedc40 feat(stand): make up наполняет стенд стартовым миром, опись сторожит его
Зачем
Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с
ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому
же состоянию, а в git лежит то, чем это состояние проверяется.

Что
- Опись мира `data/world-inventory.json`: паспорт (зерно, версия
  генератора, хеш каталога) и по строке на каждый из восьми дней — дата,
  число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит
  `test_inventory.py` — тем же способом, что свежесть описания выгрузки.
- Разовая служба `world-init` вышла из-под профиля и играет в топик восемь
  дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait`
  считает успешно отработавшую службу упавшей.
- `scripts/wait-for-world.sh` — вторая половина `make up`: приём
  асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения
  заливки. Ограниченный цикл опроса, не пауза наугад.
- Девятая проверка `make check-clickhouse`: подневный счёт событий против
  описи, рамка по датам стартового мира, счёт через `FINAL`. При
  расхождении называет, где искать, — в событиях или в браке.
- Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал
  1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с
  датой. Раздел 9 спеки закрыт: открытых вопросов не осталось.
- Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром
  (решение владельца). Оба заведены в словарь CONTEXT.md.

Проверка
`make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий.
`make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с),
`make test` — 407 тестов за 71 с, `make lint`, `make typecheck`,
`make config-test` зелёные.

Что проверка умеет краснеть, снято двумя поломками: снос партиции
2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье —
«сломан разбор». Строки опыта убраны, день переигран, счёт вернулся.
Тест свежести проверен молчаливой правкой цены в каталоге: покраснел.

Ссылка: #42
2026-08-07 18:37:01 +03:00

225 lines
17 KiB
Python

"""Конфигурация мира: все числа, которыми задан модельный магазин.
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
поэтому чек описи честно покраснеет: опись сторожит только канонический мир,
свои миры менти собирает без её гарантий (спека генератора, раздел 9).
Числа решены спекой и связаны между собой; связки сторожат тесты
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
Здесь только числа — как в контракте схемы, никакого поведения; длинные
таблицы записаны коротко, но остаются таблицами.
"""
from datetime import date
# Счётчик стенда: сайт один, номер — константа мира.
COUNTER_ID = 42150607
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate`
# у ночных событий (спека генератора, раздел 9).
COUNTER_TIMEZONE_MINUTES = 240
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
# зависит — иначе опись перестала бы быть воспроизводимой.
ORIGIN = date(2026, 6, 1)
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
# свою куку, поэтому число это же — приток кук; вторые куки двухкуковых пар
# добавляют к нему меньше процента.
DAILY_INFLUX = 3_800
# Недельная волна мира, проценты от среднего: понедельник … воскресенье.
# Волна задана притоку — по ней в мир приходят новые люди. Трафик наследует
# её через дневную аудиторию, а не вторым умножением (довод — у суточной
# волны выходного дня), поэтому доля новичков по дням недели ровная, а
# недельный размах трафика выходит мягче притока: ±7% против ±10%. В сумме
# ровно 700: за неделю средний день остаётся средним.
WEEKLY_PROFILE_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
# Первый рычаг метки покупателя: помеченный дольше живёт и чаще возвращается.
# Одноразовым он бывает много реже прочих, а возвращается вдвое чаще —
# отсюда кука, которая ходит неделями. Без этого рычага «постоянный
# покупатель» в данных не читается: метки в событии нет и не будет
# (кликстрим анонимен), а кука живёт меньше двух визитов за снимок — второй
# покупке негде случиться (спека генератора, раздел 9).
BUYER_ONE_SHOT_PERCENT = 35
BUYER_RETURN_COUNT_WEIGHTS = (10, 11, 12, 12, 11, 10, 9, 7, 5, 4, 3, 2)
# --- Числа дня: суточная волна, визиты, воронка ---------------------------
# Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов
# местного времени посетителя. Ночной провал, обеденный и вечерний пики до
# ~2× среднего (спека генератора, раздел 2). В сумме ровно 2400: средний час
# остаётся средним, и суточный объём от формы волны не зависит.
WEEKDAY_HOURS_PERCENT = (
35, 20, 12, 8, 8, 12, 25, 45, 70, 105, 115, 130,
170, 165, 140, 130, 130, 140, 160, 185, 210, 180, 130, 75,
) # fmt: skip
# Выходной день: подъём позже, обеденного пика нет — день ровнее, вечер
# ниже буднего. Здесь только форма, поэтому сумма та же — 2400: объём
# выходного день-функция не трогает, он приходит сам, потому что дневная
# аудитория уже дышит недельной волной через приток. Умножить на неё
# второй раз значило бы удвоить недельный размах.
WEEKEND_HOURS_PERCENT = (
45, 30, 20, 12, 10, 10, 14, 22, 40, 70, 105, 140,
165, 160, 175, 175, 170, 165, 165, 175, 180, 165, 120, 67,
) # fmt: skip
# Сколько визитов у куки в её день активности: веса для 1, 2 и 3 визитов.
# В среднем ≈1,4 — при дневной аудитории 6–7 тыс. это 8–10 тыс. визитов
# (спека, раздел 5).
VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8)
# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы
# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8
# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день,
# и до ~50 тыс. добирают торговые события.
VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16)
# Таймаут визита: пауза дольше этой рвёт визит надвое. Правило резки, по
# которому лаба сессий сверяет свою сборку с `VisitID` (мастер-спека,
# раздел 1.2), поэтому паузы внутри визита всегда короче, а соседние визиты
# одной куки всегда разведены дальше.
VISIT_TIMEOUT_SECONDS = 1800
# Пауза между соседними страницами визита, секунды: обычная и «задумался».
# Обе целиком внутри таймаута — иначе визит распался бы там, где генератор
# этого не обещал.
PAGE_PAUSE_SECONDS = (8, 300)
LONG_PAUSE_SECONDS = (300, 1500)
LONG_PAUSE_PERCENT = 12
# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от
# предыдущего. Произведение — конверсия визита в оформленный заказ
# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых
# пар проходят воронку целиком независимо от этих долей.
CART_PERCENT = 6
CHECKOUT_OF_CART_PERCENT = 45
CONFIRMATION_OF_CHECKOUT_PERCENT = 55
# Второй рычаг метки покупателя: помеченный отличается на обоих шагах
# воронки — и до корзины доходит чаще, и бросает её реже. В жизни
# различаются оба: кто пришёл смотреть, тот и кладёт реже, и до конца
# доводит реже; один шаг дал бы половину картины. Шаг подтверждения общий:
# оплата — про магазин, а не про склонность покупать.
BUYER_CART_PERCENT = 18
BUYER_CHECKOUT_OF_CART_PERCENT = 60
# --- Числа торговых событий: корзина, заказ, деньги ------------------------
# Торговое событие встаёт на несколько секунд позже своей страницы: корзина
# позже карточки, покупка позже подтверждения. Задержка короче самой
# короткой паузы между страницами — иначе событие корзины обогнало бы
# страницу, на которой посетитель его нажал.
TRADE_DELAY_SECONDS = (2, 8)
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
# магнит, обычный, залёживается.
#
# Первый ряд — визит, дошедший до страницы корзины. Он пришёл покупать и
# кладёт почти всё, что сравнивал; уровень решает, что именно из сравненного
# он выберет, и залежавшийся товар проигрывает соседям по вкладке. Верх ряда
# высок намеренно: опусти его — и корзина такого визита схлопнется до одной
# позиции, а заказ до одного товара.
#
# Второй ряд — визит, который до страницы корзины не дошёл. Кладёт он редко,
# и магнит уходит у него из карточки втрое чаще обычного товара и вшестеро
# чаще залежавшегося: случайного посетителя решает соблазнить сам товар,
# больше решать нечему. Событий при этом выходит почти поровну — магнитов
# 47% таких добавлений, обычных 42%: обычных в каталоге вдвое с лишним
# больше, и разница уровней живёт в доле открытых карточек, а не в счёте
# событий. Нулю в этом ряду места нет: обнулить уровень значило бы вернуть
# по его товарам ровно то равенство «положил — открыл корзину», ради
# которого правка и делалась.
#
# Спрос растянут во втором ряду сильнее, чем в первом, и это не подгонка:
# намерение заслоняет товар, поэтому привлекательность видна там, где
# намерения нет.
SHOPPING_ADD_PERCENT = (98, 94, 55)
BROWSING_ADD_PERCENT = (6, 2, 1)
# Доля позиций корзины, которые остались брошенными: заказ уже корзины.
# Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был
# бы её точной копией, и сравнивать было бы нечего. Пустым заказ не бывает:
# если брошены все позиции, одна остаётся (спека генератора, раздел 9).
# Число опущено с 15% вместе с отвязкой корзины от страницы корзины: корзина
# стала уже (1,7 позиции вместо 2,4), и прежние 15% чаще выносили её целиком,
# а спасённая позиция возвращала заказ к одному товару.
ABANDONED_POSITION_PERCENT = 10
# Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна,
# изредка две-три — непродовольственная розница.
ITEM_QUANTITY_WEIGHTS = (85, 11, 4)
# Валюта магазина: один регион присутствия — одна валюта.
CURRENCY = "RUB"
# Доля заказов с промокодом.
COUPON_PERCENT = 20
# Промокоды: код и скидка в процентах. Таблица — число мира, а не выдумка
# бэкенда: этап 3 берёт её готовой и обязан дать заказу с кодом скидку,
# иначе данные соврут. В клиентскую выручку скидка не входит — код на сайте
# знает корзину, а не итог расчёта (спека генератора, раздел 9). Цифры в
# коде — те же проценты: скидка в рублях потребовала бы второго правила
# чтения таблицы, а вместе с ним и второго вида скидки на стороне бэкенда.
COUPONS = (
("VESNA10", 10),
("DOMASHNIY5", 5),
("PERVYY15", 15),
("UYUT7", 7),
)
# Цели счётчика: корзина и покупка. Цели дублируют торговые события — в бою
# так и бывает (мастер-спека, раздел 1.2).
GOAL_CART_ID = 42150001
GOAL_PURCHASE_ID = 42150002