diff --git a/.scratch/feature-data-generator/issues/01-minimal-connected-visit.md b/.scratch/feature-data-generator/issues/01-minimal-connected-visit.md deleted file mode 100644 index b91fcfc..0000000 --- a/.scratch/feature-data-generator/issues/01-minimal-connected-visit.md +++ /dev/null @@ -1,37 +0,0 @@ -Status: ready-for-human - -# Минимальный связанный визит в новом ядре - -## What to build - -Построить первый проверяемый срез нового steady-stream генератора: публичный -вызов генеративного ядра создаёт один визит, который выглядит как нормальная -единица доменной модели `пользователь -> визит -> событие`. - -Визит должен выпускать несколько связанных событий: один общий `click_id`, -разные `event_id`, общий device/geo-контекст и согласованные записи для четырёх -Kafka-топиков. Kafka на этом шаге не нужна: задача проверяет форму данных на -выходе генеративного ядра. - -Источник решений: `docs/specs/2026-06-09-generator-rework-hierarchical.md`, -`docs/specs/2026-06-10-generator-math-model.md`, `CONTEXT.md`. - -## Acceptance criteria - -- [x] Тест через публичный интерфейс генератора создаёт один визит с несколькими - browser-событиями под одним `click_id`. -- [x] У всех событий визита разные валидные `event_id`, а `click_id` не берётся - из исходного сида. -- [x] Для каждого browser-события есть связанная location-запись с тем же - `event_id`. -- [x] Для каждого события визита публикуются device- и geo-записи с тем же - `click_id`; их содержимое в рамках визита одинаковое, как в сиде. -- [x] Один `click_id` связан ровно с одним `user_domain_id`. -- [x] Старый тестовый контракт "одно событие = один новый click_id" удалён или - заменён на новый контракт визита. - -## Blocked by - -None - can start immediately. - -## Comments diff --git a/.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md b/.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md deleted file mode 100644 index 9edff19..0000000 --- a/.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md +++ /dev/null @@ -1,56 +0,0 @@ -Status: ready-for-human - -# Уборка сервиса генератора перед активными визитами - -## What to build - -Разнести текущий `generator/generator.py` на понятные модули, не меняя внешнее -поведение генератора. Цель — подготовить код к задаче 03, где появятся активные -визиты между тиками. - -Источник решений: `docs/specs/2026-06-11-generator-service-cleanup.md`, -`docs/specs/2026-06-10-generator-math-model.md`, задачи 01-03 в этом каталоге. - -## Acceptance criteria - -- [x] Генеративная модель визита отделена от Kafka, состояния, метрик и - сервисного цикла. -- [x] Код загрузки и индексации JSONL-сида вынесен из сервисного слоя. -- [x] Расчёт интенсивности отделён от генерации одного визита. -- [x] Kafka publisher, Kafka-state и создание служебных топиков не смешаны с - генеративной моделью. -- [x] `generator/generator.py` остаётся тонкой точкой входа или совместимым - фасадом, чтобы не ломать запуск и тесты без причины. -- [x] `generate_tick_batch()` не остаётся в чистой генеративной модели визита: - он вынесен в переходный тиковый слой и подготовлен к замене активными визитами - в задаче 03. -- [x] Dockerfile и запуск контейнера обновлены под новую структуру файлов. -- [x] Все существующие тесты генератора проходят. -- [x] README генератора кратко отражает новую структуру файлов. - -## Non-goals - -- Не реализовывать активные визиты между тиками. -- Не добавлять популяцию пользователей и возвраты. -- Не вводить состояние версии 2. -- Не менять Kafka-топики и формат событий. -- Не удалять `generator_batch_history`, если отдельно не доказано, что он больше - не нужен. - -## Blocked by - -- `.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md` - -## Blocks - -- `.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md` - -## Comments - -Задача появилась после разбора распухания сервиса: первые два среза уже внесли -новую модель визита, а третий срез добавит состояние активных визитов. Перед ним -нужно отделить модель, тиковый слой и Kafka-интеграцию. - -2026-06-11: исходники перенесены не россыпью в `generator/`, а в пакет -`generator/src/clickstream_generator/`. `generator/generator.py` оставлен тонким -фасадом и точкой входа для совместимости. diff --git a/.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md b/.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md deleted file mode 100644 index 0185172..0000000 --- a/.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md +++ /dev/null @@ -1,37 +0,0 @@ -Status: ready-for-human - -# Путь визита по страницам и монотонное время - -## What to build - -Расширить минимальный визит до правдоподобного пути по страницам. Визит должен -начинаться со страницы, выбранной по стартовому распределению, дальше идти по -марковской цепочке и назначать событиям запланированные метки времени, которые -строго растут внутри `click_id`. - -Задача остаётся без Kafka и без популяции пользователей: она проверяет качество -одного визита как учебного объекта и как основы будущей воронки. - -Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, раздел -«События и путь по страницам». - -## Acceptance criteria - -- [x] Тест через публичный интерфейс генератора показывает, что - `event_timestamp` внутри одного `click_id` строго возрастает. -- [x] Метки времени событий являются запланированными моментами визита, а не - одинаковым `now()` для всего набора событий. -- [x] Паузы внутри визита меньше 30 минут; 95-й перцентиль на симуляции лежит в - масштабе единиц минут. -- [x] Визит не может зациклиться бесконечно: длина ограничена потолком - `GEN_MAX_SESSION_EVENTS` или его новым эквивалентом. -- [x] Доля визитов, дошедших до `/confirmation`, на симуляции сопоставима с - ориентиром сида около 25%. -- [x] Визит может продолжаться после `/confirmation`; `/confirmation` не - считается обязательным последним событием визита. - -## Blocked by - -- `.scratch/feature-data-generator/issues/01-minimal-connected-visit.md` - -## Comments diff --git a/.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md b/.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md deleted file mode 100644 index 3f9de5b..0000000 --- a/.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md +++ /dev/null @@ -1,41 +0,0 @@ -Status: ready-for-human - -# Поток по тикам с активными визитами - -## What to build - -Разложить визиты по тикам генератора. Визит может жить дольше одного тика: -генератор хранит активные визиты между вызовами, выпускает только те события, -чьё запланированное время наступило, и не приклеивает все события к текущему -тику. - -Задача проверяет основную потоковую механику без Kafka и без восстановления -после рестарта. - -Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, раздел -«Раскладка по тикам». - -## Acceptance criteria - -- [x] Тест через публичный интерфейс генератора показывает, что один `click_id` - может появляться в выходе нескольких последовательных тиков. -- [x] На каждом тике выпускаются только созревшие события активных визитов. -- [x] `event_timestamp` отражает запланированное время события, а не время - фактической отправки тика. -- [x] Завершённые визиты больше не выпускают события в следующих тиках. -- [x] При достижении потолка активных визитов новые рождения в этот тик - пропускаются, а бюджет не копится бесконечно. -- [x] Конфигурация запрещает состояние, где потолок активных визитов не меньше - потолка популяции пользователей. - -## Blocked by - -- `.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md` -- `.scratch/feature-data-generator/issues/02-5-generator-service-cleanup.md` - -## Comments - -2026-06-11: реализовано через `TickStreamGenerator` в тиковом слое. Проверка: -`uv run --with-requirements generator/requirements.txt pytest generator/tests -q` -— 81 passed. После ревью исправлено: входной `event_budget` снова трактуется -как бюджет событий, а не как число рождений визитов. diff --git a/.scratch/feature-data-generator/issues/04-user-population-and-returns.md b/.scratch/feature-data-generator/issues/04-user-population-and-returns.md deleted file mode 100644 index 8347557..0000000 --- a/.scratch/feature-data-generator/issues/04-user-population-and-returns.md +++ /dev/null @@ -1,49 +0,0 @@ -Status: ready-for-human - -# Популяция пользователей и возвраты - -## What to build - -Добавить ограниченную популяцию пользователей с постоянными `user_domain_id`. -Новые визиты должны доставаться либо новым пользователям, либо возвращающимся -пользователям из популяции, доступным после кулдауна и не имеющим активного -визита. - -Задача должна превратить поток из набора независимых визитов в живую модель -возвратов пользователей. - -Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, разделы -«Популяция пользователей» и «Визиты». - -## Acceptance criteria - -- [x] На длинной симуляции получается здоровая пирамида - `users < sessions < events`. -- [x] Один пользователь может иметь несколько `click_id` во времени. -- [x] Один `click_id` принадлежит ровно одному `user_domain_id`. -- [x] Пользователь не получает новый визит раньше минимального кулдауна возврата. -- [x] Если доступных возвращающихся пользователей нет, новый визит достаётся - новому пользователю. -- [x] Размер активной популяции не растёт выше заданного потолка; при - переполнении вытесняется давно неактивный пользователь без активного визита. - -## Blocked by - -- `.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md` - -## Comments - -2026-06-11: реализовано через ограниченную популяцию в `TickStreamGenerator`. -Профиль пользователя переиспользуется между визитами, активный пользователь не -получает второй визит, после завершения действует `GEN_MIN_RETURN_MINUTES`. -Если доступных возвратов нет или срабатывает `GEN_P_NEW_USER`, создаётся новый -пользователь с вытеснением давно неактивного профиля без роста выше -`GEN_POPULATION_MAX`. - -Проверка: `uv run --with-requirements generator/requirements.txt pytest -generator/tests -q` — 88 passed. В песочнице `/snap/bin/uv` падает на -`snap-confine`, поэтому полный pytest запускался вне песочницы. - -После свежего ревью исправлены две детали: UUID теперь создаются через единый -ГПСЧ генератора, а кулдаун пользователя начинается от запланированного времени -последнего события визита, а не от времени позднего тика. diff --git a/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md b/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md deleted file mode 100644 index 4c7f9c6..0000000 --- a/.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md +++ /dev/null @@ -1,55 +0,0 @@ -Status: ready-for-human - -# Интенсивность и калибровка потока - -## What to build - -Связать иерархическую модель с целевой интенсивностью потока. Старая идея -Пуассона, часового коэффициента и jitter должна задавать бюджет активности, из -которого рождаются визиты; фактические события выходят по запланированным -паузам активных визитов. - -На этом шаге также нужно проверить, что параметры модели дают поток, -сопоставимый с профилем сида: длина визита около 10 событий в среднем и по -медиане, доля дошедших до `/confirmation` около 25%, межсессионные паузы -согласованы с формулой из спеки. - -Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, разделы -«Связка с моделью интенсивности», «Параметры» и «Критерии приёмки». - -## Acceptance criteria - -- [x] Средняя интенсивность событий за длинное окно соответствует - `GEN_LAMBDA_BASE_PER_MIN * часовой коэффициент` с разумным отклонением. -- [x] Рождения визитов рассчитываются из бюджета событий и средней длины визита, - а не из старых границ `GEN_MIN/MAX_EVENTS_PER_TICK` в прежнем смысле. -- [x] Доля новых пользователей за длинное окно близка к `GEN_P_NEW_USER`. -- [x] Межсессионные паузы одного пользователя не меньше кулдауна, а среднее на - дефолтах находится в районе расчёта из спеки. -- [x] Распределение длины визита на симуляции сопоставимо с сидом: медиана около - 10, среднее около 10, максимум не выше потолка. -- [x] Воронка по шагам `/home -> товары -> /cart -> /payment -> /confirmation` - монотонно затухает на длинной симуляции. - -## Blocked by - -- `.scratch/feature-data-generator/issues/04-user-population-and-returns.md` - -## Comments - -2026-06-11: реализована калибровка потока через `TickStreamGenerator`: событийный -бюджет тика копится как бюджет рождений визитов через ожидаемую среднюю длину -визита (~10 событий), а фактические события выходят по запланированным паузам -активных визитов. Дефолт `GEN_LAMBDA_BASE_PER_MIN` в коде снижен до 30 -событий/мин и синхронизирован с обычным запуском через `docker-compose.yml`; -`GEN_MIN/MAX_EVENTS_PER_TICK` теперь ограничивают событийный бюджет тика до -пересчёта в рождения визитов. - -Калибровка таблицы переходов и пауз проверена тестами: средняя интенсивность -длинного окна близка к целевому бюджету, доля новых пользователей близка к -`GEN_P_NEW_USER`, межсессионные паузы не меньше кулдауна и имеют часовой -масштаб около формульного ориентира из спеки, длина визита сопоставима с сидом, -доля `/confirmation` остаётся около 25%, воронка монотонно затухает. - -Проверка: `uv run --with-requirements generator/requirements.txt pytest -generator/tests -q` — 96 passed. diff --git a/.scratch/feature-data-generator/issues/06-state-v2-and-restart.md b/.scratch/feature-data-generator/issues/06-state-v2-and-restart.md deleted file mode 100644 index 084d5cc..0000000 --- a/.scratch/feature-data-generator/issues/06-state-v2-and-restart.md +++ /dev/null @@ -1,55 +0,0 @@ -Status: ready-for-human - -# Состояние версии 2 и рестарт - -## What to build - -Расширить состояние генератора до версии 2: кроме тика и состояния генератора -случайных чисел сохранять популяцию пользователей и активные визиты. После -рестарта генератор должен продолжать коротко прерванные визиты и закрывать -сильно просроченные, не теряя популяцию пользователей. - -Задача проверяет поведение состояния через публичные методы сохранения и -восстановления, без реального Kafka-брокера там, где достаточно сериализации. - -Источник решений: `docs/specs/2026-06-10-generator-math-model.md`, раздел -«Персистентность через рестарты». - -## Acceptance criteria - -- [x] Состояние версии 2 сериализуется в JSON и восстанавливает тик, ГПСЧ, - популяцию пользователей и активные визиты. -- [x] Старое состояние версии 1 или битое состояние не валит генератор: - фиксируется предупреждение, генератор начинает с чистого листа. -- [x] После простоя не больше 30 минут активный визит продолжается, а созревшие - события досылаются со своими исходными запланированными метками времени. -- [x] После долгого простоя просроченный активный визит закрывается без досылки - остатка. -- [x] Популяция пользователей переживает простой любой длины. -- [x] `GEN_STATE_RESET=true` явно сбрасывает состояние, как и раньше. - -## Blocked by - -- `.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md` - -## Comments - -2026-06-11: задачу нельзя считать самостоятельной к реализации, пока не закрыты -предыдущие срезы генератора: 02 (путь визита и монотонное время), 03 (тики и -активные визиты), 04 (популяция и возвраты), 05 (интенсивность и калибровка). -Преждевременный кодовый задел state v2 был откатан, чтобы он не попал в коммиты -следующих задач. Возвращаться к этой задаче нужно после реализации зависимостей -и заново проверять все acceptance criteria на актуальной модели генератора. - -2026-06-11: реализовано состояние версии 2 для `TickStreamGenerator`: в снимок -попадают тик, ГПСЧ, компактная популяция пользователей, компактные активные -визиты и остаток бюджета рождений визитов. `GeneratorService` теперь владеет -одним тиковым потоком, сохраняет его снимок и восстанавливает его при старте. -Короткий простой продолжает активные визиты и досылает созревшие события с -исходными метками времени; долгий простой закрывает просроченные визиты без -досылки остатка; популяция сохраняется. - -Старое state v1 и битое v2-state деградируют в чистый старт с предупреждением; -`GEN_STATE_RESET=true` по-прежнему пропускает загрузку состояния. Проверка: -`uv run --with-requirements generator/requirements.txt pytest generator/tests -q` -— 112 passed. diff --git a/.scratch/feature-data-generator/issues/07-service-integration-and-docs.md b/.scratch/feature-data-generator/issues/07-service-integration-and-docs.md deleted file mode 100644 index e979ed4..0000000 --- a/.scratch/feature-data-generator/issues/07-service-integration-and-docs.md +++ /dev/null @@ -1,52 +0,0 @@ -Status: ready-for-human - -# Подключение к сервису и документации - -## What to build - -Подключить новое генеративное ядро к рабочему steady-stream сервису. Генератор -должен запускаться прежними командами, писать сообщения в прежние Kafka-топики, -сохранять полезные метрики Prometheus и больше не документироваться как -концептуально сломанный источник. - -На этом шаге нужно синхронизировать пользовательскую документацию и заметку об -известных проблемах с новой моделью. - -Источник решений: `docs/specs/2026-06-09-generator-rework-hierarchical.md`, -`docs/specs/2026-06-10-generator-math-model.md`, `generator/README.md`, -`generator/KNOWN_ISSUES.md`. - -## Acceptance criteria - -- [x] Генератор в режиме steady-stream пишет связанные сообщения в - `browser_events`, `location_events`, `device_events`, `geo_events`. -- [x] Существующие команды запуска и остановки генератора остаются рабочими или - документация явно описывает замену. -- [x] Метрики Prometheus продолжают показывать успешные тики, ошибки публикации - и объём отправленных событий. -- [x] Интеграционный тест или проверка с мок-публикацией подтверждает, что - сервисный контур использует новую модель, а не старую плоскую генерацию. -- [x] `generator/README.md` описывает новые параметры и новую модель без старых - предупреждений о сломанном `click_id`. -- [x] `generator/KNOWN_ISSUES.md` обновлён: старый дефект закрыт или перенесён в - исторический раздел, не как актуальный блокер. - -## Blocked by - -- `.scratch/feature-data-generator/issues/06-state-v2-and-restart.md` - -## Comments - -- 2026-06-11: добавлена проверка сервисных тиков с мок-публикацией без Kafka: - steady-stream публикует несколько событий одного визита с общим `click_id`, - разными `event_id` и согласованными записями во всех четырёх топиках; история - тиков остаётся `success`. -- 2026-06-11: `docker-compose.yml` теперь пробрасывает переменные генератора - через `${VAR:-default}`, поэтому команды вида `GEN_STATE_RESET=true docker - compose up -d generator` реально меняют окружение контейнера. Внутренние - `KAFKA_BOOTSTRAP_SERVERS` и `GEN_DATA_DIR` оставлены безопасными значениями - для контейнера. -- 2026-06-11: `generator/README.md` и `generator/KNOWN_ISSUES.md` синхронизированы - с новой моделью, состоянием v2 и историческим статусом старого дефекта. -- Проверка: `uv run --with-requirements generator/requirements.txt pytest - generator/tests -q` — 113 passed; `git diff --check` — без замечаний. diff --git a/.scratch/generator-model-time-startup-history/PRD.md b/.scratch/generator-model-time-startup-history/PRD.md deleted file mode 100644 index be3e4d1..0000000 --- a/.scratch/generator-model-time-startup-history/PRD.md +++ /dev/null @@ -1,158 +0,0 @@ -# Модельное время и стартовая история генератора - -Status: Implemented - -## Зачем - -Нужно довести решение ADR-0005 и ADR-0006 до рабочего процесса: генератор живёт -по модельному времени, умеет быстро создать прошлое, сохранить слепок состояния -и продолжить поток так, чтобы результат был проверяем в ClickHouse. - -Источник решений: - -- `docs/specs/2026-06-14-generator-model-time-and-startup-history.md` -- `docs/adr/0005-generator-model-clock.md` -- `docs/adr/0006-generation-as-sole-analytics-source.md` -- `docs/research/2026-06-11-subagent-coordinator-experiment.md` - -## Общие правила приёмки - -- Каждый кодовый срез должен заканчиваться проверкой через ClickHouse, а не - только локальными тестами генератора. -- Кодовые AFK-задачи идут через `/tdd`: один поведенческий тест, минимальная - реализация, зелёная проверка. HITL- и документные задачи идут через - обсуждение и фиксацию решения, без искусственного теста. -- Координатор не работает в `/goal` на всю цепочку. Один worker получает один - issue, не коммитит и не реализует следующие задачи. -- После реализации worker делает саморевью без правок. Координатор - классифицирует находки и возвращает только обязательные исправления. -- В задачах 03 и 05 worker заранее даёт промежуточный статус, если статистический - прогон или стендовая проверка идут долго. -- Если исправление после review gate меняет дизайн, формат state, манифест или - схему проверки, результат исправления проходит повторный review gate. -- Коммиты делает координатор после своих проверок и `git status --short`. -- Ручная проверка дашбордов глазами выполняется только в конце всей цепочки. - -## Сквозные инварианты - -- Рабочий контракт реализации живёт в - `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`, раздел - «Рабочий контракт реализации». Задачи 02–06 берут имена настроек, формат - манифеста, границу `T_end` и правила проверки оттуда. -- `event_timestamp` — модельное время, а не настенные часы компьютера. -- Операционные метки сервиса, история пачек, метрики здоровья и длительность - тика остаются настенным временем, если отдельная задача не докажет обратное. -- Живой ход часов идёт фиксированным модельным шагом - `GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`; промотка прошлого тоже повторяется - точно при тех же настройках и чистом состоянии. -- При ×K модельное время и событийный бюджет идут по модельной длительности - тика, а не по реальной длительности сна процесса. -- Часовой пояс модельных часов явно задан в контракте; дневной коэффициент - считается по нему, а не по неявному локальному времени. -- После сбоя точка возобновления считается из сохранённой связки модельного и - настенного времени, а не простым `datetime.now()`. -- Стартовая история — это события плюс слепок состояния плюс манифест, чтобы не - смешать данные от разных `GEN_SEED`, `T0` и `T_end`. -- Стартовая история покрывает `[T0, T_end)`, живое продолжение начинается из - слепка на `T_end`; на стыке не должно быть дублей и дыр. -- Повторная проверка на чистом стенде должна быть воспроизводимой: либо команда - явно чистит ClickHouse, Kafka-топики данных и состояние генератора, либо - процесс идемпотентен. - -## Задачи - -Номер в имени файла — просто идентификатор задачи, порядок выполнения он не -задаёт. Порядок и зависимости живут здесь и в секциях «Blocked by» самих задач; -при изменении порядка файлы не переименовываются. - -Сделано (ядро фичи, закрыто на триаже 2026-07-04): - -- `issues/01-time-and-startup-history-contract.md` — контракт модельного - времени и стартовой истории. -- `issues/02-model-time-to-clickhouse.md` — минимальный поток по модельному - времени до ClickHouse. -- `issues/03-model-speed-and-day-factor.md` — ×K и дневной коэффициент по - модельному времени. -- `issues/04-state-v2-model-resume.md` — восстановление state v2 от модельной - точки возобновления. -- `issues/05-startup-history-backfill-to-clickhouse.md` — промотка прошлого, - стартовая история и живое продолжение до ClickHouse. -- `issues/06-generated-history-as-analytics-source.md` — штатный путь стенда - переводится на стартовую историю как источник аналитики. -- `issues/07-startup-history-portable-artifact-and-usage-docs.md` — портативный - артефакт стартовой истории и runbook. -- `issues/11-generator-launch-verbs-and-profiles.md` — глаголы, длительность и - профили запуска генератора. -- Airflow-пульт генератора, быстрый `daily-wave`, фикс фактуры визита на - восстановлении, читаемый гео-график и миграция курса на генерацию — закрыты - в очереди 2026-07-04. -- Доработки по кросс-линейному ревью 2026-07-05 закрыты в очереди 2026-07-05: - граница миров, свежий стенд и Airflow-пульт, доверенные проверки - startup-history/Superset, курс на чистом стенде. Подробности и коммиты см. в - issue-файлах и `coordinator-journal.md`. - -Оставшаяся задача вне текущей очереди: - -- `issues/13-...` — доливка истории от слепка; без приоритета, - `needs-triage`. После кросс-линейного ревью 2026-07-05 блокируется - задачами 15 и 17: доливка тиражирует стыки мира и должна опираться на - доверенную проверку фактической границы. - -Дополнительная задача закрыта: - -- `issues/19-test-and-lint-targets.md` — единые `make test` и `make lint` для - коммит-гейта; `done`. Возникло из coordinator-loop 2026-07-05: - координатору пришлось вручную собирать тестовый набор из частных команд. - -Доработки по кросс-линейному ревью 2026-07-05 (закрыто): - -- `issues/15-world-boundary-after-cross-review.md` — граница миров после - обновления, сброса и консольных запусков. -- `issues/16-fresh-stand-generator-control.md` — свежий стенд и Airflow-пульт - без неявных ручных шагов. -- `issues/17-trusted-checks-startup-history-superset.md` — доверенные проверки - startup-history и Superset export. -- `issues/18-course-clean-stand-startup-history.md` — курс на чистом стенде по - startup-history. - -```mermaid -flowchart LR - i07["07 артефакт + runbook"] --> i11["11 глаголы запуска"] - i11 --> i12["12 DAG-пульт"] - i07 --> i08["08 миграция курса"] - i12 -. "приёмка через пульт" .-> i08 - i14["14 быстрый профиль"] --> i08 - i09["09 фикс стыка"] --> i13["13 доливка"] - i10["10 гео-карта"] - i15["15 граница миров"] --> i18["18 курс на чистом стенде"] - i16["16 свежий стенд"] --> i18 - i17["17 доверенные проверки"] --> i18 - i15 --> i13 - i17 --> i13 - i19["19 make test/lint"] -``` - -## Контрольные точки - -- После задачи 3 нужен внешний review gate по сквозному инварианту времени: - проверить, где ещё остались настенные часы, и не расходятся ли живой путь, - расчёт интенсивности и сохранение состояния. -- После задачи 5 нужен внешний review gate по распределениям и двум путям - генерации: проверить форму данных, стык истории и живого продолжения, - однородность визита до и после восстановления. - -Для review gate после задачи 3 reviewer другой родословной сильно желателен. -Для review gate после задачи 5 он обязателен: research показал, что форма -распределений и свойства на стыке двух путей хуже ловятся одной линией проверки. -Если такого reviewer-а нет, координатор останавливает цепочку и явно отдаёт -решение человеку. - -## Финальная ручная приёмка - -После задачи 6 человек смотрит Superset/Grafana и проверяет, что стенд живёт на -генерации: видны история, возвраты, воронка и суточное «дыхание». Если текущих -панелей не хватает для такого просмотра, создаётся отдельная задача на панель -или runbook, а не расширяется эта цепочка задним числом. - -Миграция уроков по ADR-0006 сознательно отслеживается отдельно. Задача 6 должна -создать follow-up, если учебные материалы требуют нетривиальной переделки. diff --git a/.scratch/generator-model-time-startup-history/coordinator-journal.md b/.scratch/generator-model-time-startup-history/coordinator-journal.md deleted file mode 100644 index 0af865e..0000000 --- a/.scratch/generator-model-time-startup-history/coordinator-journal.md +++ /dev/null @@ -1,577 +0,0 @@ -# Журнал coordinator-loop: generator-model-time-startup-history - -Дата старта: 2026-07-04. - -## 2026-07-04 - -- Старт цепочки: 07 -> 11. Дерево перед стартом чистое (`git status --short` - без вывода). Блокеры 07 (`05`, `06`) имеют `Status: done`. -- Issue 07 запущен worker-у. Режим ревью: гейт; требуется проверка по задаче и - по коду разными субагентами через `claude-team-review`. -- Issue 07: принят индекс worker-а со статусом `BLOCKED`. Unit: `uv run - --with-requirements generator/requirements.txt pytest generator/tests -q` — - PASS, 139 passed. Shell syntax: PASS. `git diff --check`: PASS. Стендовая - Docker-проверка импорта: BLOCKED, `docker` отсутствует в среде worker-а. - Review origin от worker-а: оба reviewer-а не созданы из-за sandbox/app-server - blocker. Chain-level решение: не коммитить, добрать два reviewer-индекса - отдельными субагентами координатора. -- Issue 07 review по задаче: `CHANGES_REQUESTED`. Finding: проверка после - импорта не доказывает совпадение ClickHouse с manifest/original; текущий - `generated-history-check` проверяет здоровье диапазона, но не читает - артефакт или `generator_startup_history_manifest`. Возвращено worker-у как - обязательное исправление issue 07. -- Issue 07 review по коду: `CHANGES_REQUESTED`. Findings: `state_version` из - manifest не участвует в live-восстановлении startup-history state; импорт - может задублировать data-топики при повторном или частично упавшем запуске. - Возвращено worker-у: первое чинить, второе чинить или явно классифицировать - как вне скоупа с защитой контракта clean-stand import. -- Issue 07: worker вернул `DONE` после исправлений. Unit: PASS, 142 passed. - Docker export/import/check: PASS; manifest-aware сверка ClickHouse с manifest: - PASS, `events=16054`, `visits=1516`, `users=445`. Повторный импорт на - непустых data-топиках: PASS, отказ до публикации дублей. `git diff --check`: - PASS. Chain-level решение: повторить review gate на обновлённом diff. -- Issue 07 rerun review по задаче: `APPROVED`. -- Issue 07 rerun review по коду: `CHANGES_REQUESTED`. Findings: импорт не - защищён от частичной записи data-топиков до compact state/manifest; артефакт - может не воспроизводить raw Kafka value байт-в-байт из-за повторной - сериализации dict. Возвращено worker-у как обязательная доработка или явное - уточнение контракта с тестами. -- Issue 07: worker вернул финальный индекс `DONE_WITH_CONCERNS`. Исправления: - preflight clean Kafka data-топиков, rollback import-топиков при ошибке, - raw JSON value в артефакте и публикация bytes без повторной сериализации. - Unit: PASS, 143 passed. Docker export/import/check: PASS. Raw Kafka value - compare: PASS, SHA совпал, `cmp=0`. Chain-level concern: после последних - исправлений code rerun ещё не выполнен. -- Issue 07 third code gate: `CHANGES_REQUESTED`. Finding: raw Kafka value в - артефакте всё ещё, по мнению reviewer-а, строится повторной сериализацией dict - и валидируется против той же сериализации, а не доказывает связь с фактически - опубликованными Kafka bytes. Возвращено worker-у для исправления или - аргументированного отклонения через контракт и тест. -- Issue 07 xhigh narrow code gate: `CHANGES_REQUESTED`. Findings: `raw_topics` - остаётся необязательным, поэтому артефакт без raw bytes может пройти import с - повторной сериализацией; fallback импорта через publisher без `publish_records` - игнорирует сохранённые raw records. Возвращается worker-у как обязательная - доработка. -- Issue 07: worker вернул `DONE_WITH_CONCERNS` после xhigh-находок. Исправлено: - для artifact v1.0 `raw_topics` обязательны; importer требует `publish_records` - и публикует только raw records. Unit: PASS, 146 passed. Chain-level concern: - xhigh code rerun после последней правки ещё не выполнен. -- Issue 07 xhigh code rerun: `APPROVED`. Findings: нет. Unit: PASS, 146 - passed. Shell syntax: PASS. `git diff --check`: PASS. Chain-level решение: - открыть коммит-гейт 07. -- Issue 07 commit gate: staged files в границах issue 07, журнал не staged. - Unit: PASS, 146 passed. Shell syntax: PASS. `git diff --cached --check`: - PASS. Commit: `3c38465` (`feat(generator): добавлен артефакт стартовой - истории`). -- Перед issue 11: процессный журнал issue 07 отделён docs-коммитом `d2899e9`. - Дерево чистое. Блокер 11 (`07`) имеет `Status: done`. -- Issue 11 запущен worker-у. Режим ревью: обычный; требуется один code review - через `claude-team-review` после реализации. Условие эскалации: если worker - меняет state-схему, сериализацию или контракт manifest, перевести в гейт. -- Issue 11: worker вернул `BLOCKED` только из-за отсутствия reviewer mechanism. - Unit: PASS, 153 passed. Shell syntax: PASS. `git diff --check`: PASS. Dry-run - `PROFILE=daily-wave ... run_generator.sh backfill`: PASS. Chain-level - решение: добрать code review отдельным reviewer-субагентом координатора. -- Issue 11 code review: `CHANGES_REQUESTED`. Finding: профиль запуска виден в - логах, но не пишется в manifest/артефакт; критерий issue 11 требует, чтобы - выбранный профиль был виден в логах/manifest. Возвращено worker-у как - обязательное исправление. -- Issue 11: worker вернул `DONE` после исправления. `launch_profile` добавлен - в manifest/артефакт, state-схема не менялась. Unit: PASS, 154 passed. - `git diff --check`: PASS. Chain-level решение: повторить code review. -- Issue 11 code rerun: `APPROVED`. Findings: нет. `make generator-test`: PASS, - 154 tests. Shell syntax: PASS. `git diff --check`: PASS. Dry-run - `backfill`/`continue`/`reset` с `PROFILE=daily-wave`: PASS. Chain-level - решение: открыть коммит-гейт 11. -- Issue 11 commit gate: Unit: PASS, 154 passed. Shell syntax: PASS. - `git diff --check`: PASS. Dry-run `PROFILE=daily-wave ... run_generator.sh - backfill`: PASS после повторного запуска вне sandbox из-за `snap-confine` - внутри sandbox. -- Issue 11 commit: `4f8f992` (`feat(generator): добавлены профили запуска`). -- Диагностика nested review tools: read-only субагент не видит - `multi_agent_v1.spawn_agent`; `tool_search` по `multi-agent spawn subagent`, - `claude-team-review`, `reviewer` не находит инструменты для запуска - субагентов. Process finding: в текущей среде worker не может сам запускать - reviewer-а; координатор должен запускать reviewer-субагентов сам и возвращать - worker-у только индекс находок. -- Final chain review запущен read-only reviewer-у в режиме `xhigh`. -- Final chain review: `CHANGES_REQUESTED`, low finding — PRD всё ещё держит - 07/11 в остатке и объясняет статус 12 отсутствием глаголов. Исправлено: - 07/11 перенесены в сделанное, остаток основной ветки начинается с 12. -- Уточнение диагностики nested review tools: первый диагност был read-only - explorer, что не доказывает toolset worker-а. Запущен отдельный диагност с - `agent_type=worker`, без правок, для проверки той же гипотезы в роли worker-а. -- Worker-диагност подтвердил: видит `web`, `image_gen`, `functions`, - `tool_search`, `multi_tool_use`; `tool_search` не находит multi-agent spawn - или `claude-team-review`; `multi_agent_v1.spawn_agent` недоступен. Вывод: - worker не может сам запускать reviewer-субагентов в этой среде; процесс нужно - лечить переносом запуска review-субагентов на координатора. -- 20:44 Новая очередь стартовала: 12 -> 14 -> 09 -> 10 -> 08. Дерево перед - стартом чистое (`git status --short` без вывода). Issue 12 запущен worker-у. - Режим ревью: обычный, один code-review от координатора после реализации. - Условие эскалации: если worker меняет state-схему, сериализацию, - межмодульный контракт manifest или нестабильный Airflow API без проверки - Context7, перевести issue в гейт. -- 20:55 Issue 12: worker вернул `DONE_WITH_CONCERNS`. Unit: PASS, 165 tests; - targeted DAG/control tests: PASS, 11 tests; `py_compile`: PASS; - `docker compose config --quiet`: PASS. Context7 Airflow 2.10.5: `Param` - enum/title/description и `TriggerDagRunOperator(wait_for_completion=True)`. - Concern: живой проход через Airflow UI не выполнен; готовая команда стенда - дана worker-ом. Chain-level решение: открыть обычный code-review. -- 20:55 Issue 12 code review запущен координатором: свежий observer-субагент, - рабочий уровень, без правок репозитория и среды. -- 20:59 Issue 12 code review: `CHANGES_REQUESTED`. Finding: - `startup_history_artifact.py:239` — backfill/import на непустом Kafka data - topic падает без подсказки `make clean`. Возвращено worker-у дословно. -- 21:00 Issue 12: worker вернул `DONE_WITH_CONCERNS` после ревью-находки. - Исправлено: `airflow_control.py:102` оборачивает ошибку непустых Kafka - data-топиков подсказкой `make clean`; добавлен регрессионный тест. Targeted - tests: PASS, 12 tests; unit: PASS, 166 tests; `py_compile`: PASS. -- 21:00 Process update: перечитан `references/effort-levels.md` после правки - пользователем. Для Codex CLI обычный worker на обычном issue = экономный - `medium`; любое code review и worker на гейтовом issue = рабочий `high`; - узкий спор/финальное chain review = усиленный `xhigh`. Дальнейшие запуски — - с явным уровнем по таблице. -- 21:00 Issue 12 code rerun запущен координатором: свежий observer-субагент, - рабочий уровень `high`, без правок репозитория и среды. -- 21:04 Issue 12 code rerun: `APPROVED`. Targeted tests: PASS; generator - tests: PASS. Chain-level решение: открыть коммит-гейт 12. -- 21:06 Issue 12 commit gate: staged files в границах issue 12, журнал не - staged. Unit: PASS, 166 tests; `docker compose config --quiet`: PASS; - `py_compile`: PASS; `git diff --cached --check`: PASS. Commit: `dd4af82` - (`feat(airflow): добавлен пульт управления генератором`). -- 21:07 Перед issue 14: процессный журнал issue 12 отделён docs-коммитом - `200bb82`. Дерево чистое. Блокеров нет; issue 12 имеет `Status: done`. - Issue 14 запущен worker-у. Режим ревью: обычный; worker — `medium`, code - review после реализации — `high`. Условие эскалации: изменение частоты - сохранения state, state-схемы или контракта восстановления переводит issue в - гейт. -- 21:12 Issue 14: worker вернул `DONE_WITH_CONCERNS`. `daily-wave` переведён - на `speed=60`, `tick=1`; `ci` не изменён; успешные live-тик логи приглушены - до DEBUG; state-сохранение не менялось. `make generator-test`: PASS, 167 - tests. Concern: стендовая backfill/import/live-проверка и дашборд не - запускались. -- 21:12 Issue 14 code review запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 21:16 Issue 14 code review: `APPROVED`. Findings: нет. Reviewer checks: - profile values PASS, DAG enum from `PROFILES` PASS, peak budget below cap - PASS. `pytest` без `--with pytest` у reviewer-а не стартовал. -- 21:17 Issue 14 commit gate: staged files в границах issue 14, журнал не - staged. `make generator-test`: PASS, 167 tests; `py_compile`: PASS; - `git diff --cached --check`: PASS. Commit: `9d1bcc4` - (`feat(generator): ускорен учебный профиль daily-wave`). -- 21:18 Перед issue 09: процессный журнал issue 14 отделён docs-коммитом - `0cbfe9b`. Дерево чистое. Блокеров нет. Issue 09 запущен worker-у. Режим - ревью: гейт; worker reasoning_effort=`high`; после реализации требуются два - ревью-субагента от координатора: task review `high` и code review `high`. -- 21:33 Issue 09: worker вернул `DONE_WITH_CONCERNS`. State поднят до v3, - `base_click_id` сериализуется и валидируется; неизвестный донор — ошибка; - добавлены unit-тесты восстановления фактуры и повторяемая стыковая проверка - `CHECK_LIVE_SEAM=1`. Unit: PASS, 171 tests; `bash -n` seam script: PASS; - `git diff --check`: PASS. Concern: стендовая ClickHouse-проверка 2d - backfill + live не запускалась. -- 21:33 Issue 09 task review запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 21:33 Issue 09 code review запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 21:38 Issue 09 task review: `CHANGES_REQUESTED`. Finding: - `scripts/check_generated_analytics.sh:315` — device/os/geo проверяются через - схлопнутый `dds.click`, а критерий требует ODS-уровень; нужен `uniqExact` по - `ods.device_by_click` и `ods.geo_by_click` для переходящих `click_id`. -- 21:38 Issue 09 code review: `CHANGES_REQUESTED`. Findings: - `runtime.py:133` — микросекунды offset считаются через float и теряют 1 мкс, - тест скрывает `event_timestamp`; `Makefile:48` — обычный - `make generated-history-check` не включает seam-блок и может быть ложнозелёным. - Индексы обоих ревью возвращены worker-у дословно. -- 21:41 Issue 09: worker вернул `DONE_WITH_CONCERNS` после гейт-находок. - Исправлено: целочисленный offset микросекунд, тест снова сравнивает - `event_timestamp`, seam check auto включается после live-строк, ODS-проверка - device/os/geo добавлена, `make generated-history-check` использует auto. - Unit: PASS, 172 tests; `bash -n`: PASS; `git diff --check`: PASS. -- 21:41 Issue 09 task rerun запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 21:41 Issue 09 code rerun запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 21:45 Issue 09 code rerun: `APPROVED`. Findings: нет. Checks: timestamp - offset без float PASS; тест исключает только `event_id` PASS; seam auto и - ODS checks PASS; unknown donor error PASS; state v3 validation PASS. -- 21:45 Issue 09 task rerun: `APPROVED`. Findings: нет. Checks: all fields - except `event_id` PASS; browser/source + ODS device/os/geo seam check PASS; - `make generated-history-check` auto seam PASS; state v3 validation PASS. - Chain-level решение: открыть коммит-гейт 09. -- 21:46 Issue 09 commit gate: staged files в границах issue 09, журнал не - staged. Unit: PASS, 172 tests; `bash -n scripts/check_generated_analytics.sh`: - PASS; `git diff --cached --check`: PASS. Commit: `e2d0684` - (`fix(generator): сохранена фактура визита при восстановлении`). -- 21:47 Перед issue 10: процессный журнал issue 09 отделён docs-коммитом - `0f435d7`. Дерево чистое. Блокеров нет. Issue 10 запущен worker-у. Режим - ревью: обычный; worker reasoning_effort=`medium`; code review после - реализации — `high`. Требование: проверить Superset-визуализации через - Context7 и зафиксировать выбор. -- 21:52 Issue 10: worker вернул `DONE_WITH_CONCERNS`. `world_map` заменён на - `echarts_timeseries_bar` top-N стран; экспорт Superset, docs и урок 06 - обновлены; контрактный тест добавлен. Context7 `/apache/superset`: `world_map` - legacy, ECharts bar поддерживает legend/tooltip/axis labels. Tests: PASS - dashboard config; `py_compile`: PASS; `jq`: PASS. Concern: скриншоты не - сняты, стенд остановлен. -- 21:52 Issue 10 code review запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 21:55 Issue 10 code review: `CHANGES_REQUESTED`. Findings: Superset export - `position_json` и `charts[].slice_name` не согласованы с `create_dashboard.py`; - тест проверяет только гео-чарт и может быть ложнозелёным; `previous_slice_names` - может оставить дубль старого и нового гео-чарта. Индекс возвращён worker-у. -- 21:58 Issue 10: worker вернул `DONE_WITH_CONCERNS` после ревью-находок. - Исправлено: export `position_json` и chart list синхронизированы с кодом, - добавлены тесты export/layout, rename удаляет старый дубль. Targeted tests: - PASS; `py_compile`: PASS; `jq`: PASS. Concern: визуальная приёмка не - выполнена, Superset не запущен. -- 21:58 Issue 10 code rerun запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 22:01 Issue 10 code rerun: `CHANGES_REQUESTED`. Finding: - `tests/test_superset_dashboard_config.py:1` — регрессионный тест лежит в - untracked `tests/`, рядом с `__pycache__`; тест должен попасть в изменение, - сгенерированный кэш — нет. Остальные проверки reviewer-а: export/layout, - chart list, rename dedupe, ECharts tooltip/legend/units, docs и неизменность - гео-фактуры — PASS. Индекс возвращён worker-у. -- 22:02 Issue 10: worker вернул `DONE_WITH_CONCERNS` после узкой находки. - `tests/__pycache__/` удалён; `git status --short --untracked-files=all tests` - показывает только `tests/test_superset_dashboard_config.py`. Targeted tests: - PASS; `py_compile`: PASS; `jq`: PASS. Concern: визуальная приёмка не - выполнена. -- 22:02 Issue 10 narrow code rerun запущен координатором: свежий - observer-субагент, reasoning_effort=`high`, без правок репозитория и среды. -- 22:05 Issue 10 narrow code rerun: `APPROVED`. Findings: нет. Checks: - статус `tests/` PASS; chart list/layout test content PASS; direct test - functions PASS. Chain-level решение: открыть коммит-гейт 10. -- 22:06 Issue 10 commit gate: staged files в границах issue 10, журнал не - staged. Dashboard config tests: PASS, 5 tests; `py_compile`: PASS; `jq`: - PASS; `git diff --cached --check`: PASS. Commit: `0c80e24` - (`fix(superset): заменена нечитаемая гео-карта`). -- 22:06 Перед issue 08: процессный журнал issue 10 отделён docs-коммитом - `de938ed`. Дерево чистое. Предпосылки 07, 12, 14, 09 выполнены. Issue 08 - запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`; - code review после реализации — `high`. HITL-проход по урокам через пульт - ожидается как concern, если не выполнен фактически. -- 22:14 Issue 08: worker вернул `DONE_WITH_CONCERNS`. Курс и TEST_PLAN - переведены с архивного сида на стартовую историю; `data/*.jsonl` оставлены - как кладовка значений генератора; старые маркеры `make data`/`kafka_load`/ - `LIMIT=`/фиксированные сид-ожидания не найдены. `git diff --check`: PASS. - Concern: нужен ручной проход уроков 00-06 через UI и оценка Superset/Grafana. -- 22:14 Issue 08 code review запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 22:17 Issue 08 code review: `CHANGES_REQUESTED`. Findings: - `docs/course/LESSON_STANDARD.md:24` — стандарт уроков всё ещё ведёт через - старый `make clean/up/ddl/data/transform` путь; `lessons/06_superset_bi.md:76` - говорит, что `generated-history-analytics` создаёт dashboard, но урок ниже - отдельно запускает `make superset-dashboard`. Индекс возвращён worker-у. -- 22:19 Issue 08: worker вернул `DONE` после ревью-находок. Исправлено: - старый путь отката в lesson standard заменён на - `make generated-history-analytics && make up`; урок 06 уточняет роль - `superset-init` и ручной `superset-dashboard`. Поиск старых маркеров: PASS; - `git diff --check`: PASS. -- 22:19 Issue 08 code rerun запущен координатором: свежий observer-субагент, - reasoning_effort=`high`, без правок репозитория и среды. -- 22:21 Issue 08 code rerun: `APPROVED`. Findings: нет. Checks: старые - команды и архивный сид как основной путь отсутствуют PASS; Superset запуск - согласован PASS; `data/*.jsonl` как кладовка значений PASS; TEST_PLAN и HITL - PASS. Chain-level решение: открыть коммит-гейт 08. -- 22:22 Issue 08 commit gate: staged files в границах issue 08, журнал не - staged. Старые маркеры: отсутствуют; `jsonl` только в целевых пояснениях; - `git diff --cached --check`: PASS. Commit: `d76c036` - (`docs(course): переведены уроки на стартовую историю`). -- 22:23 Финальное chain review запущено координатором: свежий - observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды; - цель — проверить всю цепочку 12 -> 14 -> 09 -> 10 -> 08 против PRD и issue. -- 22:31 Финальное chain review: `CHANGES_REQUESTED`. Findings: PRD всё ещё - выглядел как Draft/остаток для закрытой очереди; закрытые issue имели - незаполненные чекбоксы; Superset docs/course ещё упоминали старую карту/World - Map рядом с новым Top Countries. Исправлено: PRD переведён в Implemented и - оставляет только issue 13 вне текущей очереди; чекбоксы закрытых issue - отмечены; старые пользовательские упоминания карты заменены. Reviewer - repro-поиски: PASS. -- 22:31 Финальное chain review rerun запущено координатором: свежий - observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды. -- 22:35 Финальное chain review rerun: `CHANGES_REQUESTED`. Finding: - `docs/SUPERSET_DASHBOARD.md:70` — рядом с Top Countries оставался старый - маркер `legacy world map`. Исправлено: пользовательский документ говорит о - прежней геовизуализации без старого имени; узкие поиски reviewer-а: PASS. -- 22:35 Финальное chain review second rerun запущено координатором: свежий - observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды. -- 22:39 Финальное chain review second rerun: `CHANGES_REQUESTED`. Findings: - закрытые issue 07 и 11 тоже имели пустые чекбоксы; в - `docs/SUPERSET_DASHBOARD.md` оставался маркер `world_map`. Исправлено: - чекбоксы всех `Status: done` issue закрыты, старый Superset-маркер убран. - Reviewer repro-поиски: PASS. -- 22:39 Финальное chain review third rerun запущено координатором: свежий - observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды. -- 22:42 Финальное chain review third rerun: `CHANGES_REQUESTED`. Finding: - `superset/create_dashboard.py:152` — в комментарии остался старый маркер - `Legacy world_map`. Исправлено нейтральным описанием прежней - геовизуализации. Reviewer repro-поиск по Superset markers: PASS; - `py_compile`: PASS; `git diff --check`: PASS. -- 22:42 Финальное chain review fourth rerun запущено координатором: свежий - observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды. -- 22:54 Финальное chain review fourth rerun: `CHANGES_REQUESTED`. Findings: - `docs/OPERATIONS.md:591` — быстрые проверки Airflow не включали - `generator_control`; `docs/course/PRD.md:99` — режим курса всё ещё сводился к - `make up`. Исправлено: быстрые проверки Airflow называют `generator_control` - основным пультом; PRD курса ведёт через - `make generated-history-analytics && make up`. Минимальные проверки: PASS. -- 22:54 Финальное chain review fifth rerun запущено координатором: свежий - observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды. -- 22:59 Финальное chain review fifth rerun: `CHANGES_REQUESTED`. Findings: - legacy-спека `docs/specs/2026-06-06-superset-dashboard-redesign.md` всё ещё - содержала старые Superset-маркеры `world_map`. Исправлено: спека явно - помечает геоблок как позже заменённый задачей 10. Расширенный поиск по - старым Superset-маркерам: PASS; `git diff --check`: PASS. -- 22:59 Финальное chain review sixth rerun запущено координатором: свежий - observer-субагент, reasoning_effort=`xhigh`, без правок репозитория и среды. -- 23:03 Финальное chain review sixth rerun: `CHANGES_REQUESTED`. Findings: - `docs/ARCHITECTURE.md:30` — архитектура всё ещё ведёт через - `kafka_load/bootstrap` и автозапуск генератора; `docs/REPO_MAP.md:7` — карта - репозитория не упоминает `generator_control_dag.py` в ручном/учебном пути; - `CONTEXT.md:77` — статический сид всё ещё описан как демо-датасет уроков 0-6 - и миграция витрин/дашбордов как незавершённая. -- 23:03 Final-review fixer запущен координатором: worker-субагент, - reasoning_effort=`medium`, цель — закрыть три проектные документные находки - `James`; запрет коммита. -- 23:06 Final-review fixer вернул `DONE`. Исправлено: `docs/ARCHITECTURE.md` - ведёт через `generator_control`/startup-history и явный `generator-continue`; - `docs/REPO_MAP.md` упоминает `generator_control_dag.py`; `CONTEXT.md` - описывает `data/*.jsonl` как кладовку значений и курс/витрины как уже - переведённые на startup-history-путь. Fixer checks: PASS. -- 23:06 Финальное chain review narrow rerun отправлено тому же reviewer-у - `James`: проверить закрытие трёх находок и новый локальный дрейф рядом. -- 23:08 Финальное chain review narrow rerun: `APPROVED`. Checks: - `docs/ARCHITECTURE.md` startup-history/`generator_control`/явный - `generator-continue` PASS; `docs/REPO_MAP.md` Airflow path PASS; - `CONTEXT.md` кладовка `jsonl` и завершённая миграция курса PASS; старые - drift-маркеры рядом отсутствуют PASS. Chain-level решение: финальный гейт - закрыт, собрать handoff. - -## 2026-07-05: coordinator-loop 15-18 - -- 19:59 Старт цепочки 15 -> 16 -> 17 -> 18. `git status --short`: clean. - Issue 13 исключён из текущей цепочки: `needs-triage`, заблокирован 15 и 17. - Issue 15 запущен worker-у. Режим ревью: гейт; worker - reasoning_effort=`high`; task review `high`, code review `high`. -- 20:09 Issue 15: worker вернул `DONE`. Индекс: state v2 теперь даёт - миграционный отказ при `GEN_STATE_RESET=false`; `down`/`clean` охватывают - compose-профиль `live-generator`; общая host/DAG-проверка чистого стенда - подключена к backfill/import/continue; docs описывают миграционный отказ. - Checks: `make generator-test` PASS; `docker compose config --quiet` PASS; - `py_compile` PASS; `bash -n` PASS; точечный `uv run pytest ...` FAIL - из-за отсутствия `pytest` в uv-окружении. -- 20:10 Issue 15 task review запущен координатором: свежий observer-субагент - `Ptolemy`, reasoning_effort=`high`, без правок репозитория и среды. -- 20:10 Issue 15 code review запущен координатором: свежий observer-субагент - `Linnaeus`, reasoning_effort=`high`, без правок репозитория и среды. -- 20:13 Issue 15 code review `Linnaeus`: `CHANGES_REQUESTED`. Findings: - host backfill/import guard не проверяет уже живой live-generator; - `generated-history-analytics` при clean start делает `down` без профиля - `live-generator`. -- 20:13 Issue 15 task review `Ptolemy`: `CHANGES_REQUESTED`. Finding: - `generator-continue` не защищён на грязном стенде без совместимого state. - Индексы ревью возвращены worker-у дословно. -- 20:17 Issue 15: worker вернул `DONE` после находок ревью. Исправлено: - отдельный guard для `continue`; host metrics check перед backfill/import; - `generated-history-analytics` clean start использует профиль `live-generator`. - Checks: `make generator-test` PASS; `docker compose config --quiet` PASS; - `py_compile` PASS; `bash -n` PASS. Отклонений и споров нет. -- 20:17 Issue 15 task narrow rerun отправлен `Ptolemy`: проверить закрытие - находки по `generator-continue`. -- 20:17 Issue 15 code narrow rerun отправлен `Linnaeus`: проверить закрытие - находок по host live-generator guard и `generated-history-analytics` clean. -- 20:19 Issue 15 task narrow rerun `Ptolemy`: `APPROVED`. Findings: нет. -- 20:19 Issue 15 code narrow rerun `Linnaeus`: `APPROVED`. Findings: нет. - Chain-level решение: открыть коммит-гейт 15. -- 20:20 Issue 15 commit gate: `make test` и `make lint` отсутствуют в - Makefile; фактический гейт: `make generator-test` PASS (181 tests); - `docker compose config --quiet` PASS; `py_compile` PASS; `bash -n` PASS; - `git diff --check` PASS. Diff в границах issue 15; журнал не staged. -- 20:20 Issue 15 рабочий коммит: `080d5ae` - (`fix(generator): исключено смешение миров при запуске`). - -- 20:22 Перед issue 16: дерево clean; dependency issue 15 закрыта. Issue 16 - запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`; - code review после реализации `high`. Условие эскалации: широкая переделка - DAG-зависимостей вместо предпроверки паузы переводит issue в гейт. -- 20:28 Issue 16: worker вернул `DONE_WITH_CONCERNS`. Индекс: README/course - называют `uv` и пререквизиты; quick start добавляет `make ddl`; - `make up` пересобирает Airflow; `generator_control` проверяет pause - `etl_pipeline` перед `TriggerDagRunOperator`; решение Airflow 2.10.5 - проверено через Context7 и зафиксировано рядом с выбором API; runbook - предупреждает про дыру `daily-wave` x60. Checks: red phase PASS; generator - tests PASS (186); DAG contract PASS (16); compose config PASS. Concern: - полный clean-stand проход не выполнен. -- 20:28 Issue 16 code review запущен координатором: свежий observer-субагент - `Laplace`, reasoning_effort=`high`, без правок репозитория и среды. -- 20:30 Issue 16 code review `Laplace`: `CHANGES_REQUESTED`. Finding: - pause-check `etl_pipeline` стоит после `run_backfill`, поэтому отказ - происходит уже после записи истории и повторный backfill упирается в - clean-stand guard. Индекс возвращён worker-у дословно. -- 20:33 Обновлённый `coordinator-loop` перечитан по просьбе пользователя. - Процессный вывод по тестам уточнён: шумные команды коммит-гейта дальше - идут через файл-лог, в контекст координатора попадает только сводка. -- 20:33 Issue 16: worker вернул `DONE_WITH_CONCERNS` после code review. - Исправлено: paused-check вынесен перед `precheck`/`run` для backfill/import; - контрактный тест закрепляет порядок branch -> paused-check -> precheck -> - run -> trigger ETL. Checks: DAG contract PASS; generator tests PASS (186); - compose config PASS. Concern: полный Airflow UI clean-stand сценарий не - выполнен. -- 20:33 Issue 16 code narrow rerun отправлен `Laplace`: проверить закрытие - high-находки по порядку pause-check и отсутствие нового рядом. -- 20:34 Issue 16 code narrow rerun `Laplace`: `APPROVED`. Findings: нет. - Chain-level решение: открыть коммит-гейт 16. -- 20:38 Issue 16 commit gate: `uv run ... pytest` не дошёл до pytest из-за - `snap-confine`/`cap_dac_override` в окружении координатора; log analyst - подтвердил ошибку запуска, не тестов. `make generator-test` через Docker: - PASS (186 tests) после запуска вне sandbox. `docker compose config --quiet` - PASS; `py_compile` PASS; `git diff --check` PASS. Clean-stand probe: - `make clean` PASS, `make up` FAIL на `superset-init`; log analyst finding: - `superset-init` падает с `NoSuchTableError: dm.v_events_enriched` до шага - `make ddl`. Finding возвращён worker-у как failed commit gate. -- 20:42 Issue 16: worker вернул `DONE_WITH_CONCERNS` после failed commit - gate. Исправлено: `make up` поднимает базовый набор без `superset`/ - `superset-init`, quick start доходит до `make ddl`; README/OPERATIONS - фиксируют, что Superset-дэшборд собирается позже, когда DM готов; контрактный - тест ловит порядок. Checks worker-а: DAG contract PASS; compose config PASS; - `py_compile` PASS; `git diff --check` PASS; `make generator-test` PASS - (186); `make up` PASS; `make ddl` PASS. Concern: базовые контейнеры - остались запущены после проверки. -- 20:42 Issue 16 code narrow rerun отправлен `Laplace`: проверить новый - контракт `make up` без раннего `superset-init` и соседний quick start drift. -- 20:44 Issue 16 code narrow rerun `Laplace`: `APPROVED`. Findings: нет. - Chain-level решение: повторить коммит-гейт 16 после commit-gate фикса. -- 20:46 Issue 16 final commit gate: `make generator-test` PASS (186 tests); - `docker compose config --quiet` PASS; `py_compile` PASS; `git diff --check` - PASS; `make clean` PASS; `make up` PASS; `make ddl` PASS. Diff в границах - issue 16; журнал не staged. Стенд после проверки поднят в базовом состоянии - с применённым DDL. -- 20:46 Issue 16 рабочий коммит: `27947ce` - (`fix(airflow): исправлен запуск пульта на свежем стенде`). - -- 20:47 Перед issue 17: дерево clean; issue 15 и 16 закрыты. Issue 17 - запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`; - code review после реализации `high`. -- 20:54 Issue 17: worker вернул `DONE_WITH_CONCERNS`. Индекс: - `check_generated_analytics.sh` берёт `model_t0/model_t_end/profile` из - фактического Kafka manifest, ждёт live-строки перед seam-check и требует - ненулевые ODS device/geo; `generated-history-check` пробрасывает PROFILE; - добавлен CLI `kafka-manifest-summary`; Superset tests сравнивают полный - существенный `params`; top countries сортируется по `Events, pcs`; sync - удаляет дубли только в целевом датасете; деструктивность sync задокументирована. - Checks: generated analytics contract PASS; Superset config PASS; startup - history artifact PASS; `py_compile` PASS; `bash -n` PASS; `git diff --check` - PASS. Concern: полный `PROFILE=daily-wave CHECK_LIVE_SEAM=1 make - generated-history-check` не выполнен. -- 20:55 Issue 17 code review запущен координатором: свежий observer-субагент - `Bacon`, reasoning_effort=`high`, без правок репозитория и среды. -- 20:58 Issue 17 code review `Bacon`: `CHANGES_REQUESTED`. Findings: - Superset sync всё ещё может перезаписать/удалить одноимённый чарт вне - целевого dashboard при том же датасете; `CHECK_LIVE_SEAM=auto` может зелёно - пропустить попытку live без строк после стыка. Индекс возвращён worker-у - дословно. -- 21:02 Issue 17: worker вернул `DONE_WITH_CONCERNS` после code review. - Исправлено: Superset sync/delete ограничены чартами целевого dashboard; - обычный `generated-history-check` требует live-строки, а backfill-only путь - явно отключает seam-check. Checks: generated analytics + Superset tests PASS; - startup history artifact PASS; `bash -n` PASS; `py_compile` PASS; - `git diff --check` PASS. Отклонений и споров нет; полный runtime-прогон - ещё не выполнен. -- 21:02 Issue 17 code narrow rerun отправлен `Bacon`: проверить закрытие - находок по Superset sync ownership и live seam default. -- 21:03 Issue 17 code narrow rerun `Bacon`: `APPROVED`. Findings: нет. - Chain-level решение: открыть коммит-гейт 17. -- 21:19 Issue 17 commit gate partial: локальный `uv` не дошёл до pytest из-за - `snap-confine`/`cap_dac_override`; Docker target tests PASS (12); - `make generator-test` PASS (186); `py_compile` PASS; `bash -n` PASS; - `git diff --check` PASS. Runtime prep `PROFILE=daily-wave make - generated-history-analytics` был остановлен после долгого выполнения: - generator-run и ClickHouse были активны, но gate не завершался за разумное - время; после SIGINT running services только `clickhouse`, `kafka`. Finding - возвращён worker-у: нужен точный bounded runtime-путь или изменение проверки. -- 21:27 Issue 17: worker вернул `DONE` после runtime gate finding. - Добавлен bounded `make generated-history-runtime-check`: короткий daily-wave - backfill 1h, live через Compose env, ожидание STG growth до 25s, второй batch - и `CHECK_LIVE_SEAM=1 REQUIRE_SUPERSET=0`. Контрактный тест и OPERATIONS - обновлены. Checks worker-а: Docker target tests PASS (13); `bash -n` PASS; - `py_compile` PASS; `git diff --check` PASS; `make - generated-history-runtime-check` PASS. Ключевые факты runtime: - profile=`daily-wave`, `model_t_end=2026-01-01T01:00:00+00:00`, - STG 10256 -> 10975, crossing visits 19, ODS device/geo 19/19. Concern: - runtime gate чистит volumes и оставляет `clickhouse`, `kafka` running. -- 21:28 Issue 17 code narrow rerun отправлен `Bacon`: проверить bounded - runtime gate и отсутствие нового рядом с уже одобренными исправлениями. -- 21:29 Issue 17 code narrow rerun `Bacon`: `APPROVED`. Findings: нет. - Chain-level решение: повторить коммит-гейт 17 с bounded runtime gate. -- 21:32 Issue 17 final commit gate: Docker target tests PASS (13); - `make generator-test` PASS (186); `py_compile` PASS; `bash -n` PASS; - `git diff --check` PASS; `make generated-history-runtime-check` PASS. - Runtime facts: STG 10256 -> 10862, `manifest_launch_profile=daily-wave`, - `manifest_model_t_end=2026-01-01T01:00:00+00:00`, crossing visits 19, - ODS device/geo 19/19. Diff в границах issue 17; журнал не staged. Runtime - gate оставил `clickhouse`, `kafka` running. -- 21:32 Issue 17 рабочий коммит: `00c97eb` - (`test(generator): усилены проверки startup-history и Superset`). - -- 21:33 Перед issue 18: дерево clean; dependencies 15, 16, 17 закрыты. Issue - 18 запущен worker-у. Режим ревью: обычный; worker reasoning_effort=`medium`; - code review/docs review после реализации `high`. Примечание: runtime gate 17 - оставил `clickhouse`, `kafka` running; worker должен сам очистить или - подготовить стенд перед проверкой курса. -- 21:51 Issue 18: worker вернул `DONE_WITH_CONCERNS`. Индекс: урок 00 - объясняет служебные топики генератора; урок 01 переводит перезаливку - `kafka_msg_ts` на `CLEAN_START=0`; урок 05 согласован с no-live default и - consumer lag; course README добавляет HITL-пункт чистого маршрута 00/01/05; - OPERATIONS ведёт STG-путь через startup-history, `make data` архивный. - Checks worker-а: `make generated-history-analytics` PASS на чистом стенде; - `make up` PASS; SQL-проверка `kafka_msg_ts` + `CLEAN_START=0` PASS - (16054/16054); восстановление схемы PASS; Kafka topics PASS; Prometheus - targets PASS (`generator=0` no-live); `system.kafka_consumers` PASS; - `git diff --check` PASS. Concern: стенд после проверки не свежий из-за - намеренного `CLEAN_START=0`. -- 21:52 Issue 18 code/docs review запущен координатором: свежий - observer-субагент `Hume`, reasoning_effort=`high`, без правок репозитория и - среды. -- 21:53 Issue 18 code/docs review `Hume`: `APPROVED` с medium finding: - урок 05 говорит о трёх Prometheus scrape-блоках, но в `configs/prometheus.yml` - их четыре, включая `generator`; находка возвращена worker-у для короткого - триажа перед коммитом. -- 21:54 Issue 18: worker вернул `DONE` после medium finding. Исправлено: - урок 05 говорит о четырёх scrape-блоках и связывает `generator:9109` с - ожидаемым `DOWN` без live. Checks: `rg` PASS; `git diff --check` по уроку - PASS. -- 21:55 Issue 18 docs narrow rerun отправлен `Hume`: проверить закрытие - medium-находки по Prometheus scrape-блокам. -- 21:55 Issue 18 docs narrow rerun `Hume`: `APPROVED`. Findings: нет. - Chain-level решение: открыть коммит-гейт 18. -- 22:00 Issue 18 commit gate: doc `rg` PASS; `git diff --check` PASS; - `make generated-history-analytics` PASS (manifest profile `ci`, - T_end=`2026-01-01T06:00:00+00:00`, 16054 events); `make up` PASS. Diff в - границах issue 18; журнал не staged. -- 22:00 Issue 18 рабочий коммит: `77e4fb6` - (`docs(course): согласованы уроки со startup-history`). -- 22:02 Перед финальным review: добавлен follow-up issue 19 про единые - `make test`/`make lint`; PRD обновлён: 15-18 закрыты, открытыми остаются - 13 (`needs-triage`) и 19 (`ready-for-agent`). -- 22:04 Финальное chain review запущено координатором: свежий - observer-субагент `Gibbs`, reasoning_effort=`xhigh`, без правок репозитория - и среды; цель — проверить цепочку 15-18 и PRD/трекер против замысла. -- 22:08 Финальное chain review `Gibbs`: `CHANGES_REQUESTED`. Finding: - docs still recommend plain `make generated-history-check` after backfill-only - `make generated-history-analytics`, but issue 17 changed default check to - require live seam. Affected docs: OPERATIONS, README, startup-history runbook. - Fixer-субагент запускается для проектных документов. -- 22:10 Final-review fixer `Peirce`: исправил README, OPERATIONS и - startup-history runbook: backfill/import-only пути используют - `CHECK_LIVE_SEAM=0`, live-проверка вынесена в - `make generated-history-runtime-check` или описана после `generator-continue` - + `make transform`. Checks: targeted `rg` PASS; `git diff --check` PASS. -- 22:10 Финальное chain review narrow rerun отправлено `Gibbs`: проверить - закрытие finding по `generated-history-check` docs drift и новый локальный - drift рядом. -- 22:11 Финальное chain review narrow rerun `Gibbs`: `APPROVED`. Findings: - нет. Chain-level решение: финальный review закрыт, собрать handoff. -- 22:12 Handoff для кросс-линейного ревью собран: - `.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md`. diff --git a/.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md b/.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md deleted file mode 100644 index c4ee829..0000000 --- a/.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md +++ /dev/null @@ -1,65 +0,0 @@ -Status: done - -# Контракт модельного времени и стартовой истории - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Зафиксировать рабочий контракт для реализации модельного времени и стартовой -истории. Это HITL-срез: до кода нужно решить внешний интерфейс, формат -стартовой истории и правила восстановления, чтобы worker-и не угадывали -поведение через тесты. - -Контракт должен остаться коротким и понятным: что задаёт `T0`, как задаётся ×K, -что такое `T_end`, как выглядит стартовая история, какие метки времени -модельные, а какие операционные. - -## Acceptance criteria - -- [x] Выбраны имена и формат настроек для `T0`, скорости ×K и режима промотки - прошлого. -- [x] Описан живой ход часов: модельное время идёт фиксированным шагом - `tick_seconds * K`; измеренный настенный интервал между тиками не входит в - текущий контракт. -- [x] Разделены уровни повторяемости: точная повторяемость промотки прошлого и - точная повторяемость живого режима при тех же настройках и числе успешных - тиков. -- [x] Описано, как после сбоя вычисляется модельная точка возобновления при ×K: - из сохранённой модельной метки, сохранённой настенной метки и скорости. -- [x] Описано, что короткий и долгий простой считаются по модельному времени: - при большом ×K короткий настенный простой может стать долгим модельным. -- [x] Описан манифест стартовой истории: минимум `GEN_SEED`, `T0`, `T_end`, - настройки генерации, версия state и контрольные числа. -- [x] Описана граница `T_end`: где заканчивается прошлое и с какой метки - начинается живое продолжение, без дублей и дыр. -- [x] Разделены модельные метки событий и настенные операционные метки сервиса. -- [x] Зафиксирован часовой пояс модельных часов для дневного коэффициента. -- [x] Решено, как координатор будет получать повторяемую ClickHouse-проверку: - через очистку данных или идемпотентный прогон. -- [x] Если выбран чистый прогон, перечислены поверхности сброса: таблицы - ClickHouse, Kafka-топики данных и состояние генератора (`generator_state` или - явный сброс состояния при старте). -- [x] Контракт записан в durable-документ: обновление `PRD.md`, короткий - design note в `.scratch/generator-model-time-startup-history/` или уточнение - спеки. Сам issue 01 только ссылается на источник истины. - -## Решение - -Контракт записан в durable-документ: -`docs/specs/2026-06-14-generator-model-time-and-startup-history.md`, раздел -«Рабочий контракт реализации». - -PRD ссылается на этот раздел в сквозных инвариантах. Для следующих задач важная -граница: стартовая история покрывает `[T0, T_end)`, а живое продолжение стартует -из слепка на `T_end`. - -Дополнительную матрицу «фиксированный шаг / измеренная настенная дельта» не -вводим: ADR-0005 не требовала такого публичного выбора, а текущей учебной -цепочке нужен один повторяемый живой ход часов. - -## Blocked by - -None - can start immediately diff --git a/.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md b/.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md deleted file mode 100644 index 8534269..0000000 --- a/.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md +++ /dev/null @@ -1,51 +0,0 @@ -Status: done - -# Модельное время до ClickHouse - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Сделать минимальный живой поток, где генератор стартует от `T0`, пишет -`event_timestamp` по модельному времени и доводит события до ClickHouse -обычным путём стенда. Срез должен доказать не внутренний класс часов, а -наблюдаемое поведение: данные в ClickHouse начинаются от модельной точки и -повторяются при тех же настройках. - -## Acceptance criteria - -- [x] При фиксированных `GEN_SEED` и `T0` первый короткий прогон пишет события с - модельными `event_timestamp`, начинающимися около `T0`. -- [x] Повторный чистый прогон с теми же настройками даёт те же контрольные - числа в ClickHouse по правилу повторяемости из контракта задачи 1. -- [x] Повторный чистый прогон явно сбрасывает или обходит старое состояние - генератора, чтобы сервис не продолжил прошлый запуск из `generator_state`. -- [x] Расчёт дневного коэффициента в этом срезе больше не зависит от реального - часа запуска процесса. -- [x] Локальные тесты проверяют поведение через публичный интерфейс генератора - или сервиса, без привязки к внутреннему устройству часов. -- [x] Есть команда или короткая инструкция для координатора: поднять стенд, - прогнать поток, выполнить SQL-проверку в ClickHouse. -- [x] Документация запуска не утверждает, что `event_timestamp` равен - настенному времени. - -## Решение - -Живой сервис получает модельную точку из `GEN_MODEL_T0`, считает дневной -коэффициент по модельному времени и передаёт эту же точку в тиковый поток. После -успешного тика модельная точка сдвигается на -`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. - -Проверка ClickHouse выполнена двумя чистыми прогонами с `GEN_STATE_RESET=true`. -Оба раза получены одинаковые контрольные числа: 5 событий, диапазон -`event_ts = 2026-01-01 10:00:00.000000`, 5 уникальных `event_id` и 5 уникальных -`click_id`. - -Риски по восстановлению state v2 оставлены для задачи 04: текущий срез -доказывает чистый live-старт, а не возобновление после сбоя. - -## Blocked by - -- `.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md` diff --git a/.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md b/.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md deleted file mode 100644 index e854fcc..0000000 --- a/.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md +++ /dev/null @@ -1,86 +0,0 @@ -Status: done - -# ×K и дневной коэффициент по модельному времени - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Добавить ускоренный живой ход модельных часов. При ×K за один реальный тик -должна проходить большая модельная длительность, а событийный бюджет должен -считаться по этой модельной длительности. Дневной коэффициент считается по -модельному часу. - -Результат должен быть виден в ClickHouse: модельные метки уходят вперёд быстрее -реального времени, а объём событий соответствует пройденному модельному -интервалу. - -## Acceptance criteria - -- [x] На ×1 поведение остаётся совместимым с обычным живым режимом. -- [x] На ×K модельные `event_timestamp` за короткий реальный прогон покрывают - примерно `K` раз большую модельную длительность. -- [x] Событийный бюджет считается по модельной длительности тика, а не по - реальному времени сна процесса. -- [x] Дневной коэффициент меняется при переходе модельного времени через - дневные/ночные часы в зафиксированном часовом поясе, независимо от реального - часа запуска. -- [x] ClickHouse-проверка показывает повторяемые контрольные числа при тех же - `GEN_SEED`, `T0`, скорости и настройках. -- [x] Worker даёт промежуточный статус, если статистический прогон или стендовая - проверка занимает заметное время. -- [x] В `generator/README.md` или `docs/OPERATIONS.md` кратко описано, что ×K - ускоряет именно модельное время стенда. - -## Решение - -`calculate_events_count()` теперь считает λ тика по модельной длительности: -`GEN_TICK_SECONDS * GEN_MODEL_TIME_SPEED`. При `GEN_MODEL_TIME_SPEED=1` -формула остаётся прежней. - -Для больших λ старый Knuth-алгоритм заменён нормальным приближением, чтобы -ускорение ×K не упиралось в underflow `exp(-λ)` и событийный бюджет продолжал -расти вместе с модельной длительностью. - -Добавлены проверки: - -- средний бюджет ×10 примерно в 10 раз больше бюджета ×1; -- большой λ не залипает на старом underflow-ограничении; -- `hour_factor()` берёт час из заданного `GEN_MODEL_TIMEZONE`; -- сервисный live-тик при ×10 сдвигает `event_timestamp` с `10:00` на `10:10`; -- старый live-сценарий ×1 остаётся зелёным. - -Документация в `generator/README.md` и `docs/OPERATIONS.md` уточняет, что ×K -ускоряет модельную длительность тика и событийный бюджет. - -## Проверка - -- `make generator-test` — `120 passed`. -- ClickHouse, быстрый прогон ×60 с `GEN_STATE_RESET=true`, - `GEN_MODEL_T0=2026-01-01T05:58:00+00:00`, `GEN_TICK_SECONDS=1`: за короткий - реальный прогон `event_ts` покрыл `2026-01-01 05:58:00` … - `2026-01-01 06:09:00`; после 06:00 UTC поминутные числа выросли с `40/42` до - примерно `58..63`, что показывает смену ночного коэффициента. -- ClickHouse, повторяемость: два чистых запуска с одинаковыми - `GEN_SEED=4242`, `GEN_MODEL_T0=2026-01-01T10:00:00+00:00`, - `GEN_MODEL_TIME_SPEED=60`, `GEN_TICK_SECONDS=60`, - `GEN_STATE_RESET=true` дали одинаковые контрольные числа: - `events=73`, `unique_events=73`, `unique_clicks=73`, - `min_event_ts=max_event_ts=2026-01-01 10:00:00`. - -## Риски и границы - -- State v2 resume, backfill, manifest и startup history не менялись. Известные - риски по ним остаются задачами 04/05. -- Сервис всё ещё пишет операционные метки state и истории пачек по настенному - времени; это соответствует текущему контракту issue 03. -- Нормальное приближение для больших λ не является точным Poisson, но для - событийного бюджета учебного демо достаточно сохраняет масштаб, среднее и - дисперсию. Точную статистическую выборку можно выделить в отдельную задачу, - если она станет учебной целью. - -## Blocked by - -- `.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md` diff --git a/.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md b/.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md deleted file mode 100644 index 75671ce..0000000 --- a/.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md +++ /dev/null @@ -1,114 +0,0 @@ -Status: done - -# Восстановление state v2 от модельной точки - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Привести восстановление state v2 к модельному времени. Один и тот же слепок -должен уметь восстанавливаться в двух разных случаях: после сбоя, где время -действительно прошло, и при старте из стартовой истории, где продолжение идёт -от `T_end` без искусственного разрыва. - -Срез должен доказать поведение не только локальными тестами состояния, но и -данными в ClickHouse: активные визиты продолжаются или закрываются по правилам -модельного времени. - -## Acceptance criteria - -- [x] State сохраняет достаточно данных, чтобы после сбоя вычислить модельную - точку возобновления при ×K. -- [x] Восстановление после короткого модельного сбоя продолжает активные визиты - и досылает созревшие события с исходными модельными метками. -- [x] Восстановление после долгого модельного сбоя закрывает сильно просроченные - активные визиты без досылки остатка. -- [x] Восстановление из стартовой истории использует `T_end` как точку - возобновления и не обрывает активные визиты из-за настенного простоя. -- [x] Визит, переживший восстановление, остаётся однородным: события до и после - восстановления не меняют контекст визита и не создают второй путь генерации - внутри одного `click_id`. -- [x] Повреждённый или несовместимый state не валит сервис: генератор стартует - с чистого листа и пишет предупреждение. -- [x] ClickHouse-проверка подтверждает, что на стыке восстановления нет дублей - событий и нет разрыва `click_id` внутри продолжающегося визита. -- [x] После реализации выполнено саморевью worker-а и отдельное reviewer-ревью, - потому что задача меняет state/serialization и сервисное восстановление. -- [x] Если исправление после ревью меняет формат state или способ восстановления, - выполнен повторный reviewer-круг. - -## Blocked by - -- `.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md` -- Review gate из `PRD.md`: сквозной инвариант времени после задачи 3 - -## Решение - -- State v2 теперь хранит `model_timestamp`, `wall_timestamp`, - `model_time_speed`, `model_timezone`, `model_t0` и `gen_seed`. -- Старый v2 без этих полей считается несовместимым: `from_dict_safe` возвращает - `None`, сервис пишет предупреждение и стартует с чистого состояния. -- Live-восстановление считает точку продолжения по формуле - `model_timestamp + max(0, wall_now_utc - wall_timestamp) * model_time_speed`. -- Перед live-восстановлением сервис сверяет state с текущим `Config` по - `gen_seed`, `model_t0`, `model_timezone` и `model_time_speed`. Несовпадение - считается несовместимым state: сервис пишет warning и стартует с чистого - состояния. -- Восстановление из стартовой истории вынесено в минимальный метод - `GeneratorService.restore_from_startup_history(state, model_t_end)`: он берёт - переданный `T_end` напрямую и не применяет wall-дельту. -- Сохранение state после успешного тика пишет модельную точку следующего тика, - а не настенное время. Это убирает повторный старт с уже обработанной модельной - точки. - -## Проверки - -- Точечный прогон после проверки reviewer-а: - `docker run --rm -v ... generator:test pytest tests/test_state.py::TestGeneratorState tests/test_state.py::TestGeneratorStateValidation::test_from_dict_safe_returns_state_on_valid tests/test_state.py::TestGeneratorStateValidation::test_from_dict_safe_returns_none_on_invalid_gen_seed tests/test_service.py::TestGeneratorServiceStateV2 -v` - — 15 passed. -- `make generator-test` — 126 passed. -- `uv run pytest ...` и `uv run python -m pytest ...` не запускались до тестов: - в текущем uv-окружении нет `pytest`. -- `make lint` недоступен: в `Makefile` нет такой цели. - -## ClickHouse-проверка - -Выполнены два чистых стендовых сценария. Оба начинались со сброса ClickHouse, -Kafka и state генератора. - -1. Короткое восстановление, `GEN_MODEL_TIME_SPEED=1`. - Первый запуск с `GEN_STATE_RESET=true` записал один тик, второй запуск с - `GEN_STATE_RESET=false` восстановился из state и продолжил активные визиты. - Проверка ClickHouse: - - `dds.event`: `rows=332`, `uniq_events=332`, `duplicate_events=0`. - - `continued_from_first_tick=67` для визитов, начатых на - `2026-01-01 10:00:00`. - - Для этих `click_id` нет смены `user_domain_id`, `device_type`, `os_name`, - `geo_country`, `geo_region_name` и `geo_timezone`. -2. Долгое восстановление, `GEN_MODEL_TIME_SPEED=3600`. - Первый запуск с `GEN_STATE_RESET=true` записал один тик на - `2026-01-01 10:00:00`, второй запуск с `GEN_STATE_RESET=false` - восстановился на модельной точке `2026-01-03T22:51:51.552000+00:00`. - Проверка ClickHouse: - - `dds.event`: `rows=200`, `uniq_events=200`, `duplicate_events=0`. - - диапазон `event_ts`: от `2026-01-01 10:00:00.000000` до - `2026-01-03 22:51:51.552000`. - - `continued_old_clicks=0`: старые `click_id` с первого тика не продолжились - после долгой модельной паузы. - -## Риски - -- Старые записи state v2 без модельной связки больше не восстанавливаются. Это - намеренно: без этих полей нельзя корректно посчитать точку продолжения. -- Полноценный манифест и backfill не реализованы: добавлен только минимальный - интерфейс восстановления от переданного `T_end`. -- `restore_from_startup_history` пока принимает state и `T_end` напрямую, без - проверки manifest. Это риск issue 05, где появится полноценная стартовая - история и запрет смешивания артефактов. -- `GeneratorState.__post_init__` оставляет in-memory совместимость для прямого - создания state в тестах и коде. JSON/Kafka путь остаётся строгим: отсутствие - новых полей не маскируется и ведёт к fresh start. -- После reviewer-fix формат state снова изменился (`gen_seed`). Повторный - reviewer-круг выполнен, новых блокирующих находок нет. diff --git a/.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md b/.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md deleted file mode 100644 index 27c1104..0000000 --- a/.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md +++ /dev/null @@ -1,218 +0,0 @@ -Status: done - -# Стартовая история до ClickHouse - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Сделать промотку прошлого: генератор быстро проходит от `T0` до `T_end`, создаёт -события за этот отрезок, сохраняет слепок состояния и манифест стартовой -истории. Эту историю нужно загрузить в ClickHouse и доказать, что живой поток -может продолжить её с `T_end`. - -Это главный срез стартовой истории. Он должен проверять не только факт наличия -данных, но и форму данных: пирамиду, возвраты, длину визита, воронку и стык -между прошлым и живым продолжением. - -## Acceptance criteria - -- [x] Промотка прошлого создаёт события за `[T0, T_end)`, слепок состояния и - манифест с контрольными данными. -- [x] При одинаковых `GEN_SEED`, `T0`, `T_end` и настройках артефакт промотки - прошлого повторяем точно; проверки в ClickHouse следуют правилам допуска из - контракта задачи 1. -- [x] История загружается в ClickHouse штатной или явно описанной командой. -- [x] SQL-проверка показывает здоровую пирамиду: пользователей меньше, чем - визитов, визитов меньше, чем событий. -- [x] SQL-проверка показывает возвраты: у части пользователей больше одного - визита. -- [x] SQL-проверка длины визита проверяет форму, а не только среднее: долю - коротких визитов, медиану и долю срезов о потолок. -- [x] SQL-проверка воронки `/home -> товары -> /cart -> /payment -> - /confirmation` монотонно убывает, а доля дошедших до `/confirmation` в - согласованном коридоре. -- [x] Живое продолжение после `T_end` не создаёт дублей на границе и не выглядит - как независимый второй мир. -- [x] Визиты, которые переходят через `T_end`, остаются однородными: контекст - визита не меняется на стыке стартовой истории и живого продолжения. -- [x] Подготовлены данные, команды и SQL-проверки, достаточные для внешнего - review gate по распределениям и двум путям генерации из `PRD.md`. -- [x] Worker даёт промежуточный статус, если промотка, загрузка в ClickHouse или - распределительные проверки занимают заметное время. - -## Blocked by - -- `.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md` - -## Фактический прогон worker-а - -Дата проверки: 2026-06-14. - -Настройки стенда: - -- `GEN_SEED=4242` -- `GEN_MODEL_T0=2026-01-01T00:00:00+00:00` -- `GEN_MODEL_T_END=2026-01-01T06:00:00+00:00` -- `GEN_MODEL_TIMEZONE=UTC` -- `GEN_MODEL_TIME_SPEED=1` -- `GEN_TICK_SECONDS=60` -- `GEN_LAMBDA_BASE_PER_MIN=120` -- `GEN_JITTER_PCT=0` -- `GEN_MIN_EVENTS_PER_TICK=1` -- `GEN_MAX_EVENTS_PER_TICK=1000` - -Команды проверки: - -```bash -make clean -docker compose up -d clickhouse kafka -make ddl -docker compose build generator - -docker compose run --rm --no-deps \ - -e GEN_RUN_MODE=backfill \ - -e GEN_STATE_RESET=true \ - -e GEN_SEED=4242 \ - -e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \ - -e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \ - -e GEN_MODEL_TIMEZONE=UTC \ - -e GEN_MODEL_TIME_SPEED=1 \ - -e GEN_TICK_SECONDS=60 \ - -e GEN_LAMBDA_BASE_PER_MIN=120 \ - -e GEN_JITTER_PCT=0 \ - -e GEN_MIN_EVENTS_PER_TICK=1 \ - -e GEN_MAX_EVENTS_PER_TICK=1000 \ - generator - -sleep 10 -bash scripts/run_batch.sh -``` - -Повторяемость проверена двумя чистыми прогонами с полным сбросом -ClickHouse/Kafka/state через `make clean`. В обоих прогонах manifest и ClickHouse -дали одинаковые контрольные числа: - -- manifest `browser_events.checksum_sha256`: - `3b1946bd7fd3d669e2461fcbae88d8aed6980bb5f126779fb4e7480ce15a3269` -- manifest `location_events.checksum_sha256`: - `370816c8d0b0311592282b9b75372e18862db9ef4984f7032885e935f9436522` -- manifest `device_events.checksum_sha256`: - `37e157ac7d5a3203c1c27b2a11abc1b9cb9fc7f74f79c77645faa09412e08b33` -- manifest `geo_events.checksum_sha256`: - `ec0e36e4613e05b9a5e72490c7565cf7e5f767d39a7a58b383e56e2eb191fc20` -- ClickHouse digest: - `CC1A73E65E897D1F1FC982CFA4237A07` - -Backfill в ClickHouse: - -- `events=31825` -- `unique_events=31825` -- `visits=3020` -- `users=1048` -- `min_event_ts=2026-01-01 00:00:00.000000` -- `max_event_ts=2026-01-01 05:59:59.521599` -- `pyramid_ok=1` -- `half_open_ok=1` - -Проверка возвратов: - -- `users=1048` -- `returning_users=708` -- `returning_share=0.6755725190839694` -- `max_visits_per_user=11` - -Форма длины визита: - -- `visits=3020` -- `short_visit_share=0.16490066225165562` -- `median_events_per_visit=8` -- `avg_events_per_visit=10.538079470198676` -- `capped_visit_share=0.0619205298013245` -- `median_duration_sec=202` -- `p95_duration_sec=823` -- `max_events_per_visit=30` - -Воронка: - -- строгая упорядоченная: - `home=2743`, `products=1626`, `cart=995`, `payment=673`, - `confirmation=394`, `monotonic_ok=1`, - `ordered_confirmation_share=0.14363835216915785`; -- калибровочная по наличию страницы в визите: - `home=2743`, `products=2706`, `cart=2003`, `payment=1368`, - `confirmation=824`, `monotonic_ok=1`, - `confirmation_share_all_visits=0.2728476821192053`, - `confirmation_share_from_home=0.3004010207801677`. - -Live-продолжение: - -```bash -docker compose run -d --name issue05-live --no-deps \ - -e GEN_RUN_MODE=live \ - -e GEN_STATE_RESET=false \ - -e GEN_SEED=4242 \ - -e GEN_MODEL_T0=2026-01-01T00:00:00+00:00 \ - -e GEN_MODEL_T_END=2026-01-01T06:00:00+00:00 \ - -e GEN_MODEL_TIMEZONE=UTC \ - -e GEN_MODEL_TIME_SPEED=1 \ - -e GEN_TICK_SECONDS=60 \ - -e GEN_LAMBDA_BASE_PER_MIN=120 \ - -e GEN_JITTER_PCT=0 \ - -e GEN_MIN_EVENTS_PER_TICK=1 \ - -e GEN_MAX_EVENTS_PER_TICK=1000 \ - generator -``` - -Лог live-запуска подтвердил восстановление из стартовой истории: -`model_time=2026-01-01T06:00:00+00:00`, затем прошли тики `361`-`364`. - -После live и повторного `bash scripts/run_batch.sh`: - -- `users=1079` -- `visits=3069` -- `events=32145` -- `min_event_ts=2026-01-01 00:00:00.000000` -- `max_event_ts=2026-01-01 06:03:00.000000` -- `history_events=31825` -- `live_events=320` -- `duplicate_events=0` -- `boundary_events=13` - -`boundary_events=13` — это live-события ровно на `T_end`; они не входили в -backfill `[T0, T_end)`. - -Визиты через `T_end`: - -- `crossing_visits=36` -- `visits_with_both_sides=36` -- `homogeneous_visits=36` -- `context_ok=1` -- `min_before_events=2` -- `min_after_events=1` -- `max_after_events=10` - -## Риски для review gate - -- Manifest, state и события связаны метаданными (`GEN_SEED`, `T0`, `T_end`, - настройки генерации, `last_batch_id`) и checksum событий по топикам. Полной - криптографической связки `state + events + manifest` пока нет. -- Для воронки есть две SQL-формы. Строгая ordered-форма проверяет порядок - `/home -> товары -> /cart -> /payment -> /confirmation`. Калибровочная - contains-форма проверяет, была ли страница в визите. Для review gate - `confirmation_share` сравнивается с коридором мат-модели по contains-форме. - -## Review gate - -- Саморевью worker-а нашло блокирующий риск: backfill не должен сохранять - state/manifest при частичной публикации. Исправлено fail-fast поведением и - тестом. -- Первый reviewer gate нашёл два блокера: startup-history state мог остаться без - manifest и восстановиться как live-state, а startup-history restore не сверял - поля самого state. Оба пункта исправлены до коммита. -- Повторный reviewer gate: `gate pass`, новых блокирующих находок нет. -- Проверки после исправлений: `make generator-test` — 134 passed. -- Остаточный риск процесса: reviewer был со свежим контекстом, но той же - родословной, а не внешней моделью. diff --git a/.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md b/.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md deleted file mode 100644 index 5c4d983..0000000 --- a/.scratch/generator-model-time-startup-history/issues/06-generated-history-as-analytics-source.md +++ /dev/null @@ -1,227 +0,0 @@ -Status: done - -# Стартовая история как источник аналитики - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Перевести штатный путь стенда на стартовую историю как источник аналитики. -Чистый стенд должен получать данные из генерации: стартовая история загружается, -STG→ODS→DDS→DM строится на ней, а Superset работает с этими витринами. Архивный -статический сид остаётся только временной кладовкой фактуры для генератора, а не -источником аналитического контура. - -Срез не требует финальной ручной оценки красоты дашбордов, но должен дать -техническое доказательство: витрины и датасеты не пустые, контрольные числа -берутся из генерации. - -Границы среза: штатный путь стенда и документы запуска. Переписывание уроков, -новые панели и улучшение формы дашбордов остаются follow-up, если по ходу не -окажутся маленькой обязательной правкой для запуска. - -## Acceptance criteria - -- [x] Штатная команда запуска чистого стенда создаёт или загружает стартовую - историю и прогоняет её до DM-витрин. -- [x] `kafka_load_dag` или заменяющий его путь больше не использует архивный - `data/*.jsonl` как источник аналитики. -- [x] ClickHouse-проверки из задачи 5 доступны как повторяемая команда для - координатора или CI. -- [x] Основные DM-витрины, на которых стоят дашборды, непустые и показывают - данные генерации. -- [x] Superset-датасеты и дашборды технически открываются на данных генерации; - ручная оценка формы графиков остаётся финальной HITL-приёмкой. -- [x] `README.md`, `docs/OPERATIONS.md` и `generator/README.md` больше не - описывают архивный сид как основной источник аналитики. -- [x] Если уроки или дашборды требуют нетривиальной переделки, создан follow-up - issue вместо расширения этой задачи. -- [x] Если учебные материалы ещё описывают архивный сид как основной источник, - создан отдельный follow-up на миграцию уроков по ADR-0006. -- [x] Описан повторный чистый прогон: какие данные очищаются и какие команды - выполняются, чтобы координатор мог надёжно перепроверить результат. - -## Blocked by - -- `.scratch/generator-model-time-startup-history/issues/05-startup-history-backfill-to-clickhouse.md` -- Review gate из `PRD.md`: распределения и два пути генерации после задачи 5 - -## Фактический прогон worker-а - -Дата проверки: 2026-06-14. - -Штатная команда: - -```bash -make generated-history-analytics -``` - -Команда выполняет чистый прогон: `docker compose down -v --remove-orphans`, -запуск ClickHouse и Kafka, DDL, `GEN_RUN_MODE=backfill`, batch -STG -> ODS -> DDS -> DM, инициализацию Superset и итоговую проверку -`make generated-history-check`. - -Быстрая команда issue 06 проверяет путь backfill -> DM -> Superset. Стык -backfill/live, отсутствие дублей на границе и однородность визитов через -`T_end` проверены в issue 05 и не входят в быстрый штатный check issue 06. - -Быстрый проверочный профиль по умолчанию: - -- `GEN_SEED=4242` -- `GEN_MODEL_T0=2026-01-01T00:00:00+00:00` -- `GEN_MODEL_T_END=2026-01-01T06:00:00+00:00` -- `GEN_MODEL_TIMEZONE=UTC` -- `GEN_MODEL_TIME_SPEED=1` -- `GEN_TICK_SECONDS=60` -- `GEN_LAMBDA_BASE_PER_MIN=60` -- `GEN_JITTER_PCT=0` -- `GEN_MIN_EVENTS_PER_TICK=1` -- `GEN_MAX_EVENTS_PER_TICK=1000` - -Суточный профиль остаётся доступен явно: - -```bash -GEN_MODEL_T_END=2026-01-02T00:00:00+00:00 make generated-history-analytics -``` - -Backfill завершился: - -- `events=16054` -- `visits=1516` -- `users=445` - -Batch STG -> ODS -> DDS -> DM: - -- STG, суммарно по 4 потокам: `64216` -- `ods.browser_event=16054` -- `ods.location_event=16054` -- `ods.device_by_click=1516` -- `ods.geo_by_click=1516` -- `dds.event=16054` -- `dds.click=1516` -- `dds.event_without_click=0` -- `dm.v_events_enriched` в `dm.dq_summary`: `16054` - -Повторная команда проверки: - -```bash -make generated-history-check -``` - -Результат ClickHouse: - -- `events=16054` -- `visits=1516` -- `users=445` -- `min_event_ts=2026-01-01 00:00:00.000000` -- `max_event_ts=2026-01-01 05:59:59.353407` -- `digest=B7E183DD1835E6593CC4B33C7F5B2817` - -Возвраты: - -- `users=445` -- `returning_users=351` -- `returning_share=0.7887640449438202` -- `max_visits_per_user=8` - -Форма длины визита: - -- `visits=1516` -- `short_visit_share=0.17678100263852242` -- `median_events_per_visit=8` -- `avg_events_per_visit=10.589709762532982` -- `capped_visit_share=0.06398416886543536` -- `median_duration_sec=204` -- `p95_duration_sec=835` -- `max_events_per_visit=30` - -Ordered funnel: - -- `home=1387` -- `products=849` -- `cart=511` -- `payment=332` -- `confirmation=182` -- `monotonic_ok=1` -- `confirmation_share=0.13121845710165825` - -Contains funnel: - -- `home=1387` -- `products=1254` -- `cart=880` -- `payment=568` -- `confirmation=324` -- `monotonic_ok=1` -- `confirmation_share=0.2335976928622927` - -Основные DM-витрины: - -- `dm.v_events_enriched=16054` -- `dm.v_daily_traffic=797` -- `dm.v_top_pages_daily=6` -- `dm.v_utm_effectiveness=33` -- `dm.v_session_overview=1516` -- `dm.dq_summary=20` - -Superset technical check: - -- `superset_datasets=6` -- `superset_dashboards=1` -- `superset_dashboard_charts=10` -- `dashboard_url=http://localhost:8088/superset/dashboard/ecommerce-analytics/` -- `login_api_status=200` -- `dashboard_api_status=200` -- `dashboard_title=🛒 E-commerce Analytics Dashboard` -- зарегистрированная Superset database `clickhouse_dwh` читает - `dm.v_events_enriched` через SQLAlchemy engine: - `superset_clickhouse_events=16054`. - -Проверки: - -- `bash -n scripts/run_generated_history_analytics.sh` — ok. -- `bash -n scripts/check_generated_analytics.sh` — ok. -- `make generated-history-analytics` — ok. -- `make generated-history-check` — ok. -- `make generator-test` — 134 passed. - -Документы запуска обновлены: - -- `README.md` -- `docs/OPERATIONS.md` -- `generator/README.md` -- `docs/REPO_MAP.md` -- `docs/SUPERSET_DASHBOARD.md` - -Follow-up: - -- `.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md` - — миграция учебных материалов с архивного сида на генерацию. - -## Риски и что не проверено - -- Ручная оценка формы dashboard глазами не выполнялась: это финальная HITL-приёмка. -- Суточный backfill не прогонялся до конца в этом срезе: он доступен через env, - но для координатора выбран быстрый 6-часовой профиль. -- `kafka_load_dag.py` физически оставлен как архивный ручной путь; штатные - документы больше не ведут через него как основной источник аналитики. -- `scripts/check_generated_analytics.sh` рассчитан на штатный UTC-профиль - (`GEN_MODEL_T0/T_END` с `+00:00`). Для произвольного timezone offset в этих - переменных нужна отдельная нормализация границ. -- Проверка `no_2022_rows` доказывает отсутствие архивного сида внутри быстрого - чистого модельного диапазона. Она не доказывает отсутствие старых строк на - грязном стенде вне этого диапазона; штатная команда закрывает это через - `docker compose down -v`. - -## Review gate - -- Саморевью worker-а нашло слабые доказательства по проверкам issue 05 и Superset: - добавлены возвраты, форма длины визита, ordered/contains funnel, Superset login - API, dashboard API и чтение ClickHouse через Superset database. -- Независимый reviewer gate: `gate pass`, блокирующих находок нет. -- Minor-находка reviewer-а по старой подсказке `make data` в `scripts/run_batch.sh` - исправлена до коммита. -- Проверки после исправлений: `make generated-history-check` — ok, - `bash -n scripts/*.sh` — ok. diff --git a/.scratch/generator-model-time-startup-history/issues/07-startup-history-portable-artifact-and-usage-docs.md b/.scratch/generator-model-time-startup-history/issues/07-startup-history-portable-artifact-and-usage-docs.md deleted file mode 100644 index d169ddb..0000000 --- a/.scratch/generator-model-time-startup-history/issues/07-startup-history-portable-artifact-and-usage-docs.md +++ /dev/null @@ -1,117 +0,0 @@ -Status: done - -# Портативный артефакт стартовой истории и 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. Портативного файла-артефакта, который можно -сгенерировать один раз и быстро восстановить на чистом стенде без запуска -генератора, нет. - -Из-за этого неудобно: мгновенно сбросить стенд, держать длинную стартовую -историю (2+ суток, чтобы суточная волна повторялась на графике) без повторной -генерации. Сейчас «дёшево» только live-возобновление из слепка и перезапуск без -`down -v`; полный сброс требует регенерации. - -Этот пункт работает на главную цель: если стенд поднимается одной командой и есть -короткий runbook, генератор остаётся скрытой инфраструктурой и менти не нужно -знать его устройство. Развилка курса «урок про генератор vs скрытая -инфраструктура» уже закрыта в пользу скрытой инфраструктуры -(`docs/course/PRD.md` §7, 2026-07-04) — эта задача обеспечивает решению опору. - -## What to build - -- **Экспорт** стартовой истории в портативный файл-артефакт: события (в формате - сообщений топиков) плюс слепок состояния плюс манифест — один связный набор, - чтобы не смешать `GEN_SEED`, `T0`, `T_end` и настройки генерации. -- **Импорт** (решение 2026-07-04): воспроизвести события артефакта в Kafka-топики - и вернуть слепок с манифестом в служебные compact-топики. **Напрямую в - ClickHouse импорт не пишет ничего**: стенд наполняется штатным путём - (Kafka engine + MV -> STG, батч-ETL -> витрины). Так не появляется обходного - пути данных, каждый импорт заодно прогоняет весь пайплайн, а менти видит, - как пустой стенд наполняется изучаемыми механизмами. Live продолжает с `T_end`. -- **Громкий отказ при несовместимом state** (решение 2026-07-04, пересмотр - правила спеки). Различать два случая. Нет состояния или оно повреждено -> - чистый старт с предупреждением (как сейчас, оставить). Состояние есть и - читается, но настройки несовместимы при `GEN_STATE_RESET=false` (оператор - намерен продолжить) -> жёсткое падение с указанием разошедшихся полей - (`seed`/`T0`/`timezone`/`speed`) и подсказкой выставить `GEN_STATE_RESET=true`, - если новый мир нужен осознанно. Текущее тихое поведение — `service.py:217-220`. - Правка идёт вместе с обновлением - `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`. -- **Runbook «как пользоваться стендом на генерации»**: как сгенерировать, - сохранить, восстановить, выбрать длительность стартовой истории; что дёшево - (live-возобновление, перезапуск без чистки), а что требует регенерации. -- Новые команды экспорта/импорта делать в стиле глаголов (явное действие одной - командой), а не новыми комбинациями env-переменных. - -## Acceptance criteria - -- [x] Есть команда экспорта: стартовая история -> портативный файл-артефакт - (события + слепок + манифест) одним связным набором. -- [x] Есть команда импорта: на чистом стенде артефакт воспроизводится в Kafka - (события + служебные compact-топики) **без запуска генерации**; напрямую в - ClickHouse импорт не пишет. После штатного ETL контрольные числа в ClickHouse - совпадают с манифестом и исходной генерацией. -- [x] После импорта live продолжает с `T_end`: без дублей на границе и без - смешения миров. -- [x] Сохранено антисмешивание: импорт отвергает артефакт, несовместимый по - манифесту (`GEN_SEED`, `T0`, `T_end`, настройки генерации, версия state). -- [x] Громкий отказ: живое читаемое состояние + несовместимые настройки при - намерении продолжить -> падение с перечислением разошедшихся полей и - подсказкой; нет состояния или повреждено -> чистый старт с предупреждением - (как сейчас). Спека обновлена в этом же изменении. -- [x] Runbook описывает генерацию один раз, дешёвое восстановление, выбор - длительности и то, что переживает перезапуск, а что требует регенерации. -- [x] Runbook — про **использование**, устройство генератора в нём не - объясняется; за конструкцией он отсылает к `generator/README.md` и - `docs/specs/`. -- [x] Документы запуска (`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 его и закрывает, расширяя до полного цикла «генерация — сохранение — - восстановление». -- Откуда экспорту брать события — решить при реализации и зафиксировать в - спеке/runbook. Кандидаты: писать файл артефакта прямо при backfill (вторая - копия рядом с публикацией в Kafka), вычитать топики событий (учесть retention) - или выгрузить из STG (следить за точностью формата сообщений). Критерий - выбора: артефакт должен байт в байт воспроизводить сообщения топиков. -- Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация, - формат данных, правка спеки — всё из порогов риска). -- Связано с `08-migrate-course-from-archive-seed.md`: миграция уроков идёт после - этой задачи и будет ссылаться на runbook отсюда (номера отражают порядок, - переставлены 2026-07-04). - -## Идеи интерфейса — решения (2026-07-04) - -Бывший раздел «на будущее, не решено» разобран с пользователем. Судьба идей: - -- **Громкий отказ при несовместимом state** — включён в эту задачу - (см. What to build и критерии). -- **Глаголы / длительность / профили** — отдельная задача - `11-generator-launch-verbs-and-profiles.md`, делать **после этой и до - DAG-пульта**: чистый интерфейс «под капотом» делает DAG тонкой обёрткой с - простыми и понятными параметрами. -- **Airflow-DAG как пульт генератора** — приоритет поднят (пользователь, - 2026-07-04): это будущий основной человеческий интерфейс стенда — «слишком - сложно» лечится формой в веб-UI, а не только runbook'ом. Отдельная задача - `12-generator-control-dag.md`, делать после задачи 11. -- **Доливка прошлого кусочком** — отдельная задача - `13-backfill-top-up-from-snapshot.md`, без приоритета. - -## 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` diff --git a/.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md b/.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md deleted file mode 100644 index 4b44b0c..0000000 --- a/.scratch/generator-model-time-startup-history/issues/08-migrate-course-from-archive-seed.md +++ /dev/null @@ -1,90 +0,0 @@ -Status: done - -# Миграция учебных материалов с архивного сида на генерацию - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Why - -После перевода штатного аналитического контура на стартовую историю генератора -часть учебных материалов всё ещё описывает `data/*.jsonl` как основной источник -данных стенда. Это нельзя править внутри issue 06: потребуется пройти уроки и -сохранить понятный учебный путь. - -Развилка «отдельный урок про генератор vs скрытая инфраструктура» закрыта -(2026-07-04, `docs/course/PRD.md` §7): **отдельного урока не будет, генератор — -скрытая инфраструктура**. Устройство генератора — это разработка бэкенда и -математика, не инженерия данных; в цели курса не попадает. Уроки адаптируем, -не вводя марковские цепи и сложный Python в путь менти. - -## What to build - -Обновить курс и связанные материалы (тест-план) так, чтобы основной путь был: - -```text -startup-history/backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset -``` - -Архивный `data/*.jsonl` оставить только как временную фактуру генератора. -Генератор в уроках подаётся как готовый источник данных стенда («откуда берутся -данные»), без погружения в его устройство. - -## Acceptance criteria - -- [x] `docs/course/` больше не ведёт ученика через `make data` или `kafka_load` - как основной путь получения аналитических данных. -- [x] Уроки явно объясняют, что `data/*.jsonl` пока остаётся кладовкой значений - для генератора, а не источником аналитического контура. -- [x] Уроки не вводят устройство генератора (марковская модель, внутренний - Python) в путь менти: генератор упоминается только как готовый источник данных. -- [x] Тест-план согласован с новым штатным путём запуска. -- [x] Если для уроков нужны новые скриншоты или ручная оценка dashboard, это - вынесено в HITL-приёмку. - -## Notes - -Реальный объём больше, чем кажется: `make data` вплетён во **все** уроки 00–05, -а не только в урок 06 (проверка 2026-07-04, `grep -rn "make data" docs/course/`): - -- `docs/course/README.md` — быстрый старт через `LIMIT=50 make data`; -- `lessons/00_kafka_intro.md` — `make data` играет роль трекера, на нём построен - весь разбор партиций и offset'ов; -- `lessons/01–05` — `LIMIT=50 make data` в запуске, «полном сбросе» и таблицах - проверок; -- `docs/TEST_PLAN.md` — сценарии на старом пути. - -Тонкое место — урок 00: там ручная заливка используется как учебный приём -(наглядно видно сообщения в топике). Решить при миграции, чем её заменить, не -потеряв наглядность; критерий приёмки запрещает `make data` как **основной путь -аналитики**, а не как локальный демонстрационный приём внутри урока. - -Схема потока в `docs/course/PRD.md` §1 уже обновлена (поправка 2026-07-04) — PRD -дополнительно править не нужно. - -Демо-материалов в скоупе нет: демо-употребление стенда устарело (демо выросло в -отдельный проект), шпаргалки удалены из репозитория 2026-07-04 (см. поправку в -шапке `docs/course/PRD.md`). - -Рекомендуемый режим ревью по coordinator-loop: обычный (документы и уроки, кода -генератора и state задача не трогает); человеческая приёмка — проход по урокам -через пульт (HITL). - -## Blocked by - -- Жёстких блокеров нет: штатный путь (`make generated-history-analytics`) уже - существует, уроки можно вести через него. -- Мягкая зависимость от - `07-startup-history-portable-artifact-and-usage-docs.md`: runbook из issue 07 - изменит команды запуска и восстановления стенда, а уроки будут на него - ссылаться. Делать эту задачу после него, иначе уроки придётся править дважды. -- Мягкая зависимость от `12-generator-control-dag.md` (решение 2026-07-04): - человеческая приёмка миграции — это проход по урокам 00–05, и вести её удобнее - через DAG-пульт, а не через консоль. Поэтому эта задача идёт последней в - очереди — после 12. Порядок всей очереди — в PRD, раздел «Задачи». -- Мягкая зависимость от `14-fast-teaching-profile.md` и - `09-seam-browser-fixture-not-preserved.md` (дополнено 2026-07-04): обе меняют - мир — 14 переводит профиль `daily-wave` на быстрый темп, 09 меняет схему - state. Артефакт стартовой истории для уроков должен родиться **после** них, - иначе он протухнет по антисмешиванию и проход по урокам придётся повторять. diff --git a/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md b/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md deleted file mode 100644 index a64807a..0000000 --- a/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md +++ /dev/null @@ -1,159 +0,0 @@ -Status: done - -# Браузерная фактура не сохраняется на стыке восстановления визита - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Что нашли - -Визит, переживший стык backfill->live (активен на `T_end` и продолжается живьём), -меняет браузерную фактуру в live-продолжении (фактура — конкретные значения полей -события: какой `browser_name`, user agent, язык стоят в строках). Внутри одного `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 — а он был выполнен той же -родословной, и дефект прошёл. - -## Причина (подтверждена чтением кода, 2026-07-04) - -Первоначальная гипотеза «рассинхрон `event_index`» не подтвердилась: восстановление -перебирает визит с нуля, индекс события выровнен правильно (точнее, берётся -`event_index % len` — по модулю длины списка, см. `runtime.py:332`). Разошёлся сам -**источник** фактуры: - -- При рождении визита (`generation.py`, `generate_batch`) браузерные строки берутся - у **случайного** click_id из словаря — «донора» (`rng.choice(visit_candidates)`, - затем `browser_by_click_id[base_click_id]`). Кто донор — нигде не сохраняется, - он живёт только в памяти процесса. -- При восстановлении из state (`runtime.py`, `_compact_visit_batch`) фактура - пересобирается из **другого** click_id — того, к которому привязан пользователь - (`browser_by_click_id[user.seed_click_id]`). - -Донор и seed — разные click_id, отсюда разные браузеры. Внутри одного click_id -сида браузер практически постоянен (реальный клик одного человека), поэтому -«каждая сторона внутренне постоянна». `browser_language` разошёлся у 22/22 -(случайные click_id почти всегда с разными языками), `browser_name` — у 17/22 -(популярные браузеры иногда совпадают случайно). device и geo не расходятся, -потому что оба пути берут их из `UserProfile`. - -Расхождение шире, чем браузер (уточнение по ревью 2026-07-04). Шаблон location -ищется по event_id браузерной строки (`location_by_event_id[...]` в обоих путях), -то есть тоже зависит от того, чей click_id взят источником. Из state -восстанавливаются только `page_url` / `page_url_path`; остальные per-event поля -location — `referer_url`, `referer_medium`, `utm_*` — приходят от источника и на -стыке расходятся так же, как браузер. Эмпирика выше их просто не замеряла. - -Тот же `restore_state` -> `_visit_from_state` -> `_compact_visit_batch` работает и -при восстановлении после сбоя (задача 04) — дефект касается и этого пути, не -только стыка backfill->live. Стык и рестарт на уровне генератора — один и тот же -код, различие только в отбрасывании просроченных визитов. - -## Масштаб и важность - -- Затрагивает только визиты, активные ровно на `T_end` (здесь 22 из 17397 визитов, - ~0.13%). Чистый backfill и чистый live — без дефекта. -- Тот же механизм (фактура пересобирается при restore) вероятно затрагивает и - восстановление после сбоя (задача 04), не только backfill->live; проверка задачи 04 - его не ловила, потому что смотрела только `dds.click`. -- Для учебного демо урон низкий, но это реальное нарушение стыковой однородности и - сигнал, что браузерная фактура не входит в восстановимое состояние визита. - -## Решение по направлению фикса (пользователь, 2026-07-04) - -**Хранить донора в state.** В сериализацию визита (`_visit_to_state`) добавить поле -с click_id донора фактуры (`base_click_id`), рядом с `page_url_paths` / -`timestamps`. При восстановлении `_compact_visit_batch` берёт строки из -`browser_by_click_id[base_click_id]` — по уже существующему абсолютному индексу -восстановленная часть точно продолжает выпущенную. - -Рассмотренные альтернативы (отклонены): - -- Брать браузер от `seed_click_id` пользователя уже при рождении визита — schema - state не меняется и семантика красивее («один человек — один браузер»), но - меняется весь выпуск генератора: контрольные суммы, распределение браузеров, - артефакты пришлось бы перегенерировать. Это уже не багфикс, а смена поведения; - если захочется — отдельная задача. -- Сериализовать браузерные строки визита целиком — надёжно, но раздувает state и - дублирует словарь. Перебор: донор + абсолютный индекс дают тот же результат. - -Детали для исполнителя: - -- В основной ветке рождения донор покрывает визит целиком: кандидаты фильтруются - по `len(browser_events) >= len(visit_path)` (`generation.py:170`), так что - индекс не выйдет за список строк донора и `% len` становится безобидным - холостым ходом (оставить или убрать — на усмотрение при фиксе). -- **Запасная ветка рождения** (`generation.py:182-185`): если кандидатов нет, - берётся одна случайная браузерная строка и повторяется на весь визит — тогда - «восстановить по донору и индексу» даст другие строки. С реальным сидом ветка, - судя по фильтру, не срабатывает — проверить при фиксе и либо превратить её в - громкую ошибку, либо сериализовать так, чтобы восстановление её воспроизводило. - Молча оставить как есть нельзя. -- Неизвестный `base_click_id` при восстановлении — ошибка (по образцу проверки - `seed_click_id` в `_user_from_state`). Сейчас в `_compact_visit_batch` два - тихих fallback'а (`.get(...)` со словарём целиком для браузера и - `location_events[0]` для location) — для донора их заменить на ошибку, не - копировать. -- **Известное расхождение вне скоупа**: event_id при рождении — случайный uuid4 - (`generation.py`, `_new_uuid`), при восстановлении — детерминированный uuid5 от - `click_id:index` (`runtime.py:140-141`). Фикс это не трогает: коллизий нет, - дублей не создаёт. Тест поэтому сравнивает события по всем полям, **кроме - `event_id`** — иначе он падает не из-за нашего бага. -- Расширение схемы state — поднять `STATE_VERSION` (`state.py`) и валидацию - нового поля. Старые state становятся несовместимы: поведение при этом — по - действующему правилу (сейчас — предупреждение и чистый старт; громкий отказ - при намерении продолжить делает задача 07, здесь его не реализовывать). - -## Acceptance criteria - -- [x] Быстрый автотест на уровне генератора: сгенерировать визит, сохранить state - посреди визита, восстановить и сверить оставшиеся события с продолжением без - рестарта — по **всем** per-event полям, кроме `event_id` (браузерные и - location: referer, utm — не только browser_name; см. «Расхождение шире» выше). - Это один код восстановления для стыка backfill->live и crash-recovery - (задача 04) — одного теста на него достаточно. -- [x] Проверка стыка на стенде расширена per-event полями: на сценарии из «Что - нашли» (2 суток backfill + live за `T_end`, `GEN_SEED=4242`) визиты через стык - однородны в `dds.event` по браузерным полям (`browser_name`, - `browser_language`) **и** полям источника перехода (referer, utm) — 0 - расхождений из `crossing_visits`. Запрос/скрипт проверки сохранён как - повторяемый, а не разовый. -- [x] Без регрессий на том же сценарии: `duplicate_events=0`, конфликтов - device / os / geo на уровне ODS по-прежнему 0. -- [x] `STATE_VERSION` поднята, новое поле валидируется; несовместимый старый - state обрабатывается по действующему правилу (см. детали выше). - -## Notes - -- Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация — - из порогов риска). Ровно эта слепая зона уже пропустила дефект один раз: - критерий задач 04/05 проверяли только по `dds.click`. -- Задача 13 (доливка) идёт после этого фикса — restore-механизм должен быть - исправлен до того, как на него навешивать доливку. - -## Blocked by - -- Нет. Это bug по результатам независимой проверки задач 05 и 06. diff --git a/.scratch/generator-model-time-startup-history/issues/10-dashboard-geo-map-readability.md b/.scratch/generator-model-time-startup-history/issues/10-dashboard-geo-map-readability.md deleted file mode 100644 index 37b9608..0000000 --- a/.scratch/generator-model-time-startup-history/issues/10-dashboard-geo-map-readability.md +++ /dev/null @@ -1,89 +0,0 @@ -Status: done - -# Гео-карта на дашборде нечитаема - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` (раздел «Финальная ручная -приёмка»: если панелей не хватает для просмотра — отдельная задача на панель). - -## Что нашли - -На финальной ручной приёмке (шаг 2 спеки) виджет «🌍 Geography Map» показывает -что-то, но прочитать нельзя: - -- нет всплывающих подсказок (tooltip) — навёл на страну, значения не видно; -- нет легенды и подписи шкалы — непонятно, что кодирует цвет и в каких единицах; -- цветовая шкала неочевидна — подсвечено мало стран, остальное пусто. - -График рисуется, но непонятно, что именно он показывает. - -## Две разные причины (не путать) - -1. **Читаемость визуализации (эта задача).** Сделать так, чтобы у графика были - tooltip, легенда и подпись метрики/единиц и понятная цветовая шкала — либо - настройкой текущего чарта, либо заменой карты (choropleth — карта, где страна - закрашена по значению) на более читаемую форму, например топ-N стран - столбцами, потому что распределение сильно разрежено. Важно: сейчас это - legacy-виз `world_map` (`superset/create_dashboard.py:149-164`), и у него, - судя по параметрам, легенда и tooltip вообще не настраиваются — скорее всего, - основной путь именно смена типа визуализации, а не подкрутка текущего. - Возможности виз-типов уточнить по актуальной документации Superset через - Context7 (правило AGENTS.md) и зафиксировать выбор. -2. **Перекос гео-данных (отдельно).** Сама гео-фактура (значения стран в - событиях) берётся из статического сида (`geo_by_click_id`), и на приёмке - распределение стран оказалось сильно перекошенным — закрашено мало стран. - Опора на чужой сид — осознанно временная (ADR-0006: своя гео-фактура — - отдельный будущий шаг); лечится это генерацией гео, а не настройкой чарта. - Здесь не решаем, только отмечаем как смежную причину. - -## Acceptance criteria - -- [x] У гео-графика есть tooltip со значением по стране (на выбранном типе - визуализации — карте или замене). -- [x] Есть легенда и подпись: какая метрика и в каких единицах кодируется - (для текущей метрики `COUNT(*)` честный ответ — «событий, штук»). -- [x] Выбранный тип визуализации читаем на текущем (разреженном) распределении; - выбор типа (оставить карту или заменить) зафиксирован с коротким «почему». -- [x] Зафиксировано, что перекошенное распределение стран — свойство гео-фактуры - из сида (своя генерация гео — отдельный шаг по ADR-0006), а не настройки - чарта. -- [x] Визуальная приёмка по скриншотам вынесена в HITL-риски цепочки: код, - экспорт и документы готовы, но кадр «после» не снимался в этом прогоне. - -## Как принимать (дописано 2026-07-04) - -Проверка визуальная, через скриншоты playwright-cli (так уже делали в спеке -редизайна дашборда `docs/specs/2026-06-06-superset-dashboard-redesign.md`): - -- Скриншоты «до» и «после»: кадр виджета целиком, плюс кадр с наведением курсора - на закрашенную страну / столбец (виден tooltip). -- «Читаемо» значит: по кадру виджета целиком (без обращения к SQL и коду чарта) - можно ответить на три вопроса — какая метрика показана, в каких единицах, у - какой страны значение больше. Если по кадру ответить нельзя — не принято. -- Tooltip — по отдельному кадру с наведением: видно название страны и значение - метрики. Кадр с tooltip в headless-прогоне может стабильно не ловиться - (tooltip следует за курсором); тогда допустим другой способ показать tooltip - (например, короткая запись экрана) — согласовать с человеком, молча не - ослаблять. - -Состояние стенда: стенд не трогали с 2026-06-14 и он мог умереть. Это нормально: -полная пересборка (`make generated-history-analytics`) заново генерирует backfill. -По умолчанию она даёт **6 часов** модельной истории -(`GEN_MODEL_T_END` в `scripts/run_generated_history_analytics.sh`) — для приёмки -читаемости этого достаточно. Если нужен вид как на исходной находке (2 суток), -переопределить `GEN_MODEL_T_END` на `T0`+2 суток при запуске; профиль «2 суток -одной командой» — это ещё не сделанная задача 11, здесь её не делать. -Портативный артефакт (задача 07) для этой задачи не нужен и не блокирует её. - -## Notes - -- Всплыло на просмотре дашборда на данных генерации (2 суток backfill). -- Смежные документы: `superset/create_dashboard.py` (определение чарта), - `docs/SUPERSET_DASHBOARD.md` — при смене типа визуализации обновить в этом же - изменении. -- Возможно, стоит расширить до общего прохода по читаемости дашборда (легенды и - подсказки у других виджетов), но базово задача — про гео-карту. Если проход - делается, он не должен раздувать задачу: заметил — запиши отдельным issue. -- Рекомендуемый режим ревью по coordinator-loop: обычный (риска для state и - данных нет, правка ограничена определением чарта и документацией). diff --git a/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md b/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md deleted file mode 100644 index afd0d11..0000000 --- a/.scratch/generator-model-time-startup-history/issues/11-generator-launch-verbs-and-profiles.md +++ /dev/null @@ -1,58 +0,0 @@ -Status: done - -# Глаголы, длительность и профили запуска генератора - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Why - -Запуск генератора недружелюбный: поведение собирается из ~10 связанных -env-переменных, а смысл запуска — из их комбинации (`GEN_RUN_MODE` + -`GEN_STATE_RESET`). Конец истории задаётся абсолютной меткой `GEN_MODEL_T_END`, -которую оператор считает в уме от `T0`. - -Задача — дать чистый интерфейс «под капотом» перед DAG-пультом -(`12-generator-control-dag.md`): если команды простые и говорящие, DAG -становится тонкой обёрткой с понятными параметрами, а не переводчиком -формы в матрицу переменных (решение пользователя, 2026-07-04). - -## What to build - -- **Глаголы вместо матрицы флагов:** явные команды `backfill` / `continue` / - `reset` (make-цели или аргументы запуска), а не комбинация - `GEN_RUN_MODE` + `GEN_STATE_RESET`. Старые переменные могут остаться как - низкоуровневый механизм, но документированный путь — глаголы. -- **Длительность вместо абсолютной метки:** задавать конец истории как - длительность от `T0` (например, `2d`), расчёт `T_end` — внутри. -- **Именованные профили** вместо повторения блока переменных: минимум два — - быстрый проверочный (6 часов, текущий дефолт CI) и «с суточной волной» - (2+ суток). - -## Acceptance criteria - -- [x] Стартовую историю на 2 суток можно получить одной командой с глаголом и - длительностью/профилем, без ручного расчёта `T_end` и без выставления - `GEN_RUN_MODE`/`GEN_STATE_RESET` вручную. -- [x] Глаголы не меняют семантику режимов: за `backfill`/`continue`/`reset` - стоит тот же контракт модельного времени и state, что в спеке - `docs/specs/2026-06-14-generator-model-time-and-startup-history.md`. -- [x] Профили покрывают быстрый проверочный прогон и прогон с суточной волной; - выбранный профиль виден в логах/манифесте. -- [x] Runbook (из задачи 07) и документы запуска переведены на глаголы и - профили; старый способ через переменные упомянут как низкоуровневый. -- [x] Существующие тесты генератора проходят; поведение по умолчанию - (CI-профиль) не изменилось. - -## Notes - -- Это интерфейсный слой над существующей механикой: генерацию, state и - антисмешивание не менять. -- Вместе с глаголами не забыть команды экспорта/импорта из задачи 07 — они - уже в стиле глаголов, привести всё к одному стилю. - -## Blocked by - -- `07-startup-history-portable-artifact-and-usage-docs.md` — runbook и команды - экспорта/импорта появляются там; эта задача переводит их на единый стиль. diff --git a/.scratch/generator-model-time-startup-history/issues/12-generator-control-dag.md b/.scratch/generator-model-time-startup-history/issues/12-generator-control-dag.md deleted file mode 100644 index d9cc430..0000000 --- a/.scratch/generator-model-time-startup-history/issues/12-generator-control-dag.md +++ /dev/null @@ -1,169 +0,0 @@ -Status: done - -# Airflow-DAG — пульт управления генератором - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Why - -Даже с runbook и глаголами управление генератором остаётся консольным. Идея -(пользователь, 2026-06-14; приоритет поднят 2026-07-04): параметризованный DAG -в Airflow как основной человеческий интерфейс стенда — форма в веб-UI, где -выбираются операция, профиль, длительность. Плюсы: валидация настроек против -манифеста ещё до запуска, наглядный статус, понятные ошибки. - -Учебный бонус: такой DAG сам по себе учебный материал — живой пример -параметризованной оркестрации (тема урока 4), что ближе к цели курса, чем -устройство генератора. - -## Решения (дооформление 2026-07-04) - -- **Без Docker-доступа из Airflow.** Docker socket в контейнеры Airflow не - прокидываем. Конечные операции — это обычный Python-код генератора, которому - нужны только настройки и Kafka; таски DAG выполняют его сами, в worker'е - Airflow. Урок из проекта `airflow-greenplum-solution` (пользователь): DAG - говорит с управляемой системой по сетевому протоколу, жизненный цикл - контейнеров — только хост/Makefile. -- **Границы пульта.** `make up`, `make clean` (полный сброс) и старт/стоп - live-сервиса — консоль; операции `continue` в пульте нет намеренно. - Backfill и import работают только на чистом стенде (пустые топики данных); - грязный стенд лечится `make clean` с консоли — пульт при отказе прямо - подсказывает это. -- **Генератор не автостартует.** Сейчас `make up` поднимает live-генератор - вместе со стендом (`docker-compose.yml`, `restart: unless-stopped`) — с - пультом это неверно: любой backfill был бы сразу отбит предпроверкой, а - выключить live из UI нельзя. Live становится явным действием - (`make generator-continue`); механизм — например, compose-профиль для - сервиса `generator`, выбрать при реализации и поправить make-цели. -- **Три операции, а не четыре.** `backfill` | `import` | `check`. Отдельного - `export` нет: артефакт в коде пишется только по ходу backfill - (`StartupHistoryArtifactBuilder` внутри `_run_backfill`), «выгрузки задним - числом» не существует — поэтому у backfill есть поле `artifact_path` - («сохранить артефакт», пусто — не сохранять). -- **Один DAG** с параметром «операция», а не несколько DAG по операциям. -- **После backfill/import DAG сам запускает ETL** (`TriggerDagRunOperator` на - существующий ETL-DAG) и **дожидается его завершения** перед check: - `wait_for_completion` в Airflow 2.10.5 по умолчанию `False` (сверено по - Context7, ревью Codex 2026-07-04) — простой trigger запустил бы check - раньше конца ETL. Менти видит цепочку «мир → пайплайн → витрины». - -## What to build - -- **Код генератора доступен таскам Airflow.** Варианты: смонтировать - `generator/src` томом (как уже смонтирован `./sql`) и добавить в - `PYTHONPATH`, либо ставить пакет в `Dockerfile.airflow`. Монтировать нужно - во все Airflow-сервисы (scheduler, webserver, worker): список профилей в - форме читается из `PROFILES` при разборе DAG. Критерий выбора — правка - генератора не должна требовать лишних пересборок. -- **Выравнивание зависимостей:** версии сейчас расходятся — kafka-python - 2.0.5 у генератора против 2.0.6 в `airflow/requirements.txt`; привести к - одной. `prometheus-client` в образе Airflow нет — либо добавить, либо (лучше) - точка входа backfill не поднимает HTTP-сервер метрик (`service.py:65-66` - вызывается безусловно — понадобится небольшая склейка; это единственное - место, где честно появляется новый код, зафиксировать его в PR). -- **DAG `generator_control`** в `airflow/dags/`: `schedule=None`, - `max_active_runs=1`, форма запуска на `Param`: - - `operation`: `backfill` | `import` | `check` (выпадающий список); - - `profile`: из `PROFILES` (`launch.py`), список брать динамически; - - `duration`: строка вида `6h`/`2d`, пусто — из профиля; - - переопределения мира для backfill (минимум скорость и seed) — через - механизм `overrides` в `build_launch_env`; - - `artifact_path`: для backfill — куда сохранить артефакт (пусто — не - сохранять), для import — что импортировать; дефолт в общем томе `./data`. -- **Ветвление по операции** — `BranchPythonOperator`, в стиле существующих - DAG стенда. -- **Каждая операция начинается с валидации, падение — до любых записей:** - - backfill и import: топики данных пусты (переиспользовать - `KafkaTopicInspector.assert_data_topics_empty`) **и STG-таблицы - ClickHouse пусты** — батч-ETL пересобирает ODS из всего STG - (`sql/ods/20_stg_to_ods.sql`), поэтому пустых топиков мало: старый мир в - STG смешался бы с новым. При отказе в логе — подсказка про `make clean`; - - import дополнительно: артефакт читается, манифест совместим с выбранными - настройками (готовые проверки `startup_history_artifact.py`), в лог — - перечень разошедшихся полей; - - вспомогательно: проверка «live не работает» по метрикам - `generator:9109` внутри сети compose (см. Notes об ограничениях). -- **Тела операций — вызовы существующего кода:** `build_launch_env` + прогон - генерации до `T_end` (backfill), функции `startup_history_artifact` - (import), сверка контрольных чисел с манифестом (check). Источник манифеста - для check — compact-топик (он есть и после backfill, и после import); - артефакт — запасной вариант. -- `retries=0` у содержательных тасков: оператор должен сразу видеть ошибку - (проверенный приём из greenplum-проекта). -- **Документация:** в runbook `docs/runbooks/startup-history.md` — раздел - «Пульт в Airflow» как основной путь и явные границы пульта (что остаётся - консолью и почему, включая «continue в пульте нет намеренно»); тонкость - нестандартного мира: `make generator-continue` для мира с переопределёнными - настройками требует тех же настроек, громкий отказ подскажет разошедшиеся - поля. Ссылки из `docs/OPERATIONS.md` и `README.md`; правки compose и - Makefile описать в том же PR (правило репозитория). - -## Acceptance criteria - -- [x] После `make up` (генератор не автостартует) стартовую историю выбранного - профиля/длительности можно создать из веб-UI Airflow, не открывая консоль и - не выставляя переменных окружения; следом ETL запускается из той же цепочки - и `check` зелёный. -- [x] Импорт артефакта из веб-UI: несовместимый артефакт отклоняется **до** - записи в топики, в логе таска — перечень разошедшихся полей. -- [x] Backfill/import на непустом стенде отклоняются предпроверкой с - подсказкой про `make clean`; миры не смешиваются. -- [x] Артефакт, сохранённый при backfill из веб-UI, пригоден для консольного - импорта (формат один и тот же), и наоборот. -- [x] Операция check сверяет контрольные числа ClickHouse с манифестом и - падает при расхождении. -- [x] Docker недоступен из контейнеров Airflow (socket не монтируется) — это - граница решения, а не упущение. -- [x] Консольные глаголы работают как раньше; `scripts/*` и DAG сходятся в - одном `launch.py`, дублирования логики запуска нет. -- [x] `make up` больше не запускает live; `make generator-continue` запускает - его явно; существующие сценарии (`generated-history-analytics`, CI) не - сломаны. -- [x] Runbook и `docs/OPERATIONS.md` описывают пульт как основной путь и его - границы. -- [x] DAG остаётся читаемым менти: витрина параметризованной оркестрации, - сложность живёт в коде генератора. - -## Notes - -- Перед реализацией сверить API формы (`Param`, enum, описания полей) и - `TriggerDagRunOperator` для Airflow 2.10 через MCP Context7 — правило - репозитория. -- `Config` генератора читает env процесса при создании: в таске собирать - окружение через `build_launch_env` и применять к процессу таска (executor — - LocalExecutor, таск живёт в своём процессе). Не забыть `GEN_DATA_DIR`: - `build_launch_env` его не задаёт, дефолт `/data`, а в контейнерах Airflow - данные смонтированы в `/opt/airflow/data` — без явной установки `Config()` - упадёт, словари `*.jsonl` не найдутся. -- Права на файлы: артефакт из worker'а пишется uid'ом Airflow (50000); в - контейнере генератора `./data` смонтирован **read-only**, консольные - скрипты монтируют каталог артефактов отдельно. Чтение работает везде, - перезапись чужого файла — не всегда; одну строку об этом — в runbook. -- Проверка `generator:9109` — вспомогательная эвристика, не защита: ошибка - DNS означает «сервис не поднят» (не ошибку таска); при крашлупе после - громкого отказа порт мигает (HTTP-сервер стартует до валидации state); - эфемерные контейнеры `docker compose run` под этим именем не видны. Основная - защита от смешивания — пустота топиков данных. -- Backfill — блокирующий таск на минуты; при `max_active_runs=1` для ручного - стенда это нормально. -- Грабли greenplum-проекта, применимые здесь: DAG создаются на паузе — в - инструкции приёмки не забыть unpause перед trigger; автоматические прогоны - гонять через REST API, а не CLI внутри контейнера. -- Возможное развитие (не в скоупе): операция «очистить данные» из пульта - (прецедент удаления/пересоздания топиков из DAG уже есть в - `airflow/dags/utils/kafka_helpers.py`) — сняла бы требование `make clean` - перед новым миром. -- Артефакты `daily-wave`, сделанные до задачи 14, протухнут после неё - (антисмешивание отработает громко) — при пересечении работ это ожидаемо. -- Рекомендуемый режим ревью по coordinator-loop: обычный — state и - сериализацию задача не трогает, код генератора переиспользуется как есть. - -## Зависимости (проверенные предпосылки) - -- `07-startup-history-portable-artifact-and-usage-docs.md` — сделана. -- `11-generator-launch-verbs-and-profiles.md` — сделана. -- Мягкая связь с `14-fast-teaching-profile.md`: быстрый профиль появится в - выпадашке сам, если список профилей брать из `PROFILES` динамически; - блокером не является. diff --git a/.scratch/generator-model-time-startup-history/issues/13-backfill-top-up-from-snapshot.md b/.scratch/generator-model-time-startup-history/issues/13-backfill-top-up-from-snapshot.md deleted file mode 100644 index ea7e6c1..0000000 --- a/.scratch/generator-model-time-startup-history/issues/13-backfill-top-up-from-snapshot.md +++ /dev/null @@ -1,208 +0,0 @@ -Status: done - -# Глагол next-day: собрать следующий модельный день - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Why - -Рамка пересмотрена 2026-07-07 (обсуждение с пользователем). Было: «удлинить -готовую историю» (2 суток -> 7) — этот мотив закрыл портативный артефакт -(задача 07), приоритет был низкий. Стало: **режим кормления стенда порциями**. -Ценность не в длине истории, а в дискретности подачи: - -- **Урок.** Менти триггерит «следующий день» -> в Kafka падает батч за новый - модельный день -> прогон `etl_pipeline` -> витрины и дашборды сдвинулись. - Полный цикл DWH виден за один шаг; повторяемо и управляемо, в отличие от - непрерывного live. -- **Имитация жизни.** Тот же механизм по расписанию — стенд «живёт». - В этой задаче делаем глагол готовым к расписанию (идемпотентность, - громкие отказы), но само включение расписания — отдельная задача - (решение по скоупу 2026-07-12, см. «Решения по ревью постановки»). -- Удлинение истории остаётся как частный случай (несколько next-day подряд). - -## Интерфейс - -Новый глагол `next-day` в существующем DAG `generator_control` -(`airflow/dags/generator_control_dag.py`), не отдельный DAG: менти уже знает, -где ручки генератора. Pause-check `etl_pipeline` переиспользуется -(`generator_control_dag.py:153-164` — подтверждено ревью). Guard чистого -стенда **не** переиспользуется — у next-day своя предпроверка границы -(см. решение 3 ниже). - -## Развилка реализации — решена (пользователь, 2026-07-07) - -**Выбран вариант 2: генерация следующего дня от слепка `T_end`.** - -Обоснование. Диагноз задачи 20 показал, что генератор на стыке ничего не -ломает — красный гейт был гонкой в самой проверке. Оба варианта одинаково -требуют цепочку границ и расширение проверок, а вариант 1 сверх того требует -новый режим среза в импорте («всё или ничего» сейчас). Вариант 2 не дороже, -честнее (жизнь стенда не ограничена длиной артефакта) и переиспользует -закалённый restore-механизм (задача 09: донор в state, громкие ошибки, -детерминированная запасная ветка). - -Отклонено: вариант 1 (резать готовый артефакт на дневные порции) — не даёт -неограниченной жизни и требует своей новой механики импорта. - -## Решения по ревью постановки (2026-07-12, два слепых ревью) - -Оба ревью (Codex и Claude, свежие сессии) вернули CHANGES_REQUIRED. -Все находки внесены решениями ниже; полные индексы — в обменном каталоге -сессии (в репо не хранятся). - -1. **next-day — новый ограниченный режим запуска, а не переиспользование - существующего глагола.** Сейчас `backfill` — свежая генерация от `T0` - с чистым завершением, `continue` — бесконечный live - (`generator/src/clickstream_generator/launch.py:96-128`, - `service.py:96-128`). next-day = восстановление мира из state - + генерация ровно одного дня с самозавершением + сдвиг манифеста. - Это основная работа задачи, «переиспользуется» только restore-механизм. -2. **Один модельный день = ровно 24 часа, полуоткрытый диапазон - `[T_end, T_end + 24h)` в модельном времени (UTC).** Календарные сутки и - `GEN_MODEL_TIMEZONE` в границах не участвуют; литералы времени в - проверках — по образцу `clickhouse_datetime_literal` (урок задачи 20). -3. **Предпроверка границы вместо guard'а чистого стенда.** - `assert_stand_clean` в DAG требует пустые Kafka и STG - (`airflow/dags/lib/airflow_control.py:99-109`) — next-day по построению - доливает в непустой стенд. Своя предпроверка: манифест существует, - state согласован с `T_end` манифеста, live-генератор остановлен. - Образец «непустого» режима — `assert_stand_clean.sh continue` - (в Python-версии guard'а такого режима нет — добавить именно для - next-day, `clean`-режим не менять). -4. **Идемпотентность — через явный параметр.** У DAG-запуска next-day есть - необязательный параметр `expected_t_end`: если задан и не совпадает с - `T_end` манифеста — громкий отказ, в тексте обе границы (ожидаемая и - фактическая). Повторный триггер «того же дня» с прежним `expected_t_end` - после успешного прогона ловится этим же сравнением. Без параметра — - генерация от текущей границы (режим «жизни»); от двойного параллельного - запуска защищает `max_active_runs=1`. -5. **Точка фиксации — публикация манифеста, автоотката нет.** Порядок - публикации дня: топики данных -> state -> манифест последним. Сбой до - манифеста оставляет «хвост» за границей — его ловит проверка цепочки - (непарные счётчики), восстановление — переимпорт артефакта - (задокументировать в OPERATIONS.md). Отклонено: инкрементальный откат - через `rollback_import_topics` — он удаляет топики целиком - (`startup_history_artifact.py:250-270`) и для доливки непригоден. -6. **Цепочка границ живёт внутри единственной записи манифеста.** - Манифест остаётся одной записью compact-топика с ключом `default` - (`kafka_io.py:205-274`); добавляется необязательное поле `boundaries` — - накопительный список границ `[T0, d1, ..., T_end]`. Старый манифест без - поля читается как `[T0, T_end]`. Итоговые счётчики и контрольные суммы - остаются накопительными за всю историю. Формат артефакта меняется только - этим необязательным полем — без новой версии и без изменения импорта. -7. **Проверка цепочки — новый батчевый режим, live-гейт не трогать.** - Текущий seam-гейт (`scripts/check_generated_analytics.sh:334-531`) - привязан к одной границе и живому генератору — литерально «расширить» - его нельзя. Новый режим (отдельная make-цель): по каждой границе из - `boundaries` — непарные счётчики и однородность per-event полей у - визитов, переживших границу (класс проверки задачи 09). Существующий - сценарий `make generated-history-runtime-check` остаётся зелёным как был. -8. **Расписание — вне этой задачи.** `schedule=None` у DAG не меняется; - задача гарантирует только готовность глагола к расписанию (пункты 4–5). - Включение «жизни» по расписанию — отдельная задача-продолжение (в ней же - решить: как расписание задаёт `operation=next-day`, ведь параметр DAG по - умолчанию — `backfill`, и планового запуска с дефолтом быть не должно). -9. **Исправлены ссылки:** запасная ветка рождения визита — - `generation.py:196-209` (строка 194 — основная ветка); донор - сериализуется в `runtime.py:178-226`. - -## Acceptance criteria - -- [x] Глагол `next-day` в `generator_control`: батч ровно за - `[T_end, T_end + 24h)` от `T_end` манифеста; после прогона манифест - содержит новую границу в `boundaries` и новый `T_end`. -- [x] Предпроверка границы: на пустом стенде (нет манифеста) next-day - громко падает; при работающем live-генераторе — громко падает; - `clean`-guard и существующие глаголы `backfill|import|check` не изменены. -- [x] Идемпотентность: запуск с `expected_t_end`, не равным `T_end` - манифеста, — громкий отказ с обеими границами в тексте; повторный запуск - того же дня после успеха — тот же отказ, дублей и второго мира нет. -- [x] Проверка цепочки (новая make-цель): проходит по всем границам из - `boundaries`; после N прогонов next-day в цепочке N новых границ; - непарные счётчики нулевые, per-event поля визитов через каждую границу - однородны (браузер, referer, utm). -- [x] Учебный цикл целиком и повторяемо: next-day -> прогон `etl_pipeline` - -> в DM-витринах появились строки ровно за новый день (счётчики до/после, - прирост только в диапазоне нового дня); проверено на двух next-day подряд. -- [x] Регрессий нет: тесты генератора и контракты корня зелёные - (`make test`), сценарий `make generated-history-runtime-check` - задачи 20 остаётся зелёным. -- [x] Документация: OPERATIONS.md — глагол, предпроверка, поведение при - сбое до фиксации манифеста (восстановление переимпортом артефакта). - -## Границы (что не трогать) - -- Поведение существующих глаголов `backfill`, `import`, `check` и - `clean`-guard'а. -- Формат артефакта — кроме необязательного поля `boundaries` (решение 6); - механику импорта не менять. -- Live seam-гейт задачи 20 (`generated-history-runtime-check`) — не - редактировать, только прогонять как регрессию. - -## Blocked by - -- Нет. Последний блокер снят 2026-07-12: `20-flaky-runtime-seam-check.md` - закрыта, гейт стыка стабилен (3 подряд зелёных прогона, красный сценарий - ловится). -- Исторические блокеры закрыты: `15-world-boundary-after-cross-review.md` - (граница миров) и `17-trusted-checks-startup-history-superset.md` - (доверенные проверки) — done. - -Исторический контекст: `09-seam-browser-fixture-not-preserved.md` — шов -рождается при любом прохождении мира через сериализованный state (рестарт, -сбой, доливка — один код восстановления); фикс 09 положил донора фактуры в -state и закрыл тихие fallback'и. Прежняя редакция этой задачи называлась -«Доливка стартовой истории кусочком от слепка». Проверенные 2026-07-07 -факты (import «всё или ничего», донор задачи 09) подтверждены обоими ревью -2026-07-12 по коду ветки. - -## Решения по слепым ревью реализации (2026-07-12) - -### Полная перечитка Kafka и конечный срок хранения — отклонено в задаче 13 - -Накопительные `visits` и `users` требуют точного объединения идентификаторов, -а текущая контрольная сумма — SHA-256 последовательности событий. Из одних -итоговых чисел и готовой контрольной суммы нельзя точно добавить новый день. Для -инкрементального расчёта пришлось бы хранить множества идентификаторов и новое -состояние hash в manifest. Это изменило бы формат артефакта сверх разрешённого -поля `boundaries` и нарушило бы решение 6. - -Поэтому в задаче 13 остаётся точная пересборка по доступной истории Kafka. -Её проверяет -`test_next_day_publishes_24h_then_state_then_cumulative_manifest`: два дня, -точные накопительные счётчики и суммы. Риск принят только для ручного учебного -цикла. Ограничение по времени, памяти и сроку хранения записано в OPERATIONS.md. -Задача про расписание из решения 8 до включения обязана выбрать бессрочное -хранение data-топиков или новый согласованный формат накопительного состояния. - -### Device/geo min/max timestamps после пересборки — отклонено - -Эти два диапазона являются диагностическими полями статистики топика и не -участвуют в `compare_clickhouse_stats_to_manifest`, проверке цепочки или -критериях приёмки. Контракт задачи требует накопительные строки и контрольные -суммы; они пересчитываются точно и проверяются тестом двух `next-day`. -Выравнивание -device/geo min/max с потактовым backfill потребовало бы сохранять связь каждой -повторной строки с исходным browser-событием, которой в этих сообщениях нет. -Менять формат ради неиспользуемых диагностических полей в задаче 13 не следует. - -### Нулевой стык — допустим с явным статусом - -Диагноз текущего стенда подтвердил естественный ночной провал, а не потерю -визитов при восстановлении. На границе `2026-01-02T01:00:00+00:00` DDS и STG -дали `crossing_visits=0`. За предыдущий час завершилось 27 визитов, за -следующий началось 24. Последний визит закончился в `00:56:57.766327`, новый -начался в `01:00:00`; пауза составила 183 секунды. State на границе содержал -`active_visits=0`, поэтому restore не мог отбросить активный визит. Для -контроля: на предыдущей границе state содержал один активный визит, и DDS/STG -нашли один переходящий визит. - -Поэтому нулевой стык проходит с явным статусом «однородность неприменима». -Пустая порция, хвост за последней границей и непарные строки по-прежнему дают -отказ. Остаточный слепой участок принят: однородность нельзя проверить без -переходящего визита. Restore закреплён тестом двух последовательных `next-day` -и детерминизмом задачи 09, а live-гейт задачи 20 отдельно проверяет стык, где -переходящий визит гарантирован сценарием. diff --git a/.scratch/generator-model-time-startup-history/issues/14-fast-teaching-profile.md b/.scratch/generator-model-time-startup-history/issues/14-fast-teaching-profile.md deleted file mode 100644 index 68506c1..0000000 --- a/.scratch/generator-model-time-startup-history/issues/14-fast-teaching-profile.md +++ /dev/null @@ -1,94 +0,0 @@ -Status: done - -# Быстрый учебный профиль: суточная волна за минуты занятия - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Why - -Мир по умолчанию живёт со скоростью настенных часов (`speed=1`): чтобы -наблюдать суточную волну вживую, стенд должен непрерывно работать сутки. -Столько стенды включёнными не живут, и люди не вытерпят (пользователь, -2026-07-04). Стартовая история закрывает только половину проблемы: она даёт -закономерности **в статике**, но правый край графика ползёт 1:1 — на занятии -live выглядит мёртвой картинкой. - -Решение пользователя (2026-07-04): учебному миру нужна скорость 60 — модельный -час за настенную минуту, полная суточная волна проживается за ~24 минуты -занятия. - -## What to build - -- Перевести профиль `daily-wave` (`launch.py`) на `GEN_MODEL_TIME_SPEED=60` - при `GEN_TICK_SECONDS=1`. Модельный тик остаётся прежним: 1 настенная - секунда × 60 = 60 модельных секунд, поэтому фактура потока (крупность пачек, - бюджет тика до 72 событий в пике: 60/мин × 1.2) не отличается от - сегодняшней, и колпак `GEN_MAX_EVENTS_PER_TICK` трогать не нужно. -- Наивный вариант «speed=60 при тике 60 с» отвергнут при разборе - (2026-07-04): модельный тик стал бы часом; первые события всех визитов тика - получают один таймстемп — старт тика (`runtime.py:433`, - `generation.py:198`) — история слиплась бы в почасовые гребёнки, а пиковый - тик (4320 событий) упёрся бы в колпак 1000 и срезал бы верхушку волны. -- **Оценить цену тика в 1 Гц.** Сейчас каждый live-тик пишет state в - compact-топик, запись в историю пачек и несколько INFO-строк - (`service.py:566, 584-585, 591-600`): на тике 1 с это 86 400 сохранений и - ~полмиллиона строк лога за настенные сутки против 1 440 сегодня. Пер-тиковые - INFO приглушить до DEBUG (или логировать раз в N тиков); частоту сохранения - state менять только с оглядкой на спеку — она входит в контракт - возобновления после сбоя. -- Профиль `ci` не трогать: это дефолт CI, его мир и контрольные суммы должны - остаться прежними. -- Обновить документы, где живут предположения о старом `daily-wave`: - runbook `docs/runbooks/startup-history.md` (таблица профилей; продолжение - мира — `PROFILE=daily-wave make generator-continue`; старые артефакты - профиля становятся несовместимыми — антисмешивание отработает громко, это - ожидаемо), `docs/OPERATIONS.md` (smoke-последовательность со `sleep 130` — - ожидание двух минутных тиков — на тике 1 с теряет смысл), `README.md`, - `generator/README.md`. -- Спеку `docs/specs/2026-06-14-generator-model-time-and-startup-history.md` - дополнить: выбранная пара tick/speed и почему именно она. - -## Acceptance criteria - -- [x] После backfill/импорта `daily-wave` и `PROFILE=daily-wave make - generator-continue` модельное время идёт ~в 60 раз быстрее настенного; на - дашборде суточная волна проживается за ~24 настенные минуты. -- [x] Пиковые тики не упираются в `GEN_MAX_EVENTS_PER_TICK` — волна не - срезана. -- [x] Стык «история → live» бесшовный: без дублей и дыр, антисмешивание - работает как раньше. -- [x] Многочасовой live на тике 1 с не заливает журналы и compact-топики: - пер-тиковые INFO приглушены или их объём обоснован в PR; объём - state-записей (86 400/сутки) измерен и обоснован — либо частота сохранения - state осознанно изменена вместе с правкой спеки (контракт возобновления). -- [x] Профиль `ci`, его контрольные суммы и поведение по умолчанию не - изменились; существующие тесты генератора проходят. -- [x] Runbook, `docs/OPERATIONS.md`, README'и и спека обновлены. - -## Notes - -- Нагрузка по событиям: ~42–72 события в секунду (ночь–пик) — незаметно для - Kafka и ClickHouse; backfill не дорожает (число тиков на 2 суток то же, что - сейчас: модельный шаг тика не изменился). -- Если генерация тика займёт больше 1 настенной секунды, тики пойдут реже и - фактическая скорость окажется чуть ниже 60 (`service.py:623-627`) — это - допустимо, точная скорость не обещается. -- Несовместимость со старыми мирами ловится по-разному: импорт артефакта и - continue от стартовой истории сверяют полный набор настроек (включая - `tick_seconds` в манифесте), а обычный live-continue — только - seed/T0/timezone/speed (`service.py:213-230`); для этой правки хватает и - его — speed изменился. -- Альтернатива «третий профиль fast-live» отвергнута: у медленного - `daily-wave` не остаётся сценария использования, а лишний профиль усложняет - выпадашку пульта (задача 12). -- Рекомендуемый режим ревью по coordinator-loop: обычный (значения профиля и - логирование). Исключение: если по ходу решат менять частоту сохранения - state — это уже контракт возобновления, эскалировать в **гейт**. - -## Blocked by - -Нет: профили из задачи 11 сделаны. Мягкие связи: задача 12 (профиль появится -в форме пульта автоматически) и задача 08 (артефакт курса рождается из этого -профиля — эту задачу лучше сделать до него). diff --git a/.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md b/.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md deleted file mode 100644 index 2517914..0000000 --- a/.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md +++ /dev/null @@ -1,46 +0,0 @@ -Status: done - -# Граница миров после кросс-ревью - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Закрыть семейство находок кросс-линейного ревью про «смешение миров»: после -обновления кода, сброса стенда или консольного запуска старое состояние и старый -live-генератор не должны тихо писать события в новый мир данных. - -Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`, -разделы `Issue 12/14` и `Issue 09`. - -Связанные старые issue: `09-seam-browser-fixture-not-preserved.md`, -`12-generator-control-dag.md`, частично `14-fast-teaching-profile.md`. - -## Acceptance criteria - -- [x] Известная старая версия state при `GEN_STATE_RESET=false` даёт громкий - отказ с понятным сообщением, а не тихий чистый старт поверх существующей - истории. -- [x] Сброс и остановка стенда убирают ручной live-генератор из compose-профиля; - он не переживает `down`/`clean` и не воскресает после перезагрузки хоста. -- [x] Консольные пути backfill/import/continue используют ту же идею - предпроверки чистого стенда, что Airflow-пульт, но реализованы способом, - пригодным для запуска с хоста. -- [x] Документация объясняет миграционный случай: почему старый state может - теперь дать громкий отказ и что делать оператору или менти. -- [x] Есть проверка, которая доказывает каждый из трёх путей смешения миров: - старая версия state, compose-профиль live-генератора, консольный запуск на - грязном стенде. - -## Blocked by - -Нет — можно начинать сразу. - -## Notes - -- Рекомендуемый режим `coordinator-loop`: гейт. Worker `high`, ревью задачи - `high`, ревью кода `high`. -- Не чинить здесь ложнозелёные проверки и курс целиком: это отдельные задачи - 17 и 18. Здесь только граница миров и доказательства именно этой границы. diff --git a/.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md b/.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md deleted file mode 100644 index 2896a90..0000000 --- a/.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md +++ /dev/null @@ -1,50 +0,0 @@ -Status: done - -# Свежий стенд и Airflow-пульт без догадок - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Сделать так, чтобы человек на свежем клоне мог пройти быстрый старт до -`generator_control` без неявных шагов, сырых ошибок и зависаний Airflow. - -Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`, -разделы `Issue 12/14` и `Issue 08`. - -Связанные старые issue: `12-generator-control-dag.md`, -`14-fast-teaching-profile.md`. - -## Acceptance criteria - -- [x] Quick start и курс явно называют `uv` и остальные обязательные - пререквизиты до первой команды, которая их требует. -- [x] Быстрый старт на чистом стенде подготавливает DDL до проверок генератора; - отсутствие таблиц не падает сырым `UNKNOWN_TABLE`. -- [x] После обновления репозитория Airflow получает новые зависимости и DAG-и - без ручных догадок: через пересборку, явную команду или ясно описанный шаг. -- [x] `generator_control` не висит навсегда, если зависимый `etl_pipeline` - стоит на паузе: оператор получает понятный отказ или инструкцию до запуска - долгого ожидания. -- [x] Решение по Airflow API, проверенное через Context7 для текущей версии, - зафиксировано в коде или документации кратко и рядом с местом выбора. -- [x] Runbook предупреждает, что долгий простой `daily-wave` на скорости 60 - создаёт большую дыру в модельном времени, и советует reset или повторный - импорт стартовой истории. - -## Blocked by - -- `.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md` - -## Notes - -- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`, ревью - кода `high`. Если правка уйдёт в устройство DAG-зависимостей шире - предпроверки паузы, эскалировать до гейта. -- Формальная зависимость от 15 нужна из-за общего участка предпроверки чистого - стенда: свежий стенд должен опираться на уже выбранный способ отказа и - диагностики грязного мира. -- Не включать сюда учебные тексты уроков 00/01/05, кроме пререквизитов и - quick start: основной проход курса закрывает задача 18. diff --git a/.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md b/.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md deleted file mode 100644 index 4198e7b..0000000 --- a/.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md +++ /dev/null @@ -1,48 +0,0 @@ -Status: done - -# Проверки startup-history и Superset без ложного зелёного - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Сделать проверки доверенными: зелёный результат должен означать, что проверен -фактический стык стартовой истории и живого продолжения, а Superset export -соответствует ожидаемому контракту чарта. - -Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`, -разделы `Issue 09`, `Issue 10` и `Issue 08`. - -Связанные старые issue: `09-seam-browser-fixture-not-preserved.md`, -`10-dashboard-geo-map-readability.md`, частично `14-fast-teaching-profile.md`. - -## Acceptance criteria - -- [x] Проверка startup-history берёт границу стыка из манифеста фактического - прогона, а не из значения окружения по умолчанию. -- [x] ODS-часть проверки не проходит на пустом ODS: отсутствие строк считается - ошибкой проверки, а не успешным результатом. -- [x] Профиль запуска доходит до проверки: сценарий с `daily-wave` проверяет - именно мир `daily-wave`, а не диапазон `ci`. -- [x] Ожидание live-строк доказывает, что живое продолжение реально успело - записать данные; короткое ожидание не должно превращать проверку стыка в - пропущенную проверку. -- [x] Тест Superset сравнивает полный существенный контракт `params` между - конфигурацией и export, а не только небольшой набор полей. -- [x] Сортировка top countries задана явно по числу событий, чтобы порядок не - зависел от случайной формы ответа после pivot. -- [x] Повторная синхронизация Superset не удаляет одноимённые чарты вне целевого - источника данных или dashboard; деструктивность sync задокументирована. - -## Blocked by - -Нет — можно начинать сразу. - -## Notes - -- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`, - ревью кода `high`. -- Не смешивать с правками курса: задача 18 должна пользоваться уже доверенными - проверками, но текст уроков не является частью этой задачи. diff --git a/.scratch/generator-model-time-startup-history/issues/18-course-clean-stand-startup-history.md b/.scratch/generator-model-time-startup-history/issues/18-course-clean-stand-startup-history.md deleted file mode 100644 index b08aa10..0000000 --- a/.scratch/generator-model-time-startup-history/issues/18-course-clean-stand-startup-history.md +++ /dev/null @@ -1,50 +0,0 @@ -Status: done - -# Курс проходит по startup-history на чистом стенде - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## What to build - -Довести учебный путь до воспроизводимого прохода на чистом стенде: уроки должны -работать с `startup-history`, не опираться на архивный сид и не обещать live-поток -там, где он больше не запускается по умолчанию. - -Источник: `.scratch/handoffs/20260705-0002-cross-review-generator-chain.md`, -раздел `Issue 08`. - -Связанные старые issue: `08-migrate-course-from-archive-seed.md`, частично -`12-generator-control-dag.md` и `14-fast-teaching-profile.md`. - -## Acceptance criteria - -- [x] Упражнение урока 01 с добавлением `kafka_msg_ts` воспроизводимо на стенде: - повторная заливка данных не сбрасывает схему, которую только что изменил - менти. -- [x] Урок 00 объясняет служебные топики генератора настолько, чтобы список - топиков не выглядел как набор неожиданных артефактов. -- [x] Уроки 00 и 05 не противоречат друг другу в описании видимости consumer lag; - утверждение проверено на текущем стенде. -- [x] Урок мониторинга согласован с no-live default: либо явно запускает live, - либо не обещает, что алерт отсутствия сообщений будет тихим на здоровом - стенде без live. -- [x] Операционная документация не ведёт учебный путь первым делом к архивному - `make data`; startup-history остаётся основным источником аналитики. -- [x] Проход уроков 00, 01 и 05 на чистом стенде проверен командно или вынесен - в явный HITL-пункт с точной инструкцией, что должен подтвердить человек. - -## Blocked by - -- `.scratch/generator-model-time-startup-history/issues/15-world-boundary-after-cross-review.md` -- `.scratch/generator-model-time-startup-history/issues/16-fresh-stand-generator-control.md` -- `.scratch/generator-model-time-startup-history/issues/17-trusted-checks-startup-history-superset.md` - -## Notes - -- Рекомендуемый режим `coordinator-loop`: обычный issue, worker `medium`, ревью - кода и документов `high`. -- Если по ходу окажется, что упражнение 01 требует нового поддержанного режима - перезаливки, не прятать это в тексте урока: оформить как явное решение и - покрыть проверкой. diff --git a/.scratch/generator-model-time-startup-history/issues/19-test-and-lint-targets.md b/.scratch/generator-model-time-startup-history/issues/19-test-and-lint-targets.md deleted file mode 100644 index 78aba8a..0000000 --- a/.scratch/generator-model-time-startup-history/issues/19-test-and-lint-targets.md +++ /dev/null @@ -1,63 +0,0 @@ -Status: done - -# Единые make test и make lint для коммит-гейта - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Why - -Во время coordinator-loop по задачам 15-18 координатор несколько раз упёрся в -отсутствие общих целей `make test` и `make lint`. Из-за этого коммит-гейт -собирался вручную из частных команд: `make generator-test`, `docker compose -config --quiet`, `py_compile`, `bash -n`, `git diff --check` и отдельных -стендовых проверок. - -Для учебного проекта это плохой пример: у менти и агента должен быть один -понятный путь проверки, а не набор догадок. - -## What to build - -Добавить поддержанные цели `make test` и `make lint`, которые покрывают текущие -реальные проверки репозитория и не печатают лишний подробный вывод по умолчанию. - -## Acceptance criteria - -- [x] `make test` существует и запускает полный быстрый набор проверок, нужный - перед коммитом: тесты генератора, контрактные тесты верхнего уровня и - ключевые проверки конфигурации. -- [x] `make lint` существует и запускает статические проверки, которые сейчас - выполняются вручную: синтаксис Python, `bash -n`, `docker compose config - --quiet`, `git diff --check` или обоснованно выбранный эквивалент. -- [x] Цели используют `uv` там, где нужен Python вне Docker, и не зависят от - локального `pytest`, случайно установленного на хосте. -- [x] Шумные команды не вываливают полный список тестов при успешном прогоне; - подробный вывод доступен через явный режим или отдельную команду. -- [x] `docs/OPERATIONS.md` или `README.md` кратко объясняет, когда запускать - `make test`, `make lint` и какие более дорогие стендовые проверки остаются - отдельными. - -## Blocked by - -Нет — можно начинать сразу. - -## Notes - -- Не включать в `make test` долгие или разрушительные проверки вроде - `make generated-history-runtime-check` без явного решения: они чистят volumes - и управляют стендом. -- Отдельно учесть проблему окружения coordinator-loop 2026-07-05: локальный - `uv run ... pytest` падал до запуска тестов из-за `snap-confine`, поэтому - цель должна либо идти через устойчивый Docker-путь, либо явно диагностировать - такую ошибку. -- По ревью Claude дополнительно подключены корневые контрактные тесты, усилены - ключевые проверки поведения `CHECK_LIVE_SEAM`/профиля/DM-витрин, а также - закрыты подтверждённые обходы через `generator-reset` и безверсионный state. - -## Проверка - -- `make contract-test` — 16 passed. -- `make generator-test` — 190 passed. -- `make lint` — passed. -- `make test` — 190 generator tests, 16 contract tests, compose config passed. diff --git a/.scratch/generator-model-time-startup-history/issues/20-flaky-runtime-seam-check.md b/.scratch/generator-model-time-startup-history/issues/20-flaky-runtime-seam-check.md deleted file mode 100644 index a9d22fd..0000000 --- a/.scratch/generator-model-time-startup-history/issues/20-flaky-runtime-seam-check.md +++ /dev/null @@ -1,158 +0,0 @@ -Status: done - -# Runtime-проверка стыка нестабильна: фактура меняется через раз - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` - -## Что нашли - -Два подряд прогона `make generated-history-runtime-check` на одном и том же -стенде (2026-07-07, проверка пути менти) дали разный результат: - -- Прогон 1 (20:18–20:20): **красный** — - `Ошибка: per-event фактура меняется на стыке: 8/19` - (у 8 из 19 визитов, переживших границу backfill->live, поменялась - per-event фактура). При этом `duplicate_events=0`, events=2776. -- Прогон 2 (21:5x, та же команда, без изменений кода): **зелёный** — - «runtime-проверка startup-history/live seam прошла», конфликтов 0, - events=2815. - -Конфигурация проверки: `GEN_LAUNCH_PROFILE=daily-wave`, -`GEN_HISTORY_DURATION=1h`, `GEN_MODEL_T_END=2026-01-01T01:00:00+00:00` -(зашита в `scripts/run_generated_history_runtime_check.sh`). - -## Почему это важно - -- Это гейт, которому мы доверяем стык backfill->live (задача 17); флаки-гейт - ничего не гарантирует: красный пугает зря, зелёный ничего не доказывает. -- Смена фактуры на стыке — класс дефекта задачи 09, который считается - закрытым (донор фактуры сохранён в state, тихие fallback'и заменены на - ошибки). Либо фикс неполон, либо есть второй источник расхождения. -- Задача 13 (глагол next-day) навешивает на этот же механизм цепочку границ — - ей нужен доверенный, стабильный гейт. - -## Диагноз (Codex, 2026-07-07; ключевой факт перепроверен координатором) - -**Корень: гонка остановки live-генератора, а не смена фактуры.** Цепочка: -`run_generated_history_runtime_check.sh` останавливает live сразу после первых -новых STG-строк -> генератор публикует batch по четырём топикам -последовательно, без атомарности -> `docker compose stop generator` может -оборвать процесс между топиками -> часть browser-событий остаётся без парных -location/device/geo -> DDS строит `dds.event` через `LEFT JOIN location`, -и такие строки получают `NULL` в referer/utm -> seam-SQL считает это «сменой -фактуры внутри click_id». - -Эмпирика: в красном прогоне `browser_raw=2776`, но `location_raw=2736` — -40 live-событий без пары; в зелёном `browser_raw=location_raw=2815`, поэтому -проверка прошла (хотя device/geo и там отстали: 2781). - -Судьба гипотез: - -1. Запасная ветка рождения — **опровергнута**: ветка уже хранит донора - (`generation.py:196-209`, комментарий «Запасная ветка тоже восстановима») - и громко падает на неполных locations. Перепроверено координатором по коду. -2. Недетерминизм live — **подтверждена частично**: решает не сид, а настенный - момент остановки процесса и какие топики успели дописаться. -3. Проверочный SQL — **опровергнута в формулировке**: поля не «легитимно - различаются», SQL маскирует пропущенный location под «смену фактуры». - -Уточнение механизма обрыва (адверсарное ревью постановки, 2026-07-12; -перепроверено координатором по коду): - -- Генератор не обрабатывает SIGTERM: ловится только `KeyboardInterrupt` - (`service.py:175`), а `docker compose stop` шлёт именно SIGTERM. В compose - у сервиса generator нет `init:`/`stop_signal:`, python работает PID 1 — - SIGTERM игнорируется, и через grace-период прилетает SIGKILL: жёсткий - обрыв посреди batch, без `finally` и без flush. -- Почему отставание device/geo не валит гейт, а location валит: device/geo - привязаны к `click_id` (одно значение на визит) — при пропуске весь визит - однородно NULL, массив уникальных значений длины 1, проверка проходит; - location привязан к `event_id` — частичный пропуск даёт смешанный массив - (реальное значение + NULL) и «смену фактуры». Догонять device/geo фикс - не обязан — важна граница batch. - -## Направление фикса (развилка решена после ревью постановки 2026-07-12) - -1. **Основное: корректная обработка SIGTERM в генераторе.** Минимальный - хендлер: по SIGTERM выставить `_running = False`, дать текущему tick'у - дописаться (все четыре топика + flush + запись batch history), затем - штатный `finally`/`stop()`. Runtime-check после `docker compose stop` - дожидается фактического завершения контейнера. Так генератор завершается - только на границе batch — гонка снята по построению, а не вероятностно. - - **Отклонено: «ожидание записи `generator_batch_history` перед stop» - как самостоятельный фикс** — запись history подтверждает только - ПРОШЕДШИЙ batch (`service.py:586-603`, пишется после цикла публикации); - следующий batch к моменту stop уже может быть в полёте, и SIGKILL - оборвёт его так же. Довод — ревью постановки, перепроверен по коду. - - Bounded live mode (генератор сам останавливается по лимиту) — более - тяжёлая альтернатива; в скоуп не входит, возвращаться к ней только - если SIGTERM-хендлера окажется недостаточно (с доводом в задаче). -2. **Дополнительно:** precheck в seam-check на непарные - browser/location/device/geo live-строки — чтобы ошибка называла реальную - причину, а не «фактура поменялась». Precheck не подменяет основную - проверку: настоящая смена фактуры внутри `click_id` обязана падать - как и раньше. -3. **Отклонены любые вероятностные смягчения**, а не только «увеличить - sleep»: settle-sleep перед прогоном, рост `WAIT_LIVE_ROWS`/`LIVE_SECONDS`, - сужение окна `GEN_LIVE_CHECK_MINUTES` и прочие способы снизить - вероятность — гонку они не убирают и фиксом не считаются. - -## Принятый остаточный риск (решение координатора, 2026-07-12) - -Ревью отметило: `stop_grace_period: 1m` не ограничивает публикацию по -времени — при зависании Kafka дольше минуты SIGKILL всё ещё оборвёт batch. -Риск принят: штатный тик публикуется за секунды (минута — многократный -запас), а ограничивать публикацию таймером значило бы рвать batch уже по -построению. Ключевое отличие от исходного флаки: такой обрыв больше не -маскируется под «смену фактуры» — precheck непарных строк назовёт его -явно, гейт упадёт громко и честно. - -## Acceptance criteria - -- [x] Причина расхождения 8/19 найдена и названа (код, не догадка) — - см. «Диагноз» выше. -- [x] Генератор корректно завершается по SIGTERM: текущий batch дописывается - во все четыре топика целиком (flush + запись history), потом процесс - выходит; runtime-check дожидается фактической остановки контейнера - (направление фикса, пункт 1). Плюс `stop_grace_period: 1m` в compose. -- [x] Seam-check различает «непарные live-строки» и «смена фактуры»: - precheck называет реальную причину (пункт 2). -- [x] Красный сценарий по-прежнему ловится: контролируемое искажение - `referer_url` у события пересекающего визита в `dds.event` уронило гейт - с прежним сообщением «per-event фактура меняется на стыке: 18/19» при - нулевых счётчиках непарных строк (стендовая приёмка 2026-07-12). -- [x] Стабильность обоснована структурно (SIGTERM ставит флаг, тик - дописывает все четыре топика + flush + history и выходит на границе - batch — тест `test_sigterm_during_publish_finishes_current_batch`); - дымовая проверка поверх довода: 3 подряд зелёных - `make generated-history-runtime-check` (2026-07-12, прогоны 19:25, - 19:28, 19:30). - -## Находки стендовой приёмки (2026-07-12, исправлены в этой же задаче) - -Ревью по чтению кода их поймать не могло — вскрылись только прогонами: - -1. **Гейт жил на побочном эффекте бага.** Пересекающие визиты для проверки - стыка появлялись только потому, что генератор игнорировал SIGTERM и - дописывал ~10 секунд данных до SIGKILL. После фикса live-хвост стал - коротким и пересекающих визитов могло не быть вовсе. Фикс: шаг 6 - runtime-check ждёт не «любую новую STG-строку», а появления - пересекающего визита в STG (детерминированное предусловие проверки, - то же окно, что у seam-SQL). -2. **Часовой пояс в STG-запросах.** Сырые `event_timestamp` наивные и - означают UTC, сервер ClickHouse — Europe/Moscow: сравнение с границей, - заданной с `+00:00`, уезжало на 3 часа, precheck был зелёным вакуумно - (не видел ни одной live-строки). Фикс: явный `'UTC'` в - `parseDateTime64BestEffort*` с обеих сторон сравнения (4 места), - закреплено контрактными тестами. - -## Blocked by - -- Нет. Диагноз можно начинать сразу; стенд воспроизводит через раз. - -Связано: `09-seam-browser-fixture-not-preserved.md` (класс дефекта и решение -про донора), `13-backfill-top-up-from-snapshot.md` (нуждается в доверенном -гейте на цепочке границ), `17-trusted-checks-startup-history-superset.md` -(появление этого гейта). diff --git a/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md b/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md deleted file mode 100644 index 1a39651..0000000 --- a/.scratch/generator-model-time-startup-history/issues/21-merge-prep-gitignore-nullable-params.md +++ /dev/null @@ -1,73 +0,0 @@ -Status: done - -# Подготовка ветки к слиянию: .gitignore для артефактов и необязательные поля формы - -## Parent - -`.scratch/generator-model-time-startup-history/PRD.md` — закрывающая -гигиена перед слиянием `feature/data-generator` в `main`. Находка F1 — из -`.scratch/generator-model-time-startup-history/hitl-findings.md`. - -## Что не так - -1. **Побочные артефакты backfill не покрыты `.gitignore`.** После HITL в - `data/` лежит `ci_backfill.json` (49 МБ) — риск случайного коммита. - Сиды проекта в `data/` — это `*.jsonl`, они под git и должны остаться. -2. **F1 (BUG):** в форме `generator_control` (Trigger DAG w/ config) все - пять необязательных полей (`duration`, `seed`, `model_time_speed`, - `artifact_path`, `expected_t_end`) показаны обязательными: красная `*`, - браузер не даёт отправить форму с пустым полем. Канонический запуск - «выбрать профиль, остальное пусто» через UI невозможен. - -## Причина F1 (проверено) - -В `airflow/dags/generator_control_dag.py:230-265` эти `Param(...)` -объявлены с `type="string"`. Шаблон формы Airflow вешает `*` и HTML-атрибут -`required` на каждое поле, у которого в `type` нет `"null"`. - -Проверено по Context7 (`/apache/airflow/2.10.5`, 2026-07-19): идиома -необязательного строкового параметра — `Param(None, type=["null", "string"])`; -поле с `"null"` в `type` форма не помечает обязательным. - -Чтение параметров в DAG уже терпимо к `None`: везде -`str(_param(...) or "").strip()` (строки 71-161), поэтому смена пустого -значения с `""` на `None` ничего не ломает — но это надо перепроверить -глазами по каждому месту чтения. - -## Что сделать - -- [x] В `.gitignore` добавить правило `data/*.json` (раздел «Тестовые - данные и артефакты») с комментарием: артефакты генератора; сиды - `data/*.jsonl` остаются под git. -- [x] Пять необязательных `Param` в `airflow/dags/generator_control_dag.py` - перевести на `Param(None, type=["null", "string"])`. Обязательные - (`operation`, `profile`) не трогать. У изменённых Param коротким - комментарием зафиксировать: `"null"` в type = необязательное поле - формы (проверено по Context7, Airflow 2.10.5). -- [x] Убедиться, что каждое место чтения этих параметров в DAG переживает - `None` (сегодня везде `or ""` — сверить полный список). -- [x] В `generator/tests/test_generator_control_dag_contract.py` дополнить - контракт: у пяти необязательных Param в объявлении есть `"null"`, - у `operation`/`profile` — нет. -- [x] `docs/OPERATIONS.md`: сверить описание формы `generator_control`; - если там есть обход «заполняйте все поля» — убрать, поведение - «пусто = из профиля» оставить как есть. -- [x] Дешёвые проверки зелёные: `make test` и `make lint` (нужен docker; - если в песочнице исполнителя недоступен — явно сказать в отчёте, - прогонит оркестратор). - -## Границы - -- Логику генератора (`generator/src/`) не менять. -- Профили и их состав не трогать (это отдельная задача редизайна). -- Форму DAG не перестраивать (отдельный DAG `next-day` — тоже отдельная - задача, F3). -- `data/ci_backfill.json` не удалять и не коммитить. -- Коммиты не делать. - -## Сначала прочитать - -1. Этот файл. -2. `.scratch/generator-model-time-startup-history/hitl-findings.md` — F1. -3. `airflow/dags/generator_control_dag.py` — объявления и чтение Param. -4. `generator/tests/test_generator_control_dag_contract.py`. diff --git a/.scratch/handoffs/2026-06-10-generator-spec-to-codex.md b/.scratch/handoffs/2026-06-10-generator-spec-to-codex.md deleted file mode 100644 index 97b3bc5..0000000 --- a/.scratch/handoffs/2026-06-10-generator-spec-to-codex.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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//` -по `docs/agents/issue-tracker.md` (скилл `to-issues`, если план дробить). - -## Suggested skills (для следующей сессии) - -- **`to-issues`** — если решим дробить реализацию на задачи в локальном трекере. -- **`tdd`** — для этапа реализации (pytest-набор `generator/tests/` существует, - но писался под старую модель — пересмотр под новую неизбежен). -- **`adversarial-review`** / **`code-review`** — ревью реализации против - мат-спеки перед вливанием. -- **`conventional-commits`** — коммиты по правилам репозитория. diff --git a/.scratch/handoffs/2026-06-11-generator-issues-ready.md b/.scratch/handoffs/2026-06-11-generator-issues-ready.md deleted file mode 100644 index 24ce751..0000000 --- a/.scratch/handoffs/2026-06-11-generator-issues-ready.md +++ /dev/null @@ -1,66 +0,0 @@ -# Handoff: генератор разбит на AFK-задачи - -Дата: 2026-06-11 -Ветка: `feature/data-generator` -Жанр: одноразовые леса по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md). - -## Где остановились - -После обсуждения с пользователем дизайн переработки steady-stream генератора -переведён из больших спек в локальные задачи для агентов. Код генератора не -меняли. - -Создан каталог задач: - -- `.scratch/feature-data-generator/issues/01-minimal-connected-visit.md` -- `.scratch/feature-data-generator/issues/02-visit-page-path-and-monotonic-time.md` -- `.scratch/feature-data-generator/issues/03-tick-stream-with-active-visits.md` -- `.scratch/feature-data-generator/issues/04-user-population-and-returns.md` -- `.scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md` -- `.scratch/feature-data-generator/issues/06-state-v2-and-restart.md` -- `.scratch/feature-data-generator/issues/07-service-integration-and-docs.md` - -Все задачи имеют `Status: ready-for-agent`. Разрез сделан как последовательные -вертикальные срезы, а не как слои архитектуры. Решение пользователя: идти по -этому варианту. - -## Важный контекст - -- Старый генератор считаем слабым прототипом, а не ценным кодом для сохранения. - Сохранять надо внешние контракты, если они полезны: топики Kafka, формат - сообщений для текущего ETL, команды запуска, метрики, идею compact-топика - состояния. -- Отдельную задачу "зафиксировать внешний контракт" не создавали: контракт - встроен в первый и последний срезы. -- Детали модели не дублировать отсюда. Источники истины: - - `docs/specs/2026-06-10-generator-math-model.md` - - `docs/specs/2026-06-09-generator-rework-hierarchical.md` - - `docs/adr/0004-steady-stream-synthetic-generator.md` - - `CONTEXT.md` - - `generator/KNOWN_ISSUES.md` -- Предыдущий handoff `.scratch/handoffs/2026-06-10-generator-spec-to-codex.md` - остаётся полезным как предыстория спек. - -## Следующий шаг - -Начать с задачи 01 через TDD: - -1. написать один красный тест на публичное поведение "минимальный связанный - визит"; -2. реализовать минимальное новое генеративное ядро без Kafka; -3. не тащить старую плоскую модель `generate_batch()`; -4. после зелёного теста переходить к следующему поведению, не писать все тесты - заранее. - -Пример запуска задачи: - -```text -/goal Реализовать .scratch/feature-data-generator/issues/01-minimal-connected-visit.md с использованием /tdd. Соблюдать цикл: один тест на наблюдаемое поведение → минимальная реализация → зелёный тест → остановиться и отчитаться. Не писать все тесты заранее, не реализовывать задачи 02-07. -``` - -## Suggested skills - -- `tdd` — для реализации каждой задачи короткими циклами red-green-refactor. -- `adversarial-review` или `claude-team-review` — после реализации нескольких - срезов, чтобы сверить код с мат-спекой. -- `conventional-commits` — при фиксации следующих изменений. diff --git a/.scratch/handoffs/2026-06-11-generator-time-adr-and-pending-review.md b/.scratch/handoffs/2026-06-11-generator-time-adr-and-pending-review.md deleted file mode 100644 index d3c5dc1..0000000 --- a/.scratch/handoffs/2026-06-11-generator-time-adr-and-pending-review.md +++ /dev/null @@ -1,86 +0,0 @@ -# Handoff: ADR про модельное время генератора + отложенное ревью петли - -Дата: 2026-06-11 -Ветка: `feature/data-generator` -Жанр: одноразовый handoff по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md). - -## Зачем этот handoff - -Сессия с Fable шла в стиле brainstorm-with-docs про **модельное время -генератора**. Параллельно идёт эксперимент: автономная петля субагентов Кодекс -(координатор + worker + reviewer) доделывает срез генератора (задачи 06→07). -Эти две линии **намеренно разделены** — пересмотр времени не правит то, что петля -строит на реал-тайм-модели. Handoff фиксирует, что уже сделано и что осталось, -чтобы не потерять контекст в новой сессии. - -## Что сделано в этой сессии (durable, не дублирую — см. файлы) - -- Создан **[ADR-0005](../../docs/adr/0005-generator-model-clock.md)** «Модельные - часы генератора, отвязанные от настенного времени». Все решения там; кратко: - модельное время расцеплено с `now()`; режимы (живой ×1 / ускоренный ×K / - заливка `K → ∞`) — драйверы поверх одного шва; живой стенд масштабируется ×K - (дефолт ×1); правило 30 минут переопределяется в **модельном** времени с - параметрическим origin возобновления; «стартовая история стенда» - (сгенерированное прошлое + заморозка state v2) — **будущее направление, ещё не - строим**. -- Правки **[`CONTEXT.md`](../../CONTEXT.md)** (глоссарий): разведены три значения - слова «сид» (`GEN_SEED` / статический сид / стартовая история стенда) и добавлен - термин «модельное время и масштаб ×K». - -**Эти изменения (ADR-0005 + CONTEXT.md) ещё НЕ закоммичены.** Их коммит — выход -брейншторма пользователя, его нельзя мешать с коммитами кода от петли (06/07). -Отдельный docs-коммит. - -## Открытые線 для следующей сессии - -### 1. Отложенное ревью результата автономной петли (приоритет) - -Делать **после** того, как петля закоммитит задачу 07. Это read-only ревью. -Измерительный лист — в памяти проекта -`memory/ralph-loop-experiment-generator.md`. Главное: - -- **A (главный индикатор эксперимента):** ссылочная целостность при - `restore_state`. `_validate_v2_payload` проверяет только форму, не ссылки. - Структурно-валидный state v2 с висячей ссылкой визит→пользователь - (`generator/src/clickstream_generator/runtime.py:283`) или пользователь→словарь - (`runtime.py:253/255`) проходит `from_dict_safe` и роняет `restore_state` без - обёртки try→fresh. Прогноз: петля это **пропустит** (слепые зоны внутри одной - линии Кодекс скоррелированы). Проверить гипотезу локально структурой с висячей - ссылкой (как пользователь проверял оценку снимка 4.7 MB и падение на битой - вложенности). -- **B/D (проверено OK ранее):** кулдауны по меткам, не тикам (`runtime.py:104`); - детерминизм компактного снимка (`_stable_event_id`, restore не жжёт ГПСЧ). - Подтвердить, что 06→07 их не сломали. -- **C (риск, вне скоупа 06):** всплеск досылки созревших событий с прошлыми - метками на первом тике после рестарта — всплыл ли хоть как риск в саморевью. -- Верхнеуровневое для 07: границы задачи, честность отметок acceptance, не - смешаны ли 06/07, `git status`. - -Цель ревью двойная: (1) корректность 06/07; (2) мета-вывод — **какой класс -дефектов автономная петля систематически не видит без внешнего взгляда другой -модели**. - -### 2. Реконсиляция мат-спеки с ADR-0005 - -После ревью петли. Разделы «Персистентность» и «Воспроизводимость» в -`docs/specs/2026-06-10-generator-math-model.md` написаны от настенных часов; -привести в соответствие с ADR-0005 (модельное время, правило 30 минут в -модельном времени). Не делать сейчас — файл смежен с тем, что петля трогала. - -## Фон (память проекта, не дублирую) - -- `memory/ralph-loop-experiment-generator.md` — схема Ральф-цикла + измерительный - лист. -- `memory/generator-full-rework.md`, `memory/fable-design-codex-implementation.md` - — направление переработки генератора и разделение ролей Fable(дизайн)/Codex(код). -- `.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md` — детальная - схема эксперимента с координатором и наблюдения по циклам 05/06. - -## Suggested skills - -- `conventional-commits` — для отдельного docs-коммита ADR-0005 + `CONTEXT.md` - (не мешать с кодом петли). -- `brainstorm-with-docs` — когда вернёмся к реконсиляции спеки или к проектированию - «стартовой истории стенда» (будущее направление из ADR-0005). -- `handoff` — после ревью петли зафиксировать рефлексию эксперимента (что петля - поймала/пропустила, особенно по находке A). diff --git a/.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md b/.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md deleted file mode 100644 index 69eb7c9..0000000 --- a/.scratch/handoffs/2026-06-11-subagent-coordinator-experiment.md +++ /dev/null @@ -1,242 +0,0 @@ -# Handoff: эксперимент с координатором субагентов - -Дата: 2026-06-11 -Ветка: `feature/data-generator` -Жанр: одноразовый handoff по [ADR-0003](../../docs/adr/0003-handoffs-in-scratch.md). - -## Зачем нужен этот handoff - -Пользователь хочет провести эксперимент: не реализовывать следующие задачи -самому верхнеуровневому агенту, а использовать его как координатора цепочки -субагентов. После эксперимента планируется отдельно отрефлексировать, что -сработало, что не сработало и стоит ли закреплять такой процесс. - -Этот документ фиксирует текущее понимание процесса перед запуском, чтобы не -потерять договорённости в новой сессии. - -## Текущий рабочий контекст - -Активная линия работы — переработка steady-stream генератора кликстрима. -Задачи лежат в `.scratch/feature-data-generator/issues/`. - -Уже выполнены и ожидают человеческой приёмки: - -- `01-minimal-connected-visit.md` -- `02-visit-page-path-and-monotonic-time.md` -- `02-5-generator-service-cleanup.md` -- `03-tick-stream-with-active-visits.md` -- `04-user-population-and-returns.md` - -Оставшаяся последовательность: - -1. `05-intensity-and-flow-calibration.md` -2. `06-state-v2-and-restart.md` -3. `07-service-integration-and-docs.md` - -Задачи зависимы и, вероятно, меняют близкие файлы генератора, поэтому запускать -их надо строго последовательно, не параллельно. - -В рабочем дереве на момент обсуждения была незакоммиченная правка только в -`.scratch/feature-data-generator/issues/06-state-v2-and-restart.md`: добавлен -комментарий, что задачу 06 нельзя начинать до завершения предыдущих срезов, -особенно задачи 05. - -## Договорённая схема эксперимента - -Верхнеуровневый агент не включает для себя `/goal`. Его роль — координатор и -gatekeeper, а не непосредственный исполнитель. - -Для каждой задачи: - -1. Координатор запускает нового worker-субагента на ровно один issue. -2. Задание субагенту формулируется в стиле `/goal`: - - реализовать конкретный файл задачи; - - работать по методике `/tdd`; - - не реализовывать следующие задачи; - - не коммитить; - - не откатывать чужие изменения; - - в конце отчитаться по изменённым файлам, тестам, проверкам и рискам. -3. После реализации тот же субагент делает саморевью в отдельной роли: - - остановиться; - - проверить результат против acceptance criteria, спеки и тестов; - - искать ошибки, лишний объём, хрупкость и нарушение учебной читаемости; - - сначала выдать находки с важностью, не исправляя их сразу. -4. Координатор решает, какие замечания достаточно важные. -5. Тот же субагент исправляет только одобренные важные замечания. -6. Координатор проверяет верхнеуровневые вещи: - - границы задачи; - - список изменённых файлов; - - честность отметок acceptance criteria; - - тесты; - - `git status`; - - отсутствие смешивания нескольких задач. -7. Коммит делает координатор, не субагент. -8. Координатор закрывает агента и переходит к следующему issue новым - worker-субагентом. - -Для сложных переходов, особенно после задачи 05 перед задачей 06, можно добавить -отдельного reviewer-субагента. Это не обязательный шаг на каждую задачу, а -контрольная мера, если есть риск, что саморевью исполнителя недостаточно. - -## Роль координатора - -Координатор не должен пытаться заново глубоко реализовывать или полностью -повторять работу субагента. Его польза — в управлении процессом: - -- держать порядок задач; -- ограничивать область изменений; -- читать отчёты и принимать решения; -- запускать исправления только по существенным замечаниям; -- делать финальный обзор diff на уровне границ и рисков; -- выполнять проверки и коммитить. - -Главный риск верхнеуровневого `/goal`: он может заставить координатора -оптимизировать работу под закрытие большой цели, а не под аккуратное управление -этапами. Поэтому для координатора `/goal` не использовать. - -## Почему саморевью тем же субагентом допустимо - -Пользователь отмечает, что на практике субагенты хорошо делают “ревью свежим -взглядом” внутри уже накопленного подробного контекста задачи. Это может быть -полезнее, чем полностью внешний поверхностный обзор координатора, которому не -хватит деталей реализации. - -Ожидаемая модель: - -- субагент глубоко понимает сделанную задачу и проверяет её на несостыковки; -- координатор не доверяет этому слепо, но оценивает адекватность выводов и - границы изменений; -- для особо рискованных мест можно подключить отдельного проверяющего агента. - -## Suggested skills - -- `tdd` — должен использовать worker-субагент при реализации каждой задачи. -- `conventional-commits` — использовать координатору перед созданием каждого - коммита. -- `claude-team-review` — опционально после задачи 05 или перед задачей 06, если - нужен дополнительный независимый взгляд на модель. -- `handoff` — после эксперимента зафиксировать рефлексию: что получилось, что - не получилось, какие правила стоит оставить. - -## Возможная первая команда субагенту - -```text -/goal Реализовать .scratch/feature-data-generator/issues/05-intensity-and-flow-calibration.md с использованием /tdd. - -Работай только в границах этой задачи. Не реализовывай задачи 06-07. -Не коммить. Не откатывай чужие изменения. - -Сначала прочитай сам issue и источники решений, указанные в нём. Работай -вертикальными TDD-срезами: один тест на наблюдаемое поведение -> минимальная -реализация -> зелёная проверка -> следующий тест. - -В конце остановись и отчитайся: -- какие файлы изменены; -- какие тесты добавлены или изменены; -- какие проверки запускались и с каким результатом; -- какие acceptance criteria закрыты; -- какие риски или спорные места остались. -``` - -После этого координатор должен попросить того же субагента выполнить саморевью -результата, не исправляя замечания до отдельного решения координатора. - -## Наблюдения во время эксперимента - -### Цикл 05: интенсивность и калибровка потока - -Стартовая задача оказалась дорогой по обратной связи: это не локальный инвариант, -а отладка статистической модели на длинных симуляциях. Первый worker долго -работал без промежуточного отчёта; координатору пришлось сначала поставить -мягкий статус-чек в очередь, затем прервать агента ради статуса. Это не сломало -работу, но показало: для статистических задач лучше заранее задавать контрольные -точки или ожидать длинный первый цикл. - -Саморевью того же worker оказалось полезным. Оно нашло несколько реальных -несостыковок: - -- `docker-compose.yml` оставлял старую интенсивность `GEN_LAMBDA_BASE_PER_MIN=200`; -- тест межсессионной паузы был слишком широким; -- в тестовой конфигурации оставались старые числа; -- README мог быть двусмысленным про `GEN_MIN/MAX_EVENTS_PER_TICK`. - -Координаторская проверка тоже добавила ценность: после саморевью был найден -конфликт между новым `GEN_LAMBDA_BASE_PER_MIN=30` и старым нижним пределом -`GEN_MIN_EVENTS_PER_TICK=5`. При тике 5 секунд это давало минимум 60 событий в -минуту и ломало критерий интенсивности. Worker исправил это отдельной точечной -итерацией. - -Полезная схема цикла: - -1. worker реализует задачу; -2. координатор прерывает только если агент слишком долго молчит; -3. worker делает саморевью без правок; -4. координатор выбирает, какие замечания чинить; -5. worker чинит только выбранные пункты; -6. координатор добавляет свой узкий sanity-check по связям между дефолтами, - документацией и критерием задачи; -7. координатор коммитит. - -Результат цикла 05: - -- коммит `8f1e997 feat(generator): откалиброван поток steady-stream генератора`; -- полный прогон: `uv run --with-requirements generator/requirements.txt pytest generator/tests -q` - — 96 passed; -- `git diff --check` — без замечаний. - -Промежуточный вывод: тот же субагент действительно хорошо использует подробный -контекст задачи для саморевью, но координатор всё равно нужен как внешний -проверяющий связей между настройками, обычным запуском и acceptance criteria. - -### Reviewer-субагент между задачами - -После задачи 06 пользователь предложил добавить отдельного reviewer-субагента -между задачами. Это выглядит особенно полезно на границах вроде 06 -> 07, где -следующая задача будет опираться на уже изменённые состояние, сервисный цикл и -документацию. - -Важное ограничение: reviewer не является источником истины. Его находки нужно -рассматривать как гипотезы и классифицировать координатором: - -- `чинить до коммита` — реальный дефект или риск закрытия acceptance criteria; -- `записать как риск` — важно знать, но не блокирует текущую задачу; -- `ложная тревога` — reviewer неверно понял код, тест или границы задачи; -- `вне скоупа` — может быть полезно позже, но не относится к текущему issue. - -Только находки из первой группы возвращаются worker-агенту на исправление. -Иначе есть риск превратить reviewer-а в источник лишнего объёма и расползания -задачи. - -В цикле 06 это правило сразу пригодилось. Саморевью исполнителя и отдельный -reviewer независимо нашли два существенных риска: - -- битое state v2 с валидным ГПСЧ могло пройти `from_dict_safe`, а затем уронить - сервис уже в `restore_state`; -- снимок активных визитов сохранялся полными batch-словарями и на верхних - лимитах получался порядка мегабайт, хотя спека говорит про компактное - состояние. - -Координатор проверил обе гипотезы локально: `restore_state` действительно падал -на битой вложенной структуре, а оценка JSON-снимка при 200 активных визитах и -популяции 300 дала около 4.7 MB. Эти находки классифицированы как `чинить до -коммита` и возвращены worker-агенту. Низкие замечания про совместимость -`generate_tick_batch` и пересечение сервиса с задачей 07 не стали правками: -первое оказалось ложной тревогой, второе — допустимым пересечением для -восстановления state v2. - -После крупной переделки по замечаниям reviewer-а нужен второй круг ревью. В -цикле 06 это подтвердилось: исправление компактного состояния само изменило -дизайн снимка и восстановление активных визитов. Второй reviewer нашёл новый -дефект уже в исправленной версии: формально похожий v2-state с `population=[]` -или строковым `pending_visit_births` проходил первичную загрузку, но затем -оставлял поток без пользователей или ронял следующий тик. Координатор -подтвердил это локальной проверкой и вернул worker-агенту как единственный -обязательный пункт второго круга. - -В цикле 07 reviewer оказался полезен уже не для поиска падений, а для силы -доказательства. Worker добавил сервисный тест с мок-публикацией, но внешний -reviewer заметил, что один опубликованный event доказывает четыре топика и -связи, но слабее доказывает невырожденную модель «один визит -> несколько -событий с одним `click_id`». Координатор классифицировал это как `чинить до -коммита`: финальный интеграционный тест должен доказывать именно уход от старой -плоской модели, а не только факт публикации. diff --git a/.scratch/handoffs/2026-06-14-adr0006-source-flip-and-model-time-spec.md b/.scratch/handoffs/2026-06-14-adr0006-source-flip-and-model-time-spec.md deleted file mode 100644 index 4e1f133..0000000 --- a/.scratch/handoffs/2026-06-14-adr0006-source-flip-and-model-time-spec.md +++ /dev/null @@ -1,76 +0,0 @@ -# Handoff: ADR-0006 (источник аналитики) + спека модельного времени - -Дата: 2026-06-14 -Ветка: `feature/data-generator` -Режим работы: дизайн (brainstorm-with-docs), не реализация кода. - -## Что сделано в этой сессии (закоммичено) - -Содержание не дублирую — смотри сами файлы: - -- **ADR-0006** `docs/adr/0006-generation-as-sole-analytics-source.md` — генерация - становится единственным источником аналитики; статический сид → архивный; - перевод процесса на новый (сгенерированный) сид — цель работы. Коммит `49512b1`. -- **Спека** `docs/specs/2026-06-14-generator-model-time-and-startup-history.md` — - модельное время на практике: точка отсчёта `T0`, повторяемость, заливка - прошлого, стартовая история, сохранение состояния, скорость ×K, проверка в два - шага. Коммит `49512b1`. -- **Указатели вперёд** в ADR-0004 и ADR-0005 на ADR-0006 и спеку. Коммит `49512b1`. -- **AGENTS.md** — ужесточено правило про русский язык + добавлено правило про - понятность и краткость документов. Коммит `c4cc01e`. - -## Контекст и решения, которых нет в артефактах (важны для продолжения) - -- **Роль сида = «кладовка значений» (палитра атрибутов).** Генератор сам придумывает - структуру (иерархию, время, воронку), но фактуру (браузер/гео/устройство/utm) - копирует из строк сида: `generator/src/clickstream_generator/generation.py` - (строки ~202–228, `{**base_browser, …}`), индексы в `dictionary.py`. Профиль - популяции хранится ссылкой на сид-сессию (мат-спека, §«Профиль пользователя»). - Поэтому сид нельзя удалить, пока генератор не научится фактуре сам. -- **Сид полностью чист** — проверено: 1000 записей × 4 файла, 0 падений в DQ - (`ods.*_errors`). Значит вывод сида не ломает учебный путь про грязные данные. -- **Двухшаговая проверка** (раздел «Проверка» спеки): шаг 1 — агент на чистом - стенде сверяет числа в ClickHouse (пирамида/воронка/возвраты), детерминированно; - шаг 2 — человек смотрит дашборды (распределение похоже на задуманное + стенд - «дышит» на ×K). Вскрытый пробел: дашборда с распределением сгенерированных - данных нет (Superset на сиде, Grafana показывает пропускную способность). -- **Известные расхождения калибровки** (из прошлого ревью, не в этих артефактах): - медиана длины визита ~8 против сидовых 10, всплеск на длине 2, нет - bounce-визитов длины 1, доля `/confirmation` ~27%. Когда генерация станет - источником, это станет «правдой» стенда — учесть при заполнении коридоров шага 1 - и в будущей задаче калибровки/фактуры. - -## Очередь (следующие шаги) - -1. Выровнять `CONTEXT.md` (раздел «три значения слова сид») под ADR-0006: - статический сид → архивная кладовка значений, цель — полный вывод. -2. Пометить в мат-спеке `docs/specs/2026-06-10-generator-math-model.md` разделы - «Персистентность через рестарты» и «Воспроизводимость» как переописанные в - модельном времени новой спекой (ссылкой, без дублирования). -3. (Опционально) Этап 1 вживую: поднять стенд на текущем генераторе, снять - реальные числа в ClickHouse, заполнить коридоры в разделе «Проверка». Поднимает - docker-стек (меняет состояние) — только с согласия владельца. -4. Черновик спеки **синтеза фактуры** — то, что позволит удалить `data/*.jsonl`. -5. Черновик спеки **перестройки процесса** (загрузка `kafka_load_dag` → новый сид, - Superset/витрины, уроки) — на этапе реализации, по ADR-0006. -6. Реализация механизма (модельные часы/×K/стартовая история) — это код, зона Codex. - -## Ограничения (продолжать соблюдать) - -- Роль: Claude — ADR/спеки/ревью; Codex — код. Намерение, против которого - сверяем, живёт в ADR/спеках Claude. -- Язык: ясный русский без транслитераций (теперь зафиксировано в `AGENTS.md`). -- Коммиты: только по просьбе; формат conventional-commits; трейлер - `Co-Authored-By: Claude Opus 4.8 (1M context) `. -- Git строго аддитивно: на ветке может параллельно писать Codex — не делать - amend/rebase/reset чужих коммитов. В `main` — merge `--no-ff`, не тащить - `.scratch/`. -- Context7 для спорных/меняющихся API (особенно Airflow). -- Публичный репозиторий — без персональных данных менти. - -## Suggested skills - -- **brainstorm-with-docs** — для продолжения дизайна (пункты 1, 4, 5 очереди: - `CONTEXT.md`, спека фактуры, спека перестройки процесса). -- **conventional-commits** — для коммитов. -- **Context7 (MCP)** — при касании API. diff --git a/.scratch/handoffs/2026-06-14-coordinator-loop-context-economy.md b/.scratch/handoffs/2026-06-14-coordinator-loop-context-economy.md deleted file mode 100644 index 96884ee..0000000 --- a/.scratch/handoffs/2026-06-14-coordinator-loop-context-economy.md +++ /dev/null @@ -1,16 +0,0 @@ -# Handoff: экономия контекста в coordinator-loop (указатель) - -Дата: 2026-06-14 MSK - -Полная, авторитетная версия этого handoff перенесена **внутрь навыка**, рядом с -handoff про наблюдаемость: - -`~/dotfiles/agents/.agents/skills/coordinator-loop/handoffs/2026-06-14-coordinator-loop-context-economy.md` - -Почему там: handoff про доработку навыка `coordinator-loop`, и сам навык, и его -handoff-побратим (`2026-06-14-observability-design.md`) живут в dotfiles. Чтобы будущая -сессия по доработке навыка нашла всё в одном месте, авторитетная версия лежит у навыка. - -Здесь оставлен только указатель: диагностика, на которой стоит handoff, снята с прогона -именно этого репозитория (фичи `feature-data-generator` и -`generator-model-time-startup-history` под `/coordinator-loop`). diff --git a/.scratch/handoffs/2026-06-14-generator-model-time-startup-history-issues.md b/.scratch/handoffs/2026-06-14-generator-model-time-startup-history-issues.md deleted file mode 100644 index 0526477..0000000 --- a/.scratch/handoffs/2026-06-14-generator-model-time-startup-history-issues.md +++ /dev/null @@ -1,68 +0,0 @@ -# Handoff: задачи по модельному времени и стартовой истории - -Дата: 2026-06-14 -Жанр: одноразовый handoff по ADR-0003. - -## Что сделано - -Создан рабочий набор артефактов для следующей фазы работ: - -- `.scratch/generator-model-time-startup-history/PRD.md` -- `.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md` -- `.scratch/generator-model-time-startup-history/issues/02-model-time-to-clickhouse.md` -- `.scratch/generator-model-time-startup-history/issues/03-model-speed-and-day-factor.md` -- `.scratch/generator-model-time-startup-history/issues/04-state-v2-model-resume.md` -- `.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` - -Артефакты режут работу на 6 последовательных issue и 2 review gate. Главная -идея: каждый кодовый срез должен подтверждаться через ClickHouse, а не только -локальными тестами генератора. Финальная проверка дашбордов глазами остаётся в -конце. - -## Важные решения - -- Координатор не берёт `/goal` на всю цепочку: один worker получает один issue. -- Worker работает через `/tdd`, не коммитит и не реализует следующие задачи. -- После реализации worker делает саморевью без правок. -- Координатор классифицирует находки и коммитит сам. -- После задачи 3 нужен review gate по сквозному инварианту времени. -- После задачи 5 нужен review gate по распределениям и двум путям генерации. -- Для review gate после задачи 5 нужен reviewer другой родословной; без него - координатор останавливает цепочку и отдаёт риск человеку. Это следует из - `docs/research/2026-06-11-subagent-coordinator-experiment.md`. - -## Что важно не потерять - -- В задаче 1 нужно зафиксировать durable-контракт, а не оставить решение только - в комментарии к issue. -- При ×K восстановление после сбоя нельзя считать простым `datetime.now()`: нужна - сохранённая связка модельного и настенного времени. -- Короткий или долгий простой считается по модельному времени, а не по настенным - минутам. -- Чистый прогон должен сбрасывать не только ClickHouse, но и Kafka-топики данных - и состояние генератора. -- Стартовая история должна быть парным артефактом: события, слепок состояния и - манифест. -- Живой и восстановленный пути не должны менять контекст внутри одного визита; - это известная слепая зона из research. -- Задача 6 специально сужена до штатного пути стенда и документов запуска. - Уроки, новые панели и улучшения дашбордов уходят в follow-up, если окажутся - нетривиальными. - -## Suggested skills - -- `to-issues` — если понадобится переразбить или опубликовать дополнительные - issue. -- `tdd` — основной режим работы worker-а над каждым кодовым issue. -- `conventional-commits` — перед каждым коммитом координатора. -- `claude-team-review` или другой внешний reviewer — на двух review gate. -- `handoff` — если работа прерывается между issue или после review gate. - -## Следующий шаг - -Начать с -`.scratch/generator-model-time-startup-history/issues/01-time-and-startup-history-contract.md`. -Это HITL-задача: нужно закрепить интерфейс модельного времени, манифест -стартовой истории, правило границы `T_end` и способ повторяемой проверки в -ClickHouse. diff --git a/.scratch/handoffs/2026-06-14-generator-model-time-verification-review.md b/.scratch/handoffs/2026-06-14-generator-model-time-verification-review.md deleted file mode 100644 index 0f671b6..0000000 --- a/.scratch/handoffs/2026-06-14-generator-model-time-verification-review.md +++ /dev/null @@ -1,96 +0,0 @@ -# Handoff: независимое ревью модельного времени и стартовой истории - -Дата: 2026-06-14 -Жанр: одноразовый handoff по ADR-0003. - -## Контекст - -После прогона coordinator-loop по фичам `feature-data-generator` и -`generator-model-time-startup-history` (issues 01–06, реализация Codex) проведено -независимое ревью результатов — другой родословной (Claude). Это в том числе -закрыло пропущенный внешний review gate после задачи 5 из PRD: он был выполнен той -же родословной, что и worker, а PRD требовал другую. - -## Что проверили независимо (не по отчётам) - -- `make generator-test` — `134 passed` (своим прогоном). -- Код = «Рабочий контракт реализации» спеки буква в букву: настройки - `GEN_MODEL_*`, state v2 (`model_timestamp/wall_timestamp/model_time_speed/…`), - формула live-возобновления, полуоткрытая граница `[T0, T_end)` с - `drain_until(include_boundary=False)`, манифест и антисмешивание. -- Данные в ClickHouse реальные и сходятся с отчётами до цифры: 6 ч → 16 054; - перепрогнали 1 сутки → 93 952; 2 суток → 187 087 событий / 17 397 визитов / - 2 765 пользователей. -- Пирамида (users < visits < events), возвраты, монотонность обеих воронок, - отсутствие архивного сида (все строки 2026 года). -- Повторяемость суточной волны на 2 сутках: часовые числа day1≈day2 - (отношение в основном 0.9–1.1), форма «ночь–день» повторяется. -- Стык backfill→live (live-доливка от `T_end=2026-01-03`): дублей 0, граница - держится, device/os/гео на стыке однородны. -- Дашборд: визуальная приёмка (шаг 2 спеки) — человеком и через Playwright. - «Rows by Layer» (4 равных столбца = 187 312) корректен: пайплайн проносит - событие 1:1, потерь нет, дедуп at-least-once на чистом backfill не возникает. - -## Что нашли - -- **issue 09 — реальный дефект стыка.** Браузерная фактура не переживает - восстановление визита: у визитов, активных ровно на `T_end`, в live-продолжении - меняется `browser_name`/`browser_language` (17/22 и 22/22), хотя внутри чистого - backfill браузер постоянен (0/14556). device/гео не задеты. Нарушает заявленный - критерий однородности задач 04/05; проверялось неполно (смотрели только поля - `dds.click`, а не per-event браузер в `dds.event`). Масштаб ~0.13% визитов. -- Гео-карта на дашборде нечитаема (нет легенды/подсказок/понятной шкалы) — issue 10. -- Стартовый сид по умолчанию — 6 часов (быстрый профиль для CI); суточная волна на - нём не видна, для просмотра нужен ≥2-суточный профиль через env. - -## Открытые пробелы проверки (НЕ закрыты) - -Ревью прошло по корректности кода, форме данных, стыку и дашборду, но НЕ трогало: - -1. **×K не гоняли.** Все прогоны на `speed=1`. Ускорение модельного времени, - событийный бюджет по модельной длительности и смена дневного коэффициента при - ×K проверены только в коде/по отчёту issue 03. -2. **Live-crash recovery не воспроизводили.** Формула возобновления с настенной - дельтой и закрытие просроченных визитов — только в коде. Гипотеза: дефект - issue 09 бьёт и сюда (тот же restore-механизм), эмпирически не подтверждено. -3. **Распределения не сверяли с коридорами мат-спеки** (`2026-06-10-generator-math-model.md`). - Подтвердили форму (монотонность, возвраты, длина), но не попадание - `confirmation_share` и доли коротких визитов в спроектированные коридоры. -4. **Воспроизводимость сами не перепрогоняли** — идентичность checksum при - повторном чистом прогоне взята из отчётов issue 03/05. -5. **Второй review gate (после задачи 3) не делали** — систематический sweep на - утечку настенных часов в расчёт интенсивности и сохранение состояния. - -Помельче: не проверяли обработку повреждённого/старого state (fresh-start с -предупреждением), `browser_user_agent` (тот же механизм, что issue 09), сам процесс -coordinator-loop по правилам PRD. - -## Зафиксированные follow-up (все needs-triage, на потом) - -- `issues/07-migrate-course-from-archive-seed.md` — миграция уроков. -- `issues/08-startup-history-portable-artifact-and-usage-docs.md` — портативный - артефакт стартовой истории + runbook + идеи интерфейса (глаголы вместо флагов, - длительность/профили, громкий отказ при несовпадении, доливка кусочком, - Airflow-DAG как пульт). -- `issues/09-seam-browser-fixture-not-preserved.md` — дефект браузерной фактуры. -- `issues/10-dashboard-geo-map-readability.md` — читаемость гео-карты. -- `docs/course/PRD.md` §7 — развилка «генератор как скрытая инфраструктура vs - отдельный урок про генератор» (не грузить менти марковскими цепями). - -## Состояние стенда - -Поднят на 2 сутках генерации (+ несколько live-тиков от seam-проверки): ClickHouse, -Kafka, Superset, postgres-metadata. Дашборд: -`http://localhost:8088/superset/dashboard/ecommerce-analytics/`. - -## Suggested skills - -- `tdd` — для фикса issue 09 (один поведенческий тест на однородность браузера через - стык, затем минимальная правка). -- `conventional-commits` — перед коммитами. -- `claude-team-review` / внешний reviewer другой родословной — на пробелы 1–5. - -## Следующий шаг - -Сегодня только фиксация, без правок. Дальше — приоритизировать issue 09 (баг) против -08/10 (удобство/виз) и закрыть пробелы проверки 1–5 (особенно ×K и crash recovery). diff --git a/.scratch/handoffs/2026-07-04-generator-backlog-triage.md b/.scratch/handoffs/2026-07-04-generator-backlog-triage.md deleted file mode 100644 index 658fc68..0000000 --- a/.scratch/handoffs/2026-07-04-generator-backlog-triage.md +++ /dev/null @@ -1,91 +0,0 @@ -# Handoff: триаж бэклога генератора и целеполагание стенда - -Дата: 2026-07-04 -Жанр: одноразовый handoff по ADR-0003. - -## Рабочая форма (зафиксирована сегодня) - -С Claude — обсуждение проекта, планы, проектные документы и независимые ревью. -Исполнение кода — Codex, предпочтительно через `/goal` или `/coordinator-loop`. -Сохранено в памяти Claude (memory), новая сессия подхватит автоматически. - -## Что решено и зафиксировано - -- **Целеполагание сверено** и записано в `docs/course/PRD.md` §1 (поправка - 2026-07-04): три части трека ClickHouse (теория — внешние курсы, лабы — - clickhouse-learning-cluster, этот стенд — интеграции «как в жизни»); иерархия - целей: курс -> стенд-носитель -> генератор как скрытая инфраструктура. -- **Развилка «урок про генератор» закрыта**: урока не будет, генератор — скрытая - инфраструктура (PRD §7). -- **Демо-употребление устарело**: `docs/DEMO_CHEATSHEET_5MIN.md` и - `docs/DEMO_SCRIPT_10_15MIN.md` удалены, демо выросло в отдельный проект. -- **Новая открытая развилка** в PRD §7: кластерная конфигурация ClickHouse - (пользователь склоняется к «всё в одном», решение не принято). -- **Конвенция трекера**: завершённая задача — `Status: done` - (`docs/agents/issue-tracker.md`); задачи 01–06 фичи переведены в `done` - (стояли ошибочные `ready-for-human` от глючившего координатора). - -## Бэклог фичи `generator-model-time-startup-history` - -Список задач и порядок — в PRD фичи (раздел «Задачи», дополнен сегодня): - -- **07** (артефакт + runbook, `ready-for-agent`) — импорт строго через Kafka - (напрямую в ClickHouse не пишет — решение пользователя), громкий отказ при - несовместимом state (пересмотр правила спеки), граница runbook «использование, - не устройство». Режим ревью — гейт. -- **08** (миграция курса, `ready-for-agent`) — после 07; `make data` вплетён во - все уроки 00–05, объём больше, чем кажется; тонкое место — урок 00. -- **11** (глаголы/длительность/профили, `ready-for-agent`) — после 07, до 12. -- **12** (DAG-пульт, `needs-triage`) — дооформить после 07 и 11; приоритет - поднят пользователем: будущий основной человеческий интерфейс стенда. -- **13** (доливка, `needs-triage`) — без приоритета, после фикса 09. -- Перестановка: бывший 07 (миграция) и 08 (артефакт) поменяны местами. - -## Следующий шаг - -Весь бэклог готов к передаче Codex. Порядок пересмотрен (решение 2026-07-04, -«пульт вперёд»): узкое место — время человека на ручную приёмку, поэтому -основная цепочка теперь 07 -> 11 -> 12 (пульт появляется раньше), а миграция -курса 08 идёт последней — её приёмку (проход по урокам) человек ведёт уже через -пульт. 09 и 10 независимы, можно параллельно; 13 — после фикса 09. Граф и -подробности — в PRD фичи, раздел «Задачи»; там же зафиксировано, что номер -файла — идентификатор, а не порядок. - -## Дооформление 09 и 10 — что изменилось против прежнего понимания - -Сделано в этот же день, вторым заходом. 09 и 10 переведены в `ready-for-agent`; -детали — в самих issue, здесь только сдвиги в понимании: - -- **Гипотеза по 09 не подтвердилась.** Дело не в рассинхроне `event_index`, а в - источнике фактуры: при рождении визита браузерные строки берутся у случайного - «донора» из словаря, при восстановлении — у `seed_click_id` пользователя. - Прежняя развилка «выбор по абсолютному индексу, не трогая схему state» - оказалась нерабочей — restore не знает донора. Решение пользователя: хранить - `base_click_id` донора в state (альтернативы и причины отказа — в issue). -- **Дефект 09 шире браузера** (нашло ревью): per-event поля location (referer, - utm) расходятся на стыке так же — критерии приёмки расширены, иначе частичный - фикс «только браузер» прошёл бы приёмку. Расхождение event_id (uuid4 при - рождении, uuid5 при restore) зафиксировано как известное и вне скоупа. -- **По 10 сняты две мины:** пересборка по умолчанию даёт 6 часов истории, а не - 2 суток (профиль «2 суток одной командой» — это ещё не сделанная задача 11); - legacy-виз `world_map`, похоже, вообще не умеет легенду и tooltip — основной - путь, вероятно, смена типа визуализации, а не настройка (проверить через - Context7 при реализации). -- **Процессная заметка:** дооформленные issue прогнаны через тройное ревью - (самопроверка + два свежих агента, адверсарно, с проверкой каждого утверждения - по коду). Улов оправдал затраты — см. пункты выше; для issue с режимом «гейт» - так стоит делать и дальше. - -## Не забыть (вне бэклога) - -Открытые пробелы независимого ревью от 2026-06-14 (см. handoff -`2026-06-14-generator-model-time-verification-review.md`): ×K не гоняли, -live-crash recovery не воспроизводили, распределения не сверяли с коридорами -мат-спеки, воспроизводимость checksum не перепроверяли. - -## Suggested skills - -- `conventional-commits` — перед коммитами. -- `tdd` — для фикса issue 09 (тест на однородность браузера через стык, затем - минимальная правка). -- `coordinator-loop` — на цепочки 07 -> 08 и 07 -> 11 -> 12. diff --git a/.scratch/handoffs/2026-07-04-generator-chain-review.md b/.scratch/handoffs/2026-07-04-generator-chain-review.md deleted file mode 100644 index 687fc98..0000000 --- a/.scratch/handoffs/2026-07-04-generator-chain-review.md +++ /dev/null @@ -1,185 +0,0 @@ -# Handoff: цепочка генератора 12 -> 14 -> 09 -> 10 -> 08 - -Дата: 2026-07-04. -Жанр: одноразовый handoff по ADR-0003. -Источник: `.scratch/generator-model-time-startup-history/coordinator-journal.md`. - -## Статус - -Цепочка `12 -> 14 -> 09 -> 10 -> 08` завершена через `coordinator-loop`. -Финальный chain review закрыт `APPROVED`; правки финального review -закоммичены в `f8b419d`. - -Все issue текущей очереди имеют `Status: done`; в PRD фичи осталась только -дальнейшая задача 13 (`needs-triage`). - -## Коммиты - -- `dd4af82` — `feat(airflow): добавлен пульт управления генератором`. -- `200bb82` — `docs(issues): закрыта задача 12 по пульту генератора`. -- `9d1bcc4` — `feat(generator): ускорен учебный профиль daily-wave`. -- `0cbfe9b` — `docs(issues): закрыта задача 14 по быстрому профилю`. -- `e2d0684` — `fix(generator): сохранена фактура визита при восстановлении`. -- `0f435d7` — `docs(issues): закрыта задача 09 по фактуре визита`. -- `0c80e24` — `fix(superset): заменена нечитаемая гео-карта`. -- `de938ed` — `docs(issues): закрыта задача 10 по гео-графику`. -- `d76c036` — `docs(course): переведены уроки на стартовую историю`. -- `3d4e13c` — `docs(issues): закрыта задача 08 по миграции курса`. -- `f8b419d` — `docs(generator): закрыты находки финального ревью цепочки`. - -## Что построено - -- `airflow/dags/generator_control_dag.py` — ручной Airflow-пульт - `backfill/import/check`, без Docker socket, с ожиданием `etl_pipeline`. -- `generator/src/clickstream_generator/airflow_control.py` — общая логика - env, предпроверок чистого стенда и сверки manifest для DAG-пульта. -- `docker-compose.yml` / `Makefile` — `make up` не автостартует live-генератор; - live запускается явно через `make generator-continue`. -- `generator/src/clickstream_generator/launch.py` — `daily-wave` переведён на - `speed=60`, `tick=1`; `ci` сохранён. -- `generator/src/clickstream_generator/runtime.py` и `state.py` — state v3 - хранит `base_click_id`, восстановление визита берёт ту же per-event фактуру. -- `scripts/check_generated_analytics.sh` — стыковочная проверка browser/source и ODS - device/os/geo включается в режиме `auto` после live-строк. -- `superset/create_dashboard.py` — гео-блок заменён на `Top Countries by Events` - (`echarts_timeseries_bar`) с tooltip, легендой и единицами. -- `docs/course/` и `docs/TEST_PLAN.md` — учебный путь переведён с архивного - сида на `startup-history/backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset`. - -## Ревью по issue - -- Issue 12: обычный режим. Worker `medium` был запущен до уточнения уровней, - проверка кода сначала `medium`, повторный прогон `high`. Первая проверка нашла - отсутствие подсказки `make clean` на грязных Kafka-топиках; повторный прогон - `APPROVED`. -- Issue 14: обычный режим. Worker `medium`, проверка кода `high`; `APPROVED`. -- Issue 09: гейт. Worker `high`; проверка задачи `high` и проверка кода `high`. - Первый круг нашёл ODS-проверку device/os/geo, потерю 1 мкс в timestamp offset - и ложнозелёный `generated-history-check`; второй круг `APPROVED`. -- Issue 10: обычный режим. Worker `medium`, проверка кода `high`. - Проверка нашла рассинхрон Superset export/layout, ложнозелёный тест и риск дубля - при rename; узкие повторные прогоны закрыты, итог `APPROVED`. -- Issue 08: обычный режим. Worker `medium`, проверка кода `high`. - Проверка нашла старый путь отката в стандарте уроков и несогласованность урока 06 - вокруг `superset-dashboard`; повторный прогон `APPROVED`. - -## Финальное ревью - -Финальное ревью цепочки шло на усиленном уровне `xhigh`. - -До обновления скилла повторные прогоны запускались свежими сессиями ревьюеров: -`Bernoulli`, `Noether`, `Banach`, `Curie`, `Sartre`, `Bacon`. -Это отклонение от обновлённого процесса зафиксировано как дорогой вариант. -После обновления скилла проектные doc-находки чинил отдельный worker для правок -`Raman` (`medium`), а повторный прогон выполнял тот же ревьюер `James` через -`send_input`. - -Закрытые классы находок: - -- PRD фичи больше не `Draft` и не держит закрытые 12/09/10/14/08 в остатке. -- Все `Status: done` issue фичи без пустых `[ ]`. -- Superset docs/course/artifacts/code не содержат старые `World Map/world_map` - маркеры рядом с новым `Top Countries`. -- `docs/OPERATIONS.md`, `docs/course/PRD.md`, `docs/ARCHITECTURE.md`, - `docs/REPO_MAP.md` и `CONTEXT.md` согласованы со startup-history-путём и - `generator_control`. - -## Отклонения процесса - -- Ранние повторные прогоны ревью запускались свежими субагентами, а не продолжением - того же ревьюера. После обновления `coordinator-loop` точечные повторные прогоны - нужно делать через `send_input` тому же ревьюеру. -- До обновления скилла координатор сам правил часть project docs по финальному - ревью. После обновления project docs правил worker для правок; процессные файлы - `.scratch/...` остались зоной координатора. -- Визуальная приёмка Superset и живой проход Airflow UI не выполнялись в этой - цепочке. Это не скрыто: все такие пункты вынесены в HITL/риски. - -## Открытые риски - -- Нужен кросс-линейный ревью-проход другой родословной по фактическим изменениям - `c672ed0..HEAD`. Особый фокус: issue 12 Airflow-пульт, issue 09 state v3 и - стыковочная проверка, issue 10 Superset export/layout, issue 08 учебный путь. -- Нужна ручная HITL-приёмка: - - Airflow UI: `generator_control` backfill/import/check на чистом стенде. - - Superset: кадр `Top Countries by Events` целиком и кадр с tooltip. - - Курс 00-06: проход через новый startup-history-путь. -- Задача 13 (`backfill-top-up-from-snapshot`) остаётся `needs-triage`; после - фикса issue 09 её можно дооформлять отдельно. - -## Быстрые проверки, которые уже проходили - -- `uv run --with-requirements generator/requirements.txt pytest generator/tests -q` - — PASS на issue 12 и 09. -- `make generator-test` — PASS на issue 14. -- `uv run --with pytest pytest tests/test_superset_dashboard_config.py` — PASS - на issue 10. -- Поисковые проверки курса на старые `make data`/`kafka_load`/фиксированные - сид-ожидания — PASS на issue 08. -- Финальные `rg`-проверки по PRD, чекбоксам, Superset-маркерам и - startup-history-дрифту — PASS перед `f8b419d`. - -## Независимый ревью-проход (2026-07-04, свежий взгляд) - -Кто и как: Claude, свежим взглядом по диапазону `c672ed0..HEAD`, без субагентов -(правило проекта: «свежим взглядом» = без субагентов). Читались реальные диффы -всех пяти задач и сверялись с текстами issue; тесты прогонялись заново, а не со -слов handoff. - -Вердикт: цепочка выполнена на уровне кода и тестов; правок для приёмки не нужно. - -Доказательства (прогонял сам): - -- `generator/tests` — 172 passed (в том числе контрактный тест DAG и тесты пульта). -- `tests/test_superset_dashboard_config.py` — 5 passed. -- Грепы: старых маркеров `world_map`/`Geography Map` в коде и доках нет (остались - только законный историч. след в спеке, `previous_slice_names` и тест - переименования); во всех done-issue 0 пустых чекбоксов; `state v2` в отгруженном - коде не осталось. -- `etl_pipeline` действительно принимает `conf` `full_refresh` (`Param(True, - type="boolean")`) — имя параметра из `TriggerDagRunOperator` пульта совпадает. - -По задачам: - -- Issue 09 (гейт): донор `base_click_id` хранится в state; восстановление громко - падает на неизвестном доноре (тихие `.get`-fallback'и убраны); запасная ветка - рождения сделана восстановимой и провалидирована. Отдельная реальная находка — - µs-фикс `_timestamp_to_state_offset` (целочисленная арифметика вместо - `total_seconds()*1e6`). Тесты бьют прямо в критерии приёмки. -- Issue 12 (пульт): `retries=0`, `is_paused_upon_creation`, join после ветвления - через `NONE_FAILED_MIN_ONE_SUCCESS`, `wait_for_completion=True`, env до - `Config()`, предпроверка пустоты Kafka и STG. Compose-профиль `live-generator` - оставляет `make up` чистым; явные пути включают профиль по имени сервиса. -- Issue 14: `daily-wave` speed 1→60, tick 60→1; `ci` не тронут; пер-тиковые INFO - приглушены до DEBUG. -- Issue 10: `echarts_timeseries_bar` с tooltip, легендой и единицами; дедуп дублей - при переименовании; экспорт-JSON защищён тестами и от рассинхрона с конфигом, и - от дрейфа раскладки. -- Issue 08: доки, старый путь `make data`/`kafka_load` убран как основной, грепы - чисты. - -Мелочи (косметика, не блокеры): - -- `test_default_version` (`test_state.py`): docstring говорит «версии 2», а ассерт - уже `"3.0"`. -- `build_control_env` для `import` тоже зовёт `build_launch_env("backfill", …)` — - безвредно (важен только «мир»-env), но может смутить читателя. -- `tooltipTimeFormat: "smart_date"` на категориальной оси — рудиментарный - параметр, безвреден. - -## Открытые риски (обновление 2026-07-04) - -- Кросс-линейный проход *другой родословной* (иная модель/агент) не выполнялся: - этот ревью сделан Claude свежим взглядом без субагентов. Для кода и тестов - этого достаточно; если нужна ещё одна родословная — отдельный шаг. -- Ручная приёмка (HITL) остаётся, код к ней честен (пропуски печатаются): - - Airflow UI: `generator_control` backfill/import/check на чистом стенде. - - Superset: кадр `Top Countries by Events` целиком и кадр с tooltip. - - Курс 00-06: проход по новому startup-history-пути. -- Задача 13 (`backfill-top-up-from-snapshot`) — `needs-triage`; предпосылка - (исправный restore из issue 09) выполнена, можно дооформлять. - -## Следующий шаг - -Независимый ревью кода и тестов пройден. Осталось: ручная HITL-приёмка (Airflow -UI, Superset-кадры, проход курса) и отдельное решение по задаче 13. diff --git a/.scratch/handoffs/20260705-0002-cross-review-generator-chain.md b/.scratch/handoffs/20260705-0002-cross-review-generator-chain.md deleted file mode 100644 index b38c3bd..0000000 --- a/.scratch/handoffs/20260705-0002-cross-review-generator-chain.md +++ /dev/null @@ -1,160 +0,0 @@ -# Кросс-линейное ревью цепочки генератора 12 -> 14 -> 09 -> 10 -> 08 - -Дата: 2026-07-05 00:02. -Жанр: одноразовый handoff по ADR-0003; отчёт кросс-прохода другой родословной -(Claude) по диапазону `c672ed0..ec815ce`. Бриф — из -`.scratch/handoffs/2026-07-04-generator-chain-review.md`. -Метод: четыре параллельных адверсарных ревьюера с исполняемыми доказательствами -(мутационные тесты на копии, эксперимент с compose-профилем на реальной версии -Compose, зонды на реальном сиде, сверка Airflow API через Context7). Репозиторий -не менялся. - -## Вердикт - -**CHANGES_REQUESTED.** Счёт: 1 critical, 5 high, 10 medium, 4 low — после -`APPROVED` финального ревью той же линии. Третье подтверждение находки о -родословной: внутренние ревью закрыли локальные дефекты честно, кросс-линия -нашла сквозные сценарии. - -## Сквозные семейства (главная ценность прохода) - -1. **«Смешение миров» возвращается тремя дорогами.** Инвариант задачи 07 - («чистый стенд, громкий отказ») пробит: (а) state v2 после обновления кода - классифицируется как «повреждённый» → тихий чистый старт нового мира поверх - истории; (б) live-генератор ушёл в compose-профиль, и `make clean`/`down` - его больше **не убивают** — осиротевший генератор старого мира пишет в чистые - топики и переживает перезагрузку (`restart: unless-stopped`); (в) консольные - пути (`make generator-backfill`, `startup-history-import`) остались без - предпроверок, которые пульту сочли обязательными. -2. **Последствия «make up больше не стартует live» не выметены.** Алерт - `Kafka No Messages Produced` горит весь курс на здоровом стенде; уроки 0/5 - ожидают поток; `sleep 5` в документированной проверке шва меньше времени - старта генератора — проверка стала пустышкой. -3. **Ложнозелёные проверки.** Superset-тесты пропускают дрейф параметров - (top-15 → 500 строк — 5 тестов зелёные); стыковая проверка сверяет шов там, - куда указал env, а не где он по манифесту; ODS-блок проходит на пустом ODS. -4. **Чистый клон/свежий стенд никто не прошёл.** `uv` не заявлен в - пререквизитах; README-быстрый старт падает без `ddl_init`; пульт вечно висит - на запаused `etl_pipeline`; после `git pull` образ Airflow не пересобирается - (DAG красный); центральное упражнение урока 1 физически невоспроизводимо. - Всё это лежало ровно в вынесенной в риски HITL-приёмке. - -## Индекс находок по issue - -### Issue 08 (курс) — CHANGES_REQUESTED - -- **CRITICAL** `docs/course/lessons/01_kafka_to_clickhouse.md:192-239` — - упражнение с `kafka_msg_ts` невоспроизводимо: «перезаливка среза» заменена на - `make generated-history-analytics && make up`, который делает `down -v` и - накатывает эталонный DDL — добавленная менти колонка уничтожается, финальный - SELECT падает; в тексте остался висящий `TRUNCATE` (след нестыковки). - Нужен путь перезаливки без сброса схемы (смена `kafka_group_name` / - `CLEAN_START=0` + повторный backfill) и проверка на стенде. -- **HIGH** `docs/course/README.md:19-34`, `README.md:43-47` — первая команда - курса требует `uv` на хосте; ни пререквизиты, ни quick start его не называют. -- **HIGH** `docs/course/lessons/05_monitoring.md:254,259-262,339` — на штатном - пути (без live) `Kafka No Messages Produced` горит постоянно; урок обещает - «алерты не шумят»; панели описаны для потока, которого нет. -- **MEDIUM** `00_kafka_intro.md:56-65` — служебные топики `generator_state` и - `generator_startup_history_manifest` не объяснены (урок построен на - разглядывании списка топиков). -- **MEDIUM** `00_kafka_intro.md:117-122` vs `05_monitoring.md:156-161` — - взаимоисключающие утверждения о видимости consumer lag; проверить на стенде. -- **MEDIUM** `README.md:64-68`, `docs/OPERATIONS.md:175-199` — - `make generated-history-check` после `PROFILE=daily-wave` падает: в check - зашит диапазон ci, PROFILE не передаётся. -- **MEDIUM** `docs/OPERATIONS.md:95` — источником STG первым назван архивный - `make data`. - -### Issue 12/14 (пульт, профиль) — CHANGES_REQUESTED - -- **HIGH** `airflow/dags/generator_control_dag.py:227-235` + - `etl_pipeline_dag.py:263` — `etl_pipeline` создаётся на паузе; `trigger_etl` - (`wait_for_completion`, без `execution_timeout`) виснет навсегда, - `max_active_runs=1` блокирует повторные запуски. В Airflow 2.10 нет - `fail_when_dag_is_paused` (Context7). Нужна предпроверка «etl_pipeline не на - паузе» + шаг unpause в README/runbook. -- **HIGH** `docker-compose.yml:293-294,334`, Makefile (`down`, `clean`) — - профильный `live-generator` не удаляется `down -v` (доказано экспериментом, - Compose v5.1.3); `restart: unless-stopped` воскрешает его после перезагрузки. - В `down`/`clean`/CLEAN_START нужен `--profile live-generator`. -- **MEDIUM** `README.md:32-40` — быстрый старт без `ddl_init`: precheck падает - сырым `UNKNOWN_TABLE`; добавить шаг и человекочитаемую подсказку. -- **MEDIUM** `airflow/requirements.txt:9-10`, Makefile `up` — после `git pull` - образ Airflow не пересобирается → `ModuleNotFoundError: prometheus_client`, - DAG красный. Нужен `--build` или инструкция. -- **MEDIUM** `docs/OPERATIONS.md:237-241` vs `kafka_io.py:161,253` — `sleep 5` - меньше времени старта continue (≥10 с): live-строк ноль, `CHECK_LIVE_SEAM=auto` - молча пропускает шов. Было `sleep 130` при тике 60 с. -- **MEDIUM** `airflow_control.py:102-141` vs `run_generator.sh:56-77`, - `import_startup_history_artifact.sh` — предпроверки только в DAG; консольные - пути тихо смешивают миры. Вынести `assert_stand_clean` в общую точку входа. -- **LOW** `service.py:203-215` — простой стенда ×60: ночь = +30 модельных суток - дыры; предупредить в runbook, советовать reset/повторный импорт. -- **LOW** — фиксация Context7-проверки Airflow API только в `.scratch/`, не в - коде/доках (AGENTS.md требует); сами вызовы для 2.10.5 корректны. - -### Issue 09 (state v3, фактура) — CHANGES_REQUESTED - -- **HIGH** `state.py:236-241` → `kafka_io.py:181` → `service.py:100-102` — - поднятие STATE_VERSION молча превращает живой стенд во «второй мир»: v2 — - читаемый, несовместимый state, но version-гейт убивает его до семантики, - `from_dict_safe` даёт `None`, сервис тихо стартует новый мир с `model_t0` - поверх истории. Сверка `manifest.state_version != state.version` - (`service.py:246`) недостижима. Спека (строки 198-204, 245-247) требует - громкий отказ; `IncompatibleStateError` уже существует — использовать его при - известной старой версии и `GEN_STATE_RESET=false`; миграционной заметки в - доках нет. -- **MEDIUM** `check_generated_analytics.sh:13,273-289,342,387-393` — проверка - стыка сверяет шов по env-дефолту (`2026-01-01T06:00`), не по манифесту: после - `daily-wave` голый `make generated-history-check` «доказывает» однородность - фальшивого шва посреди backfill. LEFT JOIN `dds.click` структурно пуст; ODS-блок - проходит на пустом ODS. Брать `model_t_end` из манифеста, требовать ненулевые - ODS-строки. -- **MEDIUM** `generation.py:196-209` — предпосылка issue «fallback не - срабатывает» неверна: срабатывает у ~8% визитов (зонд, seed=4242, N=20000); - выпуск не изменился только потому, что у всех доноров сида строки внутри - click_id идентичны — свойство нигде не зафиксировано; при регенерации сида - с вариативностью ~8% визитов поедет. Зафиксировать свойство сида или - ограничить `GEN_MAX_SESSION_EVENTS` длиной максимального донора. -- Ядро фикса крепкое (проверено исполнением): инвариант «рождение == - восстановление» держится, RNG не потребляется при restore, микросекунды - чинятся честно, форма распределений не сместилась (сверка с `e2d0684^`). - -### Issue 10 (Superset) — CHANGES_REQUESTED - -- **MEDIUM** `tests/test_superset_dashboard_config.py:64-76` — тесты сверяют - подмножество параметров: мутация «top-15 по убыванию» в экспорте (row_limit - 15→500, order_desc→false и др.) проходит все 5 тестов. Нужно полное сравнение - `params` config-vs-export. Классы «возврат world_map» и «рассинхрон layout» - закрыты честно (мутации падают). -- **MEDIUM** issue `10-*:51-68`, `create_dashboard.py:158-185` — приёмка «по - кадру» выполнена декларативно, чарт никем не отрисован; порядок столбцов после - pivot не гарантирован — задать `x_axis_sort: "Events, pcs"`, - `x_axis_sort_asc: false`; снять кадр при HITL. -- **MEDIUM** `create_dashboard.py:505-520` — удаление дублей по глобальному - совпадению имени: повторный прогон молча удалит одноимённый чарт менти; - сузить по `datasource_id`, задокументировать деструктивность. -- **LOW** `create_dashboard.py:176` — `tooltipTimeFormat` бессмыслен для - категориальной оси; легенда дублирует ось при единственной серии. -- **LOW** `docs/SUPERSET_DASHBOARD.md:205-211` — путь ручного импорта экспорта - (legacy v0) ничем не проверяется. - -## Маршрутизация (предложение) - -- **Багфикс-цепочка Codex** (кандидат-порядок по риску): (1) семейство - «смешение миров» — 09-HIGH + 12-HIGH-clean + разъезд предпроверок одним - issue; (2) 12-HIGH-паузы + README/ddl_init/build — «свежий стенд» одним - issue; (3) 08-CRITICAL урок 1 + uv + алерт урока 5 — «курс на чистом клоне»; - (4) ложнозелёные проверки (09-MEDIUM-шов, 10-MEDIUM-тесты); (5) остальное. -- **HITL остаётся HITL** (кадры Superset, живой проход Airflow UI, проход по - урокам) — но пункты 08-CRITICAL и 12-HIGH показывают, что часть HITL дешевле - заменить проверками на чистом клоне в CI-манере. -- Отдельно: третий случай подтверждения находки о родословной — кандидат на - датированную поправку в `references/rationale.md` скилла coordinator-loop. - -## Что подтверждено как корректное - -Стыковка env DAG→генератор; ветвление DAG; отсутствие потребности в Docker -изнутри Airflow; make-цели/имена DAG/DDL-колонки/числа профилей в уроках; ядро -state v3; замена карты в обоих источниках; 172 unit-теста зелёные. diff --git a/.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md b/.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md deleted file mode 100644 index fbbe33f..0000000 --- a/.scratch/handoffs/20260705-2212-generator-cross-review-fixes.md +++ /dev/null @@ -1,122 +0,0 @@ -# Handoff: fixes after generator cross-line review - -Дата: 2026-07-05 22:12. -Жанр: handoff по ADR-0003 после coordinator-loop 15 -> 16 -> 17 -> 18. - -## Что закрыто - -- 15: граница миров после обновления, clean/reset и host-запусков. - Коммиты: `080d5ae`, `108053b`. -- 16: свежий стенд и `generator_control` без неявных ручных шагов. - Коммиты: `27947ce`, `9804d0b`. -- 17: доверенные проверки startup-history и Superset. - Коммиты: `00c97eb`, `6731349`. -- 18: курс 00/01/05 проходит по startup-history на чистом стенде. - Коммиты: `77e4fb6`, `6d64f39`. -- Follow-up 19: отдельная задача на единые `make test` и `make lint`. - Коммит: `2778eae`. -- Финальный review fix: документация больше не советует обычный - `make generated-history-check` после backfill-only пути. - Коммит: `8e1c733`. -- Issue 19 закрыта после внешнего Claude review: единые `make test` и - `make lint`, подключение корневых контрактных тестов и усиление проверок - startup-history. - Коммит: `9b0b063`. - -## Ключевые стыки реализации - -- `generator/src/clickstream_generator/stand_clean.py` — общий guard чистого - стенда для Airflow и host-путей. -- `scripts/assert_stand_clean.sh` — host-проверка перед backfill/import/continue. -- `airflow/dags/generator_control_dag.py` — pause-check `etl_pipeline` до - мутирующих веток. -- `scripts/check_generated_analytics.sh` — manifest boundary, fail-closed - live seam, непустой ODS и fail-closed проверка DM-витрин. -- `scripts/run_generated_history_runtime_check.sh` — bounded runtime gate для - daily-wave + live seam. -- `scripts/run_generator.sh` — `reset` теперь проходит clean-stand guard до - запуска live-генератора. -- `Makefile` — `make test`, `make lint`, `contract-test`; обычный pytest-вывод - тихий, подробность можно включить через `PYTEST_ARGS`. -- `generator/src/clickstream_generator/state.py` — любая версия state не `3.0` - даёт громкий `UnsupportedStateVersionError`; `from_dict_safe` больше не - глотает ошибку старой/неизвестной версии. -- `generator/src/clickstream_generator/airflow_control.py` — - `target_dag_trigger_error()` покрывает три ветки pause-check без импорта - Airflow. -- `superset/create_dashboard.py` — sync ограничен чартами целевого dashboard. -- `docs/course/lessons/00_kafka_intro.md`, - `docs/course/lessons/01_kafka_to_clickhouse.md`, - `docs/course/lessons/05_monitoring.md` — курс согласован с startup-history, - no-live default и consumer lag. - -## Проверки - -- Issue 15: `make generator-test`, `docker compose config --quiet`, - `py_compile`, `bash -n`. -- Issue 16: `make generator-test`, `docker compose config --quiet`, - `py_compile`, `make clean`, `make up`, `make ddl`. -- Issue 17: Docker target tests, `make generator-test`, `py_compile`, - `bash -n`, `make generated-history-runtime-check`. -- Issue 18: doc `rg`, `git diff --check`, - `make generated-history-analytics`, `make up`. -- Issue 19 / commit `9b0b063`: `make contract-test` — 16 passed; - `make generator-test` — 190 passed; `make lint` — passed; `make test` — - 190 generator tests, 16 contract tests, `docker compose config --quiet`. -- Финальный chain review `Gibbs`: initial `CHANGES_REQUESTED`, narrow rerun - `APPROVED`. - -## Происхождение ревью - -- 15: gate; task reviewer `Ptolemy` high, code reviewer `Linnaeus` high; - оба rerun `APPROVED`. -- 16: code reviewer `Laplace` high; primary review found paused-DAG order bug, - later reruns `APPROVED`. -- 17: code reviewer `Bacon` high; found Superset ownership and live-seam - defaults; bounded runtime gate rerun `APPROVED`. -- 18: docs/code reviewer `Hume` high; found Prometheus scrape-count drift; - rerun `APPROVED`. -- Финал: chain reviewer `Gibbs` xhigh; found docs drift around - `generated-history-check`; rerun `APPROVED`. -- После `9b0b063`: внешний Claude review подтвердил закрытие всех девяти - находок и дал `APPROVED`. Reviewer не перезапускал Docker-проверки сам, но - сверил код, тесты и документы; фактические прогоны были выполнены - координатором. - -## Отклонения процесса - -- Локальный `uv run ... pytest` у координатора падал до pytest из-за - `snap-confine` / `cap_dac_override`. Для координаторского gate целевые тесты - запускались через Docker-образ `generator:test`. Это вынесено в issue 19. -- Первичный runtime gate issue 17 через полный `daily-wave` был остановлен: - прогон был активным, но слишком долгим для коммит-гейта. После этого добавлен - bounded `make generated-history-runtime-check`. -- Несколько стендовых проверок чистили Docker volumes. Это ожидаемо для issue - 16-18 и зафиксировано в журнале. - -## Что вручную проверить дальше - -- Чистый ручной путь через Airflow UI: `make up`, `make ddl` или `ddl_init`, - `airflow dags unpause etl_pipeline`, затем `generator_control` backfill. -- Backfill-only путь: `make generated-history-analytics`, затем - `CHECK_LIVE_SEAM=0 make generated-history-check`. -- Стык backfill/live: `make generated-history-runtime-check`. -- Superset: после готового DM открыть dashboard и сверить, что чарты созданы и - `Top Countries by Events` читается. -- Kafka UI: в уроке 00 должны быть понятны четыре data-топика и служебные - `generator_state`, `generator_startup_history_manifest`, - `generator_batch_history`. - -## Suggested skills - -- `triage` — если продолжать issue 13: он всё ещё `needs-triage`. -- `brainstorm-with-docs` — если перед issue 13 нужно заново согласовать - модель доливки истории от слепка. -- `coordinator-loop` — только после перевода issue 13 в `ready-for-agent`. -- `code-review` — для внешнего прохода по коммитам после ручной проверки. - -## Открытые риски - -- Ручная HITL-проверка после `9b0b063` ещё не выполнена пользователем. -- Issue 13 остаётся `needs-triage`: доливка истории от слепка должна опираться - на закрытые инварианты 15 и 17. diff --git a/.scratch/handoffs/20260707-2234-mentee-path-check-and-next-day.md b/.scratch/handoffs/20260707-2234-mentee-path-check-and-next-day.md deleted file mode 100644 index d29eae4..0000000 --- a/.scratch/handoffs/20260707-2234-mentee-path-check-and-next-day.md +++ /dev/null @@ -1,70 +0,0 @@ -# Handoff: проверка пути менти, диагноз флаки-гейта, задача next-day - -Дата: 2026-07-07 22:34. -Жанр: handoff по ADR-0003 после сессии «проверка пути менти + триаж 13/20». - -## Что закрыто - -- Проверка пути менти двумя субагентами (читатель документации на Opus, - прогонщик стенда на Sonnet): - - Документация: workable-with-friction, четыре мелких находки — все - исправлены. Коммит `900e949`. - - Стенд с нуля: clean -> generated-history-analytics -> up -> проверки -> - Superset (чарты с данными) -> Kafka (4 data-топика + служебные) -> - ClickHouse (ODS/DM непустые) — всё прошло, дубликатов ноль. - - Единственное падение: `make generated-history-runtime-check` красный - через раз — заведена задача 20. -- Диагноз задачи 20 (Codex, ключевой факт перепроверен по коду): генератор - фактуру на стыке НЕ ломает; корень — гонка остановки live-генератора в - самой проверке + seam-SQL, маскирующий непарные строки под «смену фактуры». - Детали и направление фикса — в задаче. Коммит `98be722`. -- Задача 13 переписана под глагол `next-day` в `generator_control`; - оба открытых вопроса закрыты; развилка реализации решена пользователем: - **вариант 2 — генерация следующего дня от слепка** (обоснование в задаче). - Коммиты `a3ac4b4`, `98be722`; решение развилки — в коммите с этим handoff. -- Созданы репо-локальные субагенты `.claude/agents/deep-reasoner.md` (Opus) - и `fast-worker.md` (Sonnet); `.gitignore` скрывает локальное в `.claude/`. - Коммит `984d6fc`. - -## Состояние трекера - -- `20-flaky-runtime-seam-check.md` — **ready-for-agent**: диагноз есть, - направление фикса выбрано (управляемая остановка live после полного flush - всех топиков + precheck непарных строк в seam-check). Идти первым. -- `13-backfill-top-up-from-snapshot.md` — **ready-for-agent**, blocked by 20. - Вариант реализации решён, критерии в задаче. - -## Что дальше - -1. Фикс задачи 20 по конвейеру `/claude-subagent-playbook`: постановка в - задаче самодостаточна, свежий адверсарный взгляд на неё до реализации, - реализация Кодексом (прямой `codex exec`, не плагин — см. handoff в - скилле), два слепых ревью. -2. После зелёного стабильного гейта (3+ прогона) — задача 13. -3. Ручная HITL-проверка пути менти пользователем всё ещё не сделана - (Airflow UI, Superset глазами, Kafka UI) — стенд остался в рабочем - состоянии после зелёного runtime-check. - -## Проверки этой сессии - -- `make generated-history-runtime-check`: красный (8/19), затем зелёный — - эмпирика флаки в задаче 20; полные логи в session-scratchpad (одноразовые, - сводка перенесена в задачу). -- Утверждение диагноза про запасную ветку перепроверено по - `generation.py:194-209`. -- Дерево чистое после каждого коммита; `.claude/settings.local.json` - в git не попал. - -## Отклонения процесса - -- Прогонщик стенда дважды засыпал без отслеживаемого фонового процесса и - в финале умер, не собрав отчёт, — вердикт собран координатором из логов. -- Диагност через плагин codex-rescue: форвардер отчитался «finished» при - живом фоновом вызове; результат забран напрямую из output-файла задания. - Уроки процесса — в handoff рядом со скиллом claude-subagent-playbook. - -## Suggested skills - -- `claude-subagent-playbook` — для фикса задачи 20 (одна нетривиальная - задача: постановка готова, нужен конвейер). -- `conventional-commits` — коммиты. diff --git a/.scratch/handoffs/20260712-2359-task-13-done-next-schedule.md b/.scratch/handoffs/20260712-2359-task-13-done-next-schedule.md deleted file mode 100644 index 77329c8..0000000 --- a/.scratch/handoffs/20260712-2359-task-13-done-next-schedule.md +++ /dev/null @@ -1,82 +0,0 @@ -# Handoff: задача 13 закрыта конвейером, дальше — задача про расписание и HITL - -Дата: 2026-07-12 23:59. -Жанр: handoff по ADR-0003 после сессии «задача 13 (глагол next-day) по -/claude-subagent-playbook». - -## Что закрыто - -- **Задача 13 — done.** Полный конвейер плейбука: двойное слепое ревью - постановки (оба CHANGES_REQUIRED, 9 решений внесены, коммит `540989d`) - -> реализация Codex (`gpt-5.6-sol`, high, тред - `019f57d2-ff7b-70d3-8143-3740324309d9`) -> два слепых ревью кода - (Codex: CHANGES_REQUIRED 3 MAJOR; Claude: APPROVED_WITH_MINORS) -> - триаж в 2 круга + узкий диагноз нулевого стыка -> перепроверки - ALL_CLOSED у обоих -> моя приёмка. Коммит `f5dee26`. -- Суть решений, диагноз нулевого стыка (естественный ночной провал, не - дефект restore) и аргументы отклонений — в самой задаче - `.scratch/generator-model-time-startup-history/issues/13-*.md`, здесь - не дублируются. -- Приёмка своими глазами: `make test` 204+31; `generated-history-chain-check` - зелёный (граница 1: crossing=1, граница 2: crossing=0 с явным статусом, - непарные — нули); регрессия `generated-history-runtime-check` задачи 20 - зелёная (19 переходящих визитов). Учебный цикл: DM 322 -> 10 026 -> - 19 196 за два next-day. - -## Состояние - -- Ветка `feature/data-generator`, дерево чистое после `f5dee26` - (+ handoff-коммит следом), запушено в origin. -- Стенд поднят, в мире 3 модельных дня (артефакт + два next-day от - прогонов исполнителя); live-генератор остановлен. -- Модель Codex — `gpt-5.6-sol` (high для реализации/ревью; xhigh не - окупается). Перед каждым фоновым запуском — сообщать пользователю, что - запускается и какой этап. - -## Что дальше - -1. **Завести задачу про расписание** («имитация жизни»): включение - `next-day` по расписанию. Обязана решить: как расписание задаёт - `operation=next-day` (дефолт DAG — `backfill`, планового запуска с - дефолтом быть не должно) и retention/формат накопительного состояния - манифеста (см. отклонение «полная перечитка Kafka» в задаче 13 — - оно блокирует её по записи в тикете). -2. **Ручная HITL-проверка пути менти** (Airflow UI, Superset глазами, - Kafka UI) — переносится пятый handoff подряд; исполнитель задачи 13 - отметил, что Airflow UI руками не гонял (проводка next-day проверена - контрактными тестами и реальными запусками через `run_batch.sh`). -3. **Внести уроки в плейбук**: рефлексия по расходу контекста мастера - проведена в конце сессии 2026-07-12/13 (см. итоговое сообщение) — - согласовать с пользователем и записать в - `~/.claude/skills/claude-subagent-playbook/references/lessons.md`. - Ключевые кандидаты: ограничить раздел confirmations у ревьюеров - (одна строка на пункт); самый большой неконтролируемый расход — - эхо всего файла задачи в контекст мастера, когда исполнитель правит - файл, который мастер недавно писал сам. - -## Проверки этой сессии - -- `make test`: 204 (генератор) + 31 (контракты корня) — прогнаны мной, - исполнителем и ревьюерами независимо. -- Стенд: chain-check и runtime-check зелёные (лог приёмки одноразовый в - session-scratchpad, сводка — в задаче 13). -- Дерево сверялось с baseline после каждого ревью/перепроверки — - ревьюеры исходники не трогали. - -## Отклонения процесса - -- Перепроверка Codex принесла новую находку (MAJOR про микросекунды) — - это не нарушение узости, штатный случай из prompt-templates.md; - закрыта вторым кругом триажа тем же тредом исполнителя. -- Требование «громкий отказ на нулевом стыке» из обоих ревью покраснело - на честном стенде; решён диагнозом по данным (ветка «естественный - провал») и сменой семантики на явный статус — класс «артефакт - верификатора», как в задаче 20. - -## Suggested skills - -- `claude-subagent-playbook` — задача про расписание тем же конвейером - (постановку писать с нуля — этапы 1–2). -- `conventional-commits` — коммиты. -- `research` — если для задачи про расписание понадобится фактура по - Airflow (params при scheduled runs) — через Context7. diff --git a/.scratch/handoffs/20260719-2144-hitl-mentee-path-redesign.md b/.scratch/handoffs/20260719-2144-hitl-mentee-path-redesign.md deleted file mode 100644 index b9d3ac5..0000000 --- a/.scratch/handoffs/20260719-2144-hitl-mentee-path-redesign.md +++ /dev/null @@ -1,103 +0,0 @@ -# Handoff: ручной HITL пути менти пройден — вырос в набросок редизайна - -Дата: 2026-07-19 21:44. -Жанр: handoff по ADR-0003 после сессии «ручная HITL-проверка пути менти» -(долг переносился пятый handoff подряд — закрыт). - -## Что сделано - -- **HITL-проверка пути менти пройдена целиком, своими руками через UI.** - Чистый стенд (`make clean` + `make up`) -> Airflow UI: `ddl_init` -> - `generator_control` `backfill` (профиль `ci`) -> ETL -> `make superset-init` - -> дашборд глазами -> `generator_control` `next-day` -> проверка стыка. - Каждый шаг сверен по данным (ClickHouse, manifest, chain-check). -- **Результат мира:** backfill `[00:00, 06:00)` = 16 054 события; после - `next-day` = 110 602 события, визитов 10 228, пользователей 1 726; - `boundaries` = три границы, `model_t_end` = `2026-01-02 06:00`. - `generated-history-chain-check` — EXIT 0 (стык 06:00: 9 переходящих - визитов, непарных 0, однородность подтверждена). -- **9 находок + рамочная модель** — в - `.scratch/generator-model-time-startup-history/hitl-findings.md`. - Половина находок родилась из наблюдений менти. HITL из «проверить, что - не сломалось» перерос в **набросок редизайна пути менти**. - -## Ключевые выводы (кратко; подробно — в hitl-findings.md) - -- **Рамочная модель менти:** основной режим — `import` артефакта (база), - дальше две ветки от одного состояния: `next-day` (пакетно) и `continue` - (живой поток). Это три разных лабораторных. `next-day` и `continue` - не схлопывать. Подтверждено `launch.py:117-137`. -- **F7 + замер:** опыт на 6h беден; нужен готовый артефакт из файла. - Артефакт 6h = 49 МБ; `xz -9e` -> 1,1 МБ (48x, 13,5 с). На 3 дня ~13 МБ - — кладётся в обычный git. Паттерн-образец: `airflow-greenplum-solution` - (`bookings/seed/demo.sql.xz`). -- **Решение менти:** целевой стартовый мир — **3 дня на `daily-wave`** - (обсуждаемо). На `ci` три дня плоские — волну даёт `daily-wave`. -- **F8:** два профиля путают. Свести к одному учебному (волна, ×60); - `ci` (×1) — служебный для тестов. ×60 оправдан live-лабой (`continue`). -- **F9:** `next-day` растёт O(N²) из-за полной перечитки Kafka для - счётчиков (`service.py:591`). Параллелить не то (ломает детерминизм); - правильно — инкрементальные счётчики (O(N), снимает риск OOM). Это и - есть решение «новый формат состояния manifest», блокирующее расписание. -- **F1, F5 (баги UI):** форма `generator_control` держит необязательные - поля обязательными (`type="string"` без `null`); Airflow и Superset - быстро разлогинивают (ротацию секрета исключил, корень TBD). - -## Состояние - -- Ветка `feature/data-generator`. После этого handoff в дереве останутся: - `hitl-findings.md` + этот handoff (коммитятся), плюс **не коммитить** - `data/ci_backfill.json` (49 МБ, побочный от backfill; `.gitignore` его - не покрывает — риск, см. ниже). -- Стенд **оставлен поднятым** по просьбе пользователя; в мире 1,25 суток - (профиль `ci`), live-генератор не запущен. Выросший мир можно смотреть - в Superset (dashboard id 1). - -## Что дальше - -1. **Разложить находки в постановки по плейбуку** (этапы 1-2, двойное - слепое ревью). Ориентировочно ~4 задачи (номера 21+ в `issues/`): - - **Ядро:** эталонный 3-дневный артефакт (`daily-wave`) как база - менти через `import`; формат/сжатие (`xz`, нужен ли `raw_topics`); - место хранения (git после отладки сида). Снимает F7. - - **Профиль:** один учебный профиль, `ci` -> служебный тестовый. - Перед слиянием проверить, не зависит ли live-тест от скорости ×1 - (F8). - - **Расписание + retention:** беспараметрный DAG `next-day` (F1/F3), - `schedule`, инкрементальные счётчики manifest (F9/F6). Это одна - связка — их корень общий. - - **Баг-фиксы:** F1 (форма) и F5 (разлогин); F4 (JS-баг Grid) низкий. -2. **Три режима -> свои уроки/лабы** — вход в учебный контент - (`docs/course/`). Под каждую ветку отдельная лаба: `next-day` - (пакетная инкрементальная обработка) и `continue` (потоковый приём); - общая база `import` — их совместное начало. Отдельно от - инфраструктурных задач. -3. **Мелочь по гигиене:** `data/*.json` не покрыт `.gitignore` — - побочные артефакты backfill (49 МБ) рискуют попасть в коммит. Решить: - правило в `.gitignore` или чистить артефакты после прогонов. - -## Проверки этой сессии - -- `ddl_init`: 4 слоя созданы (stg 12 / ods 8 / dds 2 / dm 6 таблиц). -- Форма мира после backfill: пирамида 445 < 1516 < 16 054, интервал - `[00:00, 06:00)` точный. -- После `next-day`: 3 границы в manifest, мир до `2026-01-02 06:00`. -- `generated-history-chain-check`: EXIT 0. -- Длительность `next-day` (метаданные Airflow): задача `run_next_day` - 638 с (~10,6 мин); ETL `trigger_etl` 30 с — узкое место в генерации. -- Замер сжатия: `xz -9e` 48x за 13,5 с; gzip -9 17x. - -## Отклонения процесса - -- Ревью субагентами не запускали: пользователь просил «свежий взгляд», - что по договорённости = без субагентов. Проверка проведена мной руками. -- Обход бага F1: форму backfill/next-day заполняли значениями профиля - `ci` целиком (пустые необязательные поля UI не пропускает). - -## Suggested skills - -- `claude-subagent-playbook` — разложить находки в постановки тем же - конвейером (этапы 1-2 с нуля). -- `conventional-commits` — коммиты. -- `research` / Context7 — для задачи про профиль/форму (Airflow Param - `type=["null","string"]`) и про формат артефакта. diff --git a/.scratch/generator-model-time-startup-history/hitl-findings.md b/.scratch/hitl-findings.md similarity index 100% rename from .scratch/generator-model-time-startup-history/hitl-findings.md rename to .scratch/hitl-findings.md diff --git a/AGENTS.md b/AGENTS.md index 99e1b2c..1565fa6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,11 +41,11 @@ ### Issue tracker -Локальный markdown: задачи и PRD живут файлами в `.scratch//` (коммитятся, `.scratch/` не в `.gitignore`). GitHub Issues не используются. См. `docs/agents/issue-tracker.md`. +GitHub Issues (через CLI `gh`). Спека фичи — файлом в `docs/specs/` (источник истины), корневой issue — тонкий, со ссылкой на спеку и чек-листом дочерних issues. См. `docs/agents/issue-tracker.md`. ### Triage labels -Пять канонических ролей, строки совпадают с именами (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`. +Пять канонических ролей как метки GitHub, имена совпадают (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). См. `docs/agents/triage-labels.md`. ### Domain docs diff --git a/CONTEXT.md b/CONTEXT.md index 79775d2..1c25e86 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -87,9 +87,31 @@ user_domain_id (пользователь, постоянный) это не новые значения слова, а та же сущность) — сгенерированное прошлое (заливка `K → ∞` + заморозка состояния), с которого живой стенд стартует непрерывно. По ADR-0006 она **несущая**: именно с неё свежий стенд получает историю с первой минуты. - Механизм реализован через Airflow DAG `generator_control` и служебный чистый + Механизм реализован через Airflow DAG `world_init` и служебный чистый путь `make generated-history-analytics`; решение про часы — [ADR-0005](./docs/adr/0005-generator-model-clock.md). +### Мир (стенда) + +Совокупное состояние данных стенда: события в Kafka, слои ClickHouse, +состояние генератора (модельные часы, живая популяция) и manifest с +контрольными числами. «Вырастить мир» — добавить в него модельное время +(`next-day` или живой поток), «восстановить мир» — залить его из артефакта. + +### Эталонный мир (reference world) + +Канонический стартовый мир менти: 3 модельных дня на профиле `daily-wave`, +собран мейнтейнером один раз, хранится сжатым артефактом в git. Все менти +импортируют один и тот же файл — числа в лабах воспроизводимы +число-в-число. Решение — [спека редизайна пути менти](./docs/specs/2026-07-19-mentee-path-redesign.md). + +### Три режима менти + +`import` (база: загрузить эталонный мир) и две ветки от восстановленного +состояния: `next-day` (пакетно добавить модельный день) и `continue` +(живой поток от той же границы). Это разные педагогики и разные лабы; +`backfill` — не режим менти, а инструмент мейнтейнера для сборки +эталонного мира. См. ту же спеку. + ### Модельное время и масштаб (×K) **Модельное время стенда** отвязано от настенных часов: генератор крутит diff --git a/Makefile b/Makefile index c5db886..80c7670 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,9 @@ contract-test config-test test lint COMPOSE ?= docker compose -PROFILE ?= ci +STARTUP_HISTORY_EXPORT_ARTIFACT ?= $(or $(ARTIFACT),/tmp/clickstream-startup-history.json) +STARTUP_HISTORY_IMPORT_ARTIFACT ?= $(or $(ARTIFACT),data/startup_history/reference-world.json.xz) +PROFILE ?= daily-wave PYTEST_ARGS ?= -q -p no:cacheprovider PYTHON_CHECK_PATHS := airflow/dags configs generator scripts superset tests BASH_SCRIPTS := $(shell find scripts -type f -name '*.sh' | sort) @@ -63,11 +65,11 @@ generated-history-chain-check: # Сгенерировать стартовую историю и сохранить портативный артефакт startup-history-export: - COMPOSE_BIN="$(COMPOSE)" bash ./scripts/export_startup_history_artifact.sh + ARTIFACT="$(STARTUP_HISTORY_EXPORT_ARTIFACT)" COMPOSE_BIN="$(COMPOSE)" bash ./scripts/export_startup_history_artifact.sh # Воспроизвести портативный артефакт стартовой истории в Kafka startup-history-import: - COMPOSE_BIN="$(COMPOSE)" bash ./scripts/import_startup_history_artifact.sh + ARTIFACT="$(STARTUP_HISTORY_IMPORT_ARTIFACT)" COMPOSE_BIN="$(COMPOSE)" bash ./scripts/import_startup_history_artifact.sh # Сверить ClickHouse после импорта с manifest артефакта startup-history-check: diff --git a/README.md b/README.md index f9d8bb2..86a6420 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ витринами. Поток данных коротко: -- **стартовая история**: `generator backfill → Kafka → ClickHouse (STG) → +- **стартовая история**: `world_init → Kafka → ClickHouse (STG) → batch STG → ODS → DDS → DM → Superset`. - **живое продолжение**: `generator live → Kafka → ClickHouse (STG) → batch ETL → Superset`. @@ -31,20 +31,29 @@ Перед первой командой нужны `Docker` с `docker compose`, `make`, `bash`, `curl`, `git` и `uv`. `uv` нужен для локальных Python-проверок и команд разработки. -Для ручной работы поднимите стенд и создайте стартовую историю через Airflow: +Для ручной работы поднимите стенд: ```bash make up -make ddl docker compose ps ``` -Откройте Airflow: `http://localhost:8080` (`admin/admin`). Сначала снимите -паузу с DAG `etl_pipeline` (переключатель слева от имени): на свежем стенде он -создаётся на паузе, и `backfill` откажется стартовать. Затем запустите -`generator_control` с операцией `backfill`: DAG создаст стартовую историю, -запустит ETL и выполнит `check`. `make up` не запускает live-генератор; live -включается отдельно командой `make generator-continue`. +Дальше всё делается в Airflow: `http://localhost:8080` (`admin/admin`). +Список DAG'ов читается лесенкой сверху вниз; на свежем стенде все DAG'и +создаются на паузе, поэтому перед запуском снимайте паузу переключателем +слева от имени. + +1. `ddl_init` — снимите паузу и запустите: DAG создаст схему ClickHouse + (отдельная команда в терминале не нужна). +2. `etl_pipeline` — только снимите паузу: его запустит следующий шаг. +3. `world_init` — снимите паузу и запустите с пустой формой: DAG импортирует + эталонный мир, запустит ETL и сверит витрины. +4. `world_next_day` — когда захотите добавить ровно один модельный день, + запустите его с пустой формой. Расписание задано каждые 30 минут, но по + умолчанию DAG стоит на паузе. + +`make up` не запускает live-генератор; live включается отдельно командой +`make generator-continue`. После обновления репозитория снова выполните `make up`: команда пересобирает Airflow-образ и подтягивает новые зависимости и DAG-и. Superset-дэшборд @@ -53,20 +62,16 @@ Airflow-образ и подтягивает новые зависимости Для полностью автоматического чистого прогона из консоли есть команда — это тот же путь, что выше через Airflow UI, но одной командой и без ручных шагов -(DDL она применяет сама, отдельный `make ddl` не нужен): +(схему ClickHouse она применяет сама): ```bash make generated-history-analytics ``` -По умолчанию это быстрый профиль `ci`: 6 часов модельного времени. Историю на -2 суток с суточной волной можно прогнать одной командой. В live-продолжении -`daily-wave` идёт с ×60: модельные сутки проходят примерно за 24 настенные -минуты. - -```bash -PROFILE=daily-wave make generated-history-analytics -``` +По умолчанию используется учебный профиль `daily-wave`: 3 суток с суточной +волной. В live-продолжении он идёт с ×60: модельные сутки проходят примерно +за 24 настенные минуты. Плоский профиль `ci` на 6 часов остаётся служебным +для автоматических тестов. Разовую длительность можно задать без ручного расчёта правой границы: @@ -166,8 +171,10 @@ flowchart LR - [Запуск и эксплуатация](./docs/OPERATIONS.md) — сценарий запуска, параметры DAG-ов, мониторинг, частые проблемы. - [Runbook стартовой истории](./docs/runbooks/startup-history.md) — экспорт, - импорт и live-продолжение из готового артефакта. + импорт эталонного мира из Git по умолчанию и live-продолжение. - [Карта репозитория](./docs/REPO_MAP.md) — где какие файлы и что менять. +- [Реализм генератора](./docs/generator-realism.md) — что в потоке как в бою, + а что учебная условность. - [Курс «Кликстрим на ClickHouse»](./docs/course/README.md) — учебная программа на этом стенде. - [DE-task.md](./docs/DE-task.md) — задание, из которого вырос стенд. diff --git a/airflow/dags/utils/startup_history_tasks.py b/airflow/dags/utils/startup_history_tasks.py new file mode 100644 index 0000000..5aa87d6 --- /dev/null +++ b/airflow/dags/utils/startup_history_tasks.py @@ -0,0 +1,59 @@ +"""Общие задачи Airflow для операций над миром стенда.""" + +from airflow.exceptions import AirflowException +from airflow.models.dag import DagModel +from airflow.utils.session import provide_session +from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook + +from clickstream_generator.airflow_control import ( + assert_clickhouse_matches_manifest, + assert_live_generator_not_running_for_next_day, + assert_next_day_snapshot, + build_next_day_env, + load_manifest_from_kafka, + load_state_from_kafka, + run_next_day, + target_dag_trigger_error, +) + + +def precheck_next_day(**context) -> None: + """Проверяет точку продолжения и готовит настройки мира.""" + assert_live_generator_not_running_for_next_day() + manifest = load_manifest_from_kafka() + state = load_state_from_kafka() + assert_next_day_snapshot(manifest, state) + context["ti"].xcom_push( + key="generator_env", + value=build_next_day_env(manifest), + ) + + +def run_next_day_task(**context) -> None: + """Генерирует следующий модельный день от проверенного state.""" + env = context["ti"].xcom_pull( + task_ids="precheck_next_day", + key="generator_env", + ) + run_next_day(env) + + +def check_manifest_task(**context) -> None: + """Сверяет витрину ClickHouse с manifest стартовой истории.""" + manifest = load_manifest_from_kafka() + hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default") + assert_clickhouse_matches_manifest(manifest, hook) + + +@provide_session +def assert_target_dag_not_paused(dag_id: str, session=None) -> None: + """ + Проверяет, что зависимый DAG можно запустить. + + Context7: для Airflow 2.10.5 у старого TriggerDagRunOperator нет надёжного + fail_when_dag_is_paused, поэтому паузу проверяем заранее через DagModel. + """ + dag_model = session.query(DagModel).filter(DagModel.dag_id == dag_id).one_or_none() + error = target_dag_trigger_error(dag_id, dag_model) + if error: + raise AirflowException(error) diff --git a/airflow/dags/generator_control_dag.py b/airflow/dags/world_init_dag.py similarity index 68% rename from airflow/dags/generator_control_dag.py rename to airflow/dags/world_init_dag.py index 3b80735..3d93de9 100644 --- a/airflow/dags/generator_control_dag.py +++ b/airflow/dags/world_init_dag.py @@ -10,36 +10,28 @@ from __future__ import annotations from datetime import datetime, timedelta from airflow import DAG -from airflow.exceptions import AirflowException -from airflow.models.dag import DagModel from airflow.models.param import Param from airflow.operators.empty import EmptyOperator from airflow.operators.python import BranchPythonOperator, PythonOperator from airflow.operators.trigger_dagrun import TriggerDagRunOperator -from airflow.utils.session import provide_session from airflow.utils.trigger_rule import TriggerRule from airflow_clickhouse_plugin.hooks.clickhouse import ClickHouseHook from clickstream_generator.airflow_control import ( KAFKA_BOOTSTRAP_SERVERS, - assert_clickhouse_matches_manifest, - assert_expected_t_end, assert_live_generator_not_running, - assert_live_generator_not_running_for_next_day, - assert_next_day_snapshot, assert_stand_clean, build_control_env, - build_next_day_env, default_artifact_path, - load_manifest_from_kafka, - load_state_from_kafka, run_backfill, run_import, - run_next_day, - target_dag_trigger_error, validate_import_artifact, ) from clickstream_generator.launch import PROFILES +from utils.startup_history_tasks import ( + assert_target_dag_not_paused, + check_manifest_task, +) default_args = { @@ -107,8 +99,6 @@ def choose_operation(**context) -> str: return "check_etl_not_paused_before_backfill" if operation == "import": return "check_etl_not_paused_before_import" - if operation == "next-day": - return "check_etl_not_paused_before_next_day" if operation == "check": return "check_only" raise ValueError(f"Неизвестная операция: {operation}") @@ -151,56 +141,9 @@ def run_import_task(**context) -> None: run_import(env, artifact_path) -def precheck_next_day(**context) -> None: - """Проверяет точку продолжения и готовит неизменные настройки мира.""" - assert_live_generator_not_running_for_next_day() - manifest = load_manifest_from_kafka() - state = load_state_from_kafka() - assert_next_day_snapshot(manifest, state) - assert_expected_t_end( - str(_param(context, "expected_t_end") or "").strip(), - str(manifest["model_t_end"]), - ) - context["ti"].xcom_push( - key="generator_env", - value=build_next_day_env(manifest), - ) - - -def run_next_day_task(**context) -> None: - """Генерирует следующий модельный день от проверенного state.""" - env = context["ti"].xcom_pull( - task_ids="precheck_next_day", - key="generator_env", - ) - run_next_day(env) - - -def check_manifest_task(**context) -> None: - """Сверяет витрину ClickHouse с manifest стартовой истории.""" - manifest = load_manifest_from_kafka() - hook = ClickHouseHook(clickhouse_conn_id="clickhouse_default", database="default") - assert_clickhouse_matches_manifest(manifest, hook) - - -@provide_session -def assert_target_dag_not_paused(dag_id: str, session=None) -> None: - """ - Проверяет, что зависимый DAG можно запустить. - - Context7: для Airflow 2.10.5 у старого TriggerDagRunOperator нет надёжного - fail_when_dag_is_paused, поэтому паузу проверяем заранее через DagModel. - """ - dag_model = session.query(DagModel).filter(DagModel.dag_id == dag_id).one_or_none() - error = target_dag_trigger_error(dag_id, dag_model) - if error: - raise AirflowException(error) - - -# Context7, Airflow 2.10.5: Param поддерживает enum, а max_active_runs ограничивает -# число одновременных DAG run. Поэтому форму и блокировку next-day держим в DAG. +# Context7, Airflow 2.10.5: Param поддерживает enum. with DAG( - dag_id="generator_control", + dag_id="world_init", description="Пульт стартовой истории генератора", default_args=default_args, schedule=None, @@ -211,17 +154,17 @@ with DAG( tags=["generator", "startup-history"], params={ "operation": Param( - "backfill", + "import", type="string", - enum=["backfill", "import", "next-day", "check"], + enum=["backfill", "import", "check"], title="Операция", description=( - "Что сделать: создать историю, импортировать артефакт, " - "добавить следующий день или проверить витрины." + "Что сделать: импортировать артефакт, создать историю " + "или проверить витрины." ), ), "profile": Param( - sorted(PROFILES)[0], + "daily-wave", type="string", enum=sorted(PROFILES), title="Профиль", @@ -252,16 +195,7 @@ with DAG( title="Артефакт", description=( "Backfill: куда сохранить файл; пусто — не сохранять. " - "Import: что читать; пусто — путь по умолчанию в data." - ), - ), - "expected_t_end": Param( - None, - type=["null", "string"], - title="Ожидаемая граница next-day", - description=( - "Необязательный model_t_end до запуска. Защищает от " - "повторной доливки того же дня." + "Import: что читать; пусто — эталонный мир из репозитория." ), ), }, @@ -289,15 +223,6 @@ with DAG( python_callable=run_import_task, ) - precheck_next_day_task = PythonOperator( - task_id="precheck_next_day", - python_callable=precheck_next_day, - ) - next_day_task = PythonOperator( - task_id="run_next_day", - python_callable=run_next_day_task, - ) - check_etl_not_paused_before_backfill = PythonOperator( task_id="check_etl_not_paused_before_backfill", python_callable=assert_target_dag_not_paused, @@ -308,12 +233,6 @@ with DAG( python_callable=assert_target_dag_not_paused, op_kwargs={"dag_id": ETL_DAG_ID}, ) - check_etl_not_paused_before_next_day = PythonOperator( - task_id="check_etl_not_paused_before_next_day", - python_callable=assert_target_dag_not_paused, - op_kwargs={"dag_id": ETL_DAG_ID}, - ) - trigger_etl = TriggerDagRunOperator( task_id="trigger_etl", trigger_dag_id=ETL_DAG_ID, @@ -343,14 +262,11 @@ with DAG( route >> [ check_etl_not_paused_before_backfill, check_etl_not_paused_before_import, - check_etl_not_paused_before_next_day, check_only, ] check_etl_not_paused_before_backfill >> precheck_backfill_task check_etl_not_paused_before_import >> precheck_import_task - check_etl_not_paused_before_next_day >> precheck_next_day_task precheck_backfill_task >> backfill_task >> trigger_etl precheck_import_task >> import_task >> trigger_etl - precheck_next_day_task >> next_day_task >> trigger_etl trigger_etl >> check_after_etl >> done check_only >> done diff --git a/airflow/dags/world_next_day_dag.py b/airflow/dags/world_next_day_dag.py new file mode 100644 index 0000000..7c6beb6 --- /dev/null +++ b/airflow/dags/world_next_day_dag.py @@ -0,0 +1,69 @@ +"""DAG добавления одного модельного дня в мир стенда.""" + +from datetime import datetime, timedelta + +from airflow import DAG +from airflow.operators.python import PythonOperator +from airflow.operators.trigger_dagrun import TriggerDagRunOperator + +from utils.startup_history_tasks import ( + assert_target_dag_not_paused, + check_manifest_task, + precheck_next_day, + run_next_day_task, +) + + +ETL_DAG_ID = "etl_pipeline" + +default_args = { + "owner": "airflow", + "depends_on_past": False, + "email_on_failure": False, + "email_on_retry": False, + "retries": 0, + "retry_delay": timedelta(minutes=1), +} + + +# Context7, Airflow 2.10.5: schedule принимает cron-строку. Расписание задано +# заранее, но новый DAG остаётся на паузе до отдельного решения. +with DAG( + dag_id="world_next_day", + description="Добавление одного модельного дня в мир стенда", + default_args=default_args, + schedule="*/30 * * * *", + start_date=datetime(2024, 1, 1), + catchup=False, + max_active_runs=1, + is_paused_upon_creation=True, + tags=["generator", "startup-history"], +) as dag: + check_etl_not_paused = PythonOperator( + task_id="check_etl_not_paused", + python_callable=assert_target_dag_not_paused, + op_kwargs={"dag_id": ETL_DAG_ID}, + ) + precheck = PythonOperator( + task_id="precheck_next_day", + python_callable=precheck_next_day, + ) + generate = PythonOperator( + task_id="run_next_day", + python_callable=run_next_day_task, + ) + trigger_etl = TriggerDagRunOperator( + task_id="trigger_etl", + trigger_dag_id=ETL_DAG_ID, + conf={"full_refresh": True}, + wait_for_completion=True, + allowed_states=["success"], + failed_states=["failed"], + poke_interval=30, + ) + check = PythonOperator( + task_id="check_after_etl", + python_callable=check_manifest_task, + ) + + check_etl_not_paused >> precheck >> generate >> trigger_etl >> check diff --git a/data/startup_history/README.md b/data/startup_history/README.md new file mode 100644 index 0000000..bfd96dd --- /dev/null +++ b/data/startup_history/README.md @@ -0,0 +1,6 @@ +# Эталонный мир + +Здесь хранится `reference-world.json.xz` — готовая история за три модельных дня. +Файл собирает сопровождающий проекта по инструкции в +[`docs/runbooks/startup-history.md`](../../docs/runbooks/startup-history.md). +Ожидаемый размер сжатого файла — около 32 МБ. diff --git a/data/startup_history/reference-world.json.xz b/data/startup_history/reference-world.json.xz new file mode 100644 index 0000000..8db5276 Binary files /dev/null and b/data/startup_history/reference-world.json.xz differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb079c9..c5c7b77 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,8 +27,9 @@ flowchart LR subgraph AF["Airflow"] DAG1["ddl_init"] - DAG2["generator_control"] - DAG3["etl_pipeline"] + DAG2["world_init"] + DAG3["world_next_day"] + DAG4["etl_pipeline"] end subgraph GEN["Generator"] @@ -57,7 +58,8 @@ flowchart LR V[витрины VIEW] end - DAG2 -->|startup-history backfill/import| K + DAG2 -->|import/backfill| K + DAG3 -->|следующий день| K G -->|live после make generator-continue| K K -->|MV| S S -->|batch| O @@ -66,12 +68,13 @@ flowchart LR D1 & D2 -->|VIEW| V DAG1 -.->|DDL| STG & ODS & DDS & DM - DAG3 -.->|batch| ODS & DDS + DAG4 -.->|batch| ODS & DDS ``` В учебном стенде предусмотрены два пути загрузки: -- `startup-history`: DAG `generator_control` создаёт или импортирует историю, добавляет следующий модельный день, запускает ETL и проверяет витрины; +- `startup-history`: `world_init` импортирует или создаёт историю, а + `world_next_day` добавляет один модельный день; оба запускают ETL и проверяют витрины; - `live`: генератор запускается явно через `make generator-continue`, когда нужна непрерывная подача новых событий. ### Слои и их назначение @@ -80,8 +83,9 @@ flowchart LR flowchart LR subgraph AF["Airflow"] DAG1["ddl_init"] - DAG2["generator_control"] - DAG3["etl_pipeline"] + DAG2["world_init"] + DAG3["world_next_day"] + DAG4["etl_pipeline"] end subgraph GEN["Generator"] @@ -106,7 +110,8 @@ flowchart LR DM_T["VIEW"] end - DAG2 -->|startup-history| KAFKA + DAG2 -->|стартовый мир| KAFKA + DAG3 -->|следующий день| KAFKA G -->|live| KAFKA KAFKA -->|MV| STG_T STG_T -->|batch| ODS_T @@ -114,7 +119,7 @@ flowchart LR ODS_T -.->|ошибки| DQ DAG1 -.->|DDL| L1 & L2 & L3 & L4 - DAG3 -.->|batch| ODS_T & DDS_T + DAG4 -.->|batch| ODS_T & DDS_T ``` --- @@ -387,10 +392,12 @@ sequenceDiagram CH-->>User: ✅ Структура БД создана alt Startup-history режим - User->>Airflow: Trigger generator_control (backfill/import) + User->>Airflow: Trigger world_init с пустой формой Airflow->>K: события стартовой истории Airflow->>Airflow: trigger etl_pipeline + check K-->>User: ✅ История в Kafka и витринах + User->>Airflow: Trigger world_next_day с пустой формой + Airflow->>K: события следующего модельного дня else Live режим User->>Compose: make generator-continue loop каждые 1-10 секунд @@ -637,7 +644,8 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic; ```python # airflow/dags/ddl_init_dag.py — создание баз/таблиц -# airflow/dags/generator_control_dag.py — backfill/import/next-day/check стартовой истории +# airflow/dags/world_init_dag.py — import/backfill/check стартового мира +# airflow/dags/world_next_day_dag.py — добавление одного модельного дня # airflow/dags/etl_pipeline_dag.py — основной ETL (STG→ODS→DDS→DM) # airflow/dags/kafka_load_dag.py — архивный ручной путь из JSONL, не основной контур @@ -645,22 +653,30 @@ INSERT INTO dm.daily_traffic SELECT * FROM dm.v_daily_traffic; # - DDL и трансформации выполняются явными SQL-task через ClickHouseOperator; # - SQL-файлы вызываются по фиксированным путям; # - загрузка может идти двумя путями: -# 1) startup-history через DAG `generator_control`; +# 1) стартовый мир через `world_init` и рост через `world_next_day`; # 2) live-поток через явный `make generator-continue`. # # Базовый demo-сценарий: -# ddl_init -> generator_control(backfill/import) -> etl_pipeline -> check +# ddl_init -> world_init(import) -> etl_pipeline -> check # Расширенный учебный сценарий: # make generator-continue + периодический etl_pipeline ``` -**DAG `generator_control`**: -- `backfill`: создаёт стартовую историю через генератор -- `import`: импортирует портативный артефакт стартовой истории -- `next-day`: пакетно добавляет следующий модельный день от текущего слепка мира +**DAG `world_init`**: +- `import` по умолчанию импортирует портативный артефакт стартового мира +- `backfill` создаёт стартовую историю через генератор - `check`: сверяет ClickHouse с manifest стартовой истории - После `backfill` и `import` запускает `etl_pipeline` с `full_refresh` +**DAG `world_next_day`** без параметров пакетно добавляет следующий модельный +день, запускает `etl_pipeline` с `full_refresh` и сверяет manifest. У него задано +расписание каждые 30 минут, но DAG создаётся на паузе и не выполняет пропущенные +интервалы. Счётчики manifest продолжаются из compact-топиков state/manifest и +обновляются событиями нового дня; старые data-топики Kafka не перечитываются. +Точные множества идентификаторов лежат отдельными неизменяемыми фрагментами, а +основная запись manifest хранит только числа и ссылку на их цепочку. Полная +пересборка ETL при этом сохраняется намеренно. + **Подключение к ClickHouse:** - Connection: `clickhouse_default` - URL: `clickhouse://default:123456@clickhouse:9000/default` (native TCP для Airflow plugin) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 10e16f6..73d261f 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -48,44 +48,47 @@ volumes или live-генератором. Для стыка backfill/live от ## Airflow DAGs -Штатный ручной путь начинается с `generator_control`: чистый стенд получает -стартовую историю генератора, затем этот же DAG запускает ETL и проверку. +Штатный ручной путь начинается с `world_init`: пустая форма импортирует +эталонный мир, затем этот же DAG запускает ETL и проверку. `kafka_load` остаётся для экспериментов и совместимости учебного стенда. -### `generator_control` +### `world_init` - Запуск: ручной (`Trigger DAG`). -- Назначение: пульт стартовой истории генератора. +- Назначение: импорт или служебная сборка стартового мира. - Операции: + - `import` — операция по умолчанию: импортировать портативный артефакт, затем + запустить `etl_pipeline` и дождаться `success`; - `backfill` — создать стартовую историю, затем запустить `etl_pipeline` и дождаться `success`; - - `import` — импортировать портативный артефакт, затем запустить - `etl_pipeline` и дождаться `success`; - - `next-day` — восстановить мир из state, добавить 24 модельных часа, - затем запустить `etl_pipeline` и дождаться `success`; - `check` — сверить ClickHouse с manifest из Kafka. - Параметры: - - `operation` (`backfill` / `import` / `next-day` / `check`); + - `operation` (`import` / `backfill` / `check`); - `profile` — список берётся из `PROFILES` генератора; - `duration` — `6h`, `2d` и т.п.; пусто означает длительность профиля; - `seed`, `model_time_speed` — необязательные переопределения мира; - - `artifact_path` — для `backfill` путь сохранения, для `import` путь чтения; - - `expected_t_end` — необязательная ожидаемая граница перед `next-day`. - При расхождении запуск показывает ожидаемое и фактическое значения. + - `artifact_path` — для `backfill` путь сохранения; для `import` путь чтения. + При пустом поле импортируется эталонный мир из репозитория. Backfill/import требуют чистый стенд: пустые data-топики Kafka и пустые `stg.*_raw`. При отказе очистите стенд через `make clean`. Операции `continue` в DAG нет: live-генератор — долгоживущий сервис, его запускают с консоли через `make generator-continue`. -`next-day` работает на непустом стенде и не использует проверку чистоты. -Перед записью пульт требует manifest, state ровно на его `T_end` и остановленный -live-генератор. Настройки мира берутся из manifest; поля `profile`, `duration`, -`seed` и `model_time_speed` формы для этой операции не применяются. Один запуск -добавляет полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница -появляется в `boundaries`; старый manifest без поля читается как `[T0, T_end]`. -Расписание остаётся выключенным (`schedule=None`), а `max_active_runs=1` не даёт -двум доливкам выполняться параллельно. +### `world_next_day` + +- Запуск: вручную с пустой формой. +- Параметров нет. +- Один запуск восстанавливает мир из state, добавляет 24 модельных часа, + запускает `etl_pipeline` с полной пересборкой и сверяет витрины с manifest. +- Расписание задано каждые 30 минут, но DAG создаётся на паузе; `catchup=False`. +- `max_active_runs=1` не даёт двум доливкам выполняться параллельно. + +`world_next_day` работает на непустом стенде и не использует проверку чистоты. +Перед записью он требует manifest, state ровно на его `T_end` и остановленный +live-генератор. Настройки мира берутся из manifest. Один запуск добавляет +полуоткрытый диапазон `[T_end, T_end + 24h)` в UTC. Новая граница появляется в +`boundaries`; старый manifest без поля читается как `[T0, T_end]`. После двух доливок проверьте завершённые стыки: @@ -97,18 +100,21 @@ make generated-history-chain-check смену browser/referer/utm внутри переходящих визитов. Она не меняет `make generated-history-runtime-check` для стыка backfill/live. -Точка фиксации `next-day` — новый manifest. Порядок записи: data-топики, state, -manifest. Автоматического отката нет. Если запуск упал до публикации manifest, +Результат запуска `world_next_day` фиксируется новым manifest. Порядок записи: +data-топики, state, manifest. Автоматического отката нет. Если запуск упал до +публикации manifest, не повторяйте доливку поверх возможного хвоста. Очистите стенд и переимпортируйте -последний исправный портативный артефакт, затем повторите `next-day`. +последний исправный портативный артефакт, затем повторите запуск `world_next_day`. -Текущая версия пересчитывает накопительные счётчики и контрольные суммы по всей -доступной истории data-топиков Kafka. Поэтому время выполнения и расход памяти -каждой доливки растут вместе с историей. Стандартный срок хранения Kafka тоже -ограничивает долгую работу стенда. Для ручного учебного цикла запускайте -соседние дни без долгих пауз. Перед включением расписания отдельная задача -должна выбрать одно из решений: бессрочное хранение data-топиков или новый -формат накопительного состояния manifest. +`next-day` не перечитывает старые data-топики Kafka. Основная запись manifest +хранит суммы, катящуюся контрольную сумму и SHA-256-ссылку на цепочку точных +множеств. Сами `click_id` и `user_domain_id` разбиты на небольшие неизменяемые +фрагменты в отдельном compact-топике. Новый день записывает только новый +фрагмент, поэтому размер одной записи не растёт с возрастом мира, а загрузка +основного manifest не сканирует всю цепочку. +State ссылается на основную запись manifest, чтобы две точки продолжения нельзя +было случайно смешать. Если локальный state создан старым кодом и раздела в нём +нет, повторно запустите `world_init` с операцией `import` на чистом стенде. Перед запуском `etl_pipeline` пульт проверяет, что DAG не стоит на паузе. Если стоит, задача падает сразу с подсказкой снять паузу в UI или командой: @@ -204,7 +210,7 @@ make generator-logs | `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` | | `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` | | `GEN_RUN_MODE` | Режим: `live`, `backfill` или `next-day` | `live` | -| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` | +| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `daily-wave` | | `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | пусто | | `GEN_STATE_ENABLED` | Сохранять state v3 между рестартами | `true` | | `GEN_STATE_RESET` | Сбросить state при старте | `false` | @@ -252,7 +258,7 @@ CHECK_LIVE_SEAM=1 GEN_LIVE_CHECK_MINUTES=10 make generated-history-check ``` Для commit gate issue 17 есть короткий runtime-путь без полного `daily-wave` на -2 суток и без Superset UI: +3 суток и без Superset UI: ```bash make generated-history-runtime-check @@ -270,14 +276,10 @@ make generated-history-runtime-check LIVE_SECONDS=45 WAIT_STG_SECONDS=10 make generated-history-runtime-check ``` -По умолчанию команда использует быстрый профиль `ci`: 6 часов модельного -времени. Историю на 2 суток с суточной волной можно получить одной командой. -В live-продолжении `daily-wave` идёт с ×60 и тикает раз в секунду, поэтому -модельные сутки проходят примерно за 24 настенные минуты: - -```bash -PROFILE=daily-wave make generated-history-analytics -``` +По умолчанию команда использует учебный профиль `daily-wave`: 3 суток с +суточной волной. В live-продолжении он идёт с ×60 и тикает раз в секунду, +поэтому модельные сутки проходят примерно за 24 настенные минуты. Плоский +профиль `ci` на 6 часов остаётся служебным для автоматических тестов. Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`: @@ -293,8 +295,8 @@ GEN_HISTORY_DURATION=2d make generated-history-analytics что state совпадает с manifest, и стартует ровно с `T_end` без настенной дельты. Новый backfill записывает `boundaries=[T0, T_end]`. Каждый успешный `next-day` -добавляет одну границу, сдвигает `model_t_end` и пересчитывает накопительные -счётчики и контрольные суммы по всей истории. +добавляет одну границу, сдвигает `model_t_end` и дополняет накопительные +счётчики только событиями нового дня. Полной перечитки Kafka в этом пути нет. Если историю нужно сохранить в файл и восстановить на чистом стенде без новой генерации, используйте [runbook стартовой истории](./runbooks/startup-history.md). @@ -306,7 +308,7 @@ Kafka-топики данных, state и manifest генератора. `make g Ручной backfill без всего аналитического контура: ```bash -PROFILE=daily-wave make generator-backfill +make generator-backfill ``` Перед записью команда проверяет с хоста, что Kafka data-топики и STG пустые. @@ -336,7 +338,7 @@ Live-продолжение стартует с этого state. Использ чистом стенде: она тоже проверяет, что Kafka data-топики и STG пустые. ```bash -PROFILE=daily-wave make generator-continue +make generator-continue sleep 5 make generator-down @@ -692,8 +694,8 @@ make generated-history-runtime-check ## Быстрые проверки - Kafka ingest: наличие данных генератора в `stg.*` и типизированных строк в `ods.*`. -- Airflow UI: `http://localhost:8080` показывает DAG `ddl_init`, `generator_control`, - `kafka_load`, `etl_pipeline`; основной ручной пульт генератора — `generator_control`. +- Airflow UI: `http://localhost:8080` показывает лестницу `ddl_init` → + `world_init` → `world_next_day`, а также `kafka_load` и `etl_pipeline`. - BI: витрина `dm.v_events_enriched` отвечает за разумное время при фильтре по дате. --- diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index faa0254..b998da1 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -7,7 +7,9 @@ ### Airflow (ручной и учебный путь запуска) - `airflow/dags/ddl_init_dag.py` — инициализация схемы ClickHouse -- `airflow/dags/generator_control_dag.py` — Airflow-пульт стартовой истории: backfill/import/next-day/check +- `airflow/dags/world_init_dag.py` — импорт или служебная сборка стартового мира и проверка витрин +- `airflow/dags/world_next_day_dag.py` — беспараметрное добавление одного модельного дня +- `airflow/dags/utils/startup_history_tasks.py` — общие задачи DAG для роста и проверки мира - `airflow/dags/kafka_load_dag.py` — архивная загрузка в Kafka из JSONL; не основной источник аналитики - `airflow/dags/etl_pipeline_dag.py` — ETL процесс STG -> ODS -> DDS -> DM - `airflow/dags/utils/kafka_helpers.py` — helper-функции для Kafka diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index 37f3e9e..aff7467 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -14,9 +14,9 @@ ## Общие принципы -- По умолчанию используем быстрый профиль стартовой истории (`PROFILE=ci`). +- Для smoke и CI явно задаём служебный профиль `ci`. - Полный прогон выполняем отдельно через `PROFILE=daily-wave`. -- Основной ручной путь запуска — через Airflow DAG `generator_control`. +- Основной ручной путь запуска — через Airflow DAG `world_init` с пустой формой. - Консольный чистый прогон `make generated-history-analytics` остаётся коротким повторяемым сценарием для smoke и CI. - Критерий успеха: не только `Success` DAG, но и проверки данных/ошибок/мониторинга. @@ -31,7 +31,7 @@ ```bash # Чистый быстрый прогон: backfill -> Kafka -> STG -> ODS -> DDS -> DM -> Superset -make generated-history-analytics +PROFILE=ci make generated-history-analytics # Поднять остальные UI-сервисы после чистого прогона make up @@ -124,7 +124,7 @@ curl -s -u admin:admin "http://localhost:3000/api/dashboards/uid/airflow-overvie ### B.1 Полная стартовая история и ETL ```bash -# История на 2 суток с видимой суточной волной +# История на 3 суток с видимой суточной волной PROFILE=daily-wave make generated-history-analytics make up ``` diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index d100558..aeb9959 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -1,24 +1,63 @@ -# Issue tracker: локальный markdown +# Issue tracker: GitHub -Задачи и PRD этого репозитория живут markdown-файлами в `.scratch/`. GitHub Issues -не используются — не вызывать `gh issue create`. Файлы в `.scratch/` коммитятся -(`.scratch/` не в `.gitignore`). +Задачи этого репозитория живут в GitHub Issues. Все операции — через CLI `gh`; +репозиторий `gh` определяет сам по `git remote`. -## Соглашения +- **Создать issue**: `gh issue create --title "..." --body "..."` (многострочное + тело — heredoc'ом). +- **Прочитать issue**: `gh issue view <номер> --comments`. +- **Список**: `gh issue list --state open --json number,title,labels` с нужными + фильтрами `--label` / `--state`. +- **Комментарий**: `gh issue comment <номер> --body "..."`. +- **Метки**: `gh issue edit <номер> --add-label "..."` / `--remove-label "..."`. +- **Закрыть**: `gh issue close <номер> --comment "..."`. -- Одна фича — один каталог: `.scratch//` -- PRD фичи — `.scratch//PRD.md` (по сути транзиентная спека: снимок - понимания на момент работы; долговечные решения идут в ADR — см. `docs/agents/domain.md`) -- Задачи реализации — `.scratch//issues/-.md`, нумерация с `01` -- Состояние триажа — строкой `Status:` у верха файла задачи (строки ролей — в `triage-labels.md`) -- Завершённая задача — `Status: done`: локальный аналог «закрыть issue» в GitHub. - Это состояние жизненного цикла трекера, а не роль триажа (в `triage-labels.md` его нет) -- Комментарии и история — в конец файла под заголовком `## Comments` +## Спека — источник истины + +- Спецификация фичи — файл в `docs/specs/`, версионируется с кодом. +- Корневой issue фичи — **тонкий**: ссылка на спеку + чек-лист дочерних issues + (`- [ ] #NN`). Содержание спеки в issue не дублируется — истина одна, в git. +- Дочерние issues — полноценные самодостаточные постановки: цель, критерии + приёмки чекбоксами, границы («что трогать нельзя»), «сначала прочитать», + команды проверки. +- Итоговые резолюции и решения — в спеку/ADR тем же PR; issue — рабочая + переписка, она не обязана переживать фичу. ## Когда скилл говорит «опубликовать в issue tracker» -Создать файл в `.scratch//` (каталог создать при необходимости). +Создать GitHub issue. ## Когда скилл говорит «достать тикет» -Прочитать файл по указанному пути. Обычно путь или номер задачи передаёт пользователь. +`gh issue view <номер> --comments`. + +## PR как поверхность триажа + +**Нет** — одиночный учебный репозиторий, внешних PR не ждём. (Если включить — +`/triage` начнёт гонять PR через те же метки и состояния командами `gh pr ...`.) + +## Wayfinding-операции + +Используются `/wayfinder`. Карта — один issue, тикеты — дочерние issues. + +- **Карта**: issue с меткой `wayfinder:map` (Notes / Decisions-so-far / Fog в теле). +- **Дочерний тикет**: sub-issue карты (`gh api` на endpoint sub-issues); если + sub-issues недоступны — пункт task-list в теле карты + `Part of #` в + начале тела тикета. Метки: `wayfinder:` (`research`/`prototype`/ + `grilling`/`task`). +- **Блокировки**: нативные issue dependencies — + `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=` + (`` — числовой database id: `gh api repos///issues/ --jq .id`, + не `#номер`). Fallback — строка `Blocked by: #` в начале тела. Тикет + разблокирован, когда все блокеры закрыты. +- **Фронтир**: открытые дети карты минус заблокированные и назначенные; первый + в порядке карты. +- **Взять в работу**: `gh issue edit --add-assignee @me`. +- **Закрыть**: комментарий с ответом, `gh issue close`, указатель на контекст — + в Decisions-so-far карты. + +## Архив + +До 2026-07-19 задачи велись markdown-файлами в `.scratch//issues/` +(фичи `data-generator` и `generator-model-time-startup-history`, задачи 01–21). +Не мигрированы; доступны в истории git — срез `0e312b3`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md index 72c7dc3..a1a76a8 100644 --- a/docs/agents/triage-labels.md +++ b/docs/agents/triage-labels.md @@ -1,14 +1,15 @@ # Triage labels -Скиллы оперируют пятью каноническими ролями триажа. Здесь они сопоставлены со -строками, которые реально используются в этом репозитории (в строке `Status:` файла задачи). +Скиллы оперируют пятью каноническими ролями триажа. Здесь они сопоставлены с +метками GitHub Issues этого репозитория. -| Роль в mattpocock/skills | Строка у нас | Значение | +| Роль в mattpocock/skills | Метка GitHub | Значение | | ------------------------ | ----------------- | ---------------------------------------------- | | `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить задачу | | `needs-info` | `needs-info` | Ждём от репортёра дополнительную информацию | | `ready-for-agent` | `ready-for-agent` | Полностью специфицировано, можно отдать агенту | | `ready-for-human` | `ready-for-human` | Нужна ручная реализация человеком | -| `wontfix` | `wontfix` | Не будет сделано | +| `wontfix` | `wontfix` | Не будет сделано | -Правый столбец можно поменять под свою лексику. Сейчас — дефолт (строка = имя роли). +Правый столбец можно поменять под свою лексику. Сейчас — дефолт (метка = имя +роли); метки созданы в репозитории GitHub. diff --git a/docs/generator-realism.md b/docs/generator-realism.md new file mode 100644 index 0000000..ac96d43 --- /dev/null +++ b/docs/generator-realism.md @@ -0,0 +1,78 @@ +# Насколько генератор похож на настоящий кликстрим + +Стенд генерирует поток событий интернет-магазина. Этот документ честно +отвечает: что здесь как в бою, а что — учебная условность. Он нужен, +чтобы менти понимал, какой опыт со стенда переносится на реальную +работу напрямую, а какой требует поправки на упрощение. + +Дата оценки: 2026-07-22 (по коду `generator/src/clickstream_generator/`). + +## Что близко к бою + +**Схема событий.** Поля узнаваемы для любого продуктового трекера: +идентификаторы события и визита (`event_id`, `click_id`), страница и +UTM-атрибуция (`utm_source/medium/campaign/content`, `referer_medium`), +браузер и user agent, устройство и ОС, гео с координатами и IP, +идентификатор пользователя. Разговоры об атрибуции, сессиях и +размерностях по этому опыту — честные. + +**Воронка.** Путь `/home → /product → /cart → /payment → /confirmation` +задан матрицей переходов с реалистичными недоходами и возвратами: +в корзину уходит ~27% с товара, из корзины до оплаты — 42%. Конверсию +и отвал по шагам менти считает на данных, которые ведут себя как живые. + +**Сессии.** Визит — в среднем ~10 событий; паузы между событиями +случайны (логнормальное распределение, медиана ~20 секунд) с потолком +29 минут — фактически классический 30-минутный таймаут сессии из +веб-аналитики. + +**Суточная волна.** Интенсивность зависит от часа: рабочие часы ×1.2, +ночь ×0.7, поверх — случайный разброс. Сравнение «день к дню» и +разговор о сезонности внутри суток — по-настоящему. + +**Инженерная обвязка.** JSON-события в Kafka, слои STG/ODS/DDS/DM, +«грязные» записи не валят пайплайн (уходят в таблицы ошибок ODS), +manifest с контрольными суммами и воспроизводимый эталонный мир. +Это боевые практики, а не учебные упрощения. + +## Что упрощено + +**Четыре топика вместо одного события.** Реальные трекеры (Snowplow, +Segment, Amplitude) отдают одно широкое событие со всеми атрибутами. +Здесь событие разрезано на четыре потока (browser, location, device, +geo), которые надо склеивать по ключам. Как модель доставки кликстрима +это условность; как упражнение — честная интеграция нескольких +источников, обычная работа дата-инженера. + +**Только просмотры страниц.** Нет событий `add_to_cart` и `purchase` +с суммой заказа — воронка выводится из URL. Поэтому нет выручки и +GMV-метрик, которые в бою спрашивают в первую очередь. + +**Пользователи клонируются из словаря.** Новые визиты собираются из +небольшого набора заготовок: количество разных пользователей, устройств +и гео ограничено. У каждого визита есть email пользователя — в бою +большинство трафика анонимно, и склейка идентичностей (identity +stitching) — отдельная большая задача, которой стенд не показывает. + +**Нет «грязи» реального мира.** Ни ботов, ни дублей событий, ни +опоздавших мобильных батчей, ни расхождения часов клиента и коллектора +(в бою у события минимум две метки времени). Чистка и дедупликация — +только в теории. + +**Масштаб демо.** Объёмы на порядки меньше боевых: вопросы +партиционирования под нагрузкой, ретеншена и стоимости хранения стенд +не поднимает. + +## Что это значит для обучения + +Можно честно говорить «делал руками»: приём потока из Kafka в +ClickHouse, слоистое DWH, сессии и воронка, UTM-атрибуция, сверка +контрольных чисел, воспроизводимость мира, инкрементальность против +полной перезагрузки. + +Требует теории поверх стенда: формат доставки у реальных трекеров, +выручка и GMV, identity stitching, боты и дедупликация, поведение +под боевой нагрузкой. + +Развитие стенда в сторону боевого реализма обсуждается в трекере — +карта «Боевой реализм стенда». diff --git a/docs/runbooks/startup-history.md b/docs/runbooks/startup-history.md index 81cc5c3..4b97d80 100644 --- a/docs/runbooks/startup-history.md +++ b/docs/runbooks/startup-history.md @@ -29,27 +29,64 @@ | Профиль | Для чего | Длительность | Live-ход | |---------|----------|--------------|----------| | `ci` | Быстрая проверка и CI | `6h` | ×1, тик 60 с | -| `daily-wave` | История с видимой суточной волной | `2d` | ×60, тик 1 с | +| `daily-wave` | История с видимой суточной волной | `3d` | ×60, тик 1 с | Длительность можно переопределить через `GEN_HISTORY_DURATION`, например `2d`. Команда сама считает `GEN_MODEL_T_END` от `GEN_MODEL_T0`. +## Эталонный мир + +В репозитории хранится готовый мир за три модельных дня: +`data/startup_history/reference-world.json.xz`. Это обычный JSON-артефакт, +сжатый xz. Его добавляют прямо в Git, без Git LFS. + +Сопровождающий проекта собирает файл одним запуском из корня репозитория: + +```bash +ARTIFACT="$PWD/data/startup_history/reference-world.json.xz" \ +make startup-history-export +``` + +Команда выполняет backfill с профилем `daily-wave` и сразу пишет xz-файл. +Несжатый JSON занимает около 850 МБ, сжатый файл — около 32 МБ. После проверки +файл можно добавить обычной командой: + +```bash +git add data/startup_history/reference-world.json.xz +``` + +При импорте DAG дважды читает и распаковывает артефакт: во время предпроверки и +перед записью в Kafka. Это увеличивает время импорта, но не меняет результат. + +Менти запускает `world_init` с пустой формой. Тогда читается эталонный мир из +репозитория. Следующий модельный день добавляет отдельный беспараметрный DAG +`world_next_day`. Из консоли тот же импорт запускается без указания пути: + +```bash +make startup-history-import +``` + ## Пульт в Airflow -Основной ручной путь — DAG `generator_control` в Airflow UI: +Основной учебный путь в Airflow UI: 1. Поднимите стенд: `make up`. 2. Если DDL ещё не применён, запустите `ddl_init`. 3. Снимите паузу с `etl_pipeline`, если он ещё paused: `docker compose exec -T airflow-webserver airflow dags unpause etl_pipeline`. -4. Откройте `generator_control` и выберите `operation`. +4. Запустите `world_init` с пустой формой. По умолчанию он импортирует эталонный мир. +5. Когда нужен ещё один модельный день, запустите `world_next_day` с пустой формой. -Операции: +`world_next_day` имеет расписание каждые 30 минут, но по умолчанию стоит на паузе. +Один запуск добавляет один модельный день; `max_active_runs=1` не допускает +параллельных доливок. + +## Операции сопровождающего + +В форме `world_init` сопровождающему дополнительно доступны операции: - `backfill` — создать стартовую историю. После записи в Kafka DAG сам запускает `etl_pipeline`, ждёт завершения и выполняет `check`. -- `import` — прочитать артефакт из `artifact_path`. Несовместимый артефакт - отклоняется до записи в Kafka. - `check` — сверить ClickHouse с manifest из Kafka. Поля формы: @@ -58,7 +95,8 @@ - `duration` можно оставить пустым, тогда берётся длительность профиля. - `seed` и `model_time_speed` — необязательные переопределения мира. - `artifact_path`: для `backfill` — куда сохранить файл; пусто — не сохранять. - Для `import` — что читать; пусто — `/opt/airflow/data/startup-history-import.json`. + Для `import` — что читать; пусто — эталонный мир из репозитория: + `/opt/airflow/data/startup_history/reference-world.json.xz`. Backfill и import работают только на чистом стенде. Если Kafka-топики данных или STG уже непустые, DAG упадёт до записи и подскажет `make clean`. Консольные @@ -79,20 +117,18 @@ STG уже непустые, DAG упадёт до записи и подска ## Экспорт -По умолчанию создаётся быстрый 6-часовой артефакт: +По умолчанию создаётся трёхсуточный артефакт `daily-wave` с суточной волной: ```bash make startup-history-export ``` -Файл по умолчанию: `/tmp/clickstream-startup-history.json`. - -История на 2 суток с суточной волной. В live-продолжении этот профиль проживает -модельные сутки примерно за 24 настенные минуты: +Файл по умолчанию: `/tmp/clickstream-startup-history.json`. В live-продолжении +профиль `daily-wave` проживает модельные сутки примерно за 24 настенные минуты. +Для быстрой автоматической проверки явно задайте служебный профиль `ci`: ```bash -ARTIFACT=/tmp/clickstream-startup-history-2d.json \ -PROFILE=daily-wave \ +PROFILE=ci ARTIFACT=/tmp/clickstream-startup-history-ci.json \ make startup-history-export ``` @@ -106,11 +142,11 @@ make clean docker compose up -d clickhouse kafka make ddl -ARTIFACT=/tmp/clickstream-startup-history.json make startup-history-import +make startup-history-import sleep 10 make transform -ARTIFACT=/tmp/clickstream-startup-history.json make startup-history-check +ARTIFACT=data/startup_history/reference-world.json.xz make startup-history-check CHECK_LIVE_SEAM=0 make generated-history-check ``` @@ -122,10 +158,26 @@ CHECK_LIVE_SEAM=0 make generated-history-check compact-топики в Kafka. ClickHouse читает данные через свои Kafka-таблицы и Materialized View, затем batch строит ODS, DDS и DM. -Если артефакт создан не профилем `ci`, импорт запускайте с тем же профилем: +Во время импорта генератор за один проход по событиям создаёт локальное +накопительное состояние manifest. Эталонный файл не меняется: в нём этого +служебного раздела нет. Основная запись manifest в Kafka хранит суммы, катящуюся +контрольную сумму и ссылку на цепочку точных множеств. `click_id` и +`user_domain_id` лежат небольшими неизменяемыми фрагментами в отдельном +compact-топике. State хранит SHA-256-ссылку на основную запись. +`backfill` создаёт такое состояние сразу по ходу генерации. + +`world_next_day` восстанавливает эти числа и добавляет только события нового +дня, не читая старую историю Kafka и не перезаписывая прежние фрагменты. +Контрольная сумма складывает 256-битные +SHA-256-отпечатки записей по модулю 2^256, поэтому результат не зависит от +границ пакетов и совпадает с полным пересчётом. Если старый локальный state не +содержит ссылку на накопительные числа, очистите стенд и повторите `import`. + +Импорт запускайте с тем же профилем, на котором создан артефакт. Для служебного +артефакта `ci` профиль нужно задать явно: ```bash -PROFILE=daily-wave ARTIFACT=/tmp/clickstream-startup-history-2d.json \ +PROFILE=ci ARTIFACT=/tmp/clickstream-startup-history-ci.json \ make startup-history-import ``` @@ -142,10 +194,10 @@ ClickHouse уже успел прочитать частичные сообще ## Live-продолжение После импорта запускайте live с тем же профилем, что был в артефакте. Для -артефакта `daily-wave`: +обычного артефакта `daily-wave` достаточно команды: ```bash -PROFILE=daily-wave make generator-continue +make generator-continue ``` У `daily-wave` скорость ×60. Долгий простой стенда создаёт большую дыру в diff --git a/docs/specs/2026-06-14-generator-model-time-and-startup-history.md b/docs/specs/2026-06-14-generator-model-time-and-startup-history.md index 9954e44..9e9a93b 100644 --- a/docs/specs/2026-06-14-generator-model-time-and-startup-history.md +++ b/docs/specs/2026-06-14-generator-model-time-and-startup-history.md @@ -105,7 +105,7 @@ ADR-0005 решил отвязать время генератора от реа - `reset` — подставляет `GEN_RUN_MODE=live` и `GEN_STATE_RESET=true`. Профиль `ci` даёт быстрый 6-часовой прогон и остаётся на ×1 с тиком 60 с. -Профиль `daily-wave` даёт 2 суток, чтобы была видна суточная волна, а в live +Профиль `daily-wave` даёт 3 суток, чтобы была видна суточная волна, а в live идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки проходят примерно за 24 настенные минуты. Пара «тик 1 с, скорость ×60» выбрана, чтобы модельный шаг тика остался 60 секунд. Так событийный бюджет и форма волны diff --git a/docs/specs/2026-07-19-mentee-path-redesign.md b/docs/specs/2026-07-19-mentee-path-redesign.md new file mode 100644 index 0000000..78ba25c --- /dev/null +++ b/docs/specs/2026-07-19-mentee-path-redesign.md @@ -0,0 +1,162 @@ +# Редизайн пути менти: мир из артефакта и две ветки роста + +Статус: принято, в работе (ветка `feature/mentee-path`). +Источник: ручной HITL-прогон пути менти 2026-07-19 — находки F1–F9 +(рабочая копия — `.scratch/hitl-findings.md`; постоянный след — история git, +срез `0e312b3`). Решения приняты в обсуждении с пользователем 2026-07-19. + +## Проблема + +Сегодняшний первый шаг менти — `backfill`: генерация истории с нуля. +Это медленно (день модельной истории — минуты и десятки минут CPU), +а результат беден (6 часов плоского профиля `ci`). Вокруг — ворох трения: + +- пульт `generator_control` подсовывает `backfill` дефолтом и носит имя, + непонятное осваивающему стенд; +- два профиля (`ci`/`daily-wave`) путают: менти достаётся плоский тестовый; +- `next-day` спрятан в общем пульте и не годится для расписания: + каждый прогон перечитывает всю историю Kafka заново — стоимость растёт + квадратично по дням, память — линейно (риск OOM); +- готового артефакта в репозитории нет: механизм `import` есть, а + импортировать нечего. + +## Цели + +- Менти получает богатый мир за секунды-минуты, а не генерирует его. +- Путь менти проходится в Airflow UI пустыми формами, без справочника. +- У всех менти одинаковый мир: числа в лабах воспроизводимы число-в-число. +- Мир можно растить двумя осознанными способами: пакетно по дням и живым + потоком. +- Плановый `next-day` перестаёт дорожать с возрастом мира. + +## Целевая модель + +**Основной режим менти — `import` эталонного мира.** От восстановленного +состояния расходятся две ветки — это три режима менти (термины — в +`CONTEXT.md`): + +- **База: `import`** — загрузить эталонный мир из артефакта в git. +- **Ветка A: `next-day`** — пакетно добавить следующий модельный день. +- **Ветка B: `continue`** — живой поток от той же границы (скорость ×60). + +`backfill` уходит мейнтейнеру: это инструмент сборки эталонного мира, +не первый шаг менти (аналогия из соседнего курса Airflow: по умолчанию +загружают готовый первый месяц, а не собирают его генератором). + +### Путь менти (UX) + +1. `make up` — поднять стенд (единственная команда терминала). +2. Airflow UI: `ddl_init` — создать схему. +3. Airflow UI: `world_init` — Trigger с пустой формой. Дефолтная операция — + `import`, путь к артефакту — дефолтом на эталонный мир. DAG сам заливает + Kafka, запускает ETL и сверяет витрины. +4. `make superset-init` — разовая инициализация дашборда (терминал). +5. Superset — готовый трёхдневный мир. +6. Дальше по выбору лабы: `world_next_day` (кнопка без параметров) или + `make generator-continue` (живой поток). + +### Решения + +1. **Эталонный мир**: 3 модельных дня на профиле `daily-wave` (суточная + волна видна, есть «средний» день и сравнение день-к-дню). Собирает + мейнтейнер один раз через `backfill`; хранится в git по фиксированному + пути `data/startup_history/` сжатым `xz` (факт 2026-07-22: ~850 МБ + сырой JSON, ~32 МБ после `xz -9e`). Формат не меняется: `import` требует + `raw_topics`, сжатие снимает вопрос размера. Паттерн-образец — + `airflow-greenplum-solution` (сид `demo.sql.xz` в git, стрим-распаковка + при загрузке). +2. **Пульт переименовывается: `generator_control` → `world_init`.** + Дефолт формы — `import` (сейчас `backfill` — корень находки F3); + `backfill` и `check` остаются в выпадашке, но уходят из учебных + инструкций в runbook мейнтейнера. Список DAG'ов читается лесенкой: + `ddl_init` → `world_init` → `world_next_day`. Цена: история прогонов + старого dag_id теряется (на учебном стенде не жалко), доки и + контрактный тест правятся синхронно. +3. **`world_next_day` — отдельный беспараметрный DAG**: триггернул — день + добавился. `schedule` прописан, но DAG paused и `catchup=False`: + процесс тяжёлый, автозапуск включается осознанно (шаг лабы), живой + рост мира и так даёт ветка `continue`. +4. **Инкрементальные счётчики manifest — предусловие расписания.** + Накопительное состояние счётчиков (суммы, множества uid/click_id, + катящаяся контрольная сумма) переезжает в state/manifest; новый день + только добавляется. Полная перечитка Kafka уходит: стоимость дня + становится ~постоянной (сейчас день 2 — 638 с и растёт), риск OOM + снимается. Разбор по коду — hitl-findings, F9. +5. **Один учебный профиль**: `daily-wave` (волна, ×60). `ci` (×1, плоско, + 6h) — служебный для автотестов, менти не предлагается. Runtime-seam- + проверка уже идёт на `daily-wave`; перед слиянием проверить остальных + потребителей `ci` — дефолты `launch.py` и автотесты, где профиль зашит. + +## Чего здесь не делаем + +- **Не параллелим генерацию** — ломает детерминизм (один поток ГПСЧ, + переходящие визиты); корень стоимости не в ней, а в перечитке (F9). +- **Не трогаем гео-фактуру** — отдельная спека по ADR-0006. +- **Не мигрируем лабы курса** (`docs/course/`) — они отстали от реальности + и потребуют редизайна под три режима; отдельная работа, зафиксирована + issue в трекере. +- **Не чиним здесь F5** (быстрый разлогин Airflow/Superset) **и F4** + (JS-ошибка Grid) — отдельные issues вне фичи. +- **Не включаем расписание по умолчанию** и не занимаемся retention сверх + инкрементальных счётчиков. +- **Не делаем инкрементальный ETL**: `world_next_day` по-прежнему зовёт + `full_refresh`, его стоимость растёт с миром (на текущих объёмах — + десятки секунд). Известная цена; исторический план — + `plans/incremental-etl-v2.md` (legacy). + +## Форма работ + +Четыре дочерних issue (GitHub, по контракту `docs/agents/issue-tracker.md`; +корневой issue ссылается сюда): + +1. **Эталонный мир**: сборка 3-дневного артефакта, `xz`-хранение в git, + стрим-распаковка на `import`, дефолтный путь в форме. +2. **Поверхность DAG'ов**: переименование в `world_init`, дефолт `import`, + вынос `world_next_day` беспараметрным DAG'ом с paused-расписанием, + синхронная правка доков и контрактных тестов. +3. **Инкрементальные счётчики manifest** (без них расписание не включать). +4. **Один учебный профиль**: `daily-wave` — учебный дефолт, `ci` — + служебный; проверка независимости runtime-seam-теста от ×1. + +Зависимости: (1) и (4) сцеплены (эталонный мир собирается на `daily-wave`); +(3) блокирует включение расписания из (2), но не сам DAG. + +## Проверка + +- Чистый стенд: путь менти из раздела UX проходится пустыми формами; + терминал — только `make up` и `make superset-init`. +- `import` эталонного мира — секунды-минуты, не десятки минут. +- После `import` у любого менти совпадают manifest-числа эталонного мира + (события, визиты, пользователи, контрольная сумма). +- Задача генерации+manifest в `world_next_day` на дне N по времени + ~равна дню 1 (ETL `full_refresh` растёт с миром — это вне скоупа, + см. «Чего не делаем»); `make generated-history-chain-check` зелёный + на стыках. +- `make test` / `make lint` зелёные; профиль `ci` продолжает обслуживать + автотесты. + +## Решения и отклонённые варианты + +- **Без артефакта, просто ускорить backfill** — отклонено: время менти и + воспроизводимость (общий файл надёжнее «одинаковой генерации у всех»). +- **Мир «из коробки» при `make up`** — отклонено: механизм становится + невидимым, магия в учебном стенде мстит; явный `import` — учебная + ценность и общее начало обеих лаб. +- **Расписание включённым по умолчанию** — отклонено: эпизодический стенд, + тяжёлый процесс, сюрпризы catchup. +- **Три тонких DAG вместо пульта** — отклонено: плодит сущности; пульт с + правильным дефолтом + один тонкий `world_next_day` достаточно. +- **git-lfs / релизные ассеты для артефакта** — отклонено: после `xz` + артефакт помещается в обычный git. + +## Влияние на документацию + +- `README.md`, `docs/OPERATIONS.md`, `docs/ARCHITECTURE.md`, + `docs/REPO_MAP.md` — новый путь менти и имена DAG'ов (в тех же PR, что + и изменения). +- `docs/runbooks/startup-history.md` — `backfill`/сборка эталонного мира + переезжают сюда как процедура мейнтейнера; дополнить про размер и + хранение. +- `CONTEXT.md` — термины «мир (стенда)», «эталонный мир», «три режима + менти» (добавлены этой же спекой). +- Лабы `docs/course/` — сознательно не здесь (см. «Чего не делаем»). diff --git a/generator/README.md b/generator/README.md index 389ab04..8e4f305 100644 --- a/generator/README.md +++ b/generator/README.md @@ -78,7 +78,7 @@ generator-service -> Kafka topics -> (потребители отдельно) | `GEN_MODEL_TIMEZONE` | Часовой пояс модельных часов для дневного коэффициента | `UTC` | | `GEN_MODEL_TIME_SPEED` | Сколько модельных секунд проходит за одну настенную секунду | `1` | | `GEN_RUN_MODE` | Режим генератора | `live` | -| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `ci` | +| `GEN_LAUNCH_PROFILE` | Имя профиля запуска для логов | `daily-wave` | | `GEN_STARTUP_HISTORY_ARTIFACT` | JSON-файл для экспорта стартовой истории в режиме `backfill` | — | | `GEN_DATA_DIR` | Путь к JSONL файлам | `/data` | | `GEN_SEED` | Сид для воспроизводимости | — | @@ -126,14 +126,11 @@ make generated-history-analytics проверяет Superset metadata. Файлы `data/*.jsonl` при этом не грузятся в Kafka: они пока используются только как фактура для генератора. -По умолчанию используется быстрый профиль `ci`: 6 часов модельного времени. -Профиль `daily-wave` даёт 2 суток, чтобы была видна суточная волна. В live он -идёт с `GEN_MODEL_TIME_SPEED=60` и `GEN_TICK_SECONDS=1`: модельные сутки -проходят примерно за 24 настенные минуты. - -```bash -PROFILE=daily-wave make generated-history-analytics -``` +По умолчанию используется учебный профиль `daily-wave`: 3 суток с суточной +волной. В live он идёт с `GEN_MODEL_TIME_SPEED=60` и +`GEN_TICK_SECONDS=1`: модельные сутки проходят примерно за 24 настенные +минуты. Плоский профиль `ci` на 6 часов остаётся служебным для автоматических +тестов. Разовую длительность можно задать без ручного расчёта `GEN_MODEL_T_END`: diff --git a/generator/src/clickstream_generator/airflow_control.py b/generator/src/clickstream_generator/airflow_control.py index eacc21a..42b32c9 100644 --- a/generator/src/clickstream_generator/airflow_control.py +++ b/generator/src/clickstream_generator/airflow_control.py @@ -21,8 +21,11 @@ from clickstream_generator.service import GeneratorService from clickstream_generator.startup_history_artifact import ( KafkaRawPublisher, KafkaTopicInspector, + ManifestCounters, compare_clickhouse_stats_to_manifest, + cumulative_counter_reference, import_startup_history_artifact, + known_counter_ids_from_state, load_startup_history_artifact, manifest_boundaries, validate_startup_history_artifact, @@ -171,6 +174,38 @@ def assert_next_day_snapshot(manifest: dict | None, state) -> None: raise RuntimeError( f"state не согласован с T_end manifest: {exc}" ) from exc + counter_reference = state.cumulative_manifest_counters + counter_state = manifest.get("cumulative_manifest_counters") + if counter_reference is None or counter_state is None: + raise RuntimeError( + "В state нет накопительных счётчиков manifest; " + "повторно запустите import эталонного мира." + ) + if cumulative_counter_reference(counter_state) != counter_reference: + raise RuntimeError( + "Накопительные счётчики state и manifest не совпадают; " + "повторно запустите import эталонного мира." + ) + known_click_ids, known_user_ids = known_counter_ids_from_state(state) + try: + counters = ManifestCounters.from_state( + counter_state, + known_click_ids=known_click_ids, + known_user_ids=known_user_ids, + ) + except ValueError as exc: + raise RuntimeError( + "Накопительные счётчики manifest повреждены; " + "повторно запустите import эталонного мира." + ) from exc + if ( + counters.to_manifest_topics() != manifest.get("topics") + or counters.to_manifest_totals() != manifest.get("totals") + ): + raise RuntimeError( + "Накопительные счётчики не совпадают с числами manifest; " + "повторно запустите import эталонного мира." + ) def _parse_utc_timestamp(value: str): @@ -328,19 +363,20 @@ def target_dag_trigger_error(dag_id: str, dag_model) -> str | None: if dag_model.is_paused: return ( f"DAG {dag_id} стоит на паузе: снимите паузу в Airflow UI, " - "затем повторите generator_control." + "затем повторите запуск DAG." ) return None def default_artifact_path(operation: str) -> str: """Возвращает путь артефакта по умолчанию в общем томе data.""" - filename = ( - "startup-history-import.json" - if operation == "import" - else "startup-history.json" - ) - return str(Path(AIRFLOW_DATA_DIR) / filename) + if operation == "import": + return str( + Path(AIRFLOW_DATA_DIR) + / "startup_history" + / "reference-world.json.xz" + ) + return str(Path(AIRFLOW_DATA_DIR) / "startup-history.json") @contextmanager diff --git a/generator/src/clickstream_generator/config.py b/generator/src/clickstream_generator/config.py index e870370..1b2a0f2 100644 --- a/generator/src/clickstream_generator/config.py +++ b/generator/src/clickstream_generator/config.py @@ -102,7 +102,7 @@ class Config: else None ) launch_profile: str = field( - default_factory=lambda: os.getenv("GEN_LAUNCH_PROFILE", "ci") + default_factory=lambda: os.getenv("GEN_LAUNCH_PROFILE", "daily-wave") ) def __post_init__(self): diff --git a/generator/src/clickstream_generator/kafka_io.py b/generator/src/clickstream_generator/kafka_io.py index de77cb6..1507e09 100644 --- a/generator/src/clickstream_generator/kafka_io.py +++ b/generator/src/clickstream_generator/kafka_io.py @@ -1,5 +1,6 @@ """Kafka-интеграция генератора.""" +import hashlib import json import logging import sys @@ -14,6 +15,7 @@ from clickstream_generator.state import UnsupportedStateVersionError logger = logging.getLogger("generator") DATA_TOPICS = ("browser_events", "location_events", "device_events", "geo_events") +MAX_COMPACT_MESSAGE_BYTES = 900_000 _kafka_imported = False KafkaProducer = None @@ -207,7 +209,9 @@ class KafkaStartupHistoryManifest: """Хранение манифеста стартовой истории в Kafka compact topic.""" MANIFEST_TOPIC = "generator_startup_history_manifest" + COUNTER_TOPIC = "generator_startup_history_counter_chunks" MANIFEST_KEY = "default" + COUNTER_CHUNK_KEY_PREFIX = "id-set:" def __init__(self, bootstrap_servers: str): self.bootstrap_servers = bootstrap_servers @@ -227,6 +231,8 @@ class KafkaStartupHistoryManifest: def save(self, manifest: dict) -> None: """Сохраняет манифест стартовой истории.""" + self._assert_message_size(manifest, "manifest") + def _do_send(): self.producer.send( self.MANIFEST_TOPIC, @@ -236,6 +242,40 @@ class KafkaStartupHistoryManifest: _retry(_do_send, max_retries=3, base_delay=0.5) + def save_counter_chunk(self, chunk_sha256: str, chunk: dict) -> None: + """Сохраняет неизменяемый фрагмент точных множеств идентификаторов.""" + if not isinstance(chunk_sha256, str) or len(chunk_sha256) != 64: + raise ValueError("хеш фрагмента множеств идентификаторов неверен") + actual_sha256 = hashlib.sha256( + json.dumps( + chunk, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + ).hexdigest() + if actual_sha256 != chunk_sha256: + raise ValueError("хеш фрагмента множеств идентификаторов не совпадает") + self._assert_message_size(chunk, "фрагмент множеств идентификаторов") + + def _do_send(): + self.producer.send( + self.COUNTER_TOPIC, + key=self.COUNTER_CHUNK_KEY_PREFIX + chunk_sha256, + value=chunk, + ) + + _retry(_do_send, max_retries=3, base_delay=0.5) + + @staticmethod + def _assert_message_size(value: dict, label: str) -> None: + size = len(json.dumps(value, ensure_ascii=True).encode("utf-8")) + if size > MAX_COMPACT_MESSAGE_BYTES: + raise ValueError( + f"{label} занимает {size} байт и превышает безопасный предел " + f"{MAX_COMPACT_MESSAGE_BYTES} байт одного сообщения Kafka" + ) + def flush(self) -> None: """Сбрасывает буфер с retry.""" def _do_flush(): @@ -372,8 +412,23 @@ def ensure_topics(bootstrap_servers: str) -> None: "delete.retention.ms": "100", }, ) + counter_topic = NewTopic( + name=KafkaStartupHistoryManifest.COUNTER_TOPIC, + num_partitions=1, + replication_factor=1, + topic_configs={ + "cleanup.policy": "compact", + "min.cleanable.dirty.ratio": "0.1", + "delete.retention.ms": "100", + }, + ) - for topic in [history_topic, state_topic, manifest_topic]: + for topic in [ + history_topic, + state_topic, + manifest_topic, + counter_topic, + ]: try: admin_client.create_topics([topic]) logger.info(f"Created topic: {topic.name}") diff --git a/generator/src/clickstream_generator/launch.py b/generator/src/clickstream_generator/launch.py index 7659a9a..4ba62db 100644 --- a/generator/src/clickstream_generator/launch.py +++ b/generator/src/clickstream_generator/launch.py @@ -47,7 +47,7 @@ PROFILES = { }, ), "daily-wave": LaunchProfile( - duration="2d", + duration="3d", env={ "GEN_SEED": "4242", "GEN_MODEL_T0": "2026-01-01T00:00:00+00:00", @@ -88,7 +88,7 @@ def parse_duration(value: str) -> timedelta: def build_launch_env( verb: str, *, - profile_name: str = "ci", + profile_name: str = "daily-wave", duration: str | None = None, overrides: dict[str, str] | None = None, ) -> dict[str, str]: @@ -161,7 +161,11 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: ) parser.add_argument( "--profile", - default=os.getenv("PROFILE") or os.getenv("GEN_LAUNCH_PROFILE") or "ci", + default=( + os.getenv("PROFILE") + or os.getenv("GEN_LAUNCH_PROFILE") + or "daily-wave" + ), choices=sorted(PROFILES), ) parser.add_argument("--duration", default=os.getenv("GEN_HISTORY_DURATION")) diff --git a/generator/src/clickstream_generator/service.py b/generator/src/clickstream_generator/service.py index 2755897..6bb19b1 100644 --- a/generator/src/clickstream_generator/service.py +++ b/generator/src/clickstream_generator/service.py @@ -16,7 +16,6 @@ from clickstream_generator.generation import EventGenerator from clickstream_generator.kafka_io import ( BatchRecord, KafkaBatchHistory, - KafkaDataTopicReader, KafkaPublisher, KafkaStateManager, KafkaStartupHistoryManifest, @@ -32,7 +31,9 @@ from clickstream_generator.startup_history_artifact import ( StartupHistoryArtifactBuilder, ManifestCounters, build_manifest, + cumulative_counter_reference, generation_settings_from_config, + known_counter_ids_from_state, manifest_boundaries, write_startup_history_artifact, ) @@ -58,7 +59,6 @@ class GeneratorService: self.history: KafkaBatchHistory | None = None self.state_manager: KafkaStateManager | None = None self.manifest_manager: KafkaStartupHistoryManifest | None = None - self.data_reader: KafkaDataTopicReader | None = None self._running = False self._stop_requested = False self._shutdown_event = threading.Event() @@ -133,10 +133,7 @@ class GeneratorService: restored_state, model_t_end=model_t_end, ) - self.data_reader = KafkaDataTopicReader( - self.config.kafka_bootstrap_servers - ) - self._run_next_day(manifest) + self._run_next_day(manifest, restored_state) self.stop() return @@ -471,13 +468,24 @@ class GeneratorService: model_t0=self.config.model_t0, gen_seed=self.config.seed, ) + cumulative_state, counter_chunks = ( + artifact_builder.counters.to_state_with_chunks() + ) + state.cumulative_manifest_counters = cumulative_counter_reference( + cumulative_state + ) manifest = build_manifest( config=self.config, counters=artifact_builder.counters, state=state, + cumulative_state=cumulative_state, ) if self.manifest_manager: + for chunk_sha256, chunk in counter_chunks: + self.manifest_manager.save_counter_chunk(chunk_sha256, chunk) + if counter_chunks: + self.manifest_manager.flush() self.manifest_manager.save(manifest) self.manifest_manager.flush() @@ -519,7 +527,7 @@ class GeneratorService: f"status={status}, sent_counts={sent_counts}" ) - def _run_next_day(self, manifest: dict) -> None: + def _run_next_day(self, manifest: dict, restored_state) -> None: """Доливает ровно 24 модельных часа от границы manifest.""" if not self.publisher: raise RuntimeError("publisher не инициализирован") @@ -528,6 +536,41 @@ class GeneratorService: if not self.state_manager or not self.manifest_manager: raise RuntimeError("менеджеры state и manifest не инициализированы") + counter_reference = restored_state.cumulative_manifest_counters + counter_state = manifest.get("cumulative_manifest_counters") + if counter_reference is None or counter_state is None: + raise IncompatibleStateError( + "в state нет накопительных счётчиков manifest; " + "повторно запустите import эталонного мира" + ) + if cumulative_counter_reference(counter_state) != counter_reference: + raise IncompatibleStateError( + "накопительные счётчики state и manifest не совпадают; " + "повторно запустите import эталонного мира" + ) + known_click_ids, known_user_ids = known_counter_ids_from_state( + restored_state + ) + try: + counters = ManifestCounters.from_state( + counter_state, + known_click_ids=known_click_ids, + known_user_ids=known_user_ids, + ) + except ValueError as exc: + raise IncompatibleStateError( + "накопительные счётчики manifest повреждены; " + "повторно запустите import эталонного мира" + ) from exc + if ( + counters.to_manifest_topics() != manifest.get("topics") + or counters.to_manifest_totals() != manifest.get("totals") + ): + raise IncompatibleStateError( + "накопительные счётчики не совпадают с числами manifest; " + "повторно запустите import эталонного мира" + ) + current_t_end = self._as_aware_utc( datetime.fromisoformat(manifest["model_t_end"]) ) @@ -554,6 +597,7 @@ class GeneratorService: ) total_sent, sent_counts, status = self._publish_batch(batch) self._raise_on_next_day_publish_error(batch_id, status, sent_counts) + counters.add_batch(batch) self._write_batch_history( batch_id=batch_id, started_at=started_at, @@ -573,6 +617,7 @@ class GeneratorService: started_at = datetime.now(timezone.utc) total_sent, sent_counts, status = self._publish_batch(final_batch) self._raise_on_next_day_publish_error(batch_id, status, sent_counts) + counters.add_batch(final_batch) self._write_batch_history( batch_id=batch_id, started_at=started_at, @@ -584,11 +629,6 @@ class GeneratorService: self.publisher.flush() self._model_time = target_t_end - reader = self.data_reader or KafkaDataTopicReader( - self.config.kafka_bootstrap_servers - ) - counters = ManifestCounters() - counters.add_batch(reader.load()) state_batch_id = self._startup_state_batch_id(counters) state = self.stream.to_state( tick=self._tick, @@ -602,6 +642,10 @@ class GeneratorService: model_t0=self.config.model_t0, gen_seed=self.config.seed, ) + cumulative_state, counter_chunks = counters.to_state_with_chunks() + state.cumulative_manifest_counters = cumulative_counter_reference( + cumulative_state + ) boundaries = manifest_boundaries(manifest) + [target_t_end.isoformat()] updated_manifest = build_manifest( self.config, @@ -609,12 +653,17 @@ class GeneratorService: state, model_t_end=target_t_end, boundaries=boundaries, + cumulative_state=cumulative_state, ) - self.state_manager.save(state) - self.state_manager.flush() + for chunk_sha256, chunk in counter_chunks: + self.manifest_manager.save_counter_chunk(chunk_sha256, chunk) + if counter_chunks: + self.manifest_manager.flush() self.manifest_manager.save(updated_manifest) self.manifest_manager.flush() + self.state_manager.save(state) + self.state_manager.flush() def _raise_on_next_day_publish_error( self, diff --git a/generator/src/clickstream_generator/startup_history_artifact.py b/generator/src/clickstream_generator/startup_history_artifact.py index e77f9e6..dba85d4 100644 --- a/generator/src/clickstream_generator/startup_history_artifact.py +++ b/generator/src/clickstream_generator/startup_history_artifact.py @@ -3,9 +3,12 @@ from __future__ import annotations import argparse +import base64 import hashlib import json +import lzma import logging +import zlib from copy import deepcopy from datetime import datetime, timezone from pathlib import Path @@ -27,6 +30,11 @@ logger = logging.getLogger("generator") ARTIFACT_KIND = "clickstream_generator_startup_history" ARTIFACT_VERSION = "1.0" TOPICS = ("browser_events", "location_events", "device_events", "geo_events") +COUNTER_STATE_VERSION = "2.0" +CHECKSUM_MODULUS = 1 << 256 +ID_SET_CHUNK_VERSION = "1.0" +ID_SET_CHUNK_SIZE = 10_000 +ID_SET_STORAGE = "manifest-topic-sha256-chain-v1" IMPORT_TOPICS = ( "browser_events", "location_events", @@ -34,6 +42,7 @@ IMPORT_TOPICS = ( "geo_events", KafkaStateManager.STATE_TOPIC, KafkaStartupHistoryManifest.MANIFEST_TOPIC, + KafkaStartupHistoryManifest.COUNTER_TOPIC, ) @@ -44,17 +53,20 @@ class _TopicManifestStats: self.rows = 0 self.min_event_timestamp: str | None = None self.max_event_timestamp: str | None = None - self._checksum = hashlib.sha256() + self._checksum_sum = 0 @property def checksum(self) -> str: - return self._checksum.hexdigest() + return f"{self._checksum_sum:064x}" def add(self, event: dict, event_timestamp: str | None = None) -> None: self.rows += 1 - self._checksum.update( + event_digest = hashlib.sha256( json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8") - ) + ).digest() + self._checksum_sum = ( + self._checksum_sum + int.from_bytes(event_digest, "big") + ) % CHECKSUM_MODULUS timestamp = ( event_timestamp if event_timestamp is not None @@ -78,6 +90,55 @@ class _TopicManifestStats: "checksum_sha256": self.checksum, } + @classmethod + def from_state(cls, payload: dict) -> "_TopicManifestStats": + """Восстанавливает накопительные числа одного топика.""" + if not isinstance(payload, dict): + raise ValueError("счётчики топика должны быть объектом") + rows = payload.get("rows") + checksum = payload.get("checksum_sha256") + if isinstance(rows, bool) or not isinstance(rows, int) or rows < 0: + raise ValueError("rows в накопительных счётчиках должен быть целым") + if not isinstance(checksum, str) or len(checksum) != 64: + raise ValueError("checksum_sha256 в накопительных счётчиках неверен") + try: + checksum_sum = int(checksum, 16) + except ValueError as exc: + raise ValueError( + "checksum_sha256 в накопительных счётчиках неверен" + ) from exc + + stats = cls() + stats.rows = rows + stats.min_event_timestamp = payload.get("min_event_timestamp") + stats.max_event_timestamp = payload.get("max_event_timestamp") + stats._checksum_sum = checksum_sum + return stats + + +class _LegacyTopicManifestStats(_TopicManifestStats): + """Прежняя контрольная сумма для проверки неизменённого артефакта.""" + + def __init__(self): + super().__init__() + self._legacy_checksum = hashlib.sha256() + + @property + def checksum(self) -> str: + return self._legacy_checksum.hexdigest() + + def add(self, event: dict, event_timestamp: str | None = None) -> None: + self.rows += 1 + self._legacy_checksum.update( + json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8") + ) + timestamp = ( + event_timestamp + if event_timestamp is not None + else event.get("event_timestamp") + ) + self.add_timestamp(timestamp) + class ManifestCounters: """Счётчики стартовой истории для manifest.""" @@ -86,6 +147,11 @@ class ManifestCounters: self.topic_stats = {topic: _TopicManifestStats() for topic in TOPICS} self.click_ids: set[str] = set() self.user_ids: set[str] = set() + self._new_click_ids: set[str] = set() + self._new_user_ids: set[str] = set() + self._visit_count = 0 + self._user_count = 0 + self._previous_id_set_chain = _empty_id_set_chain() def add_batch(self, batch: dict[str, list[dict]]) -> None: browser_events = batch.get("browser_events", []) @@ -116,11 +182,23 @@ class ManifestCounters: stats.add_timestamp(max(timestamps)) stats.add(event, event_timestamp=event_timestamp) click_id = event.get("click_id") - if topic == "browser_events" and click_id: + if ( + topic == "browser_events" + and click_id + and click_id not in self.click_ids + ): self.click_ids.add(click_id) + self._new_click_ids.add(click_id) + self._visit_count += 1 user_id = event.get("user_domain_id") - if topic == "device_events" and user_id: + if ( + topic == "device_events" + and user_id + and user_id not in self.user_ids + ): self.user_ids.add(user_id) + self._new_user_ids.add(user_id) + self._user_count += 1 def to_manifest_topics(self) -> dict: return {topic: stats.to_dict() for topic, stats in self.topic_stats.items()} @@ -129,12 +207,214 @@ class ManifestCounters: browser_stats = self.topic_stats["browser_events"] return { "events": browser_stats.rows, - "visits": len(self.click_ids), - "users": len(self.user_ids), + "visits": self._visit_count, + "users": self._user_count, "min_event_timestamp": browser_stats.min_event_timestamp, "max_event_timestamp": browser_stats.max_event_timestamp, } + def to_state(self) -> dict: + """Возвращает JSON-сериализуемое накопительное состояние.""" + state, _chunks = self.to_state_with_chunks() + return state + + def to_state_with_chunks(self) -> tuple[dict, list[tuple[str, dict]]]: + """Возвращает компактное состояние и новые фрагменты точных множеств.""" + id_set_chain, chunks = _extend_id_set_chain( + self._previous_id_set_chain, + self._new_click_ids, + self._new_user_ids, + ) + state = { + "version": COUNTER_STATE_VERSION, + "checksum_algorithm": "sha256-sum-v1", + "topics": self.to_manifest_topics(), + "totals": { + "visits": self._visit_count, + "users": self._user_count, + }, + "id_sets": id_set_chain, + } + return state, chunks + + @classmethod + def from_state( + cls, + payload: dict, + *, + known_click_ids: set[str] | None = None, + known_user_ids: set[str] | None = None, + ) -> "ManifestCounters": + """Продолжает счётчики из state без чтения старых событий.""" + if not isinstance(payload, dict): + raise ValueError("накопительные счётчики должны быть объектом") + if payload.get("version") != COUNTER_STATE_VERSION: + raise ValueError("версия накопительных счётчиков не поддерживается") + if payload.get("checksum_algorithm") != "sha256-sum-v1": + raise ValueError("алгоритм накопительной контрольной суммы не поддерживается") + topic_payloads = payload.get("topics") + if not isinstance(topic_payloads, dict) or set(topic_payloads) != set(TOPICS): + raise ValueError("в накопительных счётчиках нет всех топиков") + totals = payload.get("totals") + if not isinstance(totals, dict): + raise ValueError("в накопительных счётчиках нет итогов") + visits = _non_negative_int(totals.get("visits"), "visits") + users = _non_negative_int(totals.get("users"), "users") + id_sets = payload.get("id_sets") + _validate_id_set_chain(id_sets, visits=visits, users=users) + + counters = cls() + counters.topic_stats = { + topic: _TopicManifestStats.from_state(topic_payloads[topic]) + for topic in TOPICS + } + counters.click_ids = set(known_click_ids or ()) + counters.user_ids = set(known_user_ids or ()) + counters._new_click_ids = set() + counters._new_user_ids = set() + counters._visit_count = visits + counters._user_count = users + counters._previous_id_set_chain = dict(id_sets) + return counters + + +def cumulative_counter_reference(counter_state: dict) -> dict: + """Строит компактную ссылку state на полный набор из manifest.""" + canonical = json.dumps( + counter_state, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return { + "version": COUNTER_STATE_VERSION, + "manifest_sha256": hashlib.sha256(canonical).hexdigest(), + } + + +def known_counter_ids_from_state(state: GeneratorState) -> tuple[set[str], set[str]]: + """Возвращает идентификаторы, которые уже успели попасть в data-топики.""" + click_ids = {visit["click_id"] for visit in state.active_visits} + user_ids = { + user["user_domain_id"] + for user in state.population + if user.get("active_click_id") is not None + or user.get("last_finished_at") is not None + } + return click_ids, user_ids + + +def _empty_id_set_chain() -> dict: + return { + "storage": ID_SET_STORAGE, + "latest_sha256": None, + "chunks": 0, + "click_ids": 0, + "user_ids": 0, + } + + +def _non_negative_int(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{label} в накопительных счётчиках должен быть целым") + return value + + +def _validate_id_set_chain(payload: Any, *, visits: int, users: int) -> None: + if not isinstance(payload, dict): + raise ValueError("в накопительных счётчиках нет множеств идентификаторов") + if payload.get("storage") != ID_SET_STORAGE: + raise ValueError("хранилище множеств идентификаторов не поддерживается") + chunks = _non_negative_int(payload.get("chunks"), "chunks") + click_ids = _non_negative_int(payload.get("click_ids"), "click_ids") + user_ids = _non_negative_int(payload.get("user_ids"), "user_ids") + latest = payload.get("latest_sha256") + if latest is not None: + if not isinstance(latest, str) or len(latest) != 64: + raise ValueError("ссылка на фрагмент множеств идентификаторов неверна") + try: + int(latest, 16) + except ValueError as exc: + raise ValueError( + "ссылка на фрагмент множеств идентификаторов неверна" + ) from exc + if (chunks == 0) != (latest is None): + raise ValueError("цепочка множеств идентификаторов повреждена") + if click_ids != visits or user_ids != users: + raise ValueError("размеры множеств идентификаторов не совпадают с итогами") + + +def _extend_id_set_chain( + previous: dict, + click_ids: set[str], + user_ids: set[str], +) -> tuple[dict, list[tuple[str, dict]]]: + _validate_id_set_chain( + previous, + visits=_non_negative_int(previous.get("click_ids"), "click_ids"), + users=_non_negative_int(previous.get("user_ids"), "user_ids"), + ) + tagged_ids = [("click_id", value) for value in sorted(click_ids)] + tagged_ids.extend(("user_id", value) for value in sorted(user_ids)) + previous_sha256 = previous["latest_sha256"] + chunk_count = previous["chunks"] + chunks: list[tuple[str, dict]] = [] + for offset in range(0, len(tagged_ids), ID_SET_CHUNK_SIZE): + part = tagged_ids[offset : offset + ID_SET_CHUNK_SIZE] + part_click_ids = { + value for kind, value in part if kind == "click_id" + } + part_user_ids = { + value for kind, value in part if kind == "user_id" + } + chunk = { + "version": ID_SET_CHUNK_VERSION, + "encoding": "json-zlib-base64-v1", + "sequence": chunk_count + 1, + "previous_sha256": previous_sha256, + "click_ids": _encode_id_set(part_click_ids), + "user_ids": _encode_id_set(part_user_ids), + } + digest = hashlib.sha256(_canonical_json(chunk)).hexdigest() + chunks.append((digest, chunk)) + previous_sha256 = digest + chunk_count += 1 + + return ( + { + "storage": ID_SET_STORAGE, + "latest_sha256": previous_sha256, + "chunks": chunk_count, + "click_ids": previous["click_ids"] + len(click_ids), + "user_ids": previous["user_ids"] + len(user_ids), + }, + chunks, + ) + + +def _canonical_json(value: dict) -> bytes: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + + +def _encode_id_set(values: set[str]) -> str: + raw = json.dumps(sorted(values), separators=(",", ":")).encode("utf-8") + return base64.b64encode(zlib.compress(raw, level=9)).decode("ascii") + + +class _LegacyManifestCounters(ManifestCounters): + """Полный пересчёт старой суммы только при проверке артефакта.""" + + def __init__(self): + super().__init__() + self.topic_stats = { + topic: _LegacyTopicManifestStats() for topic in TOPICS + } + class StartupHistoryArtifactBuilder: """Собирает сообщения топиков для портативного файла.""" @@ -157,12 +437,16 @@ class StartupHistoryArtifactBuilder: ) def to_artifact(self, manifest: dict, state: GeneratorState) -> dict: + artifact_manifest = deepcopy(manifest) + artifact_manifest.pop("cumulative_manifest_counters", None) + artifact_state = state.to_dict() + artifact_state.pop("cumulative_manifest_counters", None) return { "artifact_version": ARTIFACT_VERSION, "kind": ARTIFACT_KIND, "created_at": datetime.now(timezone.utc).isoformat(), - "manifest": deepcopy(manifest), - "state": state.to_dict(), + "manifest": artifact_manifest, + "state": artifact_state, "topics": deepcopy(self.topics), "raw_topics": deepcopy(self.raw_topics), } @@ -294,6 +578,7 @@ def build_manifest( *, model_t_end: datetime | None = None, boundaries: list[str] | None = None, + cumulative_state: dict | None = None, ) -> dict: """Строит manifest стартовой истории.""" selected_t_end = model_t_end or config.model_t_end @@ -324,6 +609,7 @@ def build_manifest( }, "topics": counters.to_manifest_topics(), "totals": counters.to_manifest_totals(), + "cumulative_manifest_counters": cumulative_state or counters.to_state(), } manifest_boundaries(manifest) return manifest @@ -353,6 +639,15 @@ def write_startup_history_artifact(path: str | Path, artifact: dict) -> None: """Пишет артефакт в JSON-файл.""" target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) + if target.suffix == ".xz": + with lzma.open( + target, + "wt", + encoding="utf-8", + preset=9 | lzma.PRESET_EXTREME, + ) as output: + json.dump(artifact, output, ensure_ascii=True, indent=2) + return target.write_text( json.dumps(artifact, ensure_ascii=True, indent=2), encoding="utf-8", @@ -361,7 +656,11 @@ def write_startup_history_artifact(path: str | Path, artifact: dict) -> None: def load_startup_history_artifact(path: str | Path) -> dict: """Читает артефакт из JSON-файла.""" - return json.loads(Path(path).read_text(encoding="utf-8")) + source = Path(path) + if source.suffix == ".xz": + with lzma.open(source, "rt", encoding="utf-8") as input_file: + return json.load(input_file) + return json.loads(source.read_text(encoding="utf-8")) def validate_startup_history_artifact( @@ -423,6 +722,16 @@ def import_startup_history_artifact( artifact, expected_config=expected_config, ) + counters = ManifestCounters() + counters.add_batch(topics) + cumulative_state, counter_chunks = counters.to_state_with_chunks() + state.cumulative_manifest_counters = cumulative_counter_reference( + cumulative_state + ) + manifest = deepcopy(manifest) + manifest["topics"] = counters.to_manifest_topics() + manifest["totals"] = counters.to_manifest_totals() + manifest["cumulative_manifest_counters"] = cumulative_state if topic_inspector is not None: topic_inspector.assert_data_topics_empty() snapshot = ( @@ -457,6 +766,10 @@ def import_startup_history_artifact( sent_by_topic[topic] = sent publisher.flush() + for chunk_sha256, chunk in counter_chunks: + manifest_manager.save_counter_chunk(chunk_sha256, chunk) + if counter_chunks: + manifest_manager.flush() manifest_manager.save(manifest) manifest_manager.flush() state_manager.save(state) @@ -534,10 +847,15 @@ def _validate_manifest_topics(manifest: dict, topics: dict[str, list[dict]]) -> counters.add_batch({topic: topics[topic] for topic in TOPICS}) expected_topics = manifest.get("topics") or {} expected_totals = manifest.get("totals") or {} - if counters.to_manifest_topics() != expected_topics: - raise ValueError("startup history artifact mismatch: topic checksums") if counters.to_manifest_totals() != expected_totals: raise ValueError("startup history artifact mismatch: totals") + if counters.to_manifest_topics() == expected_topics: + return + + legacy_counters = _LegacyManifestCounters() + legacy_counters.add_batch({topic: topics[topic] for topic in TOPICS}) + if legacy_counters.to_manifest_topics() != expected_topics: + raise ValueError("startup history artifact mismatch: topic checksums") def _validate_raw_topics( diff --git a/generator/src/clickstream_generator/state.py b/generator/src/clickstream_generator/state.py index 4d70463..9d45a67 100644 --- a/generator/src/clickstream_generator/state.py +++ b/generator/src/clickstream_generator/state.py @@ -204,6 +204,7 @@ class GeneratorState: population: list[dict] = field(default_factory=list) active_visits: list[dict] = field(default_factory=list) pending_visit_births: float = 0.0 + cumulative_manifest_counters: dict | None = None def __post_init__(self) -> None: if self.model_timestamp is None: @@ -221,7 +222,7 @@ class GeneratorState: def to_dict(self) -> dict: """Конвертирует в словарь для JSON-сериализации.""" - return { + payload = { "tick": self.tick, "rng_state": self.rng_state, "last_batch_id": self.last_batch_id, @@ -237,6 +238,11 @@ class GeneratorState: "active_visits": self.active_visits, "pending_visit_births": self.pending_visit_births, } + if self.cumulative_manifest_counters is not None: + payload["cumulative_manifest_counters"] = ( + self.cumulative_manifest_counters + ) + return payload @classmethod def from_dict(cls, data: dict) -> "GeneratorState": @@ -290,6 +296,9 @@ class GeneratorState: population=data.get("population", []), active_visits=data.get("active_visits", []), pending_visit_births=data.get("pending_visit_births", 0.0), + cumulative_manifest_counters=data.get( + "cumulative_manifest_counters" + ), ) except UnsupportedStateVersionError: raise diff --git a/generator/tests/test_airflow_control.py b/generator/tests/test_airflow_control.py index 931f926..03452ad 100644 --- a/generator/tests/test_airflow_control.py +++ b/generator/tests/test_airflow_control.py @@ -10,6 +10,18 @@ from types import SimpleNamespace import pytest +def test_default_artifact_paths_keep_import_and_backfill_separate(): + """Import читает эталонный мир, а backfill пишет в прежний файл.""" + from clickstream_generator.airflow_control import default_artifact_path + + assert default_artifact_path("import") == ( + "/opt/airflow/data/startup_history/reference-world.json.xz" + ) + assert default_artifact_path("backfill") == ( + "/opt/airflow/data/startup-history.json" + ) + + def test_build_control_env_uses_profile_duration_and_airflow_data_dir(): """Пульт строит env для backfill из профиля и каталога Airflow.""" from clickstream_generator.airflow_control import build_control_env @@ -47,7 +59,7 @@ def test_import_env_requires_artifact_path_and_uses_backfill_contract(): assert env["GEN_RUN_MODE"] == "backfill" assert env["GEN_STATE_RESET"] == "true" - assert env["GEN_HISTORY_DURATION"] == "2d" + assert env["GEN_HISTORY_DURATION"] == "3d" def test_next_day_env_uses_world_settings_and_current_manifest_boundary(): @@ -137,6 +149,11 @@ def test_next_day_precheck_requires_manifest_and_matching_state(): with pytest.raises(RuntimeError, match="state.*T_end"): assert_next_day_snapshot(manifest, state) + manifest["model_t_end"] = state.model_timestamp.isoformat() + manifest["state"]["model_timestamp"] = state.model_timestamp.isoformat() + with pytest.raises(RuntimeError, match="повторно запустите import"): + assert_next_day_snapshot(manifest, state) + def test_target_dag_trigger_error_covers_missing_paused_and_ready_states(): """Проверка зависимого DAG различает три реальные ветки.""" diff --git a/generator/tests/test_config.py b/generator/tests/test_config.py index 2d67531..a805e53 100644 --- a/generator/tests/test_config.py +++ b/generator/tests/test_config.py @@ -119,14 +119,14 @@ class TestConfigDefaults: if orig_value is not None: os.environ["GEN_TICK_SECONDS"] = orig_value - def test_launch_profile_defaults_to_ci(self, monkeypatch, data_dir): - """По умолчанию выбран быстрый профиль запуска ci.""" + def test_launch_profile_defaults_to_daily_wave(self, monkeypatch, data_dir): + """По умолчанию выбран учебный профиль запуска daily-wave.""" monkeypatch.setenv("GEN_DATA_DIR", str(data_dir)) monkeypatch.delenv("GEN_LAUNCH_PROFILE", raising=False) config = Config() - assert config.launch_profile == "ci" + assert config.launch_profile == "daily-wave" def test_launch_profile_can_be_read_from_env(self, monkeypatch, data_dir): """Выбранный профиль запуска попадает в конфигурацию для логов.""" diff --git a/generator/tests/test_launch.py b/generator/tests/test_launch.py index fa7660e..10ca177 100644 --- a/generator/tests/test_launch.py +++ b/generator/tests/test_launch.py @@ -5,6 +5,17 @@ from datetime import datetime, timezone +def test_daily_wave_is_the_default_for_api_and_cli(monkeypatch): + """Ручные точки входа без профиля выбирают daily-wave.""" + from clickstream_generator.launch import _parse_args, build_launch_env + + monkeypatch.delenv("PROFILE", raising=False) + monkeypatch.delenv("GEN_LAUNCH_PROFILE", raising=False) + + assert build_launch_env("backfill")["GEN_LAUNCH_PROFILE"] == "daily-wave" + assert _parse_args(["backfill"]).profile == "daily-wave" + + def test_duration_2d_sets_model_t_end_from_t0(): """Длительность 2d считается от T0 без ручного GEN_MODEL_T_END.""" from clickstream_generator.launch import build_launch_env @@ -15,15 +26,15 @@ def test_duration_2d_sets_model_t_end_from_t0(): assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" -def test_daily_wave_profile_uses_two_days_by_default(): +def test_daily_wave_profile_uses_three_days_by_default(): """Профиль daily-wave даёт историю с суточной волной.""" from clickstream_generator.launch import build_launch_env env = build_launch_env("backfill", profile_name="daily-wave") assert env["GEN_LAUNCH_PROFILE"] == "daily-wave" - assert env["GEN_HISTORY_DURATION"] == "2d" - assert env["GEN_MODEL_T_END"] == "2026-01-03T00:00:00+00:00" + assert env["GEN_HISTORY_DURATION"] == "3d" + assert env["GEN_MODEL_T_END"] == "2026-01-04T00:00:00+00:00" assert env["GEN_MODEL_TIME_SPEED"] == "60" assert env["GEN_TICK_SECONDS"] == "1" diff --git a/generator/tests/test_service.py b/generator/tests/test_service.py index 4eda6f4..3668f0f 100644 --- a/generator/tests/test_service.py +++ b/generator/tests/test_service.py @@ -25,6 +25,7 @@ from generator import ( main, ) from clickstream_generator.state import UnsupportedStateVersionError +from clickstream_generator.service import IncompatibleStateError class TestBatchRecordWithDictConversion: @@ -425,6 +426,10 @@ class TestGeneratorServiceBackfill: self, base_config ): """Backfill пишет [T0, T_end), state на T_end и повторяемый manifest.""" + from clickstream_generator.startup_history_artifact import ( + cumulative_counter_reference, + ) + model_t0 = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc) model_t_end = model_t0 + timedelta(minutes=3) config = replace( @@ -463,6 +468,11 @@ class TestGeneratorServiceBackfill: assert manifest["model_t0"] == model_t0.isoformat() assert manifest["model_t_end"] == model_t_end.isoformat() assert manifest["state"]["last_batch_id"] == saved_state.last_batch_id + assert saved_state.cumulative_manifest_counters == ( + cumulative_counter_reference( + manifest["cumulative_manifest_counters"] + ) + ) assert manifest["topics"]["browser_events"]["rows"] == len(browser_events) for topic in ( "browser_events", @@ -516,6 +526,8 @@ class TestGeneratorServiceBackfill: self._run_backfill(config) artifact = load_startup_history_artifact(artifact_path) + assert "cumulative_manifest_counters" not in artifact["state"] + assert "cumulative_manifest_counters" not in artifact["manifest"] state, manifest, topics, _ = validate_startup_history_artifact( artifact, expected_config=config, @@ -960,7 +972,8 @@ class TestGeneratorServiceBackfill: with pytest.raises(RuntimeError, match="flush down"): service._run_backfill() - service.manifest_manager.save.assert_called_once() + service.manifest_manager.save_counter_chunk.assert_called() + service.manifest_manager.save.assert_not_called() service.manifest_manager.flush.assert_called_once() service.state_manager.save.assert_not_called() service.state_manager.flush.assert_not_called() @@ -1008,13 +1021,14 @@ class TestGeneratorServiceBackfill: class TestGeneratorServiceNextDay: """Проверки ограниченной доливки следующего модельного дня.""" - def test_next_day_publishes_24h_then_state_then_cumulative_manifest( + def test_next_day_publishes_24h_then_chunks_manifest_and_state( self, base_config ): - """Next-day пишет [T_end, T_end+24h), затем state и manifest.""" + """Next-day пишет день, фрагменты множеств, manifest и затем state.""" from clickstream_generator.startup_history_artifact import ( StartupHistoryArtifactBuilder, build_manifest, + cumulative_counter_reference, ) model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) @@ -1062,6 +1076,9 @@ class TestGeneratorServiceNextDay: } initial_builder = StartupHistoryArtifactBuilder() initial_builder.add_batch(old_batch) + state.cumulative_manifest_counters = cumulative_counter_reference( + initial_builder.counters.to_state() + ) manifest = build_manifest(config, initial_builder.counters, state) published = {topic: [] for topic in old_batch} @@ -1078,21 +1095,21 @@ class TestGeneratorServiceNextDay: service.state_manager = MagicMock() service.state_manager.save.side_effect = lambda _state: order.append("state") service.manifest_manager = MagicMock() + service.manifest_manager.save_counter_chunk.side_effect = ( + lambda _chunk_sha256, _chunk: order.append("counter_chunk") + ) service.manifest_manager.save.side_effect = ( lambda _manifest: order.append("manifest") ) class DataReader: def load(self): - return { - topic: old_batch[topic] + published[topic] - for topic in old_batch - } + raise AssertionError("next-day не должен перечитывать Kafka") service.data_reader = DataReader() service.restore_from_startup_history(state, model_t_end=current_t_end) - service._run_next_day(manifest) + service._run_next_day(manifest, state) browser_events = published["browser_events"] timestamps = [ @@ -1114,18 +1131,21 @@ class TestGeneratorServiceNextDay: target_t_end.isoformat(), ] assert saved_manifest["totals"]["events"] == len(browser_events) + 1 - assert order == ["data", "state", "manifest"] + assert order == ["data", "counter_chunk", "manifest", "state"] first_day_events = len(browser_events) first_day_checksum = saved_manifest["topics"]["browser_events"][ "checksum_sha256" ] - service._run_next_day(saved_manifest) + service._run_next_day(saved_manifest, saved_state) second_state = service.state_manager.save.call_args.args[0] second_manifest = service.manifest_manager.save.call_args.args[0] expected_builder = StartupHistoryArtifactBuilder() - expected_builder.add_batch(DataReader().load()) + expected_builder.add_batch({ + topic: old_batch[topic] + published[topic] + for topic in old_batch + }) assert second_state.model_timestamp == target_t_end + timedelta(hours=24) assert second_manifest["boundaries"] == [ model_t0.isoformat(), @@ -1140,15 +1160,57 @@ class TestGeneratorServiceNextDay: != first_day_checksum ) assert second_manifest["topics"] == expected_builder.counters.to_manifest_topics() + assert second_manifest["totals"] == expected_builder.counters.to_manifest_totals() + assert second_state.cumulative_manifest_counters == ( + cumulative_counter_reference( + second_manifest["cumulative_manifest_counters"] + ) + ) assert order == [ "data", - "state", + "counter_chunk", "manifest", + "state", "data", - "state", + "counter_chunk", "manifest", + "state", ] + def test_next_day_requires_cumulative_counters_from_import(self, base_config): + """Старый локальный state требует повторного импорта, а не чтения Kafka.""" + model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + config = replace( + base_config, + run_mode="next-day", + model_t0=model_t0, + model_t_end=model_t0 + timedelta(hours=1), + ) + service = GeneratorService(config) + state = service.stream.to_state( + tick=1, + rng_state=service.generator.rng.getstate(), + last_batch_id="startup-history-old", + last_timestamp=config.model_t_end, + model_timestamp=config.model_t_end, + model_t0=config.model_t0, + ) + service.publisher = MagicMock() + service.history = MagicMock() + service.state_manager = MagicMock() + service.manifest_manager = MagicMock() + service._model_time = config.model_t_end + manifest = { + "model_t0": model_t0.isoformat(), + "model_t_end": config.model_t_end.isoformat(), + "boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()], + } + + with pytest.raises(IncompatibleStateError, match="повторно запустите import"): + service._run_next_day(manifest, state) + + service.publisher.publish.assert_not_called() + def test_next_day_publish_error_does_not_move_state_or_manifest(self, base_config): """Ошибка data-топика оставляет обе точки фиксации без изменений.""" model_t0 = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) @@ -1177,8 +1239,29 @@ class TestGeneratorServiceNextDay: "boundaries": [model_t0.isoformat(), config.model_t_end.isoformat()], } + from clickstream_generator.startup_history_artifact import ( + ManifestCounters, + cumulative_counter_reference, + ) + + state = service.stream.to_state( + tick=1, + rng_state=service.generator.rng.getstate(), + last_batch_id="startup-history-test", + last_timestamp=config.model_t_end, + model_timestamp=config.model_t_end, + model_t0=config.model_t0, + ) + empty_counters = ManifestCounters() + manifest["cumulative_manifest_counters"] = empty_counters.to_state() + manifest["topics"] = empty_counters.to_manifest_topics() + manifest["totals"] = empty_counters.to_manifest_totals() + state.cumulative_manifest_counters = cumulative_counter_reference( + manifest["cumulative_manifest_counters"] + ) + with pytest.raises(RuntimeError, match="Публикация next-day не удалась"): - service._run_next_day(manifest) + service._run_next_day(manifest, state) service.state_manager.save.assert_not_called() service.manifest_manager.save.assert_not_called() diff --git a/generator/tests/test_startup_history_artifact.py b/generator/tests/test_startup_history_artifact.py index c90d511..821f3be 100644 --- a/generator/tests/test_startup_history_artifact.py +++ b/generator/tests/test_startup_history_artifact.py @@ -4,12 +4,56 @@ from dataclasses import replace from datetime import datetime, timezone +import hashlib +import json +import lzma import pytest from generator import GeneratorState +def test_plain_json_roundtrip_keeps_existing_serialization(tmp_path): + """Обычный JSON по-прежнему пишется без сжатия и читается обратно.""" + from clickstream_generator.startup_history_artifact import ( + load_startup_history_artifact, + write_startup_history_artifact, + ) + + artifact = {"text": "мир", "nested": {"value": 42}} + path = tmp_path / "artifact.json" + + write_startup_history_artifact(path, artifact) + + assert path.read_text(encoding="utf-8") == json.dumps( + artifact, + ensure_ascii=True, + indent=2, + ) + assert load_startup_history_artifact(path) == artifact + + +def test_xz_artifact_roundtrip(tmp_path): + """XZ-артефакт потоково пишется и читается без изменения данных.""" + from clickstream_generator.startup_history_artifact import ( + load_startup_history_artifact, + write_startup_history_artifact, + ) + + artifact = {"text": "мир", "nested": {"value": 42}} + path = tmp_path / "artifact.json.xz" + + write_startup_history_artifact(path, artifact) + + assert load_startup_history_artifact(path) == artifact + with lzma.open(path, "rt", encoding="utf-8") as input_file: + assert input_file.read() == json.dumps( + artifact, + ensure_ascii=True, + indent=2, + ) + + def _state() -> GeneratorState: import random @@ -67,6 +111,144 @@ def _batch() -> dict[str, list[dict]]: } +def test_incremental_counters_equal_full_recompute(): + """Накопление по частям даёт те же числа, что полный пересчёт мира.""" + from clickstream_generator.startup_history_artifact import ManifestCounters + + first_day = _batch() + second_day = { + "browser_events": [ + { + "event_id": "event-2", + "click_id": "click-1", + "user_domain_id": "user-1", + "event_timestamp": "2026-01-02 00:00:00.000000", + }, + { + "event_id": "event-3", + "click_id": "click-2", + "user_domain_id": "user-2", + "event_timestamp": "2026-01-02 00:01:00.000000", + }, + ], + "location_events": [ + {"event_id": "event-2", "page_url_path": "/cart"}, + {"event_id": "event-3", "page_url_path": "/home"}, + ], + "device_events": [ + {"click_id": "click-1", "user_domain_id": "user-1"}, + {"click_id": "click-2", "user_domain_id": "user-2"}, + ], + "geo_events": [ + {"click_id": "click-1", "country": "RU"}, + {"click_id": "click-2", "country": "KZ"}, + ], + } + + incremental = ManifestCounters() + incremental.add_batch(first_day) + incremental = ManifestCounters.from_state( + incremental.to_state(), + known_click_ids={"click-1"}, + known_user_ids={"user-1"}, + ) + incremental.add_batch(second_day) + + full = ManifestCounters() + full.add_batch({ + topic: first_day[topic] + second_day[topic] + for topic in first_day + }) + + assert incremental.to_manifest_topics() == full.to_manifest_topics() + assert incremental.to_manifest_totals() == full.to_manifest_totals() + + +def test_exact_id_sets_are_split_into_bounded_hash_chain(monkeypatch): + """Manifest ссылается на цепочку малых точных фрагментов, а не растёт сам.""" + from clickstream_generator import startup_history_artifact as module + + monkeypatch.setattr(module, "ID_SET_CHUNK_SIZE", 2) + counters = module.ManifestCounters() + batch = _batch() + batch["browser_events"].extend([ + { + "event_id": "event-2", + "click_id": "click-2", + "event_timestamp": "2026-01-01 00:01:00.000000", + }, + { + "event_id": "event-3", + "click_id": "click-3", + "event_timestamp": "2026-01-01 00:02:00.000000", + }, + ]) + counters.add_batch(batch) + + state, chunks = counters.to_state_with_chunks() + + assert len(chunks) == 2 + assert state["totals"] == {"visits": 3, "users": 1} + assert state["id_sets"] == { + "storage": "manifest-topic-sha256-chain-v1", + "latest_sha256": chunks[-1][0], + "chunks": 2, + "click_ids": 3, + "user_ids": 1, + } + assert chunks[0][1]["previous_sha256"] is None + assert chunks[1][1]["previous_sha256"] == chunks[0][0] + assert "click-1" not in json.dumps(state) + + restored = module.ManifestCounters.from_state( + state, + known_click_ids={"click-3"}, + known_user_ids={"user-1"}, + ) + restored.add_batch({ + "browser_events": [ + { + "event_id": "event-4", + "click_id": "click-3", + "event_timestamp": "2026-01-02 00:00:00.000000", + }, + { + "event_id": "event-5", + "click_id": "click-4", + "event_timestamp": "2026-01-02 00:01:00.000000", + }, + ], + "location_events": [], + "device_events": [{"click_id": "click-4", "user_domain_id": "user-2"}], + "geo_events": [], + }) + next_state, next_chunks = restored.to_state_with_chunks() + + assert next_state["totals"] == {"visits": 4, "users": 2} + assert len(next_chunks) == 1 + assert next_chunks[0][1]["previous_sha256"] == chunks[-1][0] + assert next_state["id_sets"]["chunks"] == 3 + + +def test_counter_chunk_has_explicit_kafka_size_and_hash_guards(): + """Большой или неверно адресованный фрагмент падает до отправки в Kafka.""" + from clickstream_generator.kafka_io import ( + MAX_COMPACT_MESSAGE_BYTES, + KafkaStartupHistoryManifest, + ) + + manager = object.__new__(KafkaStartupHistoryManifest) + with pytest.raises(ValueError, match="безопасный предел"): + manager._assert_message_size( + {"payload": "x" * MAX_COMPACT_MESSAGE_BYTES}, + "фрагмент", + ) + with pytest.raises(ValueError, match="хеш.*не совпадает"): + manager.save_counter_chunk("0" * 64, {"version": "1.0"}) + + assert manager.COUNTER_TOPIC != manager.MANIFEST_TOPIC + + def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config): """Артефакт хранит события, state и manifest одним проверяемым набором.""" from clickstream_generator.startup_history_artifact import ( @@ -130,6 +312,43 @@ def test_artifact_roundtrip_keeps_events_state_and_manifest(base_config): path.unlink(missing_ok=True) +def test_legacy_artifact_checksum_remains_valid(base_config): + """Эталонный артефакт со старой суммой не требует пересборки.""" + from clickstream_generator.startup_history_artifact import ( + StartupHistoryArtifactBuilder, + build_manifest, + validate_startup_history_artifact, + ) + + state = _state() + config = replace( + base_config, + model_t0=state.model_t0, + model_t_end=state.model_timestamp, + model_time_speed=1, + model_timezone="UTC", + seed=42, + ) + builder = StartupHistoryArtifactBuilder() + builder.add_batch(_batch()) + manifest = build_manifest(config, builder.counters, state) + for topic, events in builder.topics.items(): + checksum = hashlib.sha256() + for event in events: + checksum.update( + json.dumps(event, sort_keys=True, ensure_ascii=True).encode("utf-8") + ) + manifest["topics"][topic]["checksum_sha256"] = checksum.hexdigest() + + artifact = builder.to_artifact(manifest=manifest, state=state) + + _, loaded_manifest, _, _ = validate_startup_history_artifact( + artifact, + expected_config=config, + ) + assert loaded_manifest["topics"] == artifact["manifest"]["topics"] + + def test_manifest_and_artifact_show_launch_profile(base_config): """Manifest и файл артефакта показывают выбранный профиль запуска.""" from clickstream_generator.startup_history_artifact import ( @@ -329,6 +548,8 @@ def test_raw_topic_value_can_keep_original_json_bytes(base_config): manifest=build_manifest(config=config, counters=builder.counters, state=state), state=state, ) + assert "cumulative_manifest_counters" not in artifact["state"] + assert "cumulative_manifest_counters" not in artifact["manifest"] raw_value = ( '{"event_timestamp":"2026-01-01 00:00:00.000000",' '"user_domain_id":"user-1","click_id":"click-1","event_id":"event-1"}' @@ -348,9 +569,15 @@ def test_raw_topic_value_can_keep_original_json_bytes(base_config): return None class CompactWriter: + def __init__(self): + self.chunks = [] + def save(self, value): self.saved = value + def save_counter_chunk(self, chunk_sha256, chunk): + self.chunks.append((chunk_sha256, chunk)) + def flush(self): return None @@ -566,8 +793,10 @@ def test_import_rolls_back_kafka_topics_after_partial_publish(base_config): def test_import_replays_events_and_compact_topics(base_config): """Импорт пишет события в Kafka и служебные compact-топики, не трогая ClickHouse.""" from clickstream_generator.startup_history_artifact import ( + ManifestCounters, StartupHistoryArtifactBuilder, build_manifest, + cumulative_counter_reference, import_startup_history_artifact, ) @@ -603,10 +832,14 @@ def test_import_replays_events_and_compact_topics(base_config): def __init__(self): self.saved = None self.flushed = False + self.chunks = [] def save(self, value): self.saved = value + def save_counter_chunk(self, chunk_sha256, chunk): + self.chunks.append((chunk_sha256, chunk)) + def flush(self): self.flushed = True @@ -635,7 +868,27 @@ def test_import_replays_events_and_compact_topics(base_config): ) assert result["events"] == 4 assert state_manager.saved.last_batch_id == state.last_batch_id + assert state_manager.saved.cumulative_manifest_counters is not None assert manifest_manager.saved["state"]["last_batch_id"] == state.last_batch_id + assert state_manager.saved.cumulative_manifest_counters == ( + cumulative_counter_reference( + manifest_manager.saved["cumulative_manifest_counters"] + ) + ) + seeded = ManifestCounters.from_state( + manifest_manager.saved["cumulative_manifest_counters"], + known_click_ids={"click-1"}, + known_user_ids={"user-1"}, + ) + assert seeded.click_ids == {"click-1"} + assert seeded.user_ids == {"user-1"} + assert len(manifest_manager.chunks) == 1 + assert ( + manifest_manager.saved["cumulative_manifest_counters"]["id_sets"][ + "latest_sha256" + ] + == manifest_manager.chunks[0][0] + ) assert publisher.flushed assert state_manager.flushed assert manifest_manager.flushed diff --git a/generator/tests/test_state.py b/generator/tests/test_state.py index e3b2228..964d243 100644 --- a/generator/tests/test_state.py +++ b/generator/tests/test_state.py @@ -227,6 +227,25 @@ class TestGeneratorState: assert next_values == values_after + def test_state_roundtrip_keeps_cumulative_manifest_counters(self): + """State сохраняет накопительные числа для следующего дня.""" + counter_reference = { + "version": "1.0", + "manifest_sha256": "a" * 64, + } + state = GeneratorState( + tick=1, + rng_state=_make_valid_rng_state(), + last_batch_id="startup-history-test", + last_timestamp=datetime.now(timezone.utc), + population=_minimal_population(), + cumulative_manifest_counters=counter_reference, + ) + + restored = GeneratorState.from_dict(json.loads(json.dumps(state.to_dict()))) + + assert restored.cumulative_manifest_counters == counter_reference + def test_state_roundtrip_keeps_population_and_active_visits(self): """State хранит популяцию и активные визиты в JSON.""" rng_state = _make_valid_rng_state(42) diff --git a/generator/tests/test_generator_control_dag_contract.py b/generator/tests/test_world_dags_contract.py similarity index 72% rename from generator/tests/test_generator_control_dag_contract.py rename to generator/tests/test_world_dags_contract.py index 3412034..748cd82 100644 --- a/generator/tests/test_generator_control_dag_contract.py +++ b/generator/tests/test_world_dags_contract.py @@ -1,6 +1,4 @@ -""" -Контракт DAG generator_control без запуска Airflow. -""" +"""Контракты DAG world_init и world_next_day без запуска Airflow.""" import ast from pathlib import Path @@ -8,7 +6,8 @@ from pathlib import Path from clickstream_generator.launch import PROFILES -DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "generator_control_dag.py" +DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_init_dag.py" +NEXT_DAY_DAG_PATH = Path(__file__).parents[2] / "airflow" / "dags" / "world_next_day_dag.py" REPO_ROOT = Path(__file__).parents[2] @@ -42,11 +41,30 @@ def _declared_param_types() -> dict[str, set[str]]: return param_types +def _declared_param_defaults() -> dict[str, object]: + param_defaults = {} + for node in ast.walk(_tree()): + if not isinstance(node, ast.Dict): + continue + for key, value in zip(node.keys, node.values, strict=True): + if not ( + isinstance(key, ast.Constant) + and isinstance(key.value, str) + and isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "Param" + and value.args + ): + continue + param_defaults[key.value] = ast.literal_eval(value.args[0]) + return param_defaults + + def test_dag_file_exists_and_uses_dynamic_profiles(): """DAG берёт варианты профилей из PROFILES, а не из ручного списка.""" text = DAG_PATH.read_text(encoding="utf-8") - assert "dag_id=\"generator_control\"" in text + assert "dag_id=\"world_init\"" in text assert "sorted(PROFILES)" in text for profile in PROFILES: assert profile not in {"hardcoded-profile"} @@ -55,12 +73,15 @@ def test_dag_file_exists_and_uses_dynamic_profiles(): def test_trigger_form_has_expected_param_enums(): """Форма запуска ограничивает операции и профили.""" text = DAG_PATH.read_text(encoding="utf-8") + param_defaults = _declared_param_defaults() - assert 'enum=["backfill", "import", "next-day", "check"]' in text + assert 'enum=["backfill", "import", "check"]' in text + assert param_defaults["operation"] == "import" assert "enum=sorted(PROFILES)" in text + assert param_defaults["profile"] == "daily-wave" assert '"duration": Param(' in text assert '"artifact_path": Param(' in text - assert '"expected_t_end": Param(' in text + assert '"expected_t_end": Param(' not in text def test_trigger_form_marks_only_optional_params_as_nullable(): @@ -72,7 +93,6 @@ def test_trigger_form_marks_only_optional_params_as_nullable(): "seed", "model_time_speed", "artifact_path", - "expected_t_end", }: assert "null" in param_types[name] for name in {"operation", "profile"}: @@ -80,7 +100,7 @@ def test_trigger_form_marks_only_optional_params_as_nullable(): def test_dag_branches_and_waits_for_etl_completion(): - """Backfill/import/next-day запускают ETL и ждут завершения перед check.""" + """Backfill/import запускают ETL и ждут завершения перед check.""" text = DAG_PATH.read_text(encoding="utf-8") assert "BranchPythonOperator" in text @@ -91,41 +111,43 @@ def test_dag_branches_and_waits_for_etl_completion(): assert 'failed_states=["failed"]' in text -def test_next_day_has_own_boundary_precheck_and_serial_execution(): - """Next-day не использует clean-guard и не допускает параллельных запусков.""" - text = DAG_PATH.read_text(encoding="utf-8") +def test_world_next_day_is_parameterless_paused_half_hour_dag(): + """DAG следующего дня запускается пустой формой каждые полчаса.""" + text = NEXT_DAY_DAG_PATH.read_text(encoding="utf-8") - assert "schedule=None" in text + assert 'dag_id="world_next_day"' in text + assert 'schedule="*/30 * * * *"' in text + assert "catchup=False" in text + assert "is_paused_upon_creation=True" in text assert "max_active_runs=1" in text - assert 'return "check_etl_not_paused_before_next_day"' in text - assert "assert_next_day_snapshot" in text - assert "assert_expected_t_end" in text - next_day_precheck = text.split("def precheck_next_day", maxsplit=1)[1].split( - "\ndef ", maxsplit=1 - )[0] - assert "assert_stand_clean" not in next_day_precheck - assert "assert_live_generator_not_running" in next_day_precheck - assert "run_next_day" in text + assert "params=" not in text + assert "precheck_next_day" in text + assert "run_next_day_task" in text + assert "TriggerDagRunOperator" in text + assert "wait_for_completion=True" in text + assert "check_manifest_task" in text + assert "check_etl_not_paused >> precheck" in text -def test_generator_control_prechecks_etl_dag_not_paused_before_waiting(): +def test_world_dags_precheck_etl_dag_not_paused_before_waiting(): """Пульт проверяет паузу etl_pipeline до долгого ожидания.""" text = DAG_PATH.read_text(encoding="utf-8") + shared_tasks = ( + REPO_ROOT / "airflow" / "dags" / "utils" / "startup_history_tasks.py" + ).read_text(encoding="utf-8") assert "assert_target_dag_not_paused" in text assert 'ETL_DAG_ID = "etl_pipeline"' in text assert 'op_kwargs={"dag_id": ETL_DAG_ID}' in text - assert "session.query(DagModel)" in text - assert "DagModel.dag_id == dag_id" in text - assert "target_dag_trigger_error" in text - assert "Airflow 2.10.5" in text - assert "fail_when_dag_is_paused" in text + assert "session.query(DagModel)" in shared_tasks + assert "DagModel.dag_id == dag_id" in shared_tasks + assert "target_dag_trigger_error" in shared_tasks + assert "Airflow 2.10.5" in shared_tasks + assert "fail_when_dag_is_paused" in shared_tasks assert 'return "check_etl_not_paused_before_backfill"' in text assert 'return "check_etl_not_paused_before_import"' in text - assert 'return "check_etl_not_paused_before_next_day"' in text assert "check_etl_not_paused_before_backfill >> precheck_backfill_task" in text assert "check_etl_not_paused_before_import >> precheck_import_task" in text - assert "check_etl_not_paused_before_next_day >> precheck_next_day_task" in text assert text.index("check_etl_not_paused_before_backfill >> precheck_backfill_task") < text.index( "precheck_backfill_task >> backfill_task" ) @@ -149,15 +171,16 @@ def test_make_up_rebuilds_airflow_images_after_repo_update(): assert "superset-init" not in up_block -def test_readme_quick_start_lists_prerequisites_and_runs_ddl_before_generator_control(): - """Быстрый старт называет инструменты и DDL до generator_control.""" +def test_readme_quick_start_creates_schema_via_ddl_init_before_world_init(): + """Быстрый старт: терминал — только make up, схему создаёт ddl_init до world_init.""" text = (REPO_ROOT / "README.md").read_text(encoding="utf-8") assert "uv" in text assert "Docker" in text assert "docker compose" in text - assert "make up\nmake ddl" in text - assert text.index("make ddl") < text.index("generator_control") + assert "make ddl" not in text + quick_start = text.split("## Быстрый старт", maxsplit=1)[1] + assert quick_start.index("ddl_init") < quick_start.index("world_init") def test_course_readme_lists_uv_before_first_command(): @@ -226,6 +249,22 @@ def test_console_generator_paths_precheck_clean_stand_before_writes(): ) +def test_reference_artifact_is_import_default_only(): + """Экспорт не может по умолчанию перезаписать эталонный мир.""" + makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") + export_script = ( + REPO_ROOT / "scripts" / "export_startup_history_artifact.sh" + ).read_text(encoding="utf-8") + import_script = ( + REPO_ROOT / "scripts" / "import_startup_history_artifact.sh" + ).read_text(encoding="utf-8") + + assert "STARTUP_HISTORY_EXPORT_ARTIFACT" in makefile + assert "STARTUP_HISTORY_IMPORT_ARTIFACT" in makefile + assert "/tmp/clickstream-startup-history.json" in export_script + assert "data/startup_history/reference-world.json.xz" in import_script + + def test_console_continue_checks_state_or_clean_stand_before_live_start(): """Continue не стартует новый live-мир на грязном стенде без state.""" run_generator = (REPO_ROOT / "scripts" / "run_generator.sh").read_text( diff --git a/scripts/assert_stand_clean.sh b/scripts/assert_stand_clean.sh index a3872ab..8ed54a3 100755 --- a/scripts/assert_stand_clean.sh +++ b/scripts/assert_stand_clean.sh @@ -26,7 +26,8 @@ fi if [[ "${CHECK_MODE}" == "clean" ]]; then echo "Предпроверка: live-генератор не должен быть запущен." PYTHONPATH="${REPO_ROOT}/generator/src" \ - uv run python -m clickstream_generator.stand_clean \ + uv run --with-requirements generator/requirements.txt \ + python -m clickstream_generator.stand_clean \ --mode live \ --live-metrics-url "${GENERATOR_METRICS_URL_HOST}" fi @@ -54,7 +55,8 @@ fi KAFKA_BOOTSTRAP_SERVERS="${KAFKA_BOOTSTRAP_SERVERS_HOST}" \ GEN_DATA_DIR="${REPO_ROOT}/data" \ PYTHONPATH="${REPO_ROOT}/generator/src" \ - uv run python -m clickstream_generator.stand_clean \ + uv run --with-requirements generator/requirements.txt \ + python -m clickstream_generator.stand_clean \ --mode "${CHECK_MODE}" \ --kafka-bootstrap-servers "${KAFKA_BOOTSTRAP_SERVERS_HOST}" \ --stg-counts "${stg_counts}" diff --git a/scripts/check_startup_history_manifest.sh b/scripts/check_startup_history_manifest.sh index 06b9ef0..9d0f444 100755 --- a/scripts/check_startup_history_manifest.sh +++ b/scripts/check_startup_history_manifest.sh @@ -27,7 +27,7 @@ clickhouse_datetime_literal() { [[ -s "${ARTIFACT}" ]] || fail "артефакт не найден или пуст: ${ARTIFACT}" -ARTIFACT_DIR="$(dirname "${ARTIFACT}")" +ARTIFACT_DIR="$(cd "$(dirname "${ARTIFACT}")" && pwd)" ARTIFACT_FILE="$(basename "${ARTIFACT}")" manifest_summary="$(${COMPOSE_BIN} run --rm --no-deps \ diff --git a/scripts/export_startup_history_artifact.sh b/scripts/export_startup_history_artifact.sh index a7b13a9..20c299d 100755 --- a/scripts/export_startup_history_artifact.sh +++ b/scripts/export_startup_history_artifact.sh @@ -10,7 +10,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" CLEAN_START="${CLEAN_START:-1}" ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}" -PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}" duration_args=() if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then duration_args=(--duration "${GEN_HISTORY_DURATION}") diff --git a/scripts/import_startup_history_artifact.sh b/scripts/import_startup_history_artifact.sh index 04436d9..8e91f9e 100755 --- a/scripts/import_startup_history_artifact.sh +++ b/scripts/import_startup_history_artifact.sh @@ -8,8 +8,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" -ARTIFACT="${ARTIFACT:-/tmp/clickstream-startup-history.json}" -PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +ARTIFACT="${ARTIFACT:-data/startup_history/reference-world.json.xz}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}" duration_args=() if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then duration_args=(--duration "${GEN_HISTORY_DURATION}") @@ -31,7 +31,7 @@ if [[ ! -s "${ARTIFACT}" ]]; then exit 1 fi -ARTIFACT_DIR="$(dirname "${ARTIFACT}")" +ARTIFACT_DIR="$(cd "$(dirname "${ARTIFACT}")" && pwd)" ARTIFACT_FILE="$(basename "${ARTIFACT}")" echo "=== Импорт стартовой истории ===" diff --git a/scripts/run_generated_history_analytics.sh b/scripts/run_generated_history_analytics.sh index 586bcd9..40667c3 100644 --- a/scripts/run_generated_history_analytics.sh +++ b/scripts/run_generated_history_analytics.sh @@ -15,7 +15,7 @@ COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" CLEAN_START="${CLEAN_START:-1}" WAIT_CLICKHOUSE_SECONDS="${WAIT_CLICKHOUSE_SECONDS:-60}" WAIT_STG_SECONDS="${WAIT_STG_SECONDS:-10}" -PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}" duration_args=() if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then duration_args=(--duration "${GEN_HISTORY_DURATION}") diff --git a/scripts/run_generator.sh b/scripts/run_generator.sh index c908d87..e142610 100644 --- a/scripts/run_generator.sh +++ b/scripts/run_generator.sh @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" COMPOSE_BIN="${COMPOSE_BIN:-docker compose}" -PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-ci}}" +PROFILE="${PROFILE:-${GEN_LAUNCH_PROFILE:-daily-wave}}" duration_args=() if [[ -n "${GEN_HISTORY_DURATION:-}" ]]; then duration_args=(--duration "${GEN_HISTORY_DURATION}") diff --git a/tests/test_generated_analytics_check_contract.py b/tests/test_generated_analytics_check_contract.py index 0ecc4c8..7fe8164 100644 --- a/tests/test_generated_analytics_check_contract.py +++ b/tests/test_generated_analytics_check_contract.py @@ -137,7 +137,7 @@ def test_generated_history_check_uses_actual_manifest_boundary_and_profile(): assert "kafka-manifest-summary" in script assert "manifest_model_t_end" in script assert "CH_MODEL_T_END=\"$(clickhouse_datetime_literal \"${manifest_model_t_end}\")\"" in script - assert "PROFILE ?= ci" in makefile + assert "PROFILE ?= daily-wave" in makefile assert "GEN_LAUNCH_PROFILE" in makefile assert "PROFILE" in makefile