# Математическая модель генератора: распределения, тики, персистентность Дата: 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. **Популяция: постоянное ядро + медленная ротация.** Размер активной популяции ограничен (память не растёт), но новые пользователи понемногу приходят, давно неактивные — выбывают. Кумулятивное число уникальных пользователей растёт со временем — стенд выглядит живым. «Медленно» — в масштабе наблюдения: на ориентирах по умолчанию ядро полностью сменяется примерно за 11 часов (это осознанный баланс: возвраты видны за вечер, рост uniques — за сутки). 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`) плюс свежие идентификаторы, а не копией всех полей — состояние остаётся компактным. ### Визиты (сессии) - Визит = новый `click_id`, общий для всех его событий, с device/geo из профиля пользователя. - **Каденция публикации device/geo — как в сиде и у текущего генератора:** записи `device_events` и `geo_events` публикуются **на каждое событие** (в пределах визита дублируются с одинаковым содержимым; в сиде это проверено: 1000 строк при 99 уникальных по содержимому). Контракт данных с ETL/ODS не меняется — это требование, а не деталь реализации. - **Связка с моделью интенсивности.** `_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/мин, цикл ≈ 118 мин, средняя пауза ≈ **~110 мин (~2 часа)**; медиана ниже среднего (распределение скошено вправо), порядка 1.5 ч — всё равно сильно больше 30-минутного окна, и возвраты видны уже за один вечер работы стенда. **Кто меняет λ или размер популяции — обязан пересчитать паузу по формуле**: эти величины связаны, их нельзя крутить независимо. Ориентиры здесь посчитаны при L=10; после калибровки таблицы переходов производные числа пересчитываются от измеренного L. ### События и путь по страницам - **Марковская цепочка:** страницы — `/home`, `/product_a`, `/product_b`, `/cart`, `/payment`, `/confirmation` плюс исход «ушёл». Визит начинается со страницы, разыгранной по **стартовому распределению** (оно — часть модели: в сиде `/home` — лишь ~59% входов, остальные входят на страницы товаров), дальше каждый следующий шаг разыгрывается по таблице переходов текущей страницы. Петли разрешены (вернуться на главную, посмотреть оба товара, продолжить ходить после `/confirmation` — как в сиде). Защита от зацикливания — потолок длины визита `GEN_MAX_SESSION_EVENTS` (ориентир 30, как максимум в сиде). - **Калибровка по сиду:** стартовое распределение и значения таблицы подбирает исполнитель под целевые показатели из профиля сида (см. `CONTEXT.md`): доля визитов, достигших `/confirmation`, ≈ 25%, длина визита с медианой ~10 и **средним ~10** (в сиде среднее ≈ медиане; у цепочки с геометрическим хвостом среднее легко уезжает выше — следить за обоими). Точного совпадения распределений не требуется — требуется сопоставимость, чтобы дашборд на потоке показывал привычные по сиду цифры. - **Паузы внутри визита:** интервал между соседними событиями — величина масштаба *секунд—минут* (например, лог-нормальное: большинство пауз короткие, изредка длинные). Ориентир: медиана десятки секунд. Форма распределения и параметры — за исполнителем. - **Инвариант пауз (вместо session-timeout):** все паузы внутри визита строго меньше 30 минут (с запасом: 95-й перцентиль — единицы минут); межсессионные паузы — всегда больше кулдауна. Данные не должны противоречить индустриальной семантике 30-минутного окна неактивности. (Это сознательно строже сида, где встречаются внутрисессионные паузы до 40 минут.) - **Время монотонно:** визит живёт несколько тиков, и каждому его событию заранее назначается *запланированный* момент (предыдущее событие + пауза). В `event_timestamp` пишется именно запланированный момент, а не время фактической отправки на тике — иначе метки прилипают к сетке тиков (паузы кратны 5 с, события одного тика слипаются в одну метку). В штатной работе запланированное время отстаёт от «сейчас» не больше чем на тик; монотонность внутри `click_id` строгая по построению. ### Раскладка по тикам (активные визиты как состояние) Визит длится дольше тика (5 с), поэтому генератор держит **активные визиты** как состояние между тиками. Для каждого активного визита достаточно знать: чей он, `click_id`, текущая страница, момент следующего события. На каждом тике генератор: 1. выпускает события тех активных визитов, у которых подошло время; 2. рождает новые визиты по бюджету интенсивности (см. выше); 3. завершает визиты, чей путь закончился (исход «ушёл» или потолок длины). Число одновременно активных визитов ограничено (`GEN_MAX_ACTIVE_SESSIONS`) — защита памяти; при достижении потолка новые рождения в этот тик пропускаются (бюджет не копится). На ориентирах по умолчанию стационарно активны ~15–30 визитов — потолок с большим запасом. **Инвариант конфигурации:** `GEN_MAX_ACTIVE_SESSIONS < GEN_POPULATION_MAX` — иначе правило вытеснения («без активного визита») может не найти кандидата; валидировать при старте, как существующие проверки `Config`. ## Персистентность через рестарты > **Приведено в соответствие с модельным временем.** Раздел написан от настенных > часов. Правила сохранения и восстановления состояния переописаны в спеке > [модельного времени](./2026-06-14-generator-model-time-and-startup-history.md) > (§«Сохранение и восстановление состояния»): те же 30 минут отсчитываются по > часам генератора. Ниже — исходная формулировка, оставлена как след решения; при > расхождении главенствует новая спека. - Расширить состояние в compact-топике `generator_state` (новая `version`): к тику и состоянию ГПСЧ добавляются **популяция** (компактно: идентификаторы, профили, время последней активности) и **активные визиты**. - Объём состояния ограничен потолками популяции и активных визитов — запись в Kafka остаётся маленькой (десятки КБ). - **Рестарт после простоя — «правило 30 минут», повизитно:** активный визит, чьё следующее событие запланировано не дальше 30 минут в прошлом, продолжается — созревшие за простой события досылаются со своими (прошлыми, честными) метками. Визит, просроченный сильнее, закрывается без досылки остатка — посетители «ушли», пока стенд спал. Популяция переживает простой любой длины; кулдауны отсчитываются по меткам времени, не по тикам. - Уже существующая деградация сохраняется: невалидное/отсутствующее состояние → начать с чистого листа (свежая популяция от `GEN_SEED`), предупреждение в лог. `GEN_STATE_RESET=true` — явный сброс, как сейчас. ## Воспроизводимость (`GEN_SEED`) > **Приведено в соответствие с модельным временем.** Оговорка ниже — «запуск в > другой час дня даёт другой поток» — снята: с модельными часами дневной > коэффициент считается от точки отсчёта `T0`, а не от настенных часов. > Переописано в спеке > [модельного времени](./2026-06-14-generator-model-time-and-startup-history.md) > (§«Повторяемость»). Ниже — исходная формулировка, оставлена как след решения. При одном `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 ч на дефолтах; медиана ниже, ~1.5 ч). - Доля визитов новых пользователей за длинное окно ≈ `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 «Система живёт» (пишется на этапе реализации): марковская таблица переходов — учебная «ручка» урока (поменяй вероятность → увидь сдвиг конверсии на дашборде). Оговорка скоупа: «увидеть на дашборде» предполагает, что поток доезжает до витрин — это зависимость урока 7 от инкрементальной загрузки ETL (план v2), а не требование к генератору. - `generator/README.md` — обновляется при реализации; туда же — верхнеуровневый обзор «как работает генератор» (два контура: интенсивность и сущности).