docs(generator): зафиксировано независимое ревью и задачи на потом

- Зачем:
  - после coordinator-loop нужно независимое ревью результатов модельного
    времени и стартовой истории; пропущенный внешний review gate после задачи 5
    закрыт другой родословной.
- Что:
  - добавлен verification-handoff: что проверено независимо, дефект стыка
    (issue 09) и открытые пробелы (×K, crash recovery, коридоры мат-спеки,
    воспроизводимость, review gate задачи 3).
  - заведены issues 08 (портативный артефакт + runbook + идеи интерфейса),
    09 (баг браузерной фактуры на стыке), 10 (читаемость гео-карты).
  - в docs/course/PRD.md §7 — открытый вопрос «генератор как скрытая
    инфраструктура vs отдельный урок».
- Проверка:
  - git show --stat HEAD
  - чтение .scratch/handoffs/2026-06-14-generator-model-time-verification-review.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:03:08 +03:00
co-authored by Claude Opus 4.8
parent 86bba06f09
commit d7f02fd700
5 changed files with 321 additions and 0 deletions
@@ -0,0 +1,96 @@
Status: needs-triage
# Портативный артефакт стартовой истории и runbook по стенду
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Why
Сейчас стартовая история персистится только в Kafka compact-топиках
(`generator_state`, `generator_startup_history_manifest`) и в ClickHouse. Чистая
пересборка стенда (`make generated-history-analytics` делает `down -v`) каждый раз
**заново генерирует** backfill. Портативного файла-артефакта, который можно
сгенерировать один раз и быстро залить на чистый ClickHouse без запуска
генератора, нет.
Из-за этого неудобно: раздать готовое демо, мгновенно сбросить стенд, держать
длинную стартовую историю (2+ суток, чтобы суточная волна повторялась на графике)
без повторной генерации. Сейчас «дёшево» только live-возобновление из слепка и
перезапуск без `down -v`; полный сброс требует регенерации.
Этот пункт работает на главную цель: если стенд поднимается одной командой и есть
короткий runbook, генератор становится скрытой инфраструктурой и менти не нужно
знать его устройство. Связано с открытым вопросом курса про отдельный урок по
генератору (см. `docs/course/PRD.md`, §7).
## What to build
- Экспорт стартовой истории в портативный файл-артефакт: события плюс слепок
состояния плюс манифест — один связный набор, чтобы не смешать `GEN_SEED`,
`T0`, `T_end` и настройки генерации.
- Импорт: залить артефакт на чистый ClickHouse и Kafka без прогона генерации;
live-режим продолжает с `T_end`.
- Runbook «как пользоваться стендом на генерации»: как сгенерировать, сохранить,
восстановить, выбрать длительность стартовой истории; что дёшево
(live-возобновление, перезапуск без чистки), а что требует регенерации.
## Acceptance criteria
- [ ] Есть команда экспорта: backfill -> портативный файл-артефакт
(события + слепок + манифест).
- [ ] Есть команда импорта: артефакт -> чистый ClickHouse без запуска генератора;
контрольные числа манифеста и ClickHouse совпадают с исходной генерацией.
- [ ] Сохранено антисмешивание: импорт отвергает артефакт, несовместимый по
манифесту (`GEN_SEED`, `T0`, `T_end`, настройки генерации, версия state).
- [ ] Runbook описывает генерацию один раз, дешёвое восстановление, выбор
длительности и то, что переживает перезапуск, а что требует регенерации.
- [ ] Документы запуска (`README.md`, `docs/OPERATIONS.md`,
`generator/README.md`) ссылаются на runbook.
## Notes
- Опирается на спеку `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`,
разделы «Манифест стартовой истории» и «Повторяемая проверка в ClickHouse».
- Спека уже упоминала будущий runbook «проверка генератора на стенде» — этот
issue его и закрывает, расширяя до полного цикла «генерация — сохранение —
восстановление».
- Связано с `07-migrate-course-from-archive-seed.md`: удобный стенд упрощает выбор
«адаптировать уроки», а не писать тяжёлый урок про генератор.
## Идеи интерфейса (на будущее, не решено)
Запуск сейчас недружелюбный: поведение собирается из ~10 связанных env-переменных,
`T_end` задаётся абсолютной меткой вместо длительности, а несовпадение настроек при
live-продолжении даёт тихий «свежий старт» (warning в лог, общее сообщение, без
указания разошедшегося поля — `service.py:217-220`). Идеи, как сделать удобнее:
- **Глаголы вместо матрицы флагов:** явные `backfill` / `continue` / `reset`, а не
комбинация `GEN_RUN_MODE` + `GEN_STATE_RESET`.
- **Длительность как длительность и профили:** `HISTORY_DURATION=2d` вместо ручного
расчёта `T_end`; именованные профили вместо повторения блока из ~10 переменных.
- **Громкий и адресный отказ при несовпадении (пересмотр решения спеки).** Сейчас
при `GEN_STATE_RESET=false` несовместимый по настройкам state молча ведёт к чистому
старту (`service.py:217-220`) — это сознательный выбор спеки ради устойчивости.
Предложение: различать два случая. Нет состояния или оно повреждено -> чистый старт
с предупреждением (как сейчас, оставить). Состояние есть и читается, но настройки
несовместимы при `GEN_STATE_RESET=false` (оператор намерен продолжить) -> **жёсткое
падение** с указанием разошедшихся полей (`seed`/`T0`/`timezone`/`speed`) и подсказкой
выставить `GEN_STATE_RESET=true`, если новый мир нужен осознанно. Меняет правило
спеки «несовместимо -> чистый старт», поэтому правка идёт вместе с обновлением
`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`.
- **Доливка прошлого кусочком:** backfill, продолжающий слепок от `T_end` (сейчас
backfill всегда стартует с чистого состояния от `T0`, `service.py:178-185`).
- **Airflow DAG как пульт запуска (идея пользователя, 2026-06-14):** обернуть операции
генератора в параметризованный DAG (params: режим, `T0`, длительность, скорость,
seed) — UI, валидация настроек против манифеста до запуска, повторные попытки,
наглядность. Хорошо ложится на ограниченный backfill/доливку (конечная задача);
непрерывный live — это долгоживущий сервис compose, DAG его скорее стартует/останавливает,
чем держит внутри таска. Бонус: такой DAG сам по себе учебный (тема урока 4 —
оркестрация Airflow), что ближе к цели курса, чем устройство генератора.
## Blocked by
- `.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md`
- `.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md`
@@ -0,0 +1,73 @@
Status: needs-triage
# Браузерная фактура не сохраняется на стыке восстановления визита
## Parent
`.scratch/generator-model-time-startup-history/PRD.md`
## Что нашли
Визит, переживший стык backfill->live (активен на `T_end` и продолжается живьём),
меняет браузерную фактуру в live-продолжении. Внутри одного `click_id` человек как
будто «пересел» с одного браузера на другой посреди сессии.
Эмпирика. Чистый стенд, 2 суток backfill + 6 live-тиков за `T_end`,
`GEN_SEED=4242`, `GEN_MODEL_T0=2026-01-01T00:00:00+00:00`,
`GEN_MODEL_T_END=2026-01-03T00:00:00+00:00`, `speed=1`:
- `crossing_visits=22`; `browser_name` отличается у `17/22`, `browser_language`
у `22/22`; каждая сторона внутренне постоянна (`22/22`).
- Чисто-исторические визиты (>=3 событий, целиком до `T_end`): браузер постоянен
у `0/14556`.
- device / os / geo на стыке НЕ расходятся: `0/22` конфликтов на уровне ODS.
- `duplicate_events=0`: дублей на границе нет.
- Пример `click_id`: в истории `Chrome / or_IN`, в live `Firefox / el_CY`.
## Почему это дефект
Нарушает заявленный критерий задач 04 и 05: «визит, переживший восстановление,
остаётся однородным; контекст визита не меняется на стыке и не создаёт второй путь
генерации внутри одного `click_id`». Браузер — часть контекста визита.
Критерий был помечен выполненным, но проверялся неполно: саморевью и ревьюер
смотрели только поля `dds.click` (user / device / geo), которые по одной строке на
`click_id` и потому однородны структурно. Per-event браузерные поля в `dds.event` не
проверялись. Это ровно слепая зона «двух путей генерации», под которую PRD требовал
внешний review gate другой родословной после задачи 5 — а он был выполнен той же
родословной, и дефект прошёл.
## Причина (гипотеза по коду)
Конкретные браузерные строки визита (`ActiveVisit.batch`) в state не сериализуются:
хранятся `seed_click_id`, `page_url_paths`, `timestamps`, `next_index` (`state.py`).
При восстановлении live-путь пересобирает фактуру
(`runtime.py._compact_visit_batch`: `browser_by_click_id[seed_click_id]`, индекс
`event_index % len`). device и geo переживают восстановление, потому что привязаны к
`UserProfile` (`user.device`, `user.geo`) и не зависят от индекса события; браузер
зависит от выравнивания `event_index` между уже выпущенной частью и восстановленной —
вероятен рассинхрон индекса. Точную точку уточнить при фиксе.
## Масштаб и важность
- Затрагивает только визиты, активные ровно на `T_end` (здесь 22 из 17397 визитов,
~0.13%). Чистый backfill и чистый live — без дефекта.
- Тот же механизм (фактура пересобирается при restore) вероятно затрагивает и
восстановление после сбоя (задача 04), не только backfill->live; проверка задачи 04
его не ловила, потому что смотрела только `dds.click`.
- Для учебного демо урон низкий, но это реальное нарушение стыковой однородности и
сигнал, что браузерная фактура не входит в восстановимое состояние визита.
## Направление фикса
- Либо сериализовать браузерную фактуру визита в state рядом с
`page_url_paths`/`timestamps`, чтобы восстановленная часть точно продолжила
выпущенную.
- Либо выбирать браузер по абсолютному `event_index` визита, а не по позиции в
остатке, чтобы restore воспроизводил те же строки.
- В проверку стыка добавить per-event браузерные поля (`dds.event`), а не только
`dds.click`, чтобы регресс ловился автоматически.
## Blocked by
- Нет. Это bug по результатам независимой проверки задач 05 и 06.
@@ -0,0 +1,47 @@
Status: needs-triage
# Гео-карта на дашборде нечитаема
## Parent
`.scratch/generator-model-time-startup-history/PRD.md` (раздел «Финальная ручная
приёмка»: если панелей не хватает для просмотра — отдельная задача на панель).
## Что нашли
На финальной ручной приёмке (шаг 2 спеки) виджет «🗺️ Geography Map» показывает
что-то, но прочитать нельзя:
- нет всплывающих подсказок (tooltip) — навёл на страну, значения не видно;
- нет легенды и подписи шкалы — непонятно, что кодирует цвет и в каких единицах;
- цветовая шкала неочевидна — подсвечено мало стран, остальное пусто.
Виз отрисовывается, но смысл непрозрачен: нельзя сказать, что именно он показывает.
## Две разные причины (не путать)
1. **Читаемость визуализации (эта задача).** Настройка чарта: включить tooltip,
добавить легенду и подпись метрики/единиц, выбрать понятную цветовую шкалу,
при необходимости — заменить мировую choropleth на более читаемую форму
(например, топ-N стран баром), раз распределение сильно разрежено.
2. **Перекос гео-данных (отдельно, ADR-0006).** Сама гео-фактура берётся из
статического сида (`geo_by_click_id`) с перекошенным распределением стран. Это
«временная опора на сид» до синтеза фактуры — лечится не настройкой чарта, а
генерацией гео. Здесь не решаем, только отмечаем как смежную причину.
## Acceptance criteria
- [ ] У гео-виза есть tooltip со значением по стране.
- [ ] Есть легенда и подпись: какая метрика и в каких единицах кодируется цветом.
- [ ] Цветовая шкала читаема на текущем (разреженном) распределении, либо выбран
более подходящий тип визуализации.
- [ ] Зафиксировано, что перекос распределения стран — это вопрос гео-фактуры
(ADR-0006), а не настройки чарта.
## Notes
- Всплыло на просмотре дашборда на данных генерации (2 суток backfill).
- Смежные документы: `superset/create_dashboard.py` (определение чарта),
`docs/SUPERSET_DASHBOARD.md`.
- Возможно, стоит расширить до общего прохода по читаемости дашборда (легенды и
подсказки у других виджетов), но базово задача — про гео-карту.