diff --git a/CONTEXT.md b/CONTEXT.md index b7135b1..92509db 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,8 +118,8 @@ ClickHouse. Форма файла решена, длина — нет: стро **Стартовый мир**: Первые восемь дней оси (понедельник по понедельник), которыми стенд -наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда -один и тот же: на нём принимаются следующие этапы. +наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же: +на нем принимаются следующие этапы. _Избегать_: зерновой мир **Опись мира**: diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index 9030594..8b42ca3 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -36,7 +36,7 @@ Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». `make smoke` и `make check-services` перемерены 28 августа 2026 года, `make config-test` — 21 августа, `make check-clickhouse` — 19 августа, -корневой `make lint` — 9 августа, `make test` — 7 августа; остальные стоят с +корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с замера 6 августа. | Цель | Откуда | Что утверждает | Стенд | Цена | @@ -48,7 +48,7 @@ | `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с | | `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | | `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с | -| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с | +| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с | **Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за разными дверями охватывают разное: корневая не видит ни одного файла @@ -121,9 +121,9 @@ ClickHouse отвечает сразу. из каталога `generator/`: стенд им не нужен, а `make test` из них самая дорогая. Правки дагов и Superset — корневой `make lint`. -Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней -для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли -получается мир, его надо получить. Место выбрано по той же оси «кого +Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней +для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы +узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и краснеет проверка там, где надо: сразу после правки генератора. Не будь её, расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит @@ -238,6 +238,27 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`. ## Что проверено +**Перезамер 30 августа 2026 года при исполнении #192.** Команды запускались +из `generator/`: + +```bash +time make inventory +uv run python -m clickstream_generator batch --day 0 --days 8 \ + --file /tmp/stage7-events.jsonl +for snapshot_day in 1 2 3 4 5 6 7; do + uv run python -m clickstream_generator snapshot --day "$snapshot_day" \ + --file "/tmp/stage7-snapshot-${snapshot_day}.jsonl" +done +wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl +time make test +``` + +Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт: +458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 +за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов +стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней +стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня. + **Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал 28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел 30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index c2015cf..2939f44 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -532,10 +532,11 @@ Kafka день переигрывается генератором заново: ## 8. Эталонный мир и опись -В git хранится только опись эталонного мира; сам снимок (14 модельных -дней) генерируется на месте — при `make up` и при проверках (решение -развилки «Производительность» карты #26, подробности — -[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт +В git хранится только опись эталонного мира; сам снимок генерируется на +месте — при `make up` и при проверках (решение развилки «Производительность» +карты #26, подробности — [спека генератора](2026-08-01-generator.md), +раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней +снято на этапе 7. Опись несёт паспорт мира (каноническое зерно, версия генератора), контрольные счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман @@ -555,8 +556,8 @@ Kafka день переигрывается генератором заново: названного читателя в опись не кладётся ([что хранит опись](../architecture/orders/inventory.md)). -Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить -при пересборке. +Размер снимка замерен на этапе 7 — запись в разделе «Что проверено» +[карты проверок](../architecture/testing.md). ## 9. Оценка объёма исполнения diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index a66c10a..23d7ea2 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -65,7 +65,8 @@ приём на стороне хранилища. - **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом. -- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней; +- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со + стартовым миром — 8 дней; в git — только опись мира; автоматических порогов по времени нет. ## 1. Модель мира @@ -358,10 +359,17 @@ - **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до ~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей — правдоподобный средний магазин. -- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 — - понедельник. Самая короткая длина, при которой есть замороженная зона за - окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до - месяца — дешёвый ход (пересборка описи), если понадобится. +- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник + по понедельник, полная неделя и один закрытый день за окном K = 7. + Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30): + у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи, + транспорт байты с описью не сверяет, а проверке «пересгенерируй день N — + сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие + мир на окне в две недели, не меняются: мир — функция без края, окну + замера опись не нужна. Удлинение описи остается дешевым ходом + (пересборка) и делается, когда читатель появится — например, если + растяжке `classes_vs_inventory` понадобится сверять больше одного + закрытого дня. - **В git — только опись, снимок не хранится.** Снимок генерируется при создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки @@ -389,7 +397,7 @@ | Операция | Ориентир | |---|---| | Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) | -| Пересборка эталонного мира (14 дней + опись, без транспорта) | до минуты | +| Пересборка эталонного мира (8 дней + опись, без транспорта) | 20,5 с (замер 30 августа 2026 года) | | Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) | | Лаг живого дня | секунды | @@ -925,11 +933,13 @@ ### Опись мира и словарь -- **Опись мира одна и растет до эталонной** (#42, 2026-08-07; решение владельца - при нарезке, 2026-08-01). Стартовый мир этапа 2 — первые восемь дней, +- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7, + 2026-08-30). Стартовый мир этапа 2 — первые восемь дней, понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7. - `data/world-inventory.json` — та самая опись, которую обещает раздел 5, пока - короткая; этап 7 продлит ее до 14 дней, а не заведет вторую. В JSON лежат + `data/world-inventory.json` — та самая опись, которую обещает раздел 5. + Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя + (резолюция — в разделе 5); вырастет она продолжением этого же файла, а не + вторым. В JSON лежат паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с датой, числом событий и хешем. Опись собирает `make inventory`, свежесть сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки. diff --git a/generator/src/clickstream_generator/inventory.py b/generator/src/clickstream_generator/inventory.py index 7378e3a..bf3294c 100644 --- a/generator/src/clickstream_generator/inventory.py +++ b/generator/src/clickstream_generator/inventory.py @@ -8,8 +8,9 @@ Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7. -Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением -этой же описи, а не вторым файлом. +Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 — +у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот +же файл, а не заведет второй. **Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир diff --git a/generator/tests/test_commerce.py b/generator/tests/test_commerce.py index 80637f8..483b051 100644 --- a/generator/tests/test_commerce.py +++ b/generator/tests/test_commerce.py @@ -21,7 +21,7 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component WEEKDAY = 2 WEEK = 7 -# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком — +# Окно замера — две недели: доли по уровням спроса меряются на нем целиком — # на одном дне у самого редкого уровня набирается слишком мало карточек. FORTNIGHT = 14 diff --git a/generator/tests/test_inventory.py b/generator/tests/test_inventory.py index 174d225..809e65f 100644 --- a/generator/tests/test_inventory.py +++ b/generator/tests/test_inventory.py @@ -27,6 +27,9 @@ INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory # отправляемых; этот дешевле прочих — его окно короче. RUN_DAY = 2 +# День любой: хеш дня не зависит ни от соседей, ни от края описи. +REPLAY_DAY = 4 + @pytest.fixture(scope="module") def inventory() -> dict: @@ -80,3 +83,17 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1) assert row["sha256"] == sent + + +def test_a_replayed_day_matches_its_inventory_hash(tmp_path): + """Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же: + пересчет сравнивался бы сам с собой. + """ + stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) + path = tmp_path / "day.jsonl" + + assert cli.main(["batch", "--day", str(REPLAY_DAY), "--file", str(path)]) == 0 + + sent = hashlib.sha256(path.read_bytes()).hexdigest() + row = next(row for row in stored["days"] if row["day"] == REPLAY_DAY) + assert row["sha256"] == sent diff --git a/generator/tests/test_plan.py b/generator/tests/test_plan.py index 7667ee0..2ebee26 100644 --- a/generator/tests/test_plan.py +++ b/generator/tests/test_plan.py @@ -14,8 +14,8 @@ import pytest from clickstream_generator import plan, reference, world from clickstream_generator.seeds import CANONICAL_SEED -# Горизонт эталонного снимка — две недели (спека, раздел 5). -SNAPSHOT_DAYS = 14 +# Окно замера плана — две недели: два цикла недельной сезонности. +WINDOW_DAYS = 14 @pytest.fixture(autouse=True) @@ -54,9 +54,9 @@ def test_the_plan_is_a_pure_function_of_the_seed(): def test_counters_are_a_pure_function_of_the_seed(): - first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + first = plan.counters(CANONICAL_SEED, WINDOW_DAYS) plan.cohort.cache_clear() - assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + assert first == plan.counters(CANONICAL_SEED, WINDOW_DAYS) def test_a_remembered_cohort_cannot_be_spoiled_from_outside(): @@ -216,12 +216,12 @@ def test_the_passport_points_into_the_directories(): def test_the_daily_audience_matches_the_spec_band(): """6–8 тыс. посетителей в день — правдоподобный средний магазин.""" - counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS) assert all(6_000 <= size <= 8_000 for size in counters.audience) def test_the_influx_holds_its_daily_number(): - counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS) average = sum(counters.new_cookies) / len(counters.new_cookies) assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10 @@ -232,7 +232,7 @@ def test_the_influx_breathes_with_the_week(): def mean(numbers: tuple[int, ...] | list[int]) -> float: return sum(numbers) / len(numbers) - counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS) weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5] weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5] expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean( @@ -250,7 +250,7 @@ def test_the_influx_differs_even_on_the_same_weekday(): def test_the_influx_is_visible_on_the_plan(): """Накопленная аудитория растёт с горизонтом и с дневной не сходится.""" week = plan.counters(CANONICAL_SEED, 7) - fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS) assert week.visitors < fortnight.visitors assert fortnight.visitors > 2 * max(fortnight.audience) @@ -258,21 +258,21 @@ def test_the_influx_is_visible_on_the_plan(): def test_the_horizon_is_a_prefix_not_another_world(): """Продление истории днём N+1 не трогает дни 1…N.""" week = plan.counters(CANONICAL_SEED, 7) - fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS) assert fortnight.audience[:7] == week.audience assert fortnight.new_cookies[:7] == week.new_cookies def test_counters_count_the_same_audience_that_the_day_gets(): """Счётчики и дня-функция спрашивают один план — расходиться им негде.""" - counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) - for day in (0, 7, SNAPSHOT_DAYS - 1): + counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS) + for day in (0, 7, WINDOW_DAYS - 1): actual = plan.audience(CANONICAL_SEED, day).client_id.size assert actual == counters.audience[day] def test_counters_know_the_pairs_before_a_single_event(): - counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) + counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS) assert counters.pairs > 0