Files
clickstream-ch-kafka-supers…/.scratch/generator-model-time-startup-history/issues/09-seam-browser-fixture-not-preserved.md
T
ddadminandClaude Fable 5 f59b6d128f docs(generator): issue 09 и 10 дооформлены до ready-for-agent
- Зачем:
  - закрыть последний шаг триажа: без Acceptance criteria и способа приёмки
    задачи нельзя передавать Codex на исполнение.
- Что:
  - issue 09: причина уточнена по коду (расходится источник фактуры — донор
    против seed_click_id, а не индекс), зафиксировано решение хранить донора
    в state, критерии расширены полями location (referer, utm) по итогам ревью.
  - issue 10: приёмка по скриншотам playwright-cli с определением «читаемо»,
    поправлена длительность пересборки (6 часов по умолчанию, не 2 суток),
    отмечено, что legacy world_map, похоже, не умеет легенду и tooltip.
  - handoff триажа дополнен сдвигами в понимании и итогом тройного ревью.
- Проверка:
  - утверждения по коду сверены с generation.py, runtime.py, state.py и
    sql/ddl/dds/30_dds.sql двумя независимыми ревью-агентами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:04:18 +03:00

13 KiB
Raw Blame History

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.