Status: ready-for-agent # Браузерная фактура не сохраняется на стыке восстановления визита ## 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 - [ ] Быстрый автотест на уровне генератора: сгенерировать визит, сохранить state посреди визита, восстановить и сверить оставшиеся события с продолжением без рестарта — по **всем** per-event полям, кроме `event_id` (браузерные и location: referer, utm — не только browser_name; см. «Расхождение шире» выше). Это один код восстановления для стыка backfill->live и crash-recovery (задача 04) — одного теста на него достаточно. - [ ] Проверка стыка на стенде расширена per-event полями: на сценарии из «Что нашли» (2 суток backfill + live за `T_end`, `GEN_SEED=4242`) визиты через стык однородны в `dds.event` по браузерным полям (`browser_name`, `browser_language`) **и** полям источника перехода (referer, utm) — 0 расхождений из `crossing_visits`. Запрос/скрипт проверки сохранён как повторяемый, а не разовый. - [ ] Без регрессий на том же сценарии: `duplicate_events=0`, конфликтов device / os / geo на уровне ODS по-прежнему 0. - [ ] `STATE_VERSION` поднята, новое поле валидируется; несовместимый старый state обрабатывается по действующему правилу (см. детали выше). ## Notes - Рекомендуемый режим ревью по coordinator-loop: **гейт** (state, сериализация — из порогов риска). Ровно эта слепая зона уже пропустила дефект один раз: критерий задач 04/05 проверяли только по `dds.click`. - Задача 13 (доливка) идёт после этого фикса — restore-механизм должен быть исправлен до того, как на него навешивать доливку. ## Blocked by - Нет. Это bug по результатам независимой проверки задач 05 и 06.