# Модельное время на практике: сохранение состояния, заливка прошлого и стартовая история

Дата: 2026-06-14
Статус: Draft
Связано: [ADR-0005](../adr/0005-generator-model-clock.md) (решение про модельные
часы — эта спека его дорабатывает), [ADR-0006](../adr/0006-generation-as-sole-analytics-source.md)
(стартовая история как источник аналитики), мат-спека
[`2026-06-10-generator-math-model.md`](./2026-06-10-generator-math-model.md)
(её разделы «Персистентность через рестарты» и «Воспроизводимость» здесь
**приводятся в соответствие** с модельным временем — не повторяются, а
переописываются ссылкой), [`CONTEXT.md`](../../CONTEXT.md), задача
[`06-state-v2-and-restart`](../../.scratch/feature-data-generator/issues/06-state-v2-and-restart.md)
(сохранение состояния построено на настенных часах).

Уровень документа — **модель и правила**, как в мат-спеке. Раздел «Рабочий
контракт реализации» ниже фиксирует внешний интерфейс для задач 02–06. Внутренние
классы, функции и разбиение кода остаются за исполнителем.

## Проблема

ADR-0005 решил отвязать время генератора от реальных часов и ввёл три скорости
его хода: обычную (×1), ускоренную (×K) и мгновенную «промотку» прошлого. Но два
правила старой модели — как генератор сохраняет состояние между перезапусками и
как добивается повторяемости — описаны от реальных часов. А ADR-0006 сделал
стартовую историю (готовое сгенерированное прошлое) единственным источником
данных стенда.

Осталось довести модель до конца: как работает промотка прошлого, его заморозка и
непрерывный запуск стенда с этого момента; как при этом меняются правила
сохранения состояния и повторяемости; и как всё это проверить. Всё это — **один
механизм**: промотать прошлое → заморозить → продолжить живьём. Это один и тот же
путь сохранения и восстановления, просто с разных сторон.

## Цели

- **Дать генератору собственные часы и сделать поток повторяемым.** Генератор
  должен отсчитывать время от своей точки отсчёта (ниже — «стартовая модельная
  точка `T0`»), а не от реальных часов компьютера. Тогда при одних и тех же
  настройках он каждый раз порождает один и тот же поток — это нужно для тестов и
  для повторяемых уроков.

- **Переписать правила сохранения и восстановления состояния по этим часам.**
  Сейчас они завязаны на реальное время. Главное правило — «если посетитель молчал
  дольше 30 минут, считаем, что он ушёл» — должно мерить эти 30 минут по часам
  генератора. И поток больше не должен зависеть от того, в котором часу реального
  дня запущен генератор.

- **Научиться быстро «проматывать» прошлое и замораживать его как стартовый набор
  данных.** Генератор прокручивает время без пауз от точки отсчёта до нужного
  момента, порождает события прошлого и сохраняет «слепок» своего состояния. Этот
  замороженный набор — то, с чего свежий стенд начинает жить, уже имея историю:
  здоровую пирамиду «пользователей меньше, чем визитов, а визитов меньше, чем
  событий» с первой минуты.

- **Описать, как всё это проверить — в два шага.** Сначала числами: агент
  поднимает стенд и сверяет данные в ClickHouse. Потом глазами: человек смотрит на
  дашборды и видит, что распределение похоже на задуманное, а стенд «дышит» во
  времени.

## Чего здесь не делаем

- **Не учим генератор придумывать «фактуру» сам** (браузеры, гео, устройства,
  метки кампаний). Это отдельная спека — следствие ADR-0006. Пока её нет,
  стартовая история берёт фактуру из статического сида; это осознанно временно.
- **Не описываем здесь перестройку процесса** на новый сид (загрузка
  `kafka_load_dag`, витрины/Superset, уроки) — это решено в ADR-0006 и
  проектируется отдельно. Эта спека — только про сам генератор.
- **Не фиксируем** внутренние имена классов и функций — это за исполнителем.
- **Не добавляем** новые типы событий и инкрементальную загрузку ETL.

## Модель

### Рабочий контракт реализации

Этот раздел — источник истины для задач 02–06. Если кодовая задача меняет любое
имя, формат state, манифест или правило проверки, сначала обновляется этот
контракт.

#### Настройки

- `GEN_MODEL_T0` — стартовая модельная точка `T0`. Формат: ISO 8601 с часовым
  поясом, например `2026-01-01T00:00:00+00:00`. Внутри генератора метка
  нормализуется к UTC.
- `GEN_MODEL_T_END` — граница стартовой истории `T_end`. Формат такой же, как у
  `GEN_MODEL_T0`. Обязательна только для режима `backfill`.
- `GEN_MODEL_TIMEZONE` — часовой пояс модельных часов для дневного коэффициента.
  Формат: имя IANA, например `UTC` или `Europe/Moscow`. Значение по умолчанию —
  `UTC`.
- `GEN_MODEL_TIME_SPEED` — скорость `K`: сколько модельных секунд проходит за
  одну настенную секунду. Формат: положительное число, по умолчанию `1`.
- `GEN_RUN_MODE` — режим запуска: `live` или `backfill`. Значение по умолчанию —
  `live`.
- `GEN_LAUNCH_PROFILE` — имя профиля запуска для логов. Значение по умолчанию —
  `ci`; на поток не влияет.
- `GEN_STARTUP_HISTORY_ARTIFACT` — путь к JSON-файлу, куда `backfill` дополнительно
  пишет портативный артефакт стартовой истории. В обычном live-запуске не нужен.
- `GEN_SEED`, `GEN_TICK_SECONDS` и остальные настройки генерации остаются частью
  контракта повторяемости. Если они отличаются, артефакт стартовой истории
  считается другим.

Поверх этих переменных есть пользовательский слой запуска:

- `backfill` — подставляет `GEN_RUN_MODE=backfill` и `GEN_STATE_RESET=true`;
- `continue` — подставляет `GEN_RUN_MODE=live` и `GEN_STATE_RESET=false`;
- `reset` — подставляет `GEN_RUN_MODE=live` и `GEN_STATE_RESET=true`.

Профиль `ci` даёт быстрый 6-часовой прогон и остаётся на ×1 с тиком 60 с.
Профиль `daily-wave` даёт 3 суток, чтобы была видна суточная волна, а в live
идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки
проходят примерно за 24 настенные минуты. Пара «тик 1 с, скорость ×60» выбрана,
чтобы модельный шаг тика остался 60 секунд. Так событийный бюджет и форма волны
не меняются относительно старого минутного тика, а пиковый тик не упирается в
`GEN_MAX_EVENTS_PER_TICK`. Вариант «тик 60 с, скорость ×60» не подходит: один
тик стал бы модельным часом и слипал бы события в грубые часовые пачки.
`GEN_HISTORY_DURATION` задаёт длительность вида `6h` или `2d`; `GEN_MODEL_T_END`
считается от `GEN_MODEL_T0` внутри слоя запуска. Старые переменные остаются
низкоуровневым механизмом.

Цена быстрого live-хода `daily-wave`: при тике 1 с сервис пишет примерно 86 400
state-записей и столько же записей истории пачек за настенные сутки. Частота
state-записей остаётся прежним контрактом возобновления после сбоя: успешный тик
сразу сохраняет новую модельную точку. Чтобы быстрый профиль не заливал журналы,
подробные строки успешного тика пишутся на DEBUG, а не на INFO.

#### Ход часов

В живом режиме модельное время идёт фиксированным шагом. На чистом старте оно
равно `GEN_MODEL_T0`. После каждого успешного тика оно сдвигается на
`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. Событийный бюджет считается по этой
модельной длительности, а не по тому, сколько процесс реально спал.

При одинаковых `GEN_SEED`, `GEN_MODEL_T0`, `GEN_MODEL_TIME_SPEED`, настройках
генерации и числе успешных тиков живой поток повторяется точно. Это выбранный
путь для текущей цепочки: он ближе к учебной цели и не добавляет новую публичную
матрицу режимов поверх ADR-0005.

`backfill` не спит и не измеряет настенные интервалы. Генератор детерминированно
проматывает модельное время от `GEN_MODEL_T0` до `GEN_MODEL_T_END`. Артефакт
повторяется точно при тех же настройках и чистом состоянии.

Измеренный настенный интервал между тиками не входит в текущий контракт живого
режима. Если он понадобится позже, это отдельное изменение спеки или ADR, потому
что оно меняет уровень повторяемости стенда.

#### Граница `T_end`

Стартовая история покрывает полуоткрытый отрезок `[T0, T_end)`: в неё попадают
события с `event_timestamp >= T0` и `event_timestamp < T_end`. Слепок состояния
сохраняется на модельной границе `T_end`.

Живое продолжение начинается из этого слепка с модельной точки `T_end`. Событие,
которое запланировано ровно на `T_end`, не входит в стартовую историю и может
быть выпущено первым живым тиком. Так на стыке нет дублей и дыр.

#### Модельные и настенные метки

Модельными считаются:

- `event_timestamp` во всех событиях;
- метки внутри визитов и популяции: начало визита, запланированные смещения,
  `last_finished_at`;
- `GEN_MODEL_T0`, `GEN_MODEL_T_END`, точка возобновления и метка слепка state.

Настенными остаются операционные метки: время записи логов, метрики здоровья,
длительность тика, история отправки пачек, время сохранения state в Kafka и
служебная метка `generated_at` в манифесте. Они помогают обслуживать сервис, но
не должны менять `event_timestamp` и бизнес-логику визитов.

#### Восстановление после сбоя

Следующая версия state должна хранить связку:

- `model_timestamp` — модельная точка последнего сохранённого состояния;
- `wall_timestamp` — настенная UTC-метка, когда это состояние было сохранено;
- `model_time_speed`, `model_timezone` и `model_t0`.

State v3 также хранит для каждого активного визита `base_click_id` — click_id
донора браузерной и source-фактуры из статического сида. При восстановлении
активный визит берёт per-event поля от этого донора; неизвестный донор считается
битым state, а не поводом выбрать запасную фактуру.

При восстановлении живого режима после сбоя модельная точка считается так:

```text
resume_model_at =
  state.model_timestamp
  + max(0, wall_now_utc - state.wall_timestamp) * state.model_time_speed
```

Для восстановления из стартовой истории эта формула не применяется:
`resume_model_at = manifest.model_t_end`. Иначе долгий простой между созданием
артефакта и запуском стенда искусственно оборвёт активные визиты.

Короткий или долгий простой считается только по модельному времени. При большом
`GEN_MODEL_TIME_SPEED` короткая настенная пауза может стать долгой модельной
паузой, и тогда просроченные активные визиты закрываются.

Если state отсутствует или повреждён, генератор стартует чисто и пишет
предупреждение. Если state читается, `GEN_STATE_RESET=false`, но настройки
продолжения несовместимы (`GEN_SEED`, `GEN_MODEL_T0`, `GEN_MODEL_TIMEZONE`,
`GEN_MODEL_TIME_SPEED`, а для стартовой истории ещё и `GEN_MODEL_T_END` или
manifest), генератор должен упасть с перечнем разошедшихся полей и подсказкой
использовать `GEN_STATE_RESET=true` для осознанного нового мира.

#### Манифест стартовой истории

Стартовая история состоит из трёх частей: события, слепок состояния и манифест.
В рабочем стенде манифест хранится как JSON в Kafka compact-topic
`generator_startup_history_manifest`, ключ `default`. Слепок состояния хранится
в `generator_state`, ключ `default`.

Портативный файл-артефакт хранит тот же связный набор: сообщения топиков
`browser_events`, `location_events`, `device_events`, `geo_events`, слепок state
и manifest. Для событий хранится raw JSON value, чтобы импорт мог воспроизвести
Kafka-сообщения без повторной сериализации dict. Импорт артефакта воспроизводит
сообщения в Kafka и записывает state с manifest в служебные compact-топики.
Напрямую в ClickHouse импорт не пишет: ClickHouse наполняется штатным путём через
Kafka engine и Materialized View.

Импорт рассчитан на чистый стенд. Перед записью он проверяет, что data-топики
Kafka пустые. Так как текущий Python-клиент Kafka не даёт транзакционный producer
для нескольких топиков, при ошибке записи импорт удаляет import-топики Kafka,
чтобы повторный импорт не дописал дубли; если ClickHouse уже успел прочитать
частичные сообщения, стенд очищается как clean-stand сценарий.

Манифест минимум содержит:

- `manifest_version`;
- `generated_at` — настенная UTC-метка создания артефакта;
- `gen_seed`;
- `model_t0`, `model_t_end`, `model_timezone`;
- `run_mode = "backfill"`;
- настройки генерации, влияющие на поток, в `generation_settings`;
- `state_version` и ссылку на запись слепка состояния;
- контрольные числа по каждому топику: количество строк, минимум и максимум
  `event_timestamp`, контрольная сумма;
- итоговые контрольные числа для проверки в ClickHouse: события, визиты,
  пользователи и диапазон модельного времени.

Нельзя смешивать события, state и манифест от разных `GEN_SEED`, `T0`, `T_end`
или настроек генерации. Live-запуск использует manifest как стартовую историю
только если `state.last_batch_id`, `state.model_timestamp`, `GEN_SEED`,
`GEN_MODEL_T0`, `GEN_MODEL_T_END`, `GEN_MODEL_TIMEZONE`, `GEN_MODEL_TIME_SPEED`
и `generation_settings` совпадают. Startup-history state без подходящего
manifest считается несовместимым читаемым state и даёт жёсткий отказ при
`GEN_STATE_RESET=false`.

#### Повторяемая проверка в ClickHouse

Для этой цепочки выбираем чистый прогон, а не идемпотентную дозаливку. Перед
повторной проверкой нужно сбросить:

- таблицы ClickHouse в слоях STG, ODS, DDS и DM, куда попадают события стенда;
- Kafka-топики данных: `browser_events`, `location_events`, `device_events`,
  `geo_events`;
- состояние генератора: compact topic `generator_state` или явный сброс
  состояния при старте, если он гарантированно не читает старую запись.

После такого сброса один и тот же артефакт стартовой истории должен давать те же
контрольные числа в ClickHouse. Если проверка запускается без чистки, это уже
другой сценарий и его нужно описывать отдельно.

### Точка отсчёта и скорость хода часов

У генератора своя точка отсчёта времени — **стартовая модельная точка `T0`**
(задаётся в настройках). В метку события (`event_timestamp`) пишется это
внутреннее время. Скорость, с которой оно идёт относительно реальных часов,
задаётся режимами из ADR-0005:

- **×1** — как реальное время;
- **×K** — в `K` раз быстрее;
- **заливка прошлого** — время гонится без пауз от `T0` до нужного момента
  (особый случай очень большого `K`).

### Повторяемость

При одном и том же зерне `GEN_SEED`, одной и той же `T0` и одной скорости
генератор каждый раз даёт **один и тот же поток** в `backfill` и в живом режиме
при одинаковом числе успешных тиков. Дневной коэффициент считается по модельному
времени в `GEN_MODEL_TIMEZONE`, а не по реальным часам. Так раздел
«Воспроизводимость» мат-спеки приводится в соответствие с модельным временем.

### Заливка прошлого и стартовая история

«Промотать» прошлое — значит прогнать время без пауз от `T0` до момента `T_end` и
сгенерировать события этого отрезка. На выходе — события за `[T0, T_end)` и
**слепок состояния** генератора на момент `T_end`.

События плюс слепок и есть **стартовая история** («стартовый сид» или «новый сид» —
синонимы, новое значение слова «сид» не заводим). При создании стенда это прошлое заливается, и
генератор готов продолжить ровно с `T_end`. Стенд сразу живёт с готовой историей —
с настоящей пирамидой «пользователей меньше, чем визитов, визитов меньше, чем
событий», без вырождения статического сида, где пользователей ровно столько же,
сколько визитов.

### Сохранение и восстановление состояния

Правило «молчал дольше 30 минут — посетитель ушёл» остаётся; меняется только **от
какого момента отсчитывать эти 30 минут**:

- **после сбоя** — от реального «сейчас» (время и правда прошло) → как сегодня;
- **при запуске со стартовой истории** — от метки слепка (`T_end`) → разрыва
  почти нет, активные визиты не обрываются, мир продолжается без шва.

Паузы между визитами одного пользователя и обязательная пауза перед возвратом
тоже считаются по внутренним меткам времени, а не по числу тактов. Если
сохранённое состояние повреждено или его нет — генератор стартует с чистого листа
(свежая популяция от `GEN_SEED`) и пишет предупреждение в лог. Так раздел
«Персистентность» мат-спеки приводится в соответствие с модельным временем.

### С какой скоростью стенд живёт дальше

После старта стенд продолжает жить на выбранной скорости. Скорость — это **ручка
под задачу**: чтобы увидеть медленные вещи (возвраты, сдвиг воронки после правки
таблицы переходов, «дыхание» суточной нагрузки) за учебное время, нужно ускорение
(×K) — иначе суточная волна разворачивается реальные сутки. Какой скорость будет
по умолчанию (×1 «как настоящий сайт» или ускоренная «учебная») — решает
урок/исполнитель; спека лишь фиксирует, что это ручка и что именно на ней держится
наблюдаемость медленных явлений.

### Временная опора на сид (фактура)

Пока генератор не умеет придумывать фактуру сам (ADR-0006), стартовая история
одевает события в данные из статического сида (браузер, гео, устройство, метки
кампаний). Это временно и не мешает: механизм времени и стартовой истории не ждёт
синтеза фактуры, а сид до его появления остаётся кладовкой готовых значений — но
уже не источником аналитики.

## Проверка

Два шага — это разделение труда: первый объективный и повторяемый (его делает
агент или CI), второй — человеческий взгляд.

### Шаг 1 — агент сверяет числа в ClickHouse

На чистом стенде с фиксированными `GEN_SEED` и `T0` залить стартовую историю,
прогнать STG→DM и проверить:

- **пирамида:** уникальных пользователей меньше, чем визитов, а визитов меньше,
  чем событий;
- **воронка** убывает по шагам `/home → товары → /cart → /payment →
  /confirmation`; доля дошедших до `/confirmation` — в нужном коридоре;
- **возвраты:** у части пользователей больше одного визита;
- **длина визита** и доля коротких визитов — в коридорах мат-спеки.

Главное: проверки **повторяемы** (тот же `GEN_SEED` и `T0` → те же числа в
пределах допуска) и записаны как команды и запросы, чтобы их мог прогнать агент
или CI без человека. Сами запросы и числовые коридоры — при реализации.

### Шаг 2 — человек смотрит на дашборды

Глазами убедиться, что:

- **распределение похоже на задуманное** — на наши спроектированные распределения
  (мат-спека), а не на профиль чужого сида;
- **стенд «дышит»** — видна суточная волна нагрузки, копятся возвраты и воронка
  (заметно на ×K).

Здесь всплывает нехватка: дашборда с распределением сгенерированных данных пока
нет. Superset построен на сиде, а Grafana показывает только скорость работы
сервиса, не форму данных. Закрыть можно двумя способами: переключить Superset на
генерацию (часть большой миграции — позже) или добавить простую панель
«распределение из ClickHouse» в Grafana. Пошаговые действия шага 2 — в будущий
runbook «проверка генератора на стенде».

## Решения и отклонённые варианты

- **Приняли:** одна спека на время, сохранение состояния и стартовую историю —
  это один механизм, а не три задачи.
- **Приняли:** своя точка отсчёта `T0` как опора повторяемости; дневной
  коэффициент считается по внутреннему времени.
- **Приняли:** проверка в два шага (агент — числа, человек — глаза).
- **Отклонили:** жёстко подгонять генерацию под профиль сида (см. ADR-0006).
- **Отклонили:** приводить сохранение состояния в соответствие с модельным
  временем отдельно от стартовой истории — это разрезало бы один механизм надвое.

## Влияние на документацию

- **Мат-спека:** разделы «Персистентность через рестарты» и «Воспроизводимость»
  пометить как переописанные в модельном времени этой спекой (ссылкой, без
  повтора).
- **ADR-0005:** направление «стартовая история» — отмечено как запущенное.
- **CONTEXT.md:** «стартовый сид» / «новый сид» = «стартовая история стенда»
  (синонимы, не новое значение); роль статического сида — по ADR-0006.
- **Будущий runbook** «проверка генератора на стенде» — шаги шага 2.
- **`generator/README.md`, `docs/OPERATIONS.md`** — при реализации: скорость хода
  часов, ×K, стартовая история, проверки шага 1.
