Files
clickstream-ch-kafka-supers…/docs/specs/2026-06-10-generator-math-model.md
T
Dmitry DementievandClaude Fable 5 bcca8f5127 docs(generator): уточнена мат-спека по итогам ревью, handoff для передачи
- Зачем:
  - повторное адверсариальное ревью нашло ошибку в формуле межсессионной
    паузы (двойной счёт кулдауна) и незакрытый контракт публикации
    device/geo; дизайн-этап завершён, работа передаётся исполнителю.
- Что:
  - исправлена формула паузы (баланс цикла, минус длительность визита);
    зафиксированы каденция device/geo «на каждое событие, как в сиде»,
    правило выбора возвращающегося, стартовое распределение страниц,
    калибровка по медиане и среднему, инвариант потолков конфигурации,
    компактное хранение профиля ссылкой на сид-сессию.
  - уточнены критерии приёмки (среднее паузы вместо медианы, счёт шага
    «товары», имена полей времени) в обеих спеках.
  - добавлен handoff .scratch/handoffs/2026-06-10-generator-spec-to-codex.md
    (передача на детальный план/реализацию), отработавший handoff от
    2026-06-09 удалён.
- Проверка:
  - цифры спеки сверены замерами по полным data/*.jsonl (стартовые страницы
    58/24/17, шаги воронки 98>=94>=56>=35>=25, device 1000 строк / 99
    уникальных); повторное ревью свежим агентом блокеров не оставило.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:02:57 +03:00

26 KiB
Raw Blame History

Математическая модель генератора: распределения, тики, персистентность

Дата: 2026-06-10 (ревизия после адверсариального ревью в тот же день) Статус: Draft Связано: ADR-0004 (решение «генератор, не реплей»), спека формы доработки (требования к иерархии; эта спека закрывает её раздел Open questions), CONTEXT.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.

Персистентность через рестарты

  • Расширить состояние в 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 спеки формы доработки (пирамида, монотонность времени, принадлежность 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 — обновляется при реализации; туда же — верхнеуровневый обзор «как работает генератор» (два контура: интенсивность и сущности).