- Зачем:
- конвенция #63 записана, а код её не достиг: колонки времени стояли без
пояса, и сходилось всё лишь потому, что пояс сервера — UTC.
- Что:
- UTCEventTime объявлен DateTime('UTC'), служебные метки _load_ts и
kafka_timestamp — DateTime64(3, 'UTC') в STG и ODS.
- parseDateTimeOrNull получил третьим аргументом 'UTC': маска сверяет
суффикс Z как букву, зоны из строки не берёт вовсе.
- контракт схемы и описание выгрузки несут тип с поясом; имя пояса
Europe/Samara встало рядом со смещением в world.py, сходимость сверяет
тест.
- учебный комментарий о линзе — у первой колонки с явным поясом.
- Проверка:
- make lint, make typecheck, make test (408 тестов)
- make clean && make up && make check-clickhouse — 9 из 9
- замер тикета повторён: под session_timezone='Europe/Samara' колонка
показана 2026-05-31 23:37:00, как и без настроек
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зачем: формулировка в спеке дублировала строку карты проверок и делала это
хуже оригинала. «make up работает» следует из зелёных проверок и потому
пусто; настоящее условие — «с нуля» — в спеке не проговаривалось. Заодно
гигиеническая планка носила имя приёмки этапа, хотя про предмет этапа она
молчит: дашборд может быть не нарисован, а все три цели зелены. Тот же промах
разобран в ADR 0004 — там он случился с числовым порогом.
Что: раздел 9 спеки отсылает к карте проверок вместо своей формулировки и
называет вещь своим именем — гигиена, а не приёмка. Копия строки убрана из
семи карт этапов и карты #1 в трекере: одна вещь — одно место.
Проверка: правка документная. grep по docs, README и AGENTS — других копий
формулировки нет.
Зачем: этап 2 принят прогоном с нуля, и два его следа должны остаться в
доках — иначе решение «не проверяем» станет забытым долгом, а цена цели
разойдётся с замером.
Что:
- в спеке v2 раздел 11 больше не держит Kafka Engine на двух нодах: дубли и
раскладку партиций между прогонами решено не проверять — дубль возможен по
устройству движка, окно разобрано в доке хранилища, в ODS его схлопывает
ReplacingMergeTree. Половина, которую показал #37, названа;
- в карте проверок цена check-services 44 с -> 59 с и абзац с замерами
приёмки: clean+up 2 м 58 с, смоук 9 с, check-clickhouse 8 с, test 71 с.
Причина подорожания не выдумывается — названа неизвестной.
Проверка: make clean && make up, затем make smoke, make check-clickhouse,
make check-services, make lint, make typecheck, make test — всё зелёное
7 августа 2026 года.
Зачем
Холодное ревью нашло три места, где написанное сильнее сделанного.
Что
- Непустая таблица брака больше не выдаётся за доказательство сломанного
разбора. Модельного дня у брака нет, обрамить его нечем, и строки прежних
уроков лежат в нём месяц: после первого же урока с мусором проверка
давала бы неверный диагноз навсегда. Теперь она даёт признак, по которому
причину отличают, — сошлась недостача с числом брака или нет.
- Комментарий у world-init обещал, что расхождение числа дней с
STARTING_DAYS поймают счётчики. Это неправда в одну сторону: лишний день
ложится за рамкой дат описи. Обещание убрано, дыра названа.
- Довод «даг next_day этапа 5 продолжит ось» опирался на несуществующий
этап; заменён настоящей причиной — заливка замыкает цепь разовых служб.
- Потолок ожидания 300 с получил обоснование замером с кратностью, а сам
скрипт — честную оговорку: его обещание работает на пустом стенде, на
живом ждать нечего.
- Третья, пропущенная ссылка на снятый порог скорости дня убрана из спеки.
- Даты замеров в карте целей разведены: #42 менял три цели, а не шесть.
Проверка
Обе ветви диагноза сняты заново на живом стенде: без брака — «не доехали»,
с браком — признак различения. Стенд восстановлен, все 9 проверок зелёные,
брака 0. make lint, typecheck, config-test, test (407 тестов) зелёные.
Ссылка: #42
Зачем
Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с
ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому
же состоянию, а в git лежит то, чем это состояние проверяется.
Что
- Опись мира `data/world-inventory.json`: паспорт (зерно, версия
генератора, хеш каталога) и по строке на каждый из восьми дней — дата,
число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит
`test_inventory.py` — тем же способом, что свежесть описания выгрузки.
- Разовая служба `world-init` вышла из-под профиля и играет в топик восемь
дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait`
считает успешно отработавшую службу упавшей.
- `scripts/wait-for-world.sh` — вторая половина `make up`: приём
асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения
заливки. Ограниченный цикл опроса, не пауза наугад.
- Девятая проверка `make check-clickhouse`: подневный счёт событий против
описи, рамка по датам стартового мира, счёт через `FINAL`. При
расхождении называет, где искать, — в событиях или в браке.
- Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал
1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с
датой. Раздел 9 спеки закрыт: открытых вопросов не осталось.
- Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром
(решение владельца). Оба заведены в словарь CONTEXT.md.
Проверка
`make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий.
`make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с),
`make test` — 407 тестов за 71 с, `make lint`, `make typecheck`,
`make config-test` зелёные.
Что проверка умеет краснеть, снято двумя поломками: снос партиции
2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье —
«сломан разбор». Строки опыта убраны, день переигран, счёт вернулся.
Тест свежести проверен молчаливой правкой цены в каталоге: покраснел.
Ссылка: #42
Зачем: parseDateTimeBestEffort на непонятной строке не краснеет, а достраивает
недостающее — обрезанное «20:00:21» становится первым января текущего года.
Такое сообщение проходило строгий приём с тихо неверным временем, то есть с
той самой порчей, ради которой класс key_field_unparsed и заведён.
Что:
- В обеих матвью разбор метки идёт parseDateTimeOrNull по формату
'%Y-%m-%dT%H:%i:%SZ'. Форма на проводе одна и каноническая, поэтому широта
best-effort не нужна вовсе, а платится за неё отключённой проверкой.
- Замеры в ADR 0005: три записи, которые best-effort достраивает; проверка,
что настройка cast_string_to_date_time_mode не спасает JSONExtract; сверка
на настоящих данных — по всем 101 252 строкам сырья модельного дня точный
формат разобрал метку у каждой и ни на одной не разошёлся с best-effort.
- Записано наблюдение стенда: пересозданная на живом чтеце матвью пропускает
ближайшее сообщение мимо ODS, через минуту то же сообщение разбирается.
Воспроизведено дважды; на нём я сам споткнулся при проверке этой правки.
Проверка: опыт строгого приёма прогнан заново — три сообщения дали событие и
два key_field_unparsed, включая обрезанную метку, которая раньше проходила
годной. DDL применяется на живом кластере.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зачем: холодное ревью по двум линиям нашло дыру в следе опытов и три места,
где текст утверждает не то, что построено.
Что:
- Опыт «_load_ts переносится из сырья» прогнан и записан: у двух тысяч
событий метка совпала с меткой одной из доставок, случаев «метки нет среди
доставок» ноль. Туда же — ответ про форму ключа ODS: вопрос раздела 11
спеки закрывался молча.
- Дока хранилища говорила, что предикат собран из функций, не возвращающих
NULL; построено иначе — обнуляемый разбор есть, но кончается IS NOT NULL.
- Записана гарантия на JSONType: на не-JSON и пустой строке она отдаёт Null и
не бросает, то есть годится в предикат. Раньше первый класс брака стоял на
замере соседней функции.
- ttl_only_drop_parts у таблицы ошибок назван в доке хранилища.
- Комментарий матвью ужат: три вопроса строгого приёма пересказывали ADR 0005
целиком. Осталось то, чего по коду не видно, — запрет трогать arraySort и
замер про ISO-8601. Убрано неверное «в полусотне строк» и упоминание имени
таблицы хранилища в докстринге контракта генератора.
Проверка: DDL применяется на живом кластере; make lint, typecheck, docs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зачем: цепочка Kafka → STG → ODS достраивается последним этажом. Сырьё уже
доезжает (#37), настоящие события в топике есть (#41), а типизированного слоя
не было — событие негде было прочитать колонками, а брак негде увидеть.
Что:
- sql/ddl/20-ods-tables.sql — ods.event_rep/_dist на ReplacingMergeTree с
версией _load_ts, партиция по EventDate, ключ по разделу 1.3 спеки,
шардирование cityHash64(ClientID); ods.event_errors_rep/_dist с классом
брака, своими ключами и сроком жизни в месяц.
- sql/ddl/30-ods-views.sql — две матвью над stg.hits_raw_dist. Годность
считает предикат из трёх частей, вторая матвью берёт его дословное
отрицание, класс брака пишется первым совпавшим из трёх.
- Метку времени разбирает parseDateTimeBestEffortOrNull, а не JSONExtract:
ISO-8601 с суффиксом Z JSONExtract не берёт вовсе. Спека генератора
обещала обратное — обещание поправлено, форма на проводе не менялась.
- Сверка объявлений (contract-тест) снята из документов и из докстрингов
schema.py: сверх строгого приёма она ловила только смену типа.
- Документация приведена в соответствие: ADR 0005, дока хранилища и обе
спеки; группа «сказано по памяти» в доке хранилища опустела.
Проверка: make up && make check-clickhouse (8 проверок, 7,5 с); make lint,
make typecheck, make test (406), make docs без диффа. Разовые опыты при
исполнении — в теле PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- до сих пор генератор умел собирать день, но не умел его отдать: топик
hits наполнялся пробником, а не настоящими данными. Тикет #41 доводит
события до стенда и закрывает форму на проводе, на которую обопрётся
типизированный ODS (#43).
- сериализатор один по решению спеки: второе место, печатающее событие в
JSON, разошлось бы с первым молча.
- Что:
- serialize.py — канонический сериализатор на orjson: единственное место,
где событие целиком становится JSON; 47 ключей всегда, «пусто» это
пустое значение, даты ISO-8601, ecommerce строкой. Вложенный блок
ecommerce в commerce.py вторым сериализатором не считается — правило
про событие, а не про блок внутри него.
- sinks.py — приёмники: файл (одно событие — одна строка) и Kafka (одно
событие — одно сообщение). Ключа у сообщения нет: WatchID уникален,
ключом он был бы ключом лишь на вид.
- player.py, cli.py — проигрыватель и интерфейс запуска: режимы batch и
live (темп ×60), несколько дней одним запуском, ограниченная пачка,
раздельные тайминги генерации и доставки, лаг в логе.
- день на оси и имя топика умолчаний не имеют: параметр, описывающий
среду или позицию, приходит от зовущего, иначе отказ до генерации.
Умолчания зерна, числа дней и темпа остаются — они описывают мир.
- generator/Dockerfile — свой образ: зависимости из uv.lock, база
закреплена до патча, раскладка репозитория сохранена ради каталога
товаров. Образ Airflow не тронут.
- разовая служба compose под профилем, цели generate-batch и
generate-live, .dockerignore, tmp/ в .gitignore.
- решения внесены в спеку (разделы 4, 8, 9), быстрый старт — в README.
- Проверка:
- make test 406 passed, make lint, make typecheck, make config-test.
- побайтовый детерминизм: два прогона дня в независимых процессах дают
один sha256; день в контейнере совпадает с днём на машине.
- на стенде: пакетный день доехал до stg.hits_raw_dist, счёт по
Distributed сошёлся — отправлено 50626, в таблице 50626.
- топик прочитан обеими нодами: clickhouse-01 раздел 0 (26368),
clickhouse-02 раздел 1 (24258).
- живой день: модельное время 01:00 на 60-й секунде, 02:00 на 120-й —
темп ×60, лаг печатается.
- форма на проводе в колонке raw: даты читаются глазами, ecommerce лежит
строкой.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- смоук перестал быть быстрым: 42 секунды из 48 съедали шесть проверок,
которые ждут службу — запуск DAG, вход в Superset, Kafka с машины.
- имена целей врали: смоуком звались и глубокая проверка кластера, и
интеграционные проверки; префикс достался им от общего происхождения.
- нигде не было записано, зачем в репозитории каждая цель и куда класть
новую проверку, — без записи скрипт дорастёт снова.
- Что:
- ось деления — кого спрашивают, а не сколько стоит: make smoke (стенд
собран), make check-clickhouse (спрашивают у ClickHouse), новая
make check-services (службы работают).
- шесть тяжёлых проверок переехали в scripts/stand-services.sh; общее —
счёт, обращение к Compose, зависимости машины и check_containers_survived
— вынесено в scripts/stand-common.sh, копипасты нет.
- smoke-cluster переименована в check-clickhouse; имя файла скрипта не
тронуто (в него встраивается проверка договора со схемой), расхождение
названо в карте.
- смоук и check-services печатают своё время в строке ИТОГ; порога по
времени нет — по доводу ADR 0004.
- docs/architecture/testing.md: карта всех семи целей, правило быстрого
смоука словами, лесенка по частоте и правило про краснеющую проверку,
переехавшее из README; указатель из AGENTS.md.
- README: описания целей сокращены, карта не дублируется; быстрый старт
показывает работающий стенд, а не только собранный.
- планка приёмки этапа в спеке названа поимённо: три цели вместо
«smoke-проверки».
- Проверка:
- make config-test, make smoke (19 проверок, 6 с), make check-clickhouse
(8 проверок, 7 с), make check-services (7 проверок, 44 с) — зелёные.
- 19 + 7 = 25 разных проверок, как и до деления: check_containers_survived
считается дважды намеренно.
- краснеют обе разделённые цели: со снятым prometheus смоук дал три ошибки,
с подменённым UUID подключения Superset покраснел check-services.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Зачем: стенду нужен воспроизводимый холодный старт, при котором схема
хранилища и топик появляются сами, а сырьё из Kafka доезжает в STG обеими
нодами кластера — без ручных шагов между `make clean` и рабочим приёмом.
Что:
- `sql/ddl/` — три файла, применяются по порядку имён: базы `stg` и `ods`,
Kafka-чтец `hits_raw_kafka` формата RawBLOB, реплицируемая `hits_raw_rep`
с окном TTL в трое суток, распределённая `hits_raw_dist` и матвью
`hits_raw_mv`, переносящая сырьё вместе с метаданными доставки.
- `compose.yaml` — службы `kafka-init` (топик `hits` на две партиции, с
ремонтом уже созданного однопартиционного) и `clickhouse-init` (применяет
`/ddl/*.sql`); `hostname:` у обеих нод, чтобы `hostName()` отдавал имя узла,
а не идентификатор контейнера; `airflow-init` зависит от `clickhouse-init` —
без зависимого успешный одноразовый сервис считается упавшим для `--wait`.
- Доки: конвенции и раздел «Что проверено» в справочнике хранилища, указатели
и границы обещаний в ADR 0005, снятые пункты в разделе 11 спеки.
Проверка: `make lint`, `make typecheck`, `make config-test`, `make smoke`
(25 проверок), `make smoke-guards` — зелёные. Приёмочный прогон с чистого
тома подтвердил все пять критериев #37: холодный старт и идемпотентный
повтор, две партиции у `hits`, метаданные доставки у доехавшего сообщения,
обе партиции на обеих потребляющих нодах в одном прогоне, некорректный JSON
лежит сырым и приём не встаёт.
Известная граница: RawBLOB молча теряет запись с пустым значением и
запись-надгробие; принято как свойство, замер и довод — в справочнике
хранилища.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- холодное ревью связности нашло девять мест, где вставленный текст спорит с
соседним; отдельно вскрылось, что представление дат в JSON не зафиксировано
нигде, а #43 обязан его знать раньше, чем #41 напишет сериализатор.
- Что:
- гарантия приёма переписана: после снятия синхронной вставки «хотя бы один
раз» стало неправдой — есть и окно потери, и окно дубля.
- критерий выбора пяти опорных колонок приведён к списку, который он
порождает; `CounterID` оговорён отдельно.
- «переобработки у ODS нет вовсе» смягчено до пакетной: ручная вставка из
сырья в пределах окна возможна.
- в спеку генератора добавлена форма дат на проводе — ISO-8601, с доводом от
читаемости слоя сырья.
- убраны осиротевшая фраза про порядок сервисов, дубль порядка классов брака,
устаревшая датировка сверки и ещё три следа вставок.
- Проверка:
- make config-test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- три холодных ревью и сверка с документацией ClickHouse нашли противоречия
между докой, ADR и спекой: исполнитель #37 получал два разных ответа на
один вопрос, а два утверждения о движке оказались неверными.
- Что:
- раскладка файлов DDL перестроена — сначала таблицы, матвью приёма
последней: иначе часть событий тихо минует ODS.
- синхронная вставка снята с пути приёма: настройка недостижима для потока
Kafka-движка и связывает шарды; на ETL-вставках осталась.
- у таблицы ошибок появился класс брака с порядком проверки, у сырья и
ошибок названы движки и ключи сортировки.
- в доку добавлен раздел «Что проверено»: сверенное с документацией,
проверяемое на стенде и сказанное по памяти разведены.
- в спеке выправлены источник матвью разбора, пять опорных колонок, имена
четырёх витрин и ссылка на несуществующую цель make.
- Проверка:
- make config-test
- grep по устаревшим именам файлов DDL и витрин — пусто
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- тикет #37 молча опирался на конвенции хранилища, которых в проекте не
было; без них #43 и следующие этапы разъехались бы в именах, служебных
колонках и механике приёма.
- Что:
- ADR 0005: топик читается байтами в STG, разбор идёт функциями в матвью
ODS; строгий приём — сверка набора ключей плюс Nullable на пяти опорных
колонках.
- ADR 0006: суффикс вида в именах объектов (_rep, _dist, _kafka, _mv, _v).
- docs/architecture/storage.md: конвенции имён и служебных колонок, путь в
keeper, раскладка по шардам, срок жизни сырья, свойства приёма, раскладка
файлов DDL и карта таблиц.
- спеки приведены в соответствие: механизм строгого приёма, имена объектов,
контракт транспорта «одно событие — одно сообщение Kafka», три проверки
при исполнении.
- Проверка:
- make config-test
- Зачем:
- после #50 доля просмотров, доходящих до корзины, подошла к краю вилки
5–9%, и следующий исполнитель стал бы калибровать поведение под это
число. Владелец решил: пока доля правдоподобна, она ничего не сторожит —
у живых магазинов она гуляет широко.
- Что:
- в блок «Решено при исполнении #50» добавлено решение владельца: вилка —
ориентир; настоящий предел здесь дневной бюджет событий, на котором
стоят манифест (#42) и порог скорости дня.
- Проверка:
- git show --stat; следующий исполнитель читает решение, не переспрашивая.
- Зачем:
- приёмка #40 нашла в торговых данных два точных равенства, каких в живом
магазине не бывает: событие корзины случалось ровно у визитов со
страницей /cart, а внутри такого визита в корзину уходили все открытые
карточки. Привлекательность товара было нечем измерить, а аналитик читал
бы эти равенства как склейку в разметке.
- Что:
- класть в корзину может любой визит, открывший карточку; страница /cart
осталась шагом воронки, а у визита, дошедшего до неё, корзина непуста.
- у товара появился уровень спроса — колонка каталога с тремя значениями
и два ряда вероятностей в числах мира: намерение визита берётся из шага
воронки, а не из метки покупателя, которая уже действует через неё.
- уровни рассыпаны по каталогу одной колодой: одинакового расклада по
категориям нет, связи с ценой нет, и то и другое сторожится тестами.
- числа мира перемерены: конверсия карточки в корзину 8,62% (по уровням
13,1 / 9,2 / 5,8), кладут без открытия корзины 41,4% таких визитов,
средний день 49 834 события, конверсия визита в покупку 2,43%.
- спека генератора и мастер-спека приведены к новой форме каталога.
- Проверка:
- make lint && make typecheck && make test — 394 passed (было 383).
- трафиковая половина побайтово та же: sha256 по (URL, времени, WatchID)
всех просмотров за 14 канонических дней совпадает со снимком до правки.
- Зачем:
- блок «Решено при исполнении #40 после приёмки» цитировал замер на зерне 0,
а канонический мир, на котором стоят все тесты, — CANONICAL_SEED
(20260601); числа относились к другому миру.
- Что:
- замер перемерен на каноническом зерне за 14 дней: тождество «событие
корзины ↔ страница /cart» держится каждый день, на дне 2 это 828 и 828.
- уточнены средний день (49 509 событий), конверсия визита (2,43%), состав
заказа (1,87 позиции) и доля брошенных позиций (число мира 15%, замер по
покупающим корзинам 12%).
- Проверка:
- скрипт замера на 14 днях CANONICAL_SEED; git show --stat.
- Зачем:
- приёмка #40 нашла в торговых данных два точных равенства, каких в живом
магазине не бывает: событие корзины возникало ровно у визитов со
страницей /cart, а внутри такого визита в корзину ложились все открытые
карточки.
- Что:
- в спеку генератора, раздел 9, добавлен блок «Решено при исполнении #40
после приёмки»: корзина отвязана от страницы корзины, уровень спроса
становится свойством товара, границы правки — вилки раздела 5.
- записаны отклонённые варианты: свой процент у каждого sku и вывод
склонности из цены.
- в CONTEXT.md заведён термин «уровень спроса».
- Проверка:
- git show --stat; текст решений читается без обращения к переписке.
- Зачем:
- клиентская сторона мира становится целой: без add_to_cart и purchase
в данных нет ни таксономии событий, ни вложенного JSON, ни денег,
а метка «покупатель» из плана состава ни на что не влияла (#40).
- Что:
- добавлен модуль commerce: корзина шире заказа, номер заказа вида
ГГГГММДД-NNNN, промокод без скидки в сумме, сырой ecommerce через orjson;
- метка покупателя получила два рычага — долгую жизнь куки в плане и
свою воронку в дне; CART_PERCENT опущен с 8 до 6, чтобы конверсия
мира осталась около 2%;
- часть цен каталога получила копейки: productPrice округляется форматом,
purchaseRevenue несёт точную сумму — расхождение живёт внутри события;
- граница суток забирает страницу подтверждения вместе с её покупкой:
потерь на клиентской стороне этот этап не заводит;
- решения и перемеренные числа мира записаны в спеку генератора, §9.
- Проверка:
- make lint && make typecheck && make test — 383 passed (было 353).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- холодное ревью отметило, что это решение, а не калибровка: от выбора
шага зависит, как «покупатель» читается в данных, и оставлять выбор
реализации нельзя.
- Что:
- в блоке решений #40 записано, что помеченный отличается на обоих шагах
воронки — и до корзины доходит чаще, и бросает её реже; числа под
каждый шаг остаются калибровкой.
- Проверка:
- чтением: docs/specs/2026-08-01-generator.md, раздел 9, блок #40.
- Зачем:
- холодное ревью нашло две дыры уровня решений: лифт конверсии не делает
покупателя видимым в данных, а цены каталога кратны рублю — от этого
урок про Float64 остаётся без материала.
- Что:
- метка покупателя получает второй рычаг: помеченные дольше живут и чаще
возвращаются; границы правки заданы вилками раздела 5, база до правок
записана числами.
- часть цен каталога получит копейки; хвост этапу 4 переписан честнее.
- дописаны нерешённые места: количество штук и повторная карточка,
множество нумерации заказов, таблица «код — скидка», состав сырого
ecommerce, резерв времени вместо зажима к полуночи.
- два расхождения внесены в мастер-спеку и записаны в раздел 7: длина
массивов purchase* и product*, механика класса amount_delta.
- Проверка:
- чтением: docs/specs/2026-08-01-generator.md, разделы 7-9;
docs/specs/2026-07-30-stand-v2-realism.md, разделы 1, 4, 7.
- Зачем:
- перед реализацией торговых событий грилинг закрыл шесть развилок; без
записи решения и отклонённые варианты потерялись бы, а часть из них
выходит за границы тикета и меняет постановку.
- Что:
- в раздел 9 спеки генератора добавлен блок «Решено при исполнении #40»:
корзина шире заказа, метка покупателя становится значимой, место
торговых событий, номер заказа, промокод, единицы денег, разная длина
массивов purchase* и product*, orjson, хвост покупки у границы суток.
- в раздел 8 добавлены хвосты этапам 3 и 4: скидка по промокоду и
намеренное расхождение сумм.
- в словарь добавлены «покупатель» и «торговое событие».
- Проверка:
- чтением: docs/specs/2026-08-01-generator.md, разделы 8 и 9; CONTEXT.md.
Зачем: план состава отдаёт дневную аудиторию, но событий у мира ещё не было.
День-функция превращает аудиторию в поток просмотров — на нём стоят лабы про
сборку визитов и про витрины, а следующий этап вешает на него торговые события.
Что:
- `day.py` — день как чистая функция зерна и номера дня: суточная волна в
местном времени посетителя, визиты по документированным правилам нарезки,
все 47 колонок выгрузки; шов для торговых событий — ряды `page` и `product`;
- `reference.py` — справочники-литералы: профили устройств, города Поволжья с
настоящими гео-id Яндекса, источники трафика, карта сайта;
- `catalog.py` и `data/catalog/products.csv` — каталог на 180 позиций, общий у
генератора и будущего словаря ClickHouse;
- `weights.py` — выбор по целым весам, один на план и на день;
- паспорт куки (устройство и город) переехал в план состава; броски приписаны
последними, поэтому измеренные числа канонического мира не сдвинулись;
- словарь: «визит» закреплён за сессией, одноимённое понятие плана стало
«днём активности»; статьи в `CONTEXT.md`;
- решения по ходу — в спеку генератора, раздел 9; наполнение
`ParsedParamsKey1` отложено тикетом #47.
Проверка: `make lint`, `make typecheck`, `make test` — 353 passed (было 297).
Счётчики плана после правки те же: приток 3827,64/день, дневная аудитория
6235–7124, 68 119 посетителей за 14 дней, 170 двухкуковых пар. День 0 —
45 810 событий за 0,6 с, снимок 14 дней — 5,9 с при пороге 30 с на день.
Две слепые линии ревью, десять находок, все закрыты и перепроверены.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Зачем:
- ревью нашло два места, где код и документы говорили неправду, и
несколько мест, где имена или комментарии вводили в заблуждение.
- Что:
- обещание докстроки `seeds.py` подкреплено тестом: адрес в дереве
даёт тот же подпоток, что цепочка `spawn`.
- в тесте гарантии пар убран сторож-тавтология, вместо него проверка,
что заказы назначены с двух разных кук.
- `Cohort.visitors_on` — «кто пришёл в день D» спрашивается у когорты,
а не собирается снаружи из четырёх её массивов.
- имена: `CLIENT_ID_LIMIT`, `_RETURN_*_CUMULATIVE`, `first_of_day`,
`WEEKLY_PROFILE_PERCENT` — профиль недели один на весь мир, по нему
же пойдёт трафик дня-функции (#39).
- спека: в дерево зёрен внесена ветвь предыстории; окно активности —
от первого визита человека, общее на обе куки (иначе загляд назад
ленивой формы удваивается); оценка накопленной аудитории больше не
спорит с измерением.
- CONTEXT.md: «подпоток» и «конфигурация мира».
- Проверка:
- make test (296), make lint, make typecheck; мутации перепроверены
после переноса среза дня в `Cohort`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Зачем:
- тикет #38: состав мира должен быть чистой функцией зерна, а счётчики
будущего манифеста — известны до генерации хоть одного события.
- Что:
- `world.py` — конфигурация мира одним модулем чистых данных: приток,
недельная волна, профиль возвратов, доли покупателей и пар, D0.
- `seeds.py` — иерархия подпотоков на `SeedSequence`: состав мира
(ось и предыстория) отдельно от дней и их компонентов.
- `plan.py` — ленивый план состава: когорта дня, аудитория дня из
окна возвратов, гарантированные заказы пар, счётчики горизонта.
Случайность — только целыми числами.
- спека, раздел 1: вторая кука пары рождается по затухающему профилю
возвратов; раздел 9: измеренные числа канонического мира, оценка
накопленной аудитории поправлена с ≈60 до 68 тыс.
- CONTEXT.md: термин «когорта дня»; README генератора — новые модули.
- Проверка:
- make test (295 тестов), make lint, make typecheck;
- тесты проверены мутациями: 12 подмен в плане и конфигурации,
каждая роняет ровно свой тест.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Зачем:
- тикет #38 требует решить и внести в спеку генератора числа притока,
D0 и формат конфигурации мира до реализации плана состава.
- Что:
- раздел 1 спеки: ленивый план по когортам дня, предыстория с полкой
от D0, гарантия двухкуковых пар назначенными заказами (единица —
человек), пять отклонённых вариантов с доводами.
- раздел 9: приток 3 800 кук/день, окно активности 90 дней (решение
владельца), доля покупателей 5% людей, D0 = 2026-06-01,
конфигурация мира — модуль чистых данных; шапка Proposed → Accepted.
- CONTEXT.md: термины «план состава», «приток», «хвост возвратов»,
«предыстория»; «состав мира» уточнён, словарь очищен от решений.
- Проверка:
- двойное слепое ревью правок (Codex + Claude), все 20 находок
закрыты; арифметика чисел пересчитана ревьюерами независимо.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Зачем:
- контракт вёл себя как хозяин чужого слоя: поле называлось dds_name, в
описании стоял столбец «Имя в DDS», а два теста прибивали имена
гвоздями. Спека же задала вид имени (snake_case), а не список: имена
атрибутов складывает модель данных DDS, и решать это не трекеру.
- Что:
- поле контракта и столбец описания стали нормализованным именем: имя
источника в нашем стиле. В описании и в докстринге сказано прямо, что
слой DDS называет атрибуты по своей модели.
- сняты оба теста на имена — копия имён DDS и конспект состава по
мастер-спеке. Они не проверяли верность имени, только неизменность, а
неизменность и так сторожит пересборка описания: молчаливой правки
контракта не бывает, она всплывает диффом документа.
- остались проверки формы: 47 колонок, уникальность, стили имён,
заполненность, согласие типов numpy и ClickHouse, порядок групп.
- спека генератора (раздел 3) и CONTEXT.md согласованы тем же
коммитом: уточнение внесено как расхождение, найденное при исполнении.
- Проверка:
- make test (248 тестов), make lint, make config-test;
- make docs, затем git diff --exit-code docs/ — пусто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- холодное ревью (Fable, свежая сессия) нашло невыполненный хвост
тикета #32 и места, где доводы резолюций сжались до непонятности.
- Что:
- мастер-спека 1.1: «склад» заменён на «хранилище» (хвост #32);
CONTEXT.md: DWH в избегаемых, отдельная статья «Пакетный режим».
- спека: восстановлены доводы «на маке и в WSL тоже» и «менти упрётся
в красный чек манифеста»; обещания про diff привязаны к манифесту;
темп ×60 и расчёт порога согласованы с числами разделов; заголовок
притока честен про затухание; выход за мандат оговорён в «Зачем».
- раздел 9: добавлены числа притока и календарная дата-константа D0;
заметка исследования: у Faker единицы «значений/с», не «строк/с».
- Проверка:
- вычитка; решения развилок не пересматриваются, правки текстовые.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- вычитка спеки на приёмке нашла дыру: состав мира читался как
замкнутая труппа без новых посетителей — неправдоподобный магазин.
- Что:
- раздел 1: состав не замкнут — план задаёт календарь появления кук,
доля одноразовых высока; приток — часть плана, не мутация.
- раздел 6: «Faker» ослаблен до «посточные фейкеры (Faker, mimesis)»,
выбор библиотеки — этапу 2; там же оговорка про таблицы-литералы.
- раздел 9: к интерфейсу запуска добавлены вопросы «кто зовёт
генератор (даги world_init/next_day)» и «в каком контейнере живёт».
- CONTEXT.md: в «Состав мира» добавлен календарь появления.
- Проверка:
- вычитка; правки точечные, решения развилок не пересматриваются.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Зачем:
- этап 2 нельзя нарезать на тикеты без единой картины генератора,
а решения четырёх развилок карты #26 жили только в тикетах трекера.
- Что:
- новая спека docs/specs/2026-08-01-generator.md: функциональный мир,
детерминизм до байта (SeedSequence сверен через Context7), контракт
схемы, канонический сериализатор, числа и порог производительности;
отклонённые варианты записаны с доводами.
- мастер-спека согласована тем же коммитом: 1.4 — data contract вместо
автогенерации DDL, 8 — в git только манифест, 11 — числа вместо
«зафиксировать требования»; мелкие согласования в 7 и 9.
- CONTEXT.md пополнен терминами модели мира и вывода генератора.
- Проверка:
- вычитка; относительные ссылки спек указывают на существующие файлы
в docs/specs/ и docs/research/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зачем
Стенд упирался в память ноды ClickHouse: пробник валился на CREATE TABLE
ON CLUSTER, вместе с ним краснели make smoke и make smoke-guards. Причина не
та, что предполагал #21: дело не в заводских кэшах, а в коробке на гигабайт.
Около 550 МиБ RSS праздной ноды — страницы её собственного бинарника, и на
работу оставалось около 350 МиБ, которые пробник добирал за сессию.
Заодно выяснилось, откуда взялся предел 3,4 ГБ. Это была оценка расхода из
спеки, посчитанная по стенду-предшественнику до первой сборки v2 и превращённая
в жёсткий порог проверки. Порог стал критерием приёмки каждого этапа и дальше
блокировал бы любой рост стенда на этапах 2-9.
Что
- ADR 0004: бюджета памяти у стенда нет, есть требование к машине — около 8 ГБ,
доступных Docker. Ресурсный довод ADR 0001 отозван, сами решения в силе.
- Нодам ClickHouse 4 ГиБ вместо гигабайта. Остальные лимиты не тронуты: ни один
из них ни разу не сработал, а снять их скопом — то же изменение без
свидетельств, каким они были выставлены.
- Из make smoke убрана проверка суммарного потребления. Она мерила docker stats
вместе со страничным кэшем, то есть отвечала на вопрос «сколько файлов стенд
потрогал», и с появлением настоящих данных краснела бы на здоровом стенде.
Вместе с ней убрана привязанная к её сообщению проверка docs-guards.
- Взамен smoke спрашивает у Docker, не убивало ли ядро долгоживущий контейнер
за память и не включалась ли политика перезапуска. Порога у проверки нет:
убитый контейнер Docker поднимает сам, и без этого вопроса стенд отрапортует
«всё хорошо» о ноде, которая умирала.
- README и раздел «Ресурсный бюджет» спеки переписаны с предела на требование
к машине; README объясняет менти, что такое «память, доступная Docker».
Проверка
make config-test; make up; make smoke — 25 из 25; make smoke-cluster — 8 из 8;
make smoke-guards — 3 из 3, включая шаг «после восстановления стенд проходит
make smoke», который падал 31 июля.
На живом стенде с новой коробкой: max_server_memory_usage = 3,60 ГиБ, в журнале
ноды «Lowered mark cache size to 2.00 GiB because the system has limited RAM».
Семантика счётчиков Docker снята отдельными контейнерами: ручной restart
оставляет RestartCount = 0, убийство за память даёт OOMKilled = true и растущий
счётчик, убийство не за память OOMKilled не поднимает.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зачем: строчка спеки описывала только цели Prometheus, и этап 9 по ней
честно сделал бы девопсовый минимум. Разбор мониторинга предшественника
показал, что там из 28 панелей на вопросы дата-инженера отвечают шесть, а
свежесть, доля брака и сходимость Kafka с ClickHouse не измеряются вовсе.
Состав панелей решается до этапа 9: урок можно рассказать только про то,
что дашборд показывает.
Что: добавлен ADR 0002 — дашборды «данные», «кластер» и «запросы»;
ClickHouse подключается в Grafana источником данных, панели пишутся на SQL;
Prometheus сжимается до тонкого пола, сборщик метрик Airflow через StatsD
не берётся. Инфраструктурные панели сохранены отдельным дашбордом:
«слишком много частей» — ошибка дата-инженера, а видна она именно там.
Плагин источника данных ставится сборкой своего образа Grafana, а не при
старте контейнера: иначе стенд начинает зависеть от сети. Строка объёма в
спеке и пункт этапа 9 указывают на ADR.
Проверка: make config-test — зелено. Отсутствие источника данных ClickHouse
в образе Grafana подтверждено запросом к живому стенду.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Зачем: при нарезке этапов выяснилось, что образцы DAG'ов из v1 написаны под
Airflow 2 и переносятся не буквально — в третьей версии Datasets
переименованы в Assets. Без явной записи в спеке версия всплыла бы уже при
написании DAG'ов этапа 5.
Что: в раздел «Проверить при исполнении» добавлен пункт про Airflow 3.x —
версия фиксируется на этапе 1 (каркас стенда), DAG'и этапа 5 пишутся под API
третьей версии, операторы и сенсоры сверяются через Context7.
Проверка: правка текстовая, только спека; тот же порядок продублирован в
тикетах этапов 1 и 5 в трекере.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Зачем:
- спека «Боевой реализм стенда» исполняется в новом репозитории:
предшественник замораживается как стабильный учебный стенд,
v2 стартует пустым и переносит только нужное
- Что:
- README: что это, статус «строится по спеке», ссылки на спеку и на
репозиторий-предшественник
- AGENTS.md написан заново, а не скопирован: язык, uv, обязательная
проверка API через MCP Context7, контракт трекера Gitea (спека —
источник истины, корневой issue тонкий), метки триажа, новые доки в docs/
- .gitignore: Python и uv, .env, секреты, IDE, логи
- docs/specs/2026-07-30-stand-v2-realism.md перенесена из v1; содержание
не менялось, поправлены только ссылки: добавлена строка о переезде,
ссылка на generator-realism.md переведена на абсолютный URL v1
- docs/research/2026-07-26-yandex-clickstream-format.md перенесено:
источник истины по формату широкого события
- лицензии у предшественника нет, переносить нечего
- Проверка:
- git show --stat: 5 файлов
- относительные ссылки спеки ведут на существующие файлы репозитория