diff --git a/CONTEXT.md b/CONTEXT.md index 7f80b4e..5a24a68 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -22,8 +22,11 @@ `domain_sessionid`). Поэтому в этой модели **сессия ≡ визит ≡ `click_id`** — группа событий под одним идентификатором, а не окно активности по тайм-ауту. Если в будущем понадобится «настоящая» сессия по 30-минутному окну неактивности — -это отдельная производная над `user_domain_id` + `event_ts`; на текущих данных -она дала бы те же группы (разброс времени внутри `click_id` ≤ 1 мин). +это отдельная производная над `user_domain_id` + `event_ts`, и на сиде она дала +бы **другие** группы: в полном сиде 11 внутрисессионных пауз превышают 30 минут, +а разброс времени внутри `click_id` доходит до 49 минут (см. профиль +сид-датасета ниже; ранее здесь ошибочно значилось «разброс ≤ 1 мин» — это был +замер по малому срезу файла). «Сессию» и «визит» используем как синонимы; в UI предпочитаем «визит», когда важно подчеркнуть, что это не сессия-по-тайм-ауту. @@ -81,6 +84,25 @@ user_domain_id (пользователь, постоянный) усугубляет: он штампует свежий `click_id` на каждое событие, и `click_id` вырождается в «событие». См. `generator/KNOWN_ISSUES.md` на той ветке. +## Профиль сид-датасета (измерено 2026-06-10, полные файлы) + +Опорные цифры о статическом сиде (`data/*.jsonl`) — для калибровки генератора +и проверки гипотез. Измерено по **полным** файлам (1000 событий); внимание: +ранние оценки «1..7 событий на визит» делались по малому срезу и неверны. + +- **Объём:** 1000 событий, 99 визитов (`click_id`), 99 пользователей (1:1). +- **Длина визита:** 1..27 событий, медиана 10, среднее 10.1. +- **Паузы между событиями визита:** медиана ~20 с, p95 ≈ 17 мин, + максимум ≈ 40 мин; пауз длиннее 30 минут — 11 штук. Разброс времени внутри + одного `click_id` — до 49 минут. +- **Воронка:** 25% визитов достигают `/confirmation`. Пути — с петлями: + возвраты на `/home`, просмотр обоих товаров; у 21 из 25 «купивших» визитов + есть события **после** `/confirmation`, у 9 — `/confirmation` встречается + дважды. То есть `/confirmation` — последний *шаг воронки*, но не обязательно + последнее *событие визита*. +- **Страницы (по событиям):** `/home` 426, `/product_a` 236, `/product_b` 157, + `/cart` 93, `/payment` 53, `/confirmation` 35. `event_type` — 100% `pageview`. + ## Слои данных `STG → ODS → DDS → DM` — см. [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md). diff --git a/docs/adr/0004-steady-stream-synthetic-generator.md b/docs/adr/0004-steady-stream-synthetic-generator.md index 54aad28..7f496b3 100644 --- a/docs/adr/0004-steady-stream-synthetic-generator.md +++ b/docs/adr/0004-steady-stream-synthetic-generator.md @@ -21,7 +21,9 @@ jitter) сохраняется. Режим `bootstrap` (статический Проект сменил назначение на учебный стенд с курсом. Базовый курс стоит на статическом сиде, и это осознанно: сид **честен внутри визита** (`click_id` -группирует 1..7 событий — настоящая воронка). Но у сида два потолка, которые +группирует несколько событий — настоящая воронка; по полному замеру 2026-06-10 — +1..27 событий, медиана 10, см. профиль сид-датасета в `CONTEXT.md`). Но у сида +два потолка, которые сид принципиально не закрывает: - **`users == sessions`** — каждый пользователь имеет ровно один `click_id` (1:1), diff --git a/docs/specs/2026-06-09-generator-rework-hierarchical.md b/docs/specs/2026-06-09-generator-rework-hierarchical.md index 7e3d4f3..a248df2 100644 --- a/docs/specs/2026-06-09-generator-rework-hierarchical.md +++ b/docs/specs/2026-06-09-generator-rework-hierarchical.md @@ -49,9 +49,10 @@ общим device/geo), а не штампуется на каждое событие. - **Время событий внутри сессии монотонно растёт** (правдоподобный путь по страницам), а не равно `now()` для всего батча. -- **Правдоподобная воронка** по `page_url_path`: затухающая последовательность - страниц (`/home → … → /confirmation`), где до терминального шага доходит - правдоподобно малая доля — согласованно с дашбордом и сидом. +- **Правдоподобная воронка** по `page_url_path`: путь по страницам + (`/home → … → /confirmation`), где число визитов затухает от шага к шагу, + а до последнего шага воронки доходит меньшинство (в сиде ~25%) — + согласованно с дашбордом и сидом. - Сохранена модель интенсивности (жизнеподобные колебания нагрузки). - Встраивается в курс как **урок 7 «Система живёт»**, сосуществует с bootstrap-сидом и уроками 0–6. @@ -98,8 +99,9 @@ 3. **События сессии.** Сессия порождает 1..M событий, разделяющих `click_id`, **упорядоченных по монотонно растущему времени**, образующих правдоподобный путь по воронке страниц (`page_url_path`: `/home → … → /confirmation`), где до - терминального шага доходит малая доля. (Вводить ли разнообразие `event_type` - сверх `pageview` — открытый вопрос, см. ниже.) + последнего шага воронки доходит меньшинство визитов (~25%, как в сиде). + (Вводить ли разнообразие `event_type` сверх `pageview` — открытый вопрос, + см. ниже.) 4. **Интенсивность.** Поверх иерархии работает сохранённая модель интенсивности — сколько событий и когда (Poisson + дневной коэффициент + jitter). @@ -136,11 +138,14 @@ - `uniqExact(user_domain_id) < uniqExact(click_id) < count(event_id)` — пирамида не вырождена (есть возвраты и мультисобытийные сессии). -- Внутри одного `click_id` — несколько событий с **возрастающим** `event_ts`. +- У `click_id` с несколькими событиями `event_ts` строго **возрастает**; + мультисобытийные визиты составляют заметную долю потока (одностраничные + визиты-отказы допустимы и правдоподобны). - Один `click_id` принадлежит ровно одному `user_domain_id`; у пользователя бывает несколько `click_id`. -- Воронка по `page_url_path` затухает (доля доходящих до `/confirmation` - правдоподобно мала), терминальный шаг — `/confirmation`. +- Воронка по `page_url_path` затухает от шага к шагу; доля визитов, доходящих + до `/confirmation`, согласована с сидом (~25%). `/confirmation` — последний + шаг воронки, но визит может продолжаться и после него (как в сиде). - Поток крутится на слабом железе при дефолтном rate без деградации стенда. - `GEN_SEED` даёт воспроизводимый поток. @@ -157,6 +162,12 @@ ## Open questions (→ follow-up-спека по математике) +> **Закрыто 2026-06-10:** все вопросы ниже решены в +> [спеке математической модели](./2026-06-10-generator-math-model.md) +> (распределения, ротация популяции, инвариант пауз вместо session-timeout, +> активные сессии как состояние, персистентность, воспроизводимость; +> `event_type` остаётся 100% `pageview`). Список сохранён для истории. + - Конкретные распределения: события на сессию, сессии на пользователя за период, межсессионные интервалы. - **Вводить ли разнообразие `event_type`** (purchase/add_to_cart/click) сверх diff --git a/docs/specs/2026-06-10-generator-math-model.md b/docs/specs/2026-06-10-generator-math-model.md new file mode 100644 index 0000000..a0a7091 --- /dev/null +++ b/docs/specs/2026-06-10-generator-math-model.md @@ -0,0 +1,241 @@ +# Математическая модель генератора: распределения, тики, персистентность + +Дата: 2026-06-10 (ревизия после адверсариального ревью в тот же день) +Статус: Draft +Связано: [ADR-0004](../adr/0004-steady-stream-synthetic-generator.md) (решение +«генератор, не реплей»), [спека формы доработки](./2026-06-09-generator-rework-hierarchical.md) +(требования к иерархии; эта спека закрывает её раздел *Open questions*), +[`CONTEXT.md`](../../CONTEXT.md) (доменный язык и **профиль сид-датасета** — +опорные цифры для калибровки), [`generator/KNOWN_ISSUES.md`](../../generator/KNOWN_ISSUES.md) +(диагноз дефекта). + +Уровень документа: **модель и инварианты**. Детальный план реализации и код — +за исполнителем; конкретные технические решения (структуры данных, точные +алгоритмы розыгрыша, имена новых переменных окружения, не названные здесь) он +строит сам в рамках зафиксированных здесь правил. + +## Картина целиком + +Генератор имитирует жизнь сайта тремя уровнями: + +``` +популяция пользователей — кто вообще ходит на сайт + └── визиты (сессии, click_id) — пользователь время от времени заходит + └── события (event_id) — за один заход ходит по страницам +``` + +Поверх иерархии — сохранённая модель интенсивности (Пуассон + часовой +коэффициент + jitter из `_calculate_events_count()`): она задаёт, *сколько* +активности происходит в единицу времени. Путь по страницам внутри визита +задаёт **марковская цепочка** — таблица вероятностей переходов между страницами. + +## Принятые решения (развилки закрыты 2026-06-10) + +1. **Типы событий: 100% `pageview`.** Воронка остаётся страничной + (`page_url_path`), как в сиде и на дашборде. Другие типы + (purchase/add_to_cart/click) — отдельная итерация после урока 7: они тянут + согласованную правку дашборда и урока 6, что выходит за рамки переработки. +2. **Session-timeout не вводим.** Генератор порождает визиты явно и знает их + границы по построению; правило «молчал полчаса — новая сессия» — это приём + *восстановления* сессий из событий, который здесь не нужен. `CONTEXT.md` + (сессия ≡ визит ≡ `click_id`) не меняется. Вместо механизма — **инвариант + пауз** (см. ниже): данные не должны противоречить привычной 30-минутной + семантике. +3. **Популяция: постоянное ядро + медленная ротация.** Размер активной + популяции ограничен (память не растёт), но новые пользователи понемногу + приходят, давно неактивные — выбывают. Кумулятивное число уникальных + пользователей растёт со временем — стенд выглядит живым. +4. **Путь по страницам — марковская цепочка.** Для каждой страницы задана + вероятность перехода на каждую другую страницу или ухода с сайта. Один + механизм даёт сразу: длину визита, затухающую воронку, петли (возвраты на + главную, просмотр обоих товаров) и «походил после покупки» — всё то, что + реально есть в сиде. Простая «затухающая цепочка без петель» рассмотрена и + отклонена: она не может дать длины визитов как в сиде (медиана 10, до 27 + событий) и беднее как учебный объект. Таблица переходов — готовая «ручка» + для урока 7: менти меняет вероятность и видит эффект на дашборде. + +## Модель по уровням + +### Популяция пользователей + +- Активная популяция — ограниченное множество пользователей с постоянными + `user_domain_id`, не больше `GEN_POPULATION_MAX`. +- **Инициализация:** при старте с чистого листа популяция сразу предзаполняется + до потолка (детерминированно от `GEN_SEED`) — поток выходит на стационарный + режим с первых минут, без длинного «разогрева». +- **Ротация через рождение визитов:** каждый новый визит с вероятностью + `GEN_P_NEW_USER` достаётся *новому* пользователю (свежий `user_domain_id`), + иначе — пользователю из популяции, **доступному** для возврата (см. кулдаун + ниже). При переполнении популяции вытесняется дольше всех неактивный + пользователь **без активного визита**. Так приток и отток получаются из + одного простого правила. +- **Профиль пользователя:** при рождении пользователь получает полный + device/geo-контекст одной случайной сид-сессии (включая `user_custom_id`; + повторы email между пользователями допустимы — демо-данные) с заменой + `user_domain_id` на свежий. Профиль используется во всех визитах пользователя + (KISS: смену устройства не моделируем). + +### Визиты (сессии) + +- Визит = новый `click_id`, общий для всех его событий, с device/geo из профиля + пользователя. +- **Связка с моделью интенсивности.** `_calculate_events_count()` сохраняется и + выдаёт на тик *бюджет событий*. Бюджет конвертируется в рождения визитов: + ожидаемое число новых визитов за тик = бюджет ÷ средняя длина визита + (средняя длина — производная калиброванной таблицы переходов, исполнитель + измеряет её симуляцией). События же выпускаются по внутрисессионным паузам. + Следствие: фактическое число событий за конкретный тик — производная величина, + но *средняя* интенсивность за длинное окно совпадает с целевой. Прежние + границы `GEN_MIN/MAX_EVENTS_PER_TICK` в старом смысле теряют применимость; + ограничивать нужно рождения визитов за тик (имена и значения — за + исполнителем, зафиксировать в README). +- **Кулдаун возврата:** после завершения визита пользователь недоступен для + нового минимум `GEN_MIN_RETURN_MINUTES` (ориентир 30 мин) — это гарантирует + нижнюю границу межсессионной паузы (инвариант пауз). +- **Межсессионные паузы — эмерджентная величина, не свободный параметр.** + Пауза складывается из кулдауна и времени ожидания «своей очереди» и в среднем + определяется балансом потока и популяции: + + ``` + средняя пауза ≈ кулдаун + GEN_POPULATION_MAX ÷ (λ ÷ L × (1 − GEN_P_NEW_USER)) + где λ — событий/мин, L — средняя длина визита + ``` + + На ориентирах по умолчанию (λ=30, L≈10, популяция 300, p_new=0.15): + возвратов ≈ 2.6/мин, пауза ≈ **~2 часа** — больше 30-минутного окна и при + этом возвраты видны уже за один вечер работы стенда. **Кто меняет λ или + размер популяции — обязан пересчитать паузу по формуле**: эти три величины + связаны, их нельзя крутить независимо. + +### События и путь по страницам + +- **Марковская цепочка:** страницы — `/home`, `/product_a`, `/product_b`, + `/cart`, `/payment`, `/confirmation` плюс исход «ушёл». Визит начинается с + входной страницы (как правило `/home`), дальше каждый следующий шаг + разыгрывается по таблице переходов текущей страницы. Петли разрешены + (вернуться на главную, посмотреть оба товара, продолжить ходить после + `/confirmation` — как в сиде). Защита от зацикливания — потолок длины визита + `GEN_MAX_SESSION_EVENTS` (ориентир 30, как максимум в сиде). +- **Калибровка по сиду:** конкретные значения таблицы подбирает исполнитель под + два целевых показателя из профиля сида (см. `CONTEXT.md`): доля визитов, + достигших `/confirmation`, ≈ 25%, и длина визита с медианой ~10 событий. + Точного совпадения распределений не требуется — требуется сопоставимость, + чтобы дашборд на потоке показывал привычные по сиду цифры. +- **Паузы внутри визита:** интервал между соседними событиями — величина + масштаба *секунд—минут* (например, лог-нормальное: большинство пауз короткие, + изредка длинные). Ориентир: медиана десятки секунд. Форма распределения и + параметры — за исполнителем. +- **Инвариант пауз (вместо session-timeout):** все паузы внутри визита строго + меньше 30 минут (с запасом: 95-й перцентиль — единицы минут); межсессионные + паузы — всегда больше кулдауна. Данные не должны противоречить индустриальной + семантике 30-минутного окна неактивности. (Это сознательно строже сида, где + встречаются внутрисессионные паузы до 40 минут.) +- **Время монотонно:** визит живёт несколько тиков, и каждому его событию + заранее назначается *запланированный* момент (предыдущее событие + пауза). + В `event_timestamp` пишется именно запланированный момент, а не время + фактической отправки на тике — иначе метки прилипают к сетке тиков (паузы + кратны 5 с, события одного тика слипаются в одну метку). В штатной работе + запланированное время отстаёт от «сейчас» не больше чем на тик; монотонность + внутри `click_id` строгая по построению. + +### Раскладка по тикам (активные визиты как состояние) + +Визит длится дольше тика (5 с), поэтому генератор держит **активные визиты** +как состояние между тиками. Для каждого активного визита достаточно знать: чей +он, `click_id`, текущая страница, момент следующего события. На каждом тике +генератор: + +1. выпускает события тех активных визитов, у которых подошло время; +2. рождает новые визиты по бюджету интенсивности (см. выше); +3. завершает визиты, чей путь закончился (исход «ушёл» или потолок длины). + +Число одновременно активных визитов ограничено (`GEN_MAX_ACTIVE_SESSIONS`) — +защита памяти; при достижении потолка новые рождения в этот тик пропускаются +(бюджет не копится). На ориентирах по умолчанию стационарно активны ~15–30 +визитов — потолок с большим запасом. + +## Персистентность через рестарты + +- Расширить состояние в compact-топике `generator_state` (новая `version`): + к тику и состоянию ГПСЧ добавляются **популяция** (компактно: идентификаторы, + профили, время последней активности) и **активные визиты**. +- Объём состояния ограничен потолками популяции и активных визитов — запись в + Kafka остаётся маленькой (десятки КБ). +- **Рестарт после простоя — «правило 30 минут», повизитно:** активный визит, + чьё следующее событие запланировано не дальше 30 минут в прошлом, продолжается — + созревшие за простой события досылаются со своими (прошлыми, честными) + метками. Визит, просроченный сильнее, закрывается без досылки остатка — + посетители «ушли», пока стенд спал. Популяция переживает простой любой длины; + кулдауны отсчитываются по меткам времени, не по тикам. +- Уже существующая деградация сохраняется: невалидное/отсутствующее состояние → + начать с чистого листа (свежая популяция от `GEN_SEED`), предупреждение в лог. + `GEN_STATE_RESET=true` — явный сброс, как сейчас. + +## Воспроизводимость (`GEN_SEED`) + +При одном `GEN_SEED` **и одинаковых условиях запуска** детерминирована +последовательность решений: какие пользователи родились, какие визиты открылись, +какие пути выпали. Оговорка про условия существенна: часовой коэффициент и +метки времени зависят от настенных часов, поэтому запуск в другой час дня даёт +другой поток (как и у текущего генератора). Все случайные решения — только +через единый ГПСЧ генератора, состояние которого сохраняется; как зафиксировать +условия в тесте воспроизводимости — за исполнителем. + +## Параметры (ориентиры по умолчанию) + +Согласованный набор (см. формулу связи в разделе про визиты); дефолты — +ориентиры, исполнитель уточняет при калибровке. + +| Параметр | Ориентир | Смысл | +|---|---|---| +| `GEN_LAMBDA_BASE_PER_MIN` | **30** (сейчас 200) | целевая интенсивность, событий/мин | +| `GEN_POPULATION_MAX` | 300 | потолок активной популяции | +| `GEN_P_NEW_USER` | 0.15 | доля визитов, достающихся новым пользователям | +| `GEN_MIN_RETURN_MINUTES` | 30 | кулдаун возврата пользователя | +| `GEN_MAX_SESSION_EVENTS` | 30 | потолок длины визита (защита от петель) | +| `GEN_MAX_ACTIVE_SESSIONS` | 200 | потолок одновременных визитов | +| средняя длина визита | ~10 событий | производная таблицы переходов (калибровка по сиду) | +| пауза внутри визита | медиана ~десятки секунд | распределение — за исполнителем | +| межсессионная пауза | ≈ 2 ч | эмерджентная, по формуле | + +Таблица переходов и параметры распределения пауз — тоже конфигурация +(формат и имена — за исполнителем; таблица должна быть доступна менти для +экспериментов урока 7). Существующие `GEN_TICK_SECONDS`, `GEN_JITTER_PCT`, +`GEN_SEED`, `GEN_STATE_*` сохраняют смысл. + +## Критерии приёмки + +Дополняют раздел *Validation* [спеки формы доработки](./2026-06-09-generator-rework-hierarchical.md) +(пирамида, монотонность времени, принадлежность `click_id` одному пользователю, +воспроизводимость — там; здесь не дублируются): + +- Средняя интенсивность событий за длинное окно (час и больше) соответствует + целевой `λ × часовой коэффициент` с разумным отклонением. +- Паузы внутри `click_id`: 95-й перцентиль — единицы минут, максимум < 30 минут. +- Межсессионные паузы одного пользователя: минимум ≥ кулдауна, медиана — + порядка расчётной по формуле (~2 ч на дефолтах). +- Доля визитов новых пользователей за длинное окно ≈ `GEN_P_NEW_USER`; + кумулятивное число уникальных `user_domain_id` растёт со временем, размер + состояния генератора — нет. +- Длина визита (медиана ~10, максимум ≤ потолка) и доля дошедших до + `/confirmation` (~25%) сопоставимы с профилем сида из `CONTEXT.md`. +- Воронка затухает по шагам: число визитов, посетивших страницу, монотонно + убывает вдоль `/home → товары → /cart → /payment → /confirmation`. +- После рестарта с простоем ≤ 30 минут активные визиты продолжаются (нет + скачка «все пользователи новые»); после долгого простоя закрываются только + просроченные визиты, популяция сохраняется. + +## Влияние на документацию + +- Спека формы доработки: раздел *Open questions* закрыт ссылкой на этот + документ; критерии Validation уточнены по фактическому профилю сида. +- `CONTEXT.md`: добавлен **профиль сид-датасета** (измеренные распределения — + опора калибровки) и исправлено неверное утверждение о разбросе времени внутри + `click_id` (сделано вместе с этой ревизией). +- `generator/KNOWN_ISSUES.md`, ADR-0004: исправлен неверный факт «1..7 событий + на визит» (по полному замеру — 1..27, медиана 10). +- Урок 7 «Система живёт» (пишется на этапе реализации): марковская таблица + переходов — учебная «ручка» урока (поменяй вероятность → увидь сдвиг + конверсии на дашборде). +- `generator/README.md` — обновляется при реализации; туда же — верхнеуровневый + обзор «как работает генератор» (два контура: интенсивность и сущности). diff --git a/generator/KNOWN_ISSUES.md b/generator/KNOWN_ISSUES.md index 87f406f..0d6b67c 100644 --- a/generator/KNOWN_ISSUES.md +++ b/generator/KNOWN_ISSUES.md @@ -34,8 +34,10 @@ user_domain_id (постоянный пользователь, cookie) ``` - `click_id` — это **визит**, а не одиночный клик: в статическом сиде один - `click_id` честно группирует 1..7 событий (`dds.click` = «контекст сессии - пользователя», см. `sql/ddl/dds/30_dds.sql`). + `click_id` честно группирует 1..27 событий, медиана 10 (`dds.click` = + «контекст сессии пользователя», см. `sql/ddl/dds/30_dds.sql`; точные цифры — + профиль сид-датасета в `CONTEXT.md`, ранняя оценка «1..7» была замером по + срезу файла). - `user_domain_id` живёт в `device_events`, привязан к `click_id`. ## Что генератор делает правильно @@ -79,7 +81,7 @@ device_event = {**base_device, "click_id": new_click_id} # user_domain_i | | Статический сид (`data/*.jsonl`) | Поток из генератора | |---|---|---| -| `click_id` | визит-envelope (1..7 событий) | свежий на событие → `click_id` ≈ событие | +| `click_id` | визит-envelope (1..27 событий, медиана 10) | свежий на событие → `click_id` ≈ событие | | `user_domain_id` | 1:1 с `click_id` | переиспользуется (потолок ~99) | | Семантика | `Sessions == Users` (вырождено по пользователю) | `Sessions == Events` (сессия = одно событие) |