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>
This commit is contained in:
Dmitry Dementiev
2026-06-10 18:02:57 +03:00
co-authored by Claude Fable 5
parent 0b5fc64f6f
commit bcca8f5127
4 changed files with 123 additions and 81 deletions
@@ -1,52 +0,0 @@
# Handoff: переработка генератора (steady-stream, иерархическая модель)
Дата: 2026-06-09
Ветка: `feature/data-generator`
Жанр: одноразовые леса́ (ADR-0003) — durable-рассуждение в ADR/спеке, не здесь.
> Место: `.scratch/handoffs/` по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md)
> и `AGENTS.md` (перекрывают generic-дефолт скилла «temp dir» — у владельца
> мультимашинность + worktrees, где `/tmp` не переживает).
## Где остановились
Брейнсторм-сессия (`brainstorm-with-docs`) завершена **на этапе документов**
кода НЕ трогали. Зафиксировано направление переработки генератора и расчищена
рамка под реализацию. Дальше — отдельная follow-up-спека по математике, затем
реализация.
## Контекст и решения (ссылки, не пересказ)
- **Решение «почему» (генератор A, не реплей C):**
[`docs/adr/0004-steady-stream-synthetic-generator.md`](../../docs/adr/0004-steady-stream-synthetic-generator.md)
- **Форма доработки «что строим» (требования, без математики), статус Draft:**
[`docs/specs/2026-06-09-generator-rework-hierarchical.md`](../../docs/specs/2026-06-09-generator-rework-hierarchical.md)
— там же раздел **Open questions** (список вопросов для мат-спеки) и
**Validation** (критерии приёмки). Не дублирую сюда.
- **Глоссарий:** `CONTEXT.md` — добавлены «популяция пользователей» /
«возвращающийся пользователь».
- **Диагноз дефекта текущей реализации:** `generator/KNOWN_ISSUES.md`.
## Следующий шаг
**Написать follow-up-спеку по математике** генеративной модели. Перечень вопросов
— в разделе *Open questions* спеки доработки (распределения, session-timeout,
раскладка иерархии по тикам, персистентность популяции, `event_type` diversity).
## Ловушки (одной строкой; детали — в спеке)
- Воронка живёт в **`page_url_path`**, не в `event_type` (в сиде 100% `pageview`).
- device/geo — **сессионный** контекст уровня `click`, не «свойство пользователя».
- Модель интенсивности (`_calculate_events_count`, `_hour_factor`) — **сохраняем**;
переписываем только модель сущностей (`generate_batch`). Текущую реализацию
инкрементально не чиним.
## Suggested skills (для следующей сессии)
- **`conventional-commits`** — закоммитить готовые доки (см. git ниже).
- **`brainstorm-with-docs`** (или `grill-with-docs`) — для follow-up-спеки по
математике: продолжает тот же стиль (durable-доки + правка CONTEXT/ADR inline).
- **`tdd`** — на этапе реализации rewrite (у генератора уже есть pytest-набор
`generator/tests/`).
- **`adversarial-review`** / **`code-review`** — ревью переписанной модели
сущностей перед вливанием ветки.
@@ -0,0 +1,65 @@
# Handoff: спеки генератора готовы — передача на детальный план и реализацию
Дата: 2026-06-10
Ветка: `feature/data-generator`
Жанр: одноразовые леса́ (ADR-0003) — durable-рассуждение в спеках/ADR/CONTEXT, не здесь.
> Место: `.scratch/handoffs/` по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md)
> (перекрывает generic-дефолт скилла «temp dir»: мультимашинность + worktrees).
> Предыдущий handoff (2026-06-09-generator-rework.md) отработал и удалён.
## Где остановились
Дизайн-этап переработки генератора **завершён**. Спека математической модели
написана, прошла **два** адверсариальных ревью свежими агентами (второе ревью
ловило ошибки правок первого — практика себя оправдала), все находки закрыты,
ключевые цифры перепроверены замерами по полному сиду. Кода по-прежнему
не трогали.
Разделение ролей (см. память проекта): дизайн/спеки — Fable, **детальный план
и реализация — Codex 5.5**. Следующая сессия — скорее всего, подготовка задачи
для Кодекса или сама реализация.
## Документы (источники истины, не пересказываю)
- **Мат-модель (главный документ для исполнителя):**
`docs/specs/2026-06-10-generator-math-model.md` — марковская цепочка по
страницам, формула «популяция ↔ интенсивность ↔ пауза», кулдаун, правило
30 минут на рестарт, критерии приёмки.
- **Форма доработки:** `docs/specs/2026-06-09-generator-rework-hierarchical.md`
(Open questions закрыты ссылкой на мат-спеку).
- **Почему генератор, а не реплей:** `docs/adr/0004-steady-stream-synthetic-generator.md`.
- **Профиль сид-датасета (опора калибровки):** `CONTEXT.md`, раздел
«Профиль сид-датасета» — измерено по полным файлам 2026-06-10.
- **Диагноз дефекта старого кода:** `generator/KNOWN_ISSUES.md`.
## Ловушки (одной строкой; детали — в спеках)
- Ранний «факт» **«1..7 событий на визит» был неверен** (срез файла); реально
1..27, медиана 10. Если встретишь «1..7» где-то ещё в доках/коде — это
остатки ошибки, чинить по профилю сида.
- **Формула паузы в мат-спеке обязательна** при смене λ/популяции; кулдаун в
неё не прибавляется (уже учтён балансом). Вторая ревизия исправляла именно
двойной счёт — не откатить случайно.
- **device/geo публикуются на каждое событие** (как в сиде) — это контракт с
ETL, не деталь.
- `event_timestamp` событий — **запланированное** время, не момент отправки
тика (иначе метки прилипают к сетке тиков).
- Модель интенсивности (`_calculate_events_count`) сохраняем; дефолт
`GEN_LAMBDA_BASE_PER_MIN` меняется 200 → 30.
## Следующий шаг
Передать пару спек Кодексу: детальный план реализации → код. Уровень спек
сознательно «решения и инварианты, без алгоритмов» — конкретику исполнитель
достраивает сам. Возможная подготовка: оформить задачу в `.scratch/<feature>/`
по `docs/agents/issue-tracker.md` (скилл `to-issues`, если план дробить).
## Suggested skills (для следующей сессии)
- **`to-issues`** — если решим дробить реализацию на задачи в локальном трекере.
- **`tdd`** — для этапа реализации (pytest-набор `generator/tests/` существует,
но писался под старую модель — пересмотр под новую неизбежен).
- **`adversarial-review`** / **`code-review`** — ревью реализации против
мат-спеки перед вливанием.
- **`conventional-commits`** — коммиты по правилам репозитория.
@@ -138,9 +138,10 @@
- `uniqExact(user_domain_id) < uniqExact(click_id) < count(event_id)` - `uniqExact(user_domain_id) < uniqExact(click_id) < count(event_id)`
пирамида не вырождена (есть возвраты и мультисобытийные сессии). пирамида не вырождена (есть возвраты и мультисобытийные сессии).
- У `click_id` с несколькими событиями `event_ts` строго **возрастает**; - У `click_id` с несколькими событиями время строго **возрастает**
мультисобытийные визиты составляют заметную долю потока (одностраничные (`event_timestamp` в источнике, `event_ts` в DDS — проверять можно в любой
визиты-отказы допустимы и правдоподобны). точке); мультисобытийные визиты составляют заметную долю потока
(одностраничные визиты-отказы допустимы и правдоподобны).
- Один `click_id` принадлежит ровно одному `user_domain_id`; у пользователя - Один `click_id` принадлежит ровно одному `user_domain_id`; у пользователя
бывает несколько `click_id`. бывает несколько `click_id`.
- Воронка по `page_url_path` затухает от шага к шагу; доля визитов, доходящих - Воронка по `page_url_path` затухает от шага к шагу; доля визитов, доходящих
+54 -26
View File
@@ -44,7 +44,10 @@
3. **Популяция: постоянное ядро + медленная ротация.** Размер активной 3. **Популяция: постоянное ядро + медленная ротация.** Размер активной
популяции ограничен (память не растёт), но новые пользователи понемногу популяции ограничен (память не растёт), но новые пользователи понемногу
приходят, давно неактивные — выбывают. Кумулятивное число уникальных приходят, давно неактивные — выбывают. Кумулятивное число уникальных
пользователей растёт со временем — стенд выглядит живым. пользователей растёт со временем — стенд выглядит живым. «Медленно» — в
масштабе наблюдения: на ориентирах по умолчанию ядро полностью сменяется
примерно за 11 часов (это осознанный баланс: возвраты видны за вечер,
рост uniques — за сутки).
4. **Путь по страницам — марковская цепочка.** Для каждой страницы задана 4. **Путь по страницам — марковская цепочка.** Для каждой страницы задана
вероятность перехода на каждую другую страницу или ухода с сайта. Один вероятность перехода на каждую другую страницу или ухода с сайта. Один
механизм даёт сразу: длину визита, затухающую воронку, петли (возвраты на механизм даёт сразу: длину визита, затухающую воронку, петли (возвраты на
@@ -73,12 +76,19 @@
device/geo-контекст одной случайной сид-сессии (включая `user_custom_id`; device/geo-контекст одной случайной сид-сессии (включая `user_custom_id`;
повторы email между пользователями допустимы — демо-данные) с заменой повторы email между пользователями допустимы — демо-данные) с заменой
`user_domain_id` на свежий. Профиль используется во всех визитах пользователя `user_domain_id` на свежий. Профиль используется во всех визитах пользователя
(KISS: смену устройства не моделируем). (KISS: смену устройства не моделируем). В состоянии профиль хранится
**ссылкой на сид-сессию** (её `click_id`) плюс свежие идентификаторы, а не
копией всех полей — состояние остаётся компактным.
### Визиты (сессии) ### Визиты (сессии)
- Визит = новый `click_id`, общий для всех его событий, с device/geo из профиля - Визит = новый `click_id`, общий для всех его событий, с device/geo из профиля
пользователя. пользователя.
- **Каденция публикации device/geo — как в сиде и у текущего генератора:**
записи `device_events` и `geo_events` публикуются **на каждое событие**
(в пределах визита дублируются с одинаковым содержимым; в сиде это
проверено: 1000 строк при 99 уникальных по содержимому). Контракт данных с
ETL/ODS не меняется — это требование, а не деталь реализации.
- **Связка с моделью интенсивности.** `_calculate_events_count()` сохраняется и - **Связка с моделью интенсивности.** `_calculate_events_count()` сохраняется и
выдаёт на тик *бюджет событий*. Бюджет конвертируется в рождения визитов: выдаёт на тик *бюджет событий*. Бюджет конвертируется в рождения визитов:
ожидаемое число новых визитов за тик = бюджет ÷ средняя длина визита ожидаемое число новых визитов за тик = бюджет ÷ средняя длина визита
@@ -92,35 +102,47 @@
- **Кулдаун возврата:** после завершения визита пользователь недоступен для - **Кулдаун возврата:** после завершения визита пользователь недоступен для
нового минимум `GEN_MIN_RETURN_MINUTES` (ориентир 30 мин) — это гарантирует нового минимум `GEN_MIN_RETURN_MINUTES` (ориентир 30 мин) — это гарантирует
нижнюю границу межсессионной паузы (инвариант пауз). нижнюю границу межсессионной паузы (инвариант пауз).
- **Выбор возвращающегося:** равновероятно среди **доступных** пользователей
(не в кулдауне и без активного визита) — это даёт естественный разброс пауз.
Если доступных нет (краевой случай при экстремальных параметрах) — визит
достаётся новому пользователю.
- **Межсессионные паузы — эмерджентная величина, не свободный параметр.** - **Межсессионные паузы — эмерджентная величина, не свободный параметр.**
Пауза складывается из кулдауна и времени ожидания «своей очереди» и в среднем В стационарном режиме полный цикл пользователя (визит + пауза) определяется
определяется балансом потока и популяции: балансом потока и популяции — кулдаун уже «сидит» внутри этого баланса и
отдельно не прибавляется (он задаёт только нижнюю границу каждой паузы):
``` ```
средняя пауза ≈ кулдаун + GEN_POPULATION_MAX ÷ (λ ÷ L × (1 GEN_P_NEW_USER)) средняя пауза ≈ GEN_POPULATION_MAX ÷ (λ ÷ L × (1 GEN_P_NEW_USER)) − средняя длительность визита
где λ — событий/мин, L — средняя длина визита где λ — событий/мин, L — средняя длина визита
``` ```
На ориентирах по умолчанию (λ=30, L≈10, популяция 300, p_new=0.15): На ориентирах по умолчанию (λ=30, L≈10, популяция 300, p_new=0.15):
возвратов ≈ 2.6/мин, пауза ≈ **~2 часа** — больше 30-минутного окна и при возвратов ≈ 2.6/мин, цикл ≈ 118 мин, средняя пауза ≈ **~110 мин (~2 часа)**;
этом возвраты видны уже за один вечер работы стенда. **Кто меняет λ или медиана ниже среднего (распределение скошено вправо), порядка 1.5 ч — всё
размер популяции — обязан пересчитать паузу по формуле**: эти три величины равно сильно больше 30-минутного окна, и возвраты видны уже за один вечер
связаны, их нельзя крутить независимо. работы стенда. **Кто меняет λ или размер популяции — обязан пересчитать
паузу по формуле**: эти величины связаны, их нельзя крутить независимо.
Ориентиры здесь посчитаны при L=10; после калибровки таблицы переходов
производные числа пересчитываются от измеренного L.
### События и путь по страницам ### События и путь по страницам
- **Марковская цепочка:** страницы — `/home`, `/product_a`, `/product_b`, - **Марковская цепочка:** страницы — `/home`, `/product_a`, `/product_b`,
`/cart`, `/payment`, `/confirmation` плюс исход «ушёл». Визит начинается с `/cart`, `/payment`, `/confirmation` плюс исход «ушёл». Визит начинается со
входной страницы (как правило `/home`), дальше каждый следующий шаг страницы, разыгранной по **стартовому распределению** (оно — часть модели:
разыгрывается по таблице переходов текущей страницы. Петли разрешены в сиде `/home` — лишь ~59% входов, остальные входят на страницы товаров),
(вернуться на главную, посмотреть оба товара, продолжить ходить после дальше каждый следующий шаг разыгрывается по таблице переходов текущей
`/confirmation` — как в сиде). Защита от зацикливания — потолок длины визита страницы. Петли разрешены (вернуться на главную, посмотреть оба товара,
`GEN_MAX_SESSION_EVENTS` (ориентир 30, как максимум в сиде). продолжить ходить после `/confirmation` — как в сиде). Защита от
- **Калибровка по сиду:** конкретные значения таблицы подбирает исполнитель под зацикливания — потолок длины визита `GEN_MAX_SESSION_EVENTS` (ориентир 30,
два целевых показателя из профиля сида (см. `CONTEXT.md`): доля визитов, как максимум в сиде).
достигших `/confirmation`, ≈ 25%, и длина визита с медианой ~10 событий. - **Калибровка по сиду:** стартовое распределение и значения таблицы подбирает
Точного совпадения распределений не требуется — требуется сопоставимость, исполнитель под целевые показатели из профиля сида (см. `CONTEXT.md`): доля
чтобы дашборд на потоке показывал привычные по сиду цифры. визитов, достигших `/confirmation`, ≈ 25%, длина визита с медианой ~10 и
**средним ~10** (в сиде среднее ≈ медиане; у цепочки с геометрическим
хвостом среднее легко уезжает выше — следить за обоими). Точного совпадения
распределений не требуется — требуется сопоставимость, чтобы дашборд на
потоке показывал привычные по сиду цифры.
- **Паузы внутри визита:** интервал между соседними событиями — величина - **Паузы внутри визита:** интервал между соседними событиями — величина
масштаба *секунд—минут* (например, лог-нормальное: большинство пауз короткие, масштаба *секунд—минут* (например, лог-нормальное: большинство пауз короткие,
изредка длинные). Ориентир: медиана десятки секунд. Форма распределения и изредка длинные). Ориентир: медиана десятки секунд. Форма распределения и
@@ -152,7 +174,10 @@
Число одновременно активных визитов ограничено (`GEN_MAX_ACTIVE_SESSIONS`) — Число одновременно активных визитов ограничено (`GEN_MAX_ACTIVE_SESSIONS`) —
защита памяти; при достижении потолка новые рождения в этот тик пропускаются защита памяти; при достижении потолка новые рождения в этот тик пропускаются
(бюджет не копится). На ориентирах по умолчанию стационарно активны ~15–30 (бюджет не копится). На ориентирах по умолчанию стационарно активны ~15–30
визитов — потолок с большим запасом. визитов — потолок с большим запасом. **Инвариант конфигурации:**
`GEN_MAX_ACTIVE_SESSIONS < GEN_POPULATION_MAX` — иначе правило вытеснения
(«без активного визита») может не найти кандидата; валидировать при старте,
как существующие проверки `Config`.
## Персистентность через рестарты ## Персистентность через рестарты
@@ -212,15 +237,16 @@
- Средняя интенсивность событий за длинное окно (час и больше) соответствует - Средняя интенсивность событий за длинное окно (час и больше) соответствует
целевой `λ × часовой коэффициент` с разумным отклонением. целевой `λ × часовой коэффициент` с разумным отклонением.
- Паузы внутри `click_id`: 95-й перцентиль — единицы минут, максимум < 30 минут. - Паузы внутри `click_id`: 95-й перцентиль — единицы минут, максимум < 30 минут.
- Межсессионные паузы одного пользователя: минимум ≥ кулдауна, медиана - Межсессионные паузы одного пользователя: минимум ≥ кулдауна, **среднее**
порядка расчётной по формуле (~2 ч на дефолтах). порядка расчётного по формуле (~2 ч на дефолтах; медиана ниже, ~1.5 ч).
- Доля визитов новых пользователей за длинное окно ≈ `GEN_P_NEW_USER`; - Доля визитов новых пользователей за длинное окно ≈ `GEN_P_NEW_USER`;
кумулятивное число уникальных `user_domain_id` растёт со временем, размер кумулятивное число уникальных `user_domain_id` растёт со временем, размер
состояния генератора — нет. состояния генератора — нет.
- Длина визита (медиана ~10, максимум ≤ потолка) и доля дошедших до - Длина визита (медиана ~10, максимум ≤ потолка) и доля дошедших до
`/confirmation` (~25%) сопоставимы с профилем сида из `CONTEXT.md`. `/confirmation` (~25%) сопоставимы с профилем сида из `CONTEXT.md`.
- Воронка затухает по шагам: число визитов, посетивших страницу, монотонно - Воронка затухает по шагам: число визитов, посетивших шаг, монотонно убывает
убывает вдоль `/home → товары → /cart → /payment → /confirmation`. вдоль `/home → товары → /cart → /payment → /confirmation` (шаг «товары» —
визит посетил хотя бы одну из страниц товаров; так же считает дашборд).
- После рестарта с простоем ≤ 30 минут активные визиты продолжаются (нет - После рестарта с простоем ≤ 30 минут активные визиты продолжаются (нет
скачка «все пользователи новые»); после долгого простоя закрываются только скачка «все пользователи новые»); после долгого простоя закрываются только
просроченные визиты, популяция сохраняется. просроченные визиты, популяция сохраняется.
@@ -236,6 +262,8 @@
на визит» (по полному замеру — 1..27, медиана 10). на визит» (по полному замеру — 1..27, медиана 10).
- Урок 7 «Система живёт» (пишется на этапе реализации): марковская таблица - Урок 7 «Система живёт» (пишется на этапе реализации): марковская таблица
переходов — учебная «ручка» урока (поменяй вероятность → увидь сдвиг переходов — учебная «ручка» урока (поменяй вероятность → увидь сдвиг
конверсии на дашборде). конверсии на дашборде). Оговорка скоупа: «увидеть на дашборде» предполагает,
что поток доезжает до витрин — это зависимость урока 7 от инкрементальной
загрузки ETL (план v2), а не требование к генератору.
- `generator/README.md` — обновляется при реализации; туда же — верхнеуровневый - `generator/README.md` — обновляется при реализации; туда же — верхнеуровневый
обзор «как работает генератор» (два контура: интенсивность и сущности). обзор «как работает генератор» (два контура: интенсивность и сущности).