Author SHA1 Message Date
ddadminandClaude Fable 5 453950a639 docs(specs): итог тикета #203 внесен в раздел 9 спеки программы лаб
- Зачем:
  - раздел 9 отсылал к открытому тикету #203; решение принято, указатель
    должен вести на итог, а не на вопрос.
- Что:
  - фраза «докуда ведет карта #182» заменена итогом: карта закрыта приемкой
    спеки, исполнение ведет карта #206.
- Проверка:
  - вычитка раздела 9 файла docs/specs/2026-09-01-labs-program.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 09:32:27 -04:00
ddmitry c26b4530f3 Merge pull request 'docs(specs): спека программы учебных лаб' (#205) from 202-labs-program-spec into main
Reviewed-on: #205
2026-09-01 16:13:49 +03:00
ddadmin eed5b4b4e6 docs(specs): словарь программы лаб очеловечен по ревью владельца
- Зачем:
  - термины спеки перейдут в задания, а «хребет», «ветки» и «точка
    прицепа» с калькированными заголовками сюжетов смущали бы студентов.
- Что:
  - хребет → основная программа, шаги → ступени, ветки → факультативы,
    «точка прицепа» заменена формулой «открывается после ступени N»;
  - заголовки разделов и сюжетов переписаны из позиции студента
    («Почему выручка не сходится», «Куда попадают битые строки»);
  - переносы выровнены по ~80 колонок по всему файлу.
- Проверка:
  - текст без учета пробелов не изменился на шаге переносов (SHA-256);
    решения, состав и порядок программы не менялись.
2026-09-01 09:07:40 -04:00
ddadmin 4fd872f296 docs(specs): спека программы учебных лаб — хребет и ветки
- Зачем:
  - тикет #202 карты #182: собрать из инвентаризации #201 программу лаб
    целиком — состав, порядок, формы и судьбу идей-кандидатов.
- Что:
  - создан docs/specs/2026-09-01-labs-program.md: хребет из семи
    обязательных шагов и пять тематических разделов необязательных лаб;
  - узаконены границы вычитания по потокам и поименные резолюции идей
    #143, #145, #148, #149, #186, #187, #188;
  - записаны отклоненные варианты оси прогрессии и границы спеки (#203).
- Проверка:
  - вычитка владельцем (спека принята), слепое ревью фактов против
    инвентаризации и доков — все находки закрыты.
2026-09-01 08:45:43 -04:00
ddmitry 22bf09af9d Merge pull request 'docs(research): инвентаризация сюжетов лаб по слоям стенда' (#204) from research/labs-inventory into main 2026-08-31 18:39:20 +03:00
ddadminandClaude Fable 5 502d705ca1 docs(research): инвентаризация сюжетов лаб по слоям стенда
- Зачем:
  - спеке программы лаб (#202) нужен полный веер сюжетов вместо семи
    случайно накопленных идей; резолюция тикета #201 карты #182.
- Что:
  - заметка docs/research/2026-08-31-labs-inventory.md: 32 сюжета по
    слоям STG, ODS, DDS, DM, BI и поперек слоев, три формы заданий,
    разбор гипотезы «вычитание + DQ» с таблицей по потокам.
- Проверка:
  - фактура (пути, имена таблиц, номера тикетов) сверена с репозиторием
    при вычитке; кода изменение не касается.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:38:54 -04:00
ddmitry d22156a7da Merge pull request 'docs(labs): ТЗ необязательной лабы «свой дашборд в Superset»' (#199) from 197-superset-lab into main
Reviewed-on: #199
2026-08-31 11:07:53 +03:00
ddadminandClaude Fable 5 6174536cc1 docs(specs): BI-лаба узаконена в исключении раздела 10
Зачем: раздел 10 выносил все лабы v2 в отдельную работу учебного пути,
делая исключение только для домашек DM, — необязательная лаба Superset
из #197 формально в него не попадала.

Что: исключение расширено фразой про лабу «свой дашборд в Superset» с
меркой владельца: домашки DM и эта лаба продуманы до готовых ТЗ,
остальные лабы пока на уровне идей.

Проверка: вычитка; решение владельца репозитория из обсуждения #197.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:05:43 -04:00
ddadminandClaude Fable 5 989ff71495 docs(labs): ТЗ необязательной лабы «свой дашборд в Superset»
Зачем: владеть хотя бы одним BI-инструментом на базовом уровне — полезный
навык дата-инженера, но стенд в первую очередь про ClickHouse, поэтому
лаба необязательная. Обещание «отдельная домашка после этапа 8» в доке DM
висело без адреса.

Что: ТЗ docs/labs/superset-dashboard.md — пять датасетов поверх публичных
представлений заданий 1-5, свой дашборд отдельно от стендового, проверка
глазами без homework_check; шестая строка и разведение «пяти заданий по
витринам» в docs/labs/README.md; абзац о сроке жизни объектов менти в
docs/architecture/superset.md; ссылка на лабу из доки DM; правило слага в
AGENTS.md расширено до «предмет задания».

Проверка: кода и DDL нет; слепое ревью текста (9 находок закрыто, 2
дополнительные с перепроверки) и финальная вычитка.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 03:55:40 -04:00
ddmitry cd82911906 Merge pull request 'feat(superset): датасеты и дашборд «Бизнес-сюжеты» (этап 8)' (#198) from 196-superset-dashboard into main
Reviewed-on: #198
2026-08-31 10:26:41 +03:00
ddadminandClaude Fable 5 3ffce6387e feat(superset): датасеты и дашборд «Бизнес-сюжеты» деревом импорта
- Зачем:
  - этап 8 (#196): три сюжета спеки — выручка, сверка, посетители /
    известные — должны приезжать в Superset при каждой инициализации
    стенда, без ручной сборки в интерфейсе.
- Что:
  - дерево infra/superset/import/ дополнено датасетами трех витрин dm,
    тремя графиками и дашбордом «Бизнес-сюжеты»; uuid розданы рукой,
    русские строки читаемы, metadata.yaml сокращен до версии формата.
  - написан справочник docs/architecture/superset.md: устройство зоны,
    дерево импорта, маршрут свежего экспорта, ловушки импорта.
  - в docs/architecture/dm.md добавлена ссылка на справочник зоны.
- Проверка:
  - docker compose run --rm superset-init — импорт начисто, затем три
    графика отдают данные через API (54/48/10 строк).
  - прогон рецепта «чистая установка» из справочника: сброс базы superset
    в postgres-metadata, импорт, активы на месте.
  - make lint и make check-services зеленые (7/7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:42:01 -04:00
ddmitry c999153acb Merge pull request 'feat(generator): откалиброваны доли заказной стороны' (#195) from 194-calibration into main
Reviewed-on: #195
2026-08-30 23:49:13 +03:00
ddmitry c3a105a0f8 Merge pull request 'feat(generator): эталонный мир зафиксирован равным стартовому' (#193) from 192-stage7-tail into main
Reviewed-on: #193
2026-08-30 23:48:10 +03:00
ddadminandClaude Fable 5 9b1826f5ff feat(generator): откалиброваны доли заказной стороны
- Зачем:
  - семь мест обещали калибровку черновых долей «при пересборке
    эталонного мира (этап 7)», а пересборки не будет (#192); числа
    сверены замером и зафиксированы (#194, хвост этапа 7 — #9).
- Что:
  - замер на каноническом мире (28 дней, без транспорта): все доли в
    пределах ориентиров; пометки «черновое» сняты, наблюдаемые доли
    вписаны в комментарии world.py, fate.md и запись «Что проверено»;
  - потеря purchase: наблюдаемых 1,78% при броске 3% — вычеты штатные
    (пары склейки, приоритет отмены), принято решением владельца;
    ориентир класса B в мастер-спеке различает бросок и наблюдаемую долю;
  - веса мира и опись не тронуты: правки world.py — только комментарии.
- Проверка:
  - cd generator && make lint && make typecheck && make test (437 passed);
  - git diff по data/world-inventory.json пуст; grep «черновое|калибровка»
    по generator/src и docs/architecture/orders пуст.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:42:32 -04:00
ddadminandClaude Fable 5 8b7987c5c2 docs(specs): снято обещание CI-генерации на amd64 и arm64
- Зачем:
  - CI дублировал бы сторожем то, что уже гарантировано устройством:
    дисциплина целочисленной случайности снимает межархитектурные
    расхождения (решение владельца 2026-08-30, хвост этапа 7 — #9).
- Что:
  - резолюция вписана в раздел 5 спеки генератора;
  - строки этапа 7 в обеих спеках приведены к резолюции.
- Проверка:
  - grep "CI гоняет" docs/specs/ пуст; чтение раздела 5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:11:34 -04:00
ddadminandClaude Fable 5 c1b61d81b9 feat(generator): эталонный мир зафиксирован равным стартовому
- Зачем:
  - у дней 8-13 описи не нашлось читателя: обещание эталона в 14 дней
    снято решением владельца 2026-08-30 (#192, этап 7 — #9).
- Что:
  - резолюция перенесена в обе спеки, докстринг описи и словарь CONTEXT.md;
  - добавлен тест: проигранный через CLI день совпадает с хешем описи;
  - сняты замеры: пересборка описи 20,5 с, снимок 460,7 МБ, make test
    176,5 с — записаны в карту проверок и таблицу бюджетов.
- Проверка:
  - cd generator && make lint && make typecheck && make test (437 passed);
  - git diff по data/world-inventory.json пуст.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:53:52 -04:00
ddmitry f93c924b33 Merge pull request 'feat(generator): добавлено опоздание заказов в слепках' (#191) from 185-late-orders into main
Reviewed-on: #191
2026-08-29 10:06:31 +03:00
ddadmin c68a451698 docs(orders): восстановлено описание окна опоздания
- Зачем:
  - при перевёрстке абзаца пропало правило отсутствия заказа до дня приезда.
- Что:
  - восстановлен диапазон скрытых слепков d…d+δ−1.
  - исправлена пунктуация и прояснено обнуление задержки назначенного заказа.
- Проверка:
  - git diff --check.
  - make config-test.
2026-08-29 02:03:24 -04:00
ddadmin 8db1c58c12 feat(generator): добавлено опоздание заказов в слепках
- Зачем:
  - менти должен увидеть, как запоздавшая выгрузка меняет открытое окно, не подделывая аудит источника.
- Что:
  - заказам назначена задержка D, D+1 или D+2 последним броском подпотока ORDERS.
  - назначенный планом заказ приезжает сразу, а Woven передаёт эту гарантию именованным полем.
  - обновлены документы, проверки границ механики и эталонные хеши слепков.
- Проверка:
  - make lint, make typecheck и make test в generator: 436 тестов прошли.
  - make config-test: конфигурация прошла проверку.
  - world_live_day и следующий etl_pipeline перевели заказ 20260608-0001 из awaiting_order в match.
2026-08-28 17:30:55 -04:00
ddmitry 58e944aa87 Merge pull request 'feat(generator): потеря и дубль purchase (B, D) и счетчики описи' (#189) from 184-beacons into main
Reviewed-on: #189
2026-08-28 21:58:23 +03:00
ddadminandClaude Fable 5 c28f724706 feat(generator): потеря и дубль purchase (B, D) и счетчики описи
- Зачем:
  - трекер стенда был полным, и правило «деньги — по бэкенду» держалось
    на слове; классы B и D делают недосчет и пересчет трекера
    наблюдаемыми (этап 6, #184).
- Что:
  - подпоток BEACONS: точечная потеря purchase (~3%) и сюжетный дубль
    (~2%) швом после _purchases, с гарантией моста идентичностей и
    условием допуска дубля по паузе до следующего визита;
  - опись считает пять классов по приоритету спеки; фильтр
    classes_vs_inventory расширен, нулевой класс читается нулем;
  - fate.md, seeds.md, dm.md и orders/README.md приведены к имени
    BEACONS и строгому правилу допуска.
- Проверка:
  - make lint, make typecheck, make test (433 passed) и make inventory
    из generator/ — хеши слепков не изменились;
  - на стенде: make smoke, make check-clickhouse, make check-services
    зеленые; в dq_summary classes_vs_inventory и sessions_vs_reference
    pass, витрина сверки показывает lost_event и duplicate_event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 13:13:47 -04:00
ddmitry c49f39f01f Merge pull request 'docs(generator): заморозка спеки этапа 2 и ADR 0015-0017' (#181) from spec-freeze into main
Reviewed-on: #181
2026-08-28 16:27:37 +03:00
ddadmin dcf8db1305 Merge remote-tracking branch 'origin/main' into spec-freeze 2026-08-28 08:16:11 -04:00
ddadminandClaude Fable 5 6f18e9989c docs(generator): заморожена спека этапа 2, решения — в ADR 0015-0017
- Зачем:
  - спека разрослась до 1058 строк, а журнал решений в разделе 9 читался
    только целиком; агентам и людям нужен дешевый адресный заход к
    основаниям решений генератора.
- Что:
  - заведены ADR 0015 (пояс счетчика), 0016 (паспорт куки) и 0017 (номер
    заказа); четыре адресные ссылки на «раздел 9» перешиты на них.
  - раздел 9 пересобран в 11 тематических подразделов с датами и тикетами
    у каждого решения и закрыт пометкой о заморозке; устаревшие
    промежуточные замеры свернуты до указателей на актуальные.
  - строка статуса в шапке отмечает закрытие спеки как документа этапа 2.
- Проверка:
  - wc -l docs/specs/2026-08-01-generator.md (951, было 1058);
    rg -n 'раздел[а-яё]* 9|разделы 8 и 9' docs -g '*.md' — 18 попаданий,
    все классифицированы; разделы 1-8 нетронуты (сверка хешем).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 08:16:11 -04:00
48 changed files with 2724 additions and 762 deletions

No files matched your search

+1 -1
View File
@@ -160,7 +160,7 @@ Airflow) и названия из кода. Если для понятия ес
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис - Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных (`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ, заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
и правится он вместе с тем, о чем говорит. Слаг называет витрину задания. и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх - Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
в имени не нужна. в имени не нужна.
+2 -2
View File
@@ -118,8 +118,8 @@ ClickHouse. Форма файла решена, длина — нет: стро
**Стартовый мир**: **Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
один и тот же: на нём принимаются следующие этапы. на нем принимаются следующие этапы.
_Избегать_: зерновой мир _Избегать_: зерновой мир
**Опись мира**: **Опись мира**:
+11 -5
View File
@@ -151,11 +151,17 @@ def build_classes_vs_inventory() -> list[str]:
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней") raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8") query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
actual = { # GROUP BY закономерно не возвращает группу из нуля строк.
(data_date, mismatch_class): count actual = {key: 0 for key, count in expected.items() if count == 0}
for data_date, mismatch_class, count in _clickhouse_hook().get_records(query) actual.update(
if (data_date, mismatch_class) in expected {
} (data_date, mismatch_class): count
for data_date, mismatch_class, count in _clickhouse_hook().get_records(
query
)
if (data_date, mismatch_class) in expected
}
)
summaries = [] summaries = []
for data_date in sorted({day for day, _ in expected}): for data_date in sorted({day for day, _ in expected}):
details = [] details = []
+25 -23
View File
@@ -6,92 +6,94 @@
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"events": 50626, "events": 50628,
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407", "sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
"orders": { "orders": {
"match": 197, "match": 192,
"cancelled": 20, "cancelled": 20,
"lost_event": 3,
"duplicate_event": 2,
"amount_delta": 6 "amount_delta": 6
} }
}, },
{ {
"day": 1, "day": 1,
"date": "2026-06-02", "date": "2026-06-02",
"events": 49722, "events": 49727,
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff" "sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
}, },
{ {
"day": 2, "day": 2,
"date": "2026-06-03", "date": "2026-06-03",
"events": 52361, "events": 52363,
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070" "sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
}, },
{ {
"day": 3, "day": 3,
"date": "2026-06-04", "date": "2026-06-04",
"events": 53157, "events": 53159,
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3" "sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
}, },
{ {
"day": 4, "day": 4,
"date": "2026-06-05", "date": "2026-06-05",
"events": 50078, "events": 50087,
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29" "sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
}, },
{ {
"day": 5, "day": 5,
"date": "2026-06-06", "date": "2026-06-06",
"events": 47164, "events": 47169,
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c" "sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
}, },
{ {
"day": 6, "day": 6,
"date": "2026-06-07", "date": "2026-06-07",
"events": 48683, "events": 48686,
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7" "sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
}, },
{ {
"day": 7, "day": 7,
"date": "2026-06-08", "date": "2026-06-08",
"events": 49394, "events": 49394,
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7" "sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
} }
], ],
"snapshots": [ "snapshots": [
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17" "sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
}, },
{ {
"day": 1, "day": 1,
"date": "2026-06-02", "date": "2026-06-02",
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43" "sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
}, },
{ {
"day": 2, "day": 2,
"date": "2026-06-03", "date": "2026-06-03",
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172" "sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
}, },
{ {
"day": 3, "day": 3,
"date": "2026-06-04", "date": "2026-06-04",
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee" "sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
}, },
{ {
"day": 4, "day": 4,
"date": "2026-06-05", "date": "2026-06-05",
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00" "sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
}, },
{ {
"day": 5, "day": 5,
"date": "2026-06-06", "date": "2026-06-06",
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5" "sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
}, },
{ {
"day": 6, "day": 6,
"date": "2026-06-07", "date": "2026-06-07",
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221" "sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
} }
] ]
} }
+36
View File
@@ -0,0 +1,36 @@
# ADR 0015. Модельный день считается в часовом поясе счетчика
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает
дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница
модельного дня приходится на полночь счетчика.
Суточная волна посетителя привязана к его местному времени. У гостя из другого
пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается
прежней.
## Почему
Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика.
Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно
выбирать независимо от технического пояса счетчика.
Для всего потока дня нужна одна граница суток. Местное время гостя описывает
поведение в пределах этих суток, а отдельная дата для каждого посетителя не
нужна.
## Отклонено
В разделе 9 отклоненные варианты не зафиксированы.
## Следствия
У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из
готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной
волны слегка размывает общий пик активности гостей из разных поясов.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+28
View File
@@ -0,0 +1,28 @@
# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Браузер, операционная система, устройство, экран и город принадлежат куке на всю
ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в
поля событий.
Две куки одного человека получают один город и разные устройства. Броски
паспорта идут после прежних бросков состава, поэтому прежние измерения не
сдвинулись.
## Почему
Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться
между днями. Только план состава связывает две куки с одним человеком. Поэтому
он может задать им один город и разные устройства.
## Отклонено
- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет
сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного
человека один город и разные устройства.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+34
View File
@@ -0,0 +1,34 @@
# ADR 0017. Номер заказа объединяет две проекции покупки
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40.
## Решение
Номер заказа включает день по модельному времени и порядковый номер покупки в
этот день, например `20260603-0042`. Событийная сторона пишет его в
`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана
покупки.
Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых
потерь следующих этапов.
## Почему
По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции
одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно.
Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного
события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом.
## Отклонено
- Присваивать номера после внесения потерь: одна потеря сдвинула бы все
следующие номера за день и разорвала бы связь уже созданных проекций.
## Следствия
`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может
исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются.
Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2,
раздел 9.
+8 -6
View File
@@ -46,8 +46,9 @@
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md). пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
от выполненных домашек. Расширение дашборда визуализациями по своим от выполненных домашек. Свой дашборд по витринам менти —
витринам — отдельная домашка после этапа 8. [необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
стендового дашборда — в [доке зоны Superset](superset.md).
## Ритм пересборки ## Ритм пересборки
@@ -111,7 +112,7 @@
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную `purchase_id[1]`: контракт источника обещает один номер и одну объявленную
сумму, а отдельный счет событий оставляет будущий класс `duplicate_event` сумму, а отдельный счет событий оставляет класс `duplicate_event`
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
@@ -176,8 +177,8 @@ Distributed-таблица вернула бы каждый непарный з
`dds.session_v`; `dds.session_v`;
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из - `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
`orders` в описи мира, объект — число заказов класса в `orders` в описи мира, объект — число заказов класса в
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled` и `dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
`amount_delta` только для покрытых описью закрытых дней; `duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются - `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`; `orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые - `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
@@ -214,7 +215,8 @@ business_key`.
## Даг самопроверки ## Даг самопроверки
Домашки контролирует `homework_check` — отдельный даг без расписания: менти Домашки контролирует `homework_check` — отдельный даг без расписания: менти
запускает его рукой. По задаче на каждое из пяти [заданий](../labs/README.md). запускает его рукой. По задаче на каждое из
[пяти заданий по витринам](../labs/README.md).
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины, построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
+4
View File
@@ -50,6 +50,10 @@ make demo
Устройство генератора и основания решений записаны в [спеке этапа Устройство генератора и основания решений записаны в [спеке этапа
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки — 2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md). в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md).
Основания трех решений записаны в
[ADR 0015](../../adr/0015-counter-timezone.md),
[ADR 0016](../../adr/0016-cookie-passport.md) и
[ADR 0017](../../adr/0017-order-numbering.md), а спека этапа 2 закрыта.
Путеводитель появился по тикету Путеводитель появился по тикету
[#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48): [#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
он отвечает на другой вопрос — как пройти принятое устройство по коду. он отвечает на другой вопрос — как пройти принятое устройство по коду.
@@ -23,7 +23,7 @@
сохраняет его на всю жизнь. сохраняет его на всю жизнь.
Броски паспорта стоят после прежнего состава когорты. Правило такого Броски паспорта стоят после прежнего состава когорты. Правило такого
продолжения подпотока описано в [дереве зерен](seeds.md), а основания выбора продолжения подпотока описано в [дереве зерен](seeds.md), а выбор
самого паспорта — в [спеке генератора](../../specs/2026-08-01-generator.md), паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду
раздел 9. Зачем стенду нужна двухкуковая пара одного человека, фиксирует нужна двухкуковая пара одного человека, фиксирует
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5. [мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
+2 -2
View File
@@ -36,5 +36,5 @@
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл «Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
полей записаны в [описании события](../../formats/clickstream-event.md). полей записаны в [описании события](../../formats/clickstream-event.md).
Основание модели времени записано в [спеке Выбор модели времени объясняет
генератора](../../specs/2026-08-01-generator.md), раздел 9. [ADR 0015](../../adr/0015-counter-timezone.md).
+1 -1
View File
@@ -30,7 +30,7 @@
├── TRAFFIC ├── TRAFFIC
├── COMMERCE ├── COMMERCE
├── ORDERS ├── ORDERS
└── LATECOMERS — место будущей событийной стороны └── BEACONS — потеря и повторная отправка событий
``` ```
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
+2 -10
View File
@@ -27,8 +27,8 @@
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` — не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
[судьба заказа](fate.md). [судьба заказа](fate.md).
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока — - **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
заказная и событийная стороны; броски независимы, пересечения выходят `ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
арифметикой, приоритет классов работает по-настоящему — пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md). [классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов - **Опись хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов — каждого слепка и счётчики наблюдаемых классов —
@@ -51,14 +51,6 @@
## Открытые решения ## Открытые решения
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
заказа](fate.md)).
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
доли классов, стоимость доставки — калибровка при реализации; финальная
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
потребуют только числа, не устройство.
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) — - **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4. остаётся в тумане карты #69; естественное место разговора — этап 4.
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к - **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
+30 -25
View File
@@ -16,11 +16,10 @@
и `LATECOMERS` решения не переживают — они названы по классам витрины, а и `LATECOMERS` решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции. компонент называет часть мира; новые стороны занимают те же позиции.
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3 событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
судьбу* — правка событийной механики молча меняла бы байты слепка; событийной механики молча меняла бы байты слепка; *компонент на класс* —
*компонент на класс* — пять имён под ручки калибровки, которые крутятся пять имён под ручки калибровки, которые крутятся разом.
разом.
**Правило формы, без которого разделение не работает: броски заказной стороны **Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
@@ -56,8 +55,11 @@
часа покупки, и менти нашёл бы этот наклон первым же разрезом. часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как **Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
она делится между двумя дорогами — строки таблицы исходов. Точные числа — она делится между двумя дорогами — строки таблицы исходов. Замер на
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим. каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя **Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии версия; различает их история версий: сырьё STG и физические версии
@@ -108,12 +110,13 @@
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID` иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита, ради которого `VisitID` в потоке лежит. Задержка дубля —
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
покупкой, по тому же правилу, что у подтверждения с торговым хвостом. режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
подтверждения с торговым хвостом.
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* — Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
потеря перестаёт быть видна со стороны трекера. потеря перестаёт быть видна со стороны трекера.
@@ -127,18 +130,20 @@
подделывается — строка создана, когда заказ родился, — но в слепках дней подделывается — строка создана, когда заказ родился, — но в слепках дней
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
дожил: отменённый на второй день и опоздавший на третий приедет в первом же дожил: отменённый на второй день и опоздавший на третий приедет в первом же
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2» состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника: быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
день создания строки разошёлся бы с днём покупки, чьё равенство держит Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в *сдвиг `created_at`* — подделка аудита источника: день создания строки
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D — разошёлся бы с днём покупки, чьё равенство держит синхронная модель
сломалась бы та самая сверка, ради которой всё строится. ([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет **Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
+12 -9
View File
@@ -18,9 +18,10 @@
в трекере — не отдельная порода, а класс B, и делает его событийная сторона в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера выбрасыванием события после присвоения номера
([классы расхождений](fate.md)); ([классы расхождений](fate.md));
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`, - обе проекции используют один номер заказа: `order_id` = клиентский
читаемый номер «день и порядковый номер покупки» ([спека `purchaseID`;
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все читаемый номер «день и порядковый номер покупки» описан в
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
покупки, дошедшие до потока дня, — до всяких потерь; покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» — - скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9). числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
@@ -59,12 +60,14 @@
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
топиком `hits`* — убивает два режима приёма. топиком `hits`* — убивает два режима приёма.
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и **Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
собирается переигровкой этих семи дней: заказы дня — производная всей воронки них те, что уже попали в выгрузку: механика опоздания живет в
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у [судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок; проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве. *кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим **Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
+1 -1
View File
@@ -296,7 +296,7 @@ UTC+4), и пересчёт идёт один раз при наполнении
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9. расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
## Путь реплицированных таблиц в keeper ## Путь реплицированных таблиц в keeper
+165
View File
@@ -0,0 +1,165 @@
# Superset: витрины глазами
Документ описывает зону BI: куда Superset ходит за данными и за своими
метаданными, из чего собран стендовый дашборд, как снять свежий экспорт после
правки в интерфейсе и почему правка руками живет только до перезапуска.
Состав сюжетов и колонок витрин здесь не повторяется: он живет в
[спеке](../specs/2026-07-30-stand-v2-realism.md), раздел 7, и в
[доке слоя DM](dm.md).
## Устройство зоны
Superset читает ClickHouse пользователем `bi` **на ноде 2** — так задано
подключением `ClickHouse` (`clickhousedb://bi@clickhouse-02:8123`). Нода 2
здесь — ловушка правильных ошибок из
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел «Роли нод»: DDL идет
с ноды 1, и забытый `ON CLUSTER` виден в дашборде.
Пароля в подключении нет. Его отдает штатное хранилище паролей Superset
(`SQLALCHEMY_CUSTOM_PASSWORD_STORE` в `infra/superset/superset_config.py`),
которое читает `CLICKHOUSE_BI_PASSWORD` из окружения. Поэтому файл подключения
безопасно лежит в git, и экспорт из интерфейса не должен возвращать пароль
обратно в YAML.
Свои метаданные — подключения, датасеты, графики, дашборды, пользователи —
Superset держит в базе `superset` служебного `postgres-metadata`, рядом с базой
Airflow. В ClickHouse он не пишет ничего.
## Дерево импорта
`infra/superset/import/` — это дашборд стенда в виде файлов. Каталог
смонтирован в контейнер как `/app/import`.
| Файл | Что описывает |
|---|---|
| `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет |
| `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid |
| `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` |
| `charts/*.yaml` | три графика: столбики выручки, сводная таблица сверки, линии трафика |
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
`dataset_uuid`, а дашборд не называет графики вовсе: единственная связь с ними
лежит в его `position`, где узел `CHART-*` несет `meta.uuid` нужного графика.
График, которого нет в `position`, импортируется молча и не появляется нигде.
Uuid у стенда розданы рукой: общее начало
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
у датасетов, `101`–`103` у графиков, `201` у дашборда
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
обновления первой.
Датасет несет русские имена колонок, формат денег и метрику — а график только
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
не в каждом графике.
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
## Импорт при каждой инициализации
Последняя строка `infra/superset/init.sh`:
```
superset import-directory /app/import --overwrite
```
Служба `superset-init` отрабатывает перед подъемом самого Superset, то есть при
каждом `make up`. Без `--overwrite` второй прогон падал бы на первом же
знакомом uuid; с ним объект обновляется на месте, и повторная инициализация
проходит без ошибок.
Отсюда правило зоны: **стендовый дашборд не расширяют руками в интерфейсе.**
Правка живет до следующей инициализации, а потом молча заменяется тем, что
лежит в git. Пробовать интерфейс менти никто не мешает — но все, что должно
пережить перезапуск, приезжает файлом.
Это правило относится только к стендовым объектам. Свои датасеты, графики и
дашборды менти живут в той же базе метаданных Superset, а импорт трогает
только объекты с известными ему uuid. Поэтому свои объекты переживают
`make up` и `make rebuild-storage`. Их стирает только `make clean` или
адресный сброс базы метаданных по рецепту ниже.
## Как снять свежий экспорт
Маршрут один: правишь на живом стенде, забираешь экспорт, кладешь дерево в git.
1. Собрать или поправить график и дашборд в интерфейсе — на стенде, а не в
файлах. Ключи `params` графиков велики, недокументированы и меняются от
версии к версии; неверный ключ не ошибается, а рисует пустоту. Интерфейс
здесь единственный надежный оракул.
2. Снять экспорт и достать его из контейнера:
```
docker compose exec superset superset export-dashboards -f /tmp/export.zip
docker compose cp superset:/tmp/export.zip /tmp/export.zip
unzip -d /tmp/superset-export /tmp/export.zip
```
Внутри архива — один каталог `dashboard_export_<дата>` ровно той же формы,
что и дерево импорта. Команда выгружает все дашборды сразу; на стенде он
один.
3. Вычистить экспортный сор перед коммитом:
- выбросить экспортный `databases/` — наш `clickhouse.yaml` остается как
есть, с постоянным uuid и без пароля;
- оставить в `metadata.yaml` одну строку `version: 1.0.0`;
- убрать из графиков `params.datasource`, `params.slice_id` и
`params.dashboards` — это идентификаторы строк того Superset, где
снимали, а импорт выводит связи из uuid;
- убрать `query_context` — снимок запроса оттуда же. Импорт его не
требует и обратно не заполняет: после переимпорта он остается пустым, на
отрисовку это не влияет;
- перезаписать русские строки читаемыми. Экспорт кодирует их
`\uXXXX`-последовательностями, и после каждого круга их приходится
раскодировать заново: имена графиков и подписи колонок читает менти, и в
файле, и в диффе. Годится любой повторный дамп YAML с
`allow_unicode=True`;
- вернуть постоянные uuid из схемы выше.
4. Скопировать вычищенное дерево в `infra/superset/import/` и проверить
импортом **на пустых метаданных** — иначе прогон проверит только обновление
уже стоящих объектов, а молчаливый пропуск нового спрячется за старым.
Активы Superset лежат в базе `superset` службы `postgres-metadata`, поэтому
адресный сброс — эта база:
```
docker compose stop superset
docker compose exec postgres-metadata sh -c \
'psql -U "$POSTGRES_USER" -d postgres \
-c "DROP DATABASE superset WITH (FORCE)" \
-c "CREATE DATABASE superset OWNER \"$SUPERSET_METADATA_USER\""'
docker compose run --rm superset-init
docker compose up -d superset
```
Базу приходится создавать тут же: скрипт `infra/postgres/init/` заводит ее
один раз, при первой инициализации тома Postgres. `make rebuild-storage`
для этой проверки не годится — он гоняет `world_recreate` в ClickHouse и
метаданных Superset не касается. `make clean` сбросит и их, но заодно снесет
весь стенд, включая метаданные Airflow в той же службе.
После импорта дашборд смотрят глазами: это единственное доказательство —
см. ловушку ниже.
## Ловушки
**Импорт молчит.** График, чей `dataset_uuid` не разрешился, отбрасывается без
единого слова; дашборд, у которого не сошлись ссылки на графики,
пропускается. Ошибки уходят в журнал, и `init.sh`
завершается нулем в любом случае. Поэтому импорт проверяют глазами или
запросом к API — но не тем, что команда отработала.
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
## Что проверяет стенд
`make check-services` входит в Superset, находит подключение `ClickHouse`,
сверяет его uuid и безопасный URI и требует схему `dm` — то есть подтверждает
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
[доке проверок](testing.md).
+78 -6
View File
@@ -36,7 +36,7 @@
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
`make smoke` и `make check-services` перемерены 28 августа 2026 года, `make smoke` и `make check-services` перемерены 28 августа 2026 года,
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа, `make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
корневой `make lint` — 9 августа, `make test` — 7 августа; остальные стоят с корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
замера 6 августа. замера 6 августа.
| Цель | Откуда | Что утверждает | Стенд | Цена | | Цель | Откуда | Что утверждает | Стенд | Цена |
@@ -48,7 +48,7 @@
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с | | `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с | | `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` две, и это не опечатка.** Одноимённые цели за **Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
разными дверями охватывают разное: корневая не видит ни одного файла разными дверями охватывают разное: корневая не видит ни одного файла
@@ -121,10 +121,11 @@ ClickHouse отвечает сразу.
из каталога `generator/`: стенд им не нужен, а `make test` из них самая из каталога `generator/`: стенд им не нужен, а `make test` из них самая
дорогая. Правки дагов и Superset — корневой `make lint`. дорогая. Правки дагов и Superset — корневой `make lint`.
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
получается мир, его надо получить. Место выбрано по той же оси «кого узнать, тот ли получается мир, его надо получить. Место выбрано по той же
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
нужен, — и
краснеет проверка там, где надо: сразу после правки генератора. Не будь её, краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
поломкой хранилища, а не забытой пересборкой. поломкой хранилища, а не забытой пересборкой.
@@ -238,6 +239,77 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
## Что проверено ## Что проверено
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
сверены замером внутренностей генератора — окно 28 модельных дней,
канонический seed, без транспорта, — тем же приемом, каким `test_plan.py` и
`test_commerce.py` меряют мир. Команда (из `generator/`):
```bash
uv run python - <<'PY'
from collections import Counter
from clickstream_generator import day, inventory, orders, world
from clickstream_generator.seeds import CANONICAL_SEED
WINDOW = 28
class_totals: Counter[str] = Counter()
outcome_totals: Counter[int] = Counter()
delivery_totals: Counter[int] = Counter()
delay_totals: Counter[int] = Counter()
total_orders = 0
for d in range(WINDOW):
today = day.stream(CANONICAL_SEED, d)
for key, value in inventory._order_class_counts(today).items():
class_totals[key] += value
money = today.orders
total_orders += len(money)
for value in money.outcome.tolist():
outcome_totals[value] += 1
for value in money.delivery.tolist():
delivery_totals[value] += 1
for value in money.snapshot_delay.tolist():
delay_totals[value] += 1
print(total_orders, dict(class_totals), dict(outcome_totals),
dict(delivery_totals), dict(delay_totals))
PY
```
6973 заказов за 28 дней. Доли из таблицы сверки (после приоритета классов):
отменено 5,15% (ориентир мастер-спеки ~5%, в пределах; из них 3,03%
оплачен-потом-отменен и 2,12% отменен неоплаченным — веса `world.py`
95/3/2); дубль `purchase` 1,89% (ориентир ~2%, в пределах); дельта суммы
1,32% (ориентир 1–2%, в пределах); потеря `purchase` 1,78% при броске 3% —
вычеты штатные: назначенные планом покупки из броска исключены (мост
склейки), часть случаев уходит в отмену приоритетом; наблюдаемая доля
принята решением владельца (#194). Стоимость доставки — 35,5% / 49,2%
/ 15,4%, совпадает с весами `DELIVERY_KOPECKS_WEIGHTS`. Опоздание слепков
(`ORDER_SNAPSHOT_DELAY_WEIGHTS`) — доля заказов с δ>0 составила 8,39%
(ориентир мастер-спеки ~10%, в пределах). Скрипт замера в репозиторий не
входит; вывод — в отчете issue #194.
**Перезамер 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()` занимал **Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел 28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его 30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
+22 -17
View File
@@ -1,9 +1,13 @@
# Учебные задания: витрины слоя DM # Учебные задания: витрины слоя DM и дашборд в Superset
Пять домашек. Стенд привозит слой DM наполовину: витрины трех сюжетов Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
дашборда и контроль качества построены, остальные витрины строит менти. наполовину: витрины трех сюжетов дашборда и контроль качества построены,
Каждое задание — самостоятельное ТЗ: что за витрина, какое у нее зерно, остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
из чего она собирается и как проверить, что собралась верно. витрина, какое у нее зерно, из чего она собирается и как проверить, что
собралась верно.
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
дашборд поверх пяти витрин.
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
вопросы «почему так», задание отвечает на вопрос «что сделать». вопросы «почему так», задание отвечает на вопрос «что сделать».
@@ -13,18 +17,19 @@
Задания идут по нарастанию: каждое следующее опирается на то, что уже Задания идут по нарастанию: каждое следующее опирается на то, что уже
усвоено. усвоено.
| № | Витрина | Чему учит | | № | Результат | Чему учит |
|---|---|---| |---|---|---|
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных | | 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер | | 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит | | 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни | | 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение | | 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
## Общие правила ## Общие правила
Они одни на все пять заданий, поэтому записаны здесь, а не повторяются в Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
каждом ТЗ. повторяются в каждом ТЗ.
**Читать — через `_v`.** Публичный договор сущности DDS — представление с **Читать — через `_v`.** Публичный договор сущности DDS — представление с
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
@@ -74,18 +79,18 @@ DDS, и задания остаются независимыми. Исключе
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
это инструмент лабы, а не история качества стенда. это инструмент лабы, а не история качества стенда.
Проверка у всех заданий устроена одинаково и золотых констант не содержит: Проверка пяти заданий по витринам устроена одинаково и золотых констант не
рядом с витриной независимым запросом собирается тот же ответ — прямо из содержит: рядом с витриной независимым запросом собирается тот же ответ —
источника витрины, минуя ее саму. У четырех заданий источник эталона тот прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона
же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны
стороны соединяются по деловому ключу через `FULL OUTER JOIN`, и считается соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк,
число строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. где стороны разошлись или одной стороны нет. Ноль — зеленый.
Полное соединение здесь обязательно: при обычном пропавший день или Полное соединение здесь обязательно: при обычном пропавший день или
пропавший ключ просто не попал бы в результат и остался бы незамечен. пропавший ключ просто не попал бы в результат и остался бы незамечен.
Раздел «Как проверить себя» каждого ТЗ называет деловой ключ и сравниваемые Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и
величины. Тот же прием в стендовом исполнении — [даг сравниваемые величины. Тот же прием в стендовом исполнении — [даг
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq). `dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и **Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
+87
View File
@@ -0,0 +1,87 @@
# Задание 6 (необязательное): свой дашборд в Superset
**Чему учит:** довести свои витрины до потребителя — завести в Superset
датасеты поверх публичных представлений, подобрать графики под вопросы и
собрать из них один связный экран.
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
интерфейсе BI.
## Роль дашборда
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
каналы дали результат, были ли ошибки приема данных.
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
пояснений рядом с экраном.
## Свои объекты — отдельно
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
при каждой инициализации».
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
## Датасеты
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
- `events_enriched_v`;
- `top_pages_daily_v`;
- `session_overview_v`;
- `dq_errors_daily_v`;
- `utm_effectiveness_v`.
## Состав дашборда
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
графика выбери сам. На дашборде покажи следующее:
| Витрина | Что показать |
|---|---|
| `events_enriched_v` | число событий по часу и типу события |
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
Для долей используй слагаемые из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит — урок задания 3 остается в силе.
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
## Как проверить себя
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
Открой список дашбордов и проверь:
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
датасетов;
- подписи, единицы измерения и легенды читаются без обращения к SQL;
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
серверную;
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
Последний проход сделай как читатель: найди на экране самый заметный канал,
страницу и устройство, затем проверь, понятно ли из подписей, что именно
сравнивается. Если ответ приходится угадывать, поправь график или его название.
## Приемка
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
- [ ] каждая витрина представлена хотя бы одним графиком;
- [ ] дашборд проверен глазами по разделу выше.
+279
View File
@@ -0,0 +1,279 @@
# Инвентаризация сюжетов лаб по слоям стенда
Это материал для спеки программы лаб (#202): полный список сюжетов без отбора. Спека решит, что оставить и в каком порядке поставить. Здесь для каждого участка стенда записано, чему на нем научится менти. Тикет — #201, карта — #182.
Каждый сюжет описан четырьмя строками: **урок**, **материал**, **форма** и **проверка**. Возможны три формы:
- *построить свое по ТЗ* — как в пяти домашних заданиях DM: рядом есть образец, но нет готового ответа;
- *вычитание* — удалить SQL готовой трансформации и написать его заново; даги остаются без изменений (уточнение владельца в комментарии к #148);
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а менти ищет причину и объясняет ее.
Существующие шесть заданий из `docs/labs/` входят в список как готовое ядро слоя DM. Идеи из тикетов #143, #145, #148, #149, #186, #187, #188 разнесены по подходящим участкам. В конце отдельно проверяется гипотеза «вычитание + DQ» для каждого потока нижних слоев.
## STG — прием и сырье
**1. Push против pull: два способа принять топик.**
Урок: способ приема зависит от источника. Поток постоянно читает подписанная матвью, а слепок забирает отдельный запрос по команде.
Материал: оба чтеца в `sql/ddl/10-stg-tables.sql` (у `orders_raw_kafka` нет ни матвью, ни `ON CLUSTER`, зато есть `kafka_commit_on_select`), матвью приема `sql/ddl/40-stg-views.sql`, забор `sql/stg/orders_raw_load.sql`, `dags/orders_ingest.py`; сравнение цены и наблюдаемости — заготовка спеки (`docs/specs/2026-07-30-stand-v2-realism.md`, раздел 12).
Форма: расследование двух живых путей на одном стенде.
Проверка: офсеты групп `clickstream_hits` и `clickstream_orders`, счет строк сырья по `_load_id`.
**2. Кто читал топик и куда легли данные.**
Урок: нода, которая читает топик, может меняться от прогона к прогону. Раскладка данных не меняется: ее определяет ключ шардирования — свойство самих данных.
Материал: колонка `consumer_host` в `stg.hits_raw_dist`; `docs/architecture/storage.md`, «Раскладка по шардам».
Форма: расследование данных.
Проверка: пересчет ключа шардирования по `hostName()` — строк на чужом шарде нет.
**3. Вторая consumer group — дубли.**
Урок: на кластер нужна одна группа потребителей. Если завести вторую, каждая нода получит полную копию топика.
Материал: `kafka_group_name` в `sql/ddl/10-stg-tables.sql`; сцена названа спекой (раздел 12).
Форма: расследование-опыт «сломай — увидь — верни» на переигранном дне.
Проверка: счет событий против описи мира (`make check-clickhouse`); уборка — снятием партиции `_load_ts` (правило из `docs/architecture/testing.md`).
**4. Дырка в офсетах.**
Урок: сырье может сохранить не все сообщения, которые пришли из источника. Пустое сообщение или зазор после пересоздания матвью видны только по разрыву в `kafka_offset`, поэтому пропажу не нужно искать глазами.
Материал: `docs/architecture/storage.md`, «Что проверено» (опыты 5 и 7 августа); колонка `kafka_offset` в сырье.
Форма: расследование.
Проверка: непрерывность офсетов внутри партиции.
**5. Две оси времени.**
Урок: переобработка данных и очистка диска живут по разным часам. Сырье разбивается на части и удаляется по `_load_ts`, а модельный день появляется только в ODS. Поэтому TTL по модельной дате работал бы неверно.
Материал: `sql/ddl/10-stg-tables.sql` (TTL трое суток, `ttl_only_drop_parts`), `docs/architecture/storage.md`, «Срок жизни сырья».
Форма: расследование-вопрос (почему нельзя нарезать сырье по `EventDate`).
Проверка: ответ сверяется с доводом доки; счетного инварианта нет.
## ODS — типизация и брак
**6. Предикат и его буквальное отрицание.**
Урок: годные строки и брак должны делить поток без пропусков и пересечений. NULL в предикате незаметно теряет строку, а исключение роняет вставку и останавливает чтение топика.
Материал: `sql/ddl/30-ods-views.sql` (пара матвью событий), `sql/ods/order_parse.sql` (макрос `order_wire_contract`), ADR 0005.
Форма: расследование — подложить в топик по строке каждого класса брака и найти их в `ods.event_errors_dist` / `ods.order_errors_dist`.
Проверка: «сырье среза = годные + брак» на управляемой пачке; строки опыта убрать за собой.
**7. Три времени и версия-наблюдение.**
Урок: у строки заказа есть четыре временные координаты. Версию сущности задает дата слепка источника, а не время загрузки. `FINAL` живет ровно в одном месте — за представлением `_v`.
Материал: `sql/ddl/20-ods-tables.sql`, `sql/ddl/30-ods-views.sql`, ADR 0010 и 0014, `docs/architecture/orders/ingestion.md` («Роль ODS»).
Форма: расследование — физические версии `ods.order_dist` против `ods.order_v`.
Проверка: эталон `argMax` по `updated_at` прямо из сырья (прием #165).
**8. Часовой пояс как способ смотреть на время.**
Урок: у ночных событий `toDate(UTCEventTime)` может не совпадать с `EventDate`. Это нормальное свойство данных. День нужно считать по готовой колонке и явно называть часовой пояс, в котором его рассматривают.
Материал: `docs/architecture/storage.md`, «Часовые пояса»; ADR 0015.
Форма: расследование данных.
Проверка: менти сам находит ночные события и объясняет расхождение.
**9. Сырое против разобранного: `ecommerce`.**
Урок: типизированный разбор JSON может молча подменить ошибку значением. Например, `qty` со значением «oops» превратится в 0. Поэтому правильность разбора нужно подтверждать инвариантом источника.
Материал: колонка `ecommerce` рядом с массивами `purchase_*` / `product_*` в `ods.event`; разбор `items` в `sql/dds/order_rebuild.sql`; спека, раздел 1.2 («кандидат будущей лабы»).
Форма: построить свое по ТЗ — разобрать `ecommerce` запросом и сверить с готовыми массивами того же события.
Проверка: построчная сверка с колонками-массивами.
## DDS — модель
**10. Лаба сессий: своя нарезка против эталона.**
Урок: сессия — расчетная сущность, границы которой определяет логика в хранилище. Самостоятельная нарезка может разойтись с эталоном. Если расхождение невозможно, задание сводится к `GROUP BY` по готовому полю.
Материал: `docs/architecture/dds/session.md`, `sql/dds/session_rebuild.sql` (два окна, полный порядок `(event_time, event_id)`, детерминированный чекан), эталон `VisitID` в `ods.event_v`.
Форма: построить свое — менти собирает нарезку сам до знакомства со стендовым кодом; либо вычитание `session_rebuild.sql` (раздел о гипотезе ниже).
Проверка: `dq_check` / `sessions_vs_reference` (зерно кука × день против `uniqExact(VisitID)`); затем сравнение со стендовой нарезкой по `session_id`.
**11. Поштучный разбор расхождения нарезки.**
Урок: счетная сверка показывает, сколько сессий разошлось. Чтобы понять, где именно отличаются границы, нужно вручную соединить свою сессию с эталонной по времени.
Материал: `sql/dq/sessions_vs_reference.sql`, `docs/architecture/dds/session.md`, «Сверка с эталоном» («поштучное сопоставление границ — материал лабы»).
Форма: расследование.
Проверка: найденная кука, день и пауза, на которой решения разошлись.
**12. Склейка идентичностей во времени (#145).**
Урок: текущее знание о пользователе и знание на выбранную дату дают разные отчеты. Если использовать сведения из будущего, исторический график известной аудитории изменится.
Материал: `dds.identity_map_v` (пары без временной колонки — решение `docs/architecture/dds/identity.md`), момент связи выводится как минимум времени `purchase`-события, соединенного с заказом.
Форма: построить свое (запрос, без изменения стенда).
Проверка: два способа на одном наборе данных; расхождение объясняется письменно.
**13. Справочник регионов (#143).**
Урок: справочник содержит данные, которыми источник владеет вне хранилища. Если собрать словарь руками, можно разобраться в механике, которую готовый каталог стенда позволяет только использовать.
Материал: образец `sql/ddl/05-dic-catalog.sql` и ADR 0012; зацепка в контракте — `region_city_id` / `region_country_id` отдаются как есть (`dds.event_v`).
Форма: построить свое по образцу (свой CSV, подложка, словарь, `dictGet` в отчете).
Проверка: обогащенный отчет; неатомарность обновления словаря на двух нодах — дополнительное наблюдение (спека, раздел 12).
**14. Инвариант позиций: недостающая проверка.**
Урок: равенство «цена × количество = items_total» проверяет, не исказил ли молчаливый разбор данные. Сейчас это равенство проверяют вручную, хотя ему нужно постоянное место в контуре DQ.
Материал: комментарий в `sql/dds/order_rebuild.sql` («постоянное место такой проверки — контур DQ этапа 4; пока она делается руками»), образцы `sql/dq/*_vs_dds.sql`, `dags/dq_check.py`.
Форма: построить свое — новая проверка по образцу существующих пяти.
Проверка: проверка обязана уметь краснеть — испортить позицию руками и увидеть красное (`docs/architecture/testing.md`).
**15. Сверка слоя с источником по дню.**
Урок: трансформация выполняет заданное правило, но сама не замечает, что часть данных не приехала. Это обнаруживает отдельная сверка слоя с источником.
Материал: `docs/architecture/etl.md`, «Чего конвейер о себе не знает» (три слепых пятна и лекарство: счет заказов и поздний `updated_at` с обеих сторон).
Форма: построить свое — вторая самодельная DQ-проверка.
Проверка: снести часть дня в ODS-окне — проверка краснеет, конвейер сам не замечал.
## DM — витрины
**16. Пять домашек DM (готовое ядро).**
Урок: готовые ТЗ уже содержат пять отдельных уроков (`docs/labs/README.md`): `events_enriched_v` показывает, что слой задает контракт, а не копирует данные; `top_pages_daily` меняет зерно, материализует витрину и включает ее в конвейер; `session_overview` учит хранить слагаемые вместо средних и не складывать уникальные значения, даже если на текущих данных результат совпадает; `dq_errors_daily` показывает, что у брака есть собственный день и срок жизни, а витрина может пережить источник; `utm_effectiveness` объясняет, зачем в имени нужно предупреждение `declared_`.
Материал: пять готовых ТЗ в `docs/labs/README.md`.
Форма: построить свое по ТЗ.
Проверка: `dags/homework_check.py` + `sql/dq/*_vs_dds.sql` (полное внешнее соединение с независимым ответом).
**17. Свой дашборд в Superset (готовая необязательная лаба).**
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
Материал: `docs/labs/superset-dashboard.md`, `docs/architecture/superset.md`.
Форма: построить свое.
Проверка: глазами — счетного инварианта у раскладки панелей нет.
**18. Инкремент против пересборки (#149, повышенная сложность).**
Урок: чтобы обновлять меньше данных, приходится хранить историю изменений. Полная перезагрузка эту историю стирает.
Материал: `sql/dm/daily_traffic_rebuild.sql` (полный пересчет), `docs/architecture/dm.md`, «Ритм пересборки» и отклоненный вариант; момент `first_matched_at` — из сюжета 12.
Форма: построить свое (инкрементальная альтернатива рядом со стендовой).
Проверка: бесплатный контроль — результат обязан совпасть с эталонной витриной `dm.daily_traffic_v`.
**19. Вычитание стендовых витрин.**
Урок: при сверке двух источников нужен `GLOBAL FULL OUTER JOIN` с подзапросами с обеих сторон, `join_use_nulls` и явный приоритет классов. Значение с отсутствующей стороны должно остаться `NULL`, а не превратиться в ноль.
Материал: `sql/dm/purchase_vs_orders_rebuild.sql` (и соседние `revenue_daily_*`, `daily_traffic_*`), `docs/architecture/dm.md`.
Форма: вычитание — с оговоркой: запросы `sql/dq/*_vs_dds.sql` лежат открыто и почти дословно повторяют рецепт витрины. Предупреждение из `docs/labs/README.md` («открыв их до того, как задание сделано, ты обменяешь урок на готовый SQL») здесь особенно важно.
Проверка: `dq_check`, группа `marts_vs_dds` + `classes_vs_inventory`.
## BI — Superset (сюжеты сверх готовой лабы)
**20. Ловушка ноды 2.**
Урок: забытый `ON CLUSTER` и представление над локальной таблицей могут проявиться только в дашборде. Superset намеренно читает ноду 2, а DDL запускается с ноды 1.
Материал: `docs/architecture/superset.md`, «Устройство зоны»; спека, «Роли нод».
Форма: расследование-опыт (создать объект без `ON CLUSTER`, открыть дашборд).
Проверка: своя же находка и починка.
## Поперек слоев
### Airflow-переобработка по дневным партициям
**21. Переиграть день X.**
Урок: объем повторного прогона определяется состоянием хранилища. После падения конвейеру не нужно помнить параметры прошлого запуска: он ищет первую несобранную дату, а не последнюю собранную.
Материал: `sql/dds/order_scope.sql` и `session_scope.sql` (окно + хвост от первой дыры, `GLOBAL NOT IN`), `dags/dds_transform.py` (день — отдельная размноженная задача замены), `docs/architecture/etl.md`.
Форма: расследование-опыт — снести партицию дня в `dds.session_rep`, запустить `etl_pipeline` без параметров, увидеть, как объем сам нашел дыру.
Проверка: `dq_check` зеленый, день вернулся тем же (детерминированный чекан).
**22. Атомарность — пошардовая.**
Урок: `REPLACE PARTITION` заменяет данные в локальных таблицах по одному шарду. Если одна из двух замен завершится ошибкой, половина дня останется старой. Сам конвейер этого не заметит, поэтому нужна отдельная сверка.
Материал: `sql/dds/order_replace.sql` (комментарий о границе обещания), `docs/architecture/etl.md`, «Чего конвейер о себе не знает»; правило донора — `sql/ddl/45-dds-tables.sql`.
Форма: расследование (в паре с сюжетом 15).
Проверка: `purchase_vs_orders_vs_dds` или самодельная сверка по дню.
### Сверка purchase vs orders (классы A–D)
**23. Воронка подтверждений и недосчет трекера (#186, класс B).**
Урок: со стороны трекера потерянное событие можно обнаружить только по разрыву воронки между `/confirmation` и `purchase`. Отсюда правило: поведение считаем по трекеру, деньги — по бэкенду.
Материал: `lost_event` в `dm.purchase_vs_orders_v`, события дня в `dds.event_v`; механика — `docs/architecture/orders/fate.md` («потеря точечная»).
Форма: расследование.
Проверка: оцененный недосчет сходится с долей `lost_event` закрытых дней.
**24. Бизнес-дубль и наивная выручка (#187, класс D).**
Урок: движок удаляет технические дубли, а бизнес-дубли приходится определять самим — по `purchaseID`, а не по совпадению строк. `ReplacingMergeTree` не удалит дубль с новым `WatchID`, и это правильное поведение.
Материал: `duplicate_event`, свертка по `purchase_id[1]` в `sql/dm/purchase_vs_orders_rebuild.sql`; `fate.md` («дубль сюжетный»).
Форма: расследование — наивный `SUM` против дедуплицированного.
Проверка: разница равна вкладу дублей; сверка с `dm.revenue_daily_v`.
**25. Дельта суммы объясняется сравнением позиций (класс C).**
Урок: `amount_delta` обозначает конкретное расхождение, а не случайный шум. Его можно до копейки объяснить вычеркнутой позицией, если разобрать вложенный JSON и применить `ARRAY JOIN`. Ради этого навыка позиции и разбираются.
Материал: `amount_delta` в сверке, массивы `product_*` события против `item_*` заказа; `fate.md` («дельта — вычеркнутая позиция»).
Форма: расследование.
Проверка: найденная позиция и ее полная стоимость равны дельте.
**26. Две дороги отмены и история версий (класс A).**
Урок: текущее состояние заказа не сохраняет его историю. Различить случаи «оплачен и отменен» и «отменен неоплаченным» можно только по следам: физическим версиям ODS до слияния и сырью STG, пока оно хранится трое суток.
Материал: `fate.md`, «Что из двух дорог видно»; `ods.order_dist`, `stg.orders_raw_dist`; выручка дня, которая выросла и убыла.
Форма: расследование с ограничением по времени — часть случаев неразличима принципиально, и признать это тоже урок.
Проверка: списки заказов по дорогам, сверенные с историей версий.
### Опоздания и закрытие дня
**27. Опоздавшие заказы и закрытое окно (#188).**
Урок: отчет за день зависит от момента расчета. День можно считать закрытым, когда источник уже не способен его изменить. Поэтому опись хранит счетчики классов только для закрытых дней.
Материал: `awaiting_order` в `dm.purchase_vs_orders_v`, задержка слепков D+1/D+2 (`fate.md`, «Опоздание»), `data/world-inventory.json` (счетчики `orders` только у дней с закрытым окном), возврат `awaiting_order`-дней в объем — `sql/dm/purchase_vs_orders_scope.sql`.
Форма: расследование по ходу проживаемых дней.
Проверка: вчерашний отчет, не сошедшийся утром, сходится завтра; разность слепков называет опоздавших.
**28. Дыхание выручки.**
Урок: значение выручки за день меняется, пока день внутри окна изменяемости, а после выхода из окна фиксируется. Эти изменения видны как повторные пересборки партиции.
Материал: `dm.revenue_daily_v` по ходу дней, окно из ярлыка `snapshot_date` (`sql/dm/revenue_daily_scope.sql`), K = 7 — константа мира.
Форма: расследование-наблюдение (снять один день несколько раз).
Проверка: за окном значение больше не меняется.
### Мониторинг и runbook
В репозитории пока нет runbook для случаев «keeper упал / DDL повис». Спека оставляет его в хвосте этапа 9 вместе с `system.text_log` (ADR 0002), потому что такой документ должен опираться на реальный опыт. Поэтому менти может написать runbook сам: по шагам сломать сценарий, записать наблюдения и оставить стенду документ, которого сейчас не хватает.
**29. Keeper — общая память кластера.**
Урок: без keeper останавливаются DDL и репликация. На дашборде это бывает видно раньше, чем в ошибках запросов. Читаемый путь в keeper помогает понять, какие данные он хранит и за что отвечает.
Материал: панель keeper и очередь DDL в `docs/architecture/monitoring/cluster.md`, `system.zookeeper` (читаемый путь — `docs/architecture/storage.md`, «Путь реплицированных таблиц»), `make smoke` / `make check-clickhouse`.
Форма: расследование-опыт «посмотрели — сломали — увидели — вернули» (остановить keeper), итог — свой runbook.
Проверка: возврат к зеленым целям `make`; шаги записаны.
**30. Повисший DDL.**
Урок: каждый запрос `ON CLUSTER` проходит через keeper. Очередь распределенных DDL показывает, дошел ли запрос до обеих нод и что ответила каждая из них.
Материал: `system.distributed_ddl_queue`, панель «Распределенный DDL» (`cluster.md`: единственная панель без `clusterAllReplicas` — и почему).
Форма: расследование-опыт (DDL при недоступной второй ноде, таймаут `ON CLUSTER`).
Проверка: запись в очереди со статусом по нодам; починка доводит до `Finished`.
**31. Мониторинг сам стоит денег.**
Урок: запросы Grafana попадают в `system.query_log` вместе с запросами людей. Самый частый клиент хранилища — сам мониторинг. Если отфильтровать его запросы, стоимость наблюдаемости станет невидимой.
Материал: `docs/architecture/monitoring/queries.md` (панель «Кто спрашивает», строки начала и конца, куски шардов в панели ошибок).
Форма: расследование.
Проверка: собственное объяснение верха списка тяжелых запросов.
**32. Свежесть второго источника — пила, а не линия.**
Урок: возраст заказов растет в течение дня и резко уменьшается после нового слепка. Так устроен источник; это не признак деградации. Для панелей свежести, потока и брака выбираются разные слои данных. Если выбрать неверный слой, панель даст правдоподобный, но ложный ответ.
Материал: `docs/architecture/monitoring/data.md` (почему свежесть — ODS, поток и брак — сырье, окно доли брака).
Форма: расследование.
Проверка: предсказать форму графика до взгляда на него.
### За границей лаб — опорные точки лекций
Спека (раздел 12) оставляет несколько сюжетов для лекций и не включает их в лабораторные работы: `Sign` и CollapsingMergeTree (почему на стенде `sum(Sign)` равен `count()`, а в бою нет), «как выбирают топологии в бою», идентичность «как в бою» (`setUserID`, вероятностная склейка, identity graph), анти-паттерны ключа шардирования. Последний сюжет уже входит в домашние задания DM как требование защитить выбранный ключ (`docs/labs/README.md`).
## Гипотеза «вычитание + DQ» для нижних слоев
В #148 предложено удалить SQL трансформаций потока и написать его заново. Владелец уточнил, что даги менять не нужно: это несущая инфраструктура. Обратную связь дают инварианты, которые не зависят от реализации слоя. Ниже каждый поток проверяется отдельно: что можно отдать на переписывание, что должно остаться, чем проверять результат и где заканчивается эталон.
На всю таблицу влияют два общих факта:
- `homework_check` покрывает только пять витрин DM. `dq_check` содержит пять проверок, но внешний эталон есть лишь у двух: `sessions_vs_reference` использует чужой чекан `VisitID` из `ods.event_v`, а `classes_vs_inventory` — опись мира. Три проверки `*_vs_dds` пересчитывают витрину из DDS по тому же рецепту. Они находят ошибку в сборке витрины, но не ошибку в DDS, потому что обе стороны читают один слой.
- Все сверки читают таблицы `_dist`, поэтому их агрегаты не зависят от раскладки (спека, раздел 6). Если строка попала не на свой шард, счетные проверки останутся зелеными. Это ловит только пересчет ключа шардирования по `hostName()` (прием из `docs/architecture/storage.md`, замер #137). Такой пересчет нужно явно включать в ТЗ каждого вычитания.
### По потокам
**События STG (прием).** На переписывание этот участок не отдается. Здесь почти нет трансформации: `stg.hits_raw_mv` перекладывает колонки (`sql/ddl/40-stg-views.sql`). При этом пересоздание матвью на работающем стенде незаметно пропускает ближайшие сообщения (замер в `storage.md`, «Что проверено»). Чтец, таблицы сырья и матвью остаются несущей стеной. Урок этого участка раскрывают расследования из сюжетов 1–5.
**События STG → ODS (разбор).** Вся логика — в DDL, в паре матвью `sql/ddl/30-ods-views.sql`; отдельных файлов событий в `sql/ods/` нет. Удаление и пересоздание этих матвью остановит прием и снова создаст зазор с пропущенными сообщениями. Поэтому обычное вычитание здесь не подходит. Рабочая форма — **«собери рядом»**: выполнить пакетный `INSERT SELECT` из сырья в собственную таблицу по срезу `_load_ts`, а затем построчно сверить результат с нетронутым `ods.event_v`. Эталоном служат стендовый ODS и подневный счет против описи (`make check-clickhouse`). Однако опись проверяет только количество и только для покрытых ею дней. Содержимое неопорных колонок остается без независимой проверки: неверно разобранные UTM или браузер нигде не покраснеют, потому что витрины сверяются с DDS, а DDS читает ODS. Срок работы тоже ограничен: сырье хранится трое суток.
**Заказы STG (забор, `sql/stg/orders_raw_load.sql`).** Формально трансформация вынесена в отдельный файл, но ее учебный смысл сводится к перекладке колонок, а цена ошибки велика. Офсеты коммитятся при чтении, поэтому неверный повтор унесет слепок до следующего переигрыша дня через пульт. Этот участок остается стеной. Эталон описи — хеш байтов слепка — проверяет топик, но не сам забор.
**Заказы STG → ODS (`sql/ods/order_parse.sql`).** Вычитание возможно с оговоркой. Это пакетный шаг, идемпотентный по `_load_id`: повтор не меняет `ods.order_v` («Что проверено» в `orders/ingestion.md`). Брак обрабатывается отдельной целью, а слепок можно вернуть переигрышем дня. Обратную связь менти собирает сам: проверяет равенство «сырье среза = годные + брак» по `_load_id`, а текущее состояние сверяет с `argMax` по `updated_at` из сырья. Ниже по течению результат проверяет `classes_vs_inventory` через DDS и витрину. Слабое место — ветвь брака. Чистый мир не производит брак, поэтому отрицание предиката останется непроверенным, пока менти сам не подложит по строке каждого класса. Готовый рецепт есть в `orders/ingestion.md`, «Риски и проверка»; после опыта данные нужно обязательно убрать.
**DDS сессия (`session_scope` / `rebuild` / `replace`).** Это лучший поток для вычитания: здесь гипотеза подтверждается без дополнительных условий. Эталон `VisitID` внешний, а `sessions_vs_reference.sql` считает чужой чекан и не повторяет алгоритм нарезки. Ошибиться можно в важных местах: в полном порядке окон, границе суток в `PARTITION BY`, детерминированном чекане и расчете объема из позиции мира. Эталон заканчивается на атрибутах строки. Сверка работает на зерне кука × день и проверяет только число сессий. Поэтому неверные границы, длительность, вход, выход или UTM могут пройти зеленым. `session_overview` из `homework_check` тоже согласится, потому что сверяет витрину с тем же слоем. Вторая ступень обратной связи — поштучная сверка границ по `VisitID` из сюжета 11, которую менти пишет сам.
**DDS заказ (`order_scope` / `rebuild` / `replace`).** Вычитание возможно при условии: сначала нужно построить недостающую проверку. Косвенный эталон уже есть. `classes_vs_inventory` ловит потерянные заказы и неверные статусы закрытых дней, а `purchase_vs_orders` проверяет `items_total`. Но независимо не проверяются разбор массивов `item_*` и время в поясе `Europe/Samara`. Выручка сойдется сама с собой, а счетчиков выручки в описи намеренно нет (спека, раздел 8). Стенд уже подсказывает решение: до вычитания нужно реализовать инвариант «цена × количество = items_total» из сюжета 14. Последовательность «сначала сторож, потом ломать» сама становится частью урока.
**DDS карта идентичностей (`identity_map_rebuild.sql`).** Для чистого вычитания этот поток не подходит: независимого эталона нет. `daily_traffic_vs_dds` читает карту с обеих сторон, поэтому пустая или задвоенная карта пройдет проверку. Счетчики идентичности в опись не входят (спека, раздел 8). Менти может сам написать слабые инварианты: `known_users <= visitors` и «каждая пара подтверждена покупкой и заказом». Они проверят допустимость пар, но не их полноту. Подходящие формы — «собери рядом и сверь со стендовой картой» либо вычитание вместе с самодельной проверкой, которая честно названа неполной.
**DM стендовые витрины.** Формально это самый защищенный участок: `dq_check` независимо пересчитывает каждую витрину. На деле проверка содержит готовый ответ: `revenue_daily_vs_dds.sql` дословно повторяет рецепт витрины. Вычитание возможно как сюжет 19, но только с предупреждением из `docs/labs/README.md` о цене подглядывания.
### Сводная таблица
| Поток | Отдать на переписывание | Несущая стена | Обратная связь | Где кончается эталон |
|---|---|---|---|---|
| События STG | ничего | чтец, сырье, `hits_raw_mv` | опись (счет) | все, кроме счета |
| События STG→ODS | «собери рядом», не вычитание | обе матвью, `*_errors` | стендовый ODS рядом, опись | содержимое неопорных колонок; окно сырья 3 суток |
| Заказы STG | ничего | чтец, забор | хеш слепка в описи | сам забор |
| Заказы STG→ODS | `order_parse.sql` | `*_errors`, чтец | баланс среза по `_load_id`, `argMax(updated_at)`, опись классов | ветвь брака — пока не подложен брак руками |
| DDS сессия | `session_*` (все три) | донор, DDL, даг | `sessions_vs_reference` (внешний эталон) | атрибуты строки — нужна поштучная сверка |
| DDS заказ | `order_*` (все три) | донор, DDL, даг | опись классов, `items_total` в сверке | разбор позиций и пояс — сначала сюжет 14 |
| DDS карта | только с оговорками | DDL, даг | нет независимой | почти сразу; форма — «собери рядом» |
| DM витрины | `revenue/pvo/traffic` | доноры, DDL, даг | `dq_check` целиком | ответ лежит в самой проверке |
### Вывод
Гипотеза «вычитание + DQ» подходит не для всех нижних слоев. Без оговорок она работает только для сессий DDS: там есть внешний эталон, настоящие места для ошибок и проверка, которая не содержит готового решения.
Для заказов DDS и разбора заказов ODS нужна последовательность «сначала построй сторожа, потом ломай». Недостающие инварианты — сюжеты 14 и 15, а также баланс среза — сами входят в учебный материал. Их нужно построить до вычитания.
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
+17 -12
View File
@@ -265,7 +265,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
| | Расхождение | Механика в генераторе | Ориентир доли | | | Расхождение | Механика в генераторе | Ориентир доли |
|---|---|---|---| |---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе | | A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% | | B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% | | C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% | | D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
@@ -274,8 +274,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира; ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и (28 дней, 2026-08-30, #194) — запись в «Что проверено»
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
только по дням с закрытым окном (раздел 8). только по дням с закрытым окном (раздел 8).
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) — Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
@@ -532,10 +533,11 @@ Kafka день переигрывается генератором заново:
## 8. Эталонный мир и опись ## 8. Эталонный мир и опись
В git хранится только опись эталонного мира; сам снимок (14 модельных В git хранится только опись эталонного мира; сам снимок генерируется на
дней) генерируется на месте — при `make up` и при проверках (решение месте — при `make up` и при проверках (решение развилки «Производительность»
развилки «Производительность» карты #26, подробности — карты #26, подробности — [спека генератора](2026-08-01-generator.md),
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
снято на этапе 7. Опись несёт
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
@@ -555,8 +557,8 @@ Kafka день переигрывается генератором заново:
названного читателя в опись не кладётся названного читателя в опись не кладётся
([что хранит опись](../architecture/orders/inventory.md)). ([что хранит опись](../architecture/orders/inventory.md)).
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
при пересборке. [карты проверок](../architecture/testing.md).
## 9. Оценка объёма исполнения ## 9. Оценка объёма исполнения
@@ -618,8 +620,8 @@ v2 стартует пустым, поэтому объём ниже — это
выручка, сверка A+C. выручка, сверка A+C.
5. Airflow: `etl_pipeline` (партиционная переобработка). 5. Airflow: `etl_pipeline` (партиционная переобработка).
6. Расхождения B+D и опоздания; счётчики описи. 6. Расхождения B+D и опоздания; счётчики описи.
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация 7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
на amd64 и arm64. снята (спека генератора, раздел 5).
8. Superset-дашборд v2. 8. Superset-дашборд v2.
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов 9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
и границы — ADR 0002. Этап идёт не подряд, см. ниже. и границы — ADR 0002. Этап идёт не подряд, см. ниже.
@@ -658,7 +660,10 @@ ADR 0002 с приёмочными совпадают почти целиком.
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
конвейер, в который она встает, проектируются вместе. Единый стиль блока конвейер, в который она встает, проектируются вместе. Сюда же —
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
остальные лабы пока на уровне идей. Единый стиль блока
домашек — за работой учебного пути. Спека даёт будущим лабам только домашек — за работой учебного пути. Спека даёт будущим лабам только
опорные точки — контрольные числа описи (сверка, идентичность). Явное опорные точки — контрольные числа описи (сверка, идентичность). Явное
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет. следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
File diff suppressed because it is too large. Load diff
+293
View File
@@ -0,0 +1,293 @@
# Программа учебных лаб
Эта спека собирает программу лаб из
[инвентаризации сюжетов](../research/2026-08-31-labs-inventory.md), выполненной
в тикете #201. Тикет спеки — #202, карта — #182.
Статус: спека принята владельцем; судьба идей-кандидатов решена здесь поименно.
## 1. Кому и зачем
Менти уже прошел большой теоретический курс ClickHouse объемом около 60 часов.
Он знает устройство системы в теории, но почти ничего не делал своими руками.
Поэтому в программе нет вводного блока с теорией. Здесь теория становится
ремеслом: менти руками осваивает слои хранилища, DQ, оркестрацию и
идемпотентность.
Обещание программы: менти проходит конвейер стенда автором, а не читателем.
## 2. Устройство программы: основная программа и факультативы
У частей программы два статуса, третьего нет.
**Основная программа** — обязательная часть. Менти проходит ее целиком и по
порядку, потому что каждая следующая ступень опирается на предыдущую. От
ступени к ступени растет самостоятельность: сначала менти получает готовые ТЗ с
готовыми сторожами (DQ-проверками), а затем строит сторожей сам.
**Факультативы** — необязательные лабы. Они собраны в тематические разделы,
чтобы менти мог одним взглядом выбрать интересное. Для каждого раздела указано,
после какой ступени основной программы он открывается.
В программе используются четыре формы заданий:
- *построить свое по ТЗ* — рядом есть образец, но готового ответа нет;
- *вычитание* — SQL готовой трансформации удаляется и пишется заново, а даги
остаются как несущая инфраструктура;
- *собери рядом* — менти строит свою копию результата рядом с нетронутым
стендовым и сверяет их построчно;
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
менти ищет причину и объясняет ее.
## 3. Основная программа — семь ступеней
### Ступень 1. Построй пять витрин слоя DM
Первая часть программы опирается на шесть существующих заданий из
[`docs/labs/`](../labs/README.md). Пять ТЗ по витринам входят в первую ступень
основной программы и выполняются как есть. Их точечный пересмотр — работа
реализации. Шестое задание, про свой дашборд в Superset, остается факультативом
в разделе «Superset и BI». Набор уроков пяти витрин уже записан в их общем
описании: от слоя как договора до честных имен показателей.
Форма — построить свое по ТЗ. Обратную связь дает даг `homework_check`.
### Ступень 2. Свой отчет в Superset
Менти строит один график в Superset поверх витрины, которую сделал на первой
ступени. Урок — увидеть, как собственный результат из хранилища становится
отчетом в BI, не превращая знакомство с инструментом в отдельный большой
проект.
Форма — построить свое по ТЗ. Результат проверяется глазами. ТЗ для этой новой
ступени предстоит написать при реализации программы. Полный собственный дашборд
остается факультативом в разделе «Superset и BI».
### Ступень 3. Нарежь сессии сам
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
`VisitID`. Затем вручную соединяет свою сессию с эталонной и поштучно разбирает
хотя бы одно расхождение границ. Урок — счет называет проблему, но причину
приходится искать в отдельных строках.
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
из счетной сверки с `VisitID` и ручного сопоставления границ.
### Ступень 4. Перепиши нарезку стенда
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
и замену. Урок — изменить расчетную сущность внутри готового конвейера и
проверить ее внешним эталоном. Это единственный поток, для которого
инвентаризация подтвердила вычитание без оговорок: здесь есть настоящие места
для ошибок, а проверка не содержит готового ответа.
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
построенная на третьей ступени.
### Ступень 5. Сначала сторож, потом ломай: заказы
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
менти строит по образцу пяти стендовых проверок в `sql/dq/`. Обе проверки
должны покраснеть на управляемой ошибке. Затем менти вручную подкладывает по
строке каждого класса брака, проверяет разбор и убирает эти строки за собой.
Только после этого он вычитает разбор заказов ODS `order_parse` и трансформацию
заказа DDS. Урок — сторожа строят до рискованного изменения, а не после первой
поломки.
Форма — построить свои проверки по ТЗ, провести управляемое расследование и
выполнить вычитание. Обратную связь дают две новые проверки, баланс годных и
ошибочных строк по срезу и существующие сверки заказов.
### Ступень 6. Разбери сырые события и сверь результат со стендом
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
отдельно от живого приема и использовать стендовый результат как образец для
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
сообщения.
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
границы называются прямо: срок работы ограничен окном хранения сырья, а
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
независимым эталоном.
### Ступень 7. Мониторинг: куда смотреть
Сначала менти знакомится с панелями стенда. Затем проводит два безопасных
расследования. В первом он находит в `query_log`, что верх списка тяжелых
запросов занимает сам мониторинг. Во втором объясняет, почему свежесть второго
источника выглядит как пила, а не как линия, и выбирает правильный слой данных
для панели.
Форма — расследование. Обратная связь — найденные запросы и верное объяснение
формы графика и выбора слоя. Ломательные опыты остаются в факультативе «Что
делать, когда стенд сломался». Урок ступени — куда и на что смотреть в живой
системе.
**Общее правило вычитания:** ТЗ каждого вычитания на ступенях 4 и 5 включает
пересчет ключа шардирования по `hostName()`. Счетные сверки читают `_dist`,
поэтому учитывают строку даже на чужом шарде и остаются зелеными: они не видят
саму ошибку раскладки.
## 4. Факультативы — пять разделов
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
### Почему выручка не сходится
Этот раздел рекомендован первым из факультативов. Он открывается после ступени
1 основной программы: материал живет на стенде с этапа 6.
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
показывает потерянные события и границу доверия к двум источникам.
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
дубль строки, а повтор одной покупки приходится находить по деловому ключу.
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
некоторые различия после истечения срока сырья уже не восстановить.
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
становится окончательным лишь после того, как источник больше не может
изменить этот день.
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
движется, а после закрытия дня фиксируется.
### Тонкий анализ данных
Открывается после ступени 1 основной программы.
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на
дату дают разные отчеты; сведения из будущего меняют прошлое.
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
образцу каталога и обогащает отчет через `dictGet`.
### Superset и BI
Открывается после ступени 2 основной программы.
- **Свой дашборд.** Готовая лаба
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
хранить пользовательские объекты отдельно от стендовых.
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
### Инженерия стенда
Открывается после ступени 1 основной программы. Сюжеты про переигрывание дня и
пошардовую атомарность раскроются полнее после ступени 4, когда менти уже
спускался в DDS.
- **Как принимать данные из Kafka: потоком или слепком.** Поток и слепок
требуют разных способов приема и дают разную наблюдаемость.
- **Почему данные попадают на свои шарды независимо от ноды чтения.**
Нода-потребитель может меняться, а раскладку строк определяет ключ
шардирования.
- **Почему вторая группа потребителей дублирует поток.** Еще одна группа
потребителей создает еще одну полную копию потока.
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
показывает потерю, которую трудно заметить по самим данным.
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по
времени загрузки, а не по модельному дню.
- **Куда попадают битые строки.** Годные строки и брак делят поток без
пропусков и пересечений; менти подкладывает по строке каждого класса брака
событий и находит их в `ods.event_errors_dist`.
- **Какая версия заказа считается текущей.** Версию заказа задает слепок
источника, а `FINAL` скрывается за представлением.
- **Почему один момент попадает в разные дни.** Один момент дает разные дни в
разных поясах, поэтому день и пояс нужно называть явно.
- **Проверь, как стенд разобрал JSON в `ecommerce`.** Типизированный разбор
может молча подменить ошибку значением, поэтому менти сам разбирает сырой
JSON и сравнивает результат с массивами стенда.
- **Как конвейер восстанавливает пропущенный день.** Конвейер сам находит дыру
и определяет объем повторной обработки по состоянию хранилища.
- **Что будет, если день заменился не на всех шардах.** Замена дня завершается
отдельно на каждом шарде, поэтому целостность результата подтверждает
отдельная сверка.
- **Как обновлять витрину без полной пересборки (#149) — повышенная
сложность.** Экономия на пересчете требует хранить историю изменений, которую
полная пересборка стирает.
### Что делать, когда стенд сломался
Открывается после ступени 7 основной программы; весь раздел — повышенная
сложность.
- **Что ломается вместе с keeper.** Остановка keeper связывает состояние DDL и
репликации с тем, что видно на панелях и в системных таблицах.
- **Где искать запрос DDL, который не завершился.** Очередь распределенного DDL
показывает путь запроса и ответ каждой ноды.
Итог раздела — runbook, которого сейчас не хватает стенду. Менти пишет его по
результатам опытов, и документ остается в проекте.
## 5. Границы вычитания по потокам
Подробное сравнение потоков и таблица доказательств остаются в
[инвентаризации](../research/2026-08-31-labs-inventory.md). Спека берет это
сравнение за основу и проводит свои границы. Без оговорок вычитанию отдаются
только сессии DDS. Заказы можно вычитать лишь после самодельных сторожей. Для
событий ODS выбрана форма «собери рядом»; вычитание здесь отклонено. Прием STG
и забор заказов остаются несущей стеной и доступны только для расследований.
Карту идентичностей спека решает не отдавать вычитанию; причины этого выбора
названы в следующем разделе.
## 6. Что не вошло
Вычитание стендовых витрин DM не входит в программу. Оно повторяет урок
готового ядра DM, а открытые сверочные запросы в `sql/dq/` почти дословно
содержат ответ.
Форма «собери рядом» для карты идентичностей тоже не входит. Независимого
эталона нет, а слабые инварианты проверяют допустимость связей, но не полноту.
Аналитический урок идентичности уже несет склейка во времени (#145) в
факультативе «Тонкий анализ данных».
Сюжеты, которые инвентаризация относит к лекциям, остаются лекциями: `Sign` и
CollapsingMergeTree, топологии в бою и идентичность «как в бою». Эта спека
оставляет их в
[списке опорных точек раздела 12 спеки v2](2026-07-30-stand-v2-realism.md#опорные-точки-для-будущих-лекций-и-лаб).
## 7. Судьба идей-кандидатов
Эти решения закрывают тикеты после приемки спеки:
- #143, справочник регионов — вошла в факультатив «Тонкий анализ данных».
- #145, склейка во времени — вошла в факультатив «Тонкий анализ данных».
- #148, домашки ниже DM и вычитание — вошла в основную программу как ступени
4–6. Форму для каждого потока уточнила инвентаризация #201: вычитание,
«сначала сторож» или «собери рядом».
- #149, инкрементальный `daily_traffic` — вошла в факультатив «Инженерия
стенда» с пометкой «повышенная сложность».
- #186, воронка подтверждений — вошла в факультатив «Почему выручка не
сходится».
- #187, бизнес-дубль — вошла в факультатив «Почему выручка не сходится».
- #188, опоздавшие заказы — вошла в факультатив «Почему выручка не сходится».
## 8. Отклоненные варианты оси
- **Чистое «снизу вверх»: STG → ODS → DDS → DM.** Оно начинает программу с
самого тяжелого по инфраструктуре участка. Внизу быстро кончается эталон
обратной связи, а вычитание вслепую заставляет переписывать слой, которым
менти еще не пользовался.
- **Два прохода: сначала расследования снизу вверх, потом постройка сверху
вниз.** Пролог из восьми расследований откладывает первый собственный
результат. Вместо этого расследования разнесены по факультативам и
открываются по мере прохождения основной программы.
- **Сюжетами поперек слоев.** Такой порядок ломает принятое решение, по
которому готовые задания открывают программу, и превращает путь в сетку
зависимостей.
## 9. Границы спеки и что дальше
Спека не переписывает существующие ТЗ в `docs/labs/` и общий README; точечный
пересмотр относится к реализации.
Реализация программы тоже находится за границей спеки. В нее входят новые ТЗ
для ступеней 2–7 основной программы и факультативов, а также отражение порядка
программы в `docs/labs/README.md`. Карту #182 приемка этой спеки закрыла;
исполнение ведет отдельная карта — тикет #206 (решение тикета #203).
+164 -11
View File
@@ -53,6 +53,7 @@
""" """
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -160,6 +161,14 @@ class Purchases:
return self.person_id.size return self.person_id.size
class EventOutcome(IntEnum):
"""Что случилось с маячком purchase после создания заказа."""
DELIVERED = 0
LOST = 1
DUPLICATED = 2
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _Events: class _Events:
"""Строки одного вида торговых событий, готовые встать в поток. """Строки одного вида торговых событий, готовые встать в поток.
@@ -177,9 +186,18 @@ class _Events:
basket: NDArray[np.int64] basket: NDArray[np.int64]
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и @dataclass(frozen=True, slots=True)
# товары — и покупки дня вторым выходом. class Woven:
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases] """Что торговая половина отдает дню после вплетения событий."""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
purchases: Purchases
event_outcome: NDArray[np.int8]
# Для каждой покупки: назначен ли её заказ планом.
assigned_order: NDArray[np.bool_]
# День без единой корзины: покупок в нём нет, а форма у рядов есть. # День без единой корзины: покупок в нём нет, а форма у рядов есть.
_NO_PURCHASES = Purchases( _NO_PURCHASES = Purchases(
@@ -191,6 +209,8 @@ _NO_PURCHASES = Purchases(
revenue=np.empty(0, dtype=np.int64), revenue=np.empty(0, dtype=np.int64),
moment=np.empty(0, dtype="datetime64[s]"), moment=np.empty(0, dtype="datetime64[s]"),
) )
_NO_EVENT_OUTCOMES = np.empty(0, dtype=np.int8)
_NO_ASSIGNED_ORDERS = np.empty(0, dtype=np.bool_)
def weave( def weave(
@@ -200,6 +220,7 @@ def weave(
page: NDArray[np.uint8], page: NDArray[np.uint8],
product: NDArray[np.int64], product: NDArray[np.int64],
person: NDArray[np.uint64], person: NDArray[np.uint64],
assigned_order: NDArray[np.bool_],
) -> Woven: ) -> Woven:
"""Вплетает торговые события в поток и отдаёт поток и покупки дня. """Вплетает торговые события в поток и отдаёт поток и покупки дня.
@@ -207,14 +228,21 @@ def weave(
события встают между ними, и поток пересобирается одним порядком. Второй события встают между ними, и поток пересобирается одним порядком. Второй
выход — покупки дня: то же самое, чем они уехали в события. выход — покупки дня: то же самое, чем они уехали в события.
`person` — человек за кукой каждой строки, выровненный по входящему `person` и `assigned_order` выровнены по входящему потоку. Оба
потоку: личность приходит от плана состава, а не выводится из трекера признака приходят из плана состава: личность дает заказу `user_id`,
(docs/architecture/orders/identity.md). а назначение защищает мост склейки от потери purchase.
""" """
rng = day_stream(seed, day, Component.COMMERCE) rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(rng, page, product, columns["VisitID"]) baskets = _baskets(rng, page, product, columns["VisitID"])
if not len(baskets): if not len(baskets):
return columns, page, product, _NO_PURCHASES return Woven(
columns=columns,
page=page,
product=product,
purchases=_NO_PURCHASES,
event_outcome=_NO_EVENT_OUTCOMES,
assigned_order=_NO_ASSIGNED_ORDERS,
)
goods = catalog.catalog() goods = catalog.catalog()
draws = _draws(rng, baskets) draws = _draws(rng, baskets)
@@ -225,8 +253,20 @@ def weave(
_order_events(columns, baskets, goods, draws, revenue, coupons), _order_events(columns, baskets, goods, draws, revenue, coupons),
) )
columns, page, product, basket = _stream(rng, columns, page, product, events) columns, page, product, basket = _stream(rng, columns, page, product, events)
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person) purchases, assigned = _purchases(
return columns, page, product, purchases columns, basket, baskets, draws, revenue, coupons, person, assigned_order
)
columns, page, product, event_outcome = _corrupt_events(
seed, day, columns, page, product, purchases, assigned
)
return Woven(
columns=columns,
page=page,
product=product,
purchases=purchases,
event_outcome=event_outcome,
assigned_order=assigned,
)
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws: def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
@@ -653,7 +693,8 @@ def _purchases(
revenue: NDArray[np.int64], revenue: NDArray[np.int64],
coupons: NDArray[np.object_], coupons: NDArray[np.object_],
person: NDArray[np.uint64], person: NDArray[np.uint64],
) -> Purchases: assigned_order: NDArray[np.bool_],
) -> tuple[Purchases, NDArray[np.bool_]]:
"""Покупки дня — то же, чем они уехали в события, только структурой. """Покупки дня — то же, чем они уехали в события, только структурой.
Порядок здесь — порядок строк потока, он же порядок номеров заказа: Порядок здесь — порядок строк потока, он же порядок номеров заказа:
@@ -664,7 +705,7 @@ def _purchases(
here = np.flatnonzero(rows["EventType"] == PURCHASE) here = np.flatnonzero(rows["EventType"] == PURCHASE)
mine = basket[here] mine = basket[here]
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()] bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
return Purchases( purchases = Purchases(
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()), order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
person_id=person[baskets.confirmation[mine]], person_id=person[baskets.confirmation[mine]],
product=tuple(baskets.product[group] for group in bought), product=tuple(baskets.product[group] for group in bought),
@@ -673,6 +714,118 @@ def _purchases(
revenue=revenue[mine], revenue=revenue[mine],
moment=rows["UTCEventTime"][here], moment=rows["UTCEventTime"][here],
) )
return purchases, assigned_order[baskets.confirmation[mine]]
def _corrupt_events(
seed: int,
day: int,
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
purchases: Purchases,
assigned: NDArray[np.bool_],
) -> tuple[
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int8]
]:
"""Теряет purchase или повторяет пару подтверждения.
Заказы уже собраны, а номера покупок уже вписаны в строки. Поэтому
порча меняет только событийную сторону. Все броски делаются на полную
длину покупок дня, чтобы отбор одного класса не сдвигал другой.
"""
count = len(purchases)
if not count:
return rows, page, product, _NO_EVENT_OUTCOMES
rng = day_stream(seed, day, Component.BEACONS)
lost = (rng.integers(0, 100, count) < world.LOST_PURCHASE_PERCENT) & ~assigned
duplicate_draw = rng.integers(0, 100, count) < world.DUPLICATE_PURCHASE_PERCENT
delay = rng.integers(*world.DUPLICATE_DELAY_SECONDS, count)
watch = ids.unique_apart_from(rng, 2 * count, rows["WatchID"]).reshape(count, 2)
purchase_rows = np.flatnonzero(rows["EventType"] == PURCHASE)
confirmation_rows = _confirmation_rows(rows, page, purchase_rows)
eligible = _duplicate_eligibility(rows, purchase_rows, delay)
duplicated = ~lost & duplicate_draw & eligible
outcome = np.full(count, EventOutcome.DELIVERED, dtype=np.int8)
outcome[lost] = EventOutcome.LOST
outcome[duplicated] = EventOutcome.DUPLICATED
keep = np.ones(page.size, dtype=bool)
keep[purchase_rows[lost]] = False
sources = np.column_stack(
(confirmation_rows[duplicated], purchase_rows[duplicated])
)
selected = np.flatnonzero(duplicated)
if selected.size:
copies = sources.ravel()
shifted = np.repeat(delay[selected], 2).astype("timedelta64[s]")
additions = {name: values[copies] for name, values in rows.items()}
additions["UTCEventTime"] = additions["UTCEventTime"] + shifted
additions["WatchID"] = watch[selected].ravel()
# Первая строка пары — обновление confirmation: ее предыдущая
# страница — она же, а не checkout из первого просмотра.
additions["Referer"] = additions["URL"].copy()
rows = {
name: np.concatenate((values[keep], additions[name]))
for name, values in rows.items()
}
page = np.concatenate((page[keep], page[copies]))
product = np.concatenate((product[keep], product[copies]))
else:
rows = {name: values[keep] for name, values in rows.items()}
page = page[keep]
product = product[keep]
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
return (
{name: values[order] for name, values in rows.items()},
page[order],
product[order],
outcome,
)
def _confirmation_rows(
rows: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
purchase_rows: NDArray[np.int64],
) -> NDArray[np.int64]:
"""Находит просмотр, с которым purchase образует пару."""
confirmation = (rows["EventType"] == "pageview") & (page == Page.CONFIRMATION)
by_visit = {
int(rows["VisitID"][row]): row for row in np.flatnonzero(confirmation).tolist()
}
return np.array(
[by_visit[int(rows["VisitID"][row])] for row in purchase_rows.tolist()],
dtype=np.int64,
)
def _duplicate_eligibility(
rows: dict[str, NDArray[Any]],
purchase_rows: NDArray[np.int64],
delay: NDArray[np.int64],
) -> NDArray[np.bool_]:
"""Проверяет полночь и запас до следующего визита куки."""
moment = rows["UTCEventTime"][purchase_rows]
shifted = moment + delay.astype("timedelta64[s]")
before_midnight = shifted < _midnight(rows) + np.timedelta64(1, "D")
eligible = before_midnight.copy()
for number, row in enumerate(purchase_rows.tolist()):
later_visit = (
(rows["ClientID"] == rows["ClientID"][row])
& (rows["VisitID"] != rows["VisitID"][row])
& (rows["UTCEventTime"] > moment[number])
)
if np.any(later_visit):
next_start = rows["UTCEventTime"][later_visit].min()
eligible[number] &= next_start - moment[number] > np.timedelta64(
world.VISIT_TIMEOUT_SECONDS + int(delay[number]), "s"
)
return eligible
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64: def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
+10 -5
View File
@@ -100,6 +100,8 @@ class Day:
page: NDArray[np.uint8] page: NDArray[np.uint8]
product: NDArray[np.int64] product: NDArray[np.int64]
orders: orders.Orders orders: orders.Orders
# Событийный исход каждого заказа, в том же порядке.
purchase_outcome: NDArray[np.int8]
def __len__(self) -> int: def __len__(self) -> int:
return self.page.size return self.page.size
@@ -162,20 +164,23 @@ def stream(seed: int, day: int) -> Day:
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят # целиком: в нём же они и упорядочиваются. Вторым выходом приходят
# покупки дня — из них заказная сторона собирает заказы. # покупки дня — из них заказная сторона собирает заказы.
person = np.repeat(audience.person_id[visits.cookie], visits.pages) person = np.repeat(audience.person_id[visits.cookie], visits.pages)
columns, page, product, purchases = commerce.weave( assigned_order = np.repeat(visits.ordering, visits.pages)
woven = commerce.weave(
seed, seed,
day, day,
{name: value[order] for name, value in columns.items()}, {name: value[order] for name, value in columns.items()},
page[alive][order], page[alive][order],
product[alive][order], product[alive][order],
person[alive][order], person[alive][order],
assigned_order[alive][order],
) )
return Day( return Day(
day=day, day=day,
columns=columns, columns=woven.columns,
page=page, page=woven.page,
product=product, product=woven.product,
orders=orders.of_day(seed, day, purchases), orders=orders.of_day(seed, day, woven.purchases, woven.assigned_order),
purchase_outcome=woven.event_outcome,
) )
@@ -8,8 +8,9 @@
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7. понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
этой же описи, а не вторым файлом. у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
же файл, а не заведет второй.
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем **Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
@@ -119,17 +120,38 @@ def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]
def _order_class_counts(today: day_module.Day) -> dict[str, int]: def _order_class_counts(today: day_module.Day) -> dict[str, int]:
"""Заказы дня по итоговому классу: отмена перевешивает дельту суммы.""" """Заказы дня по итоговому классу по приоритету спеки."""
purchase = today.columns["EventType"] == commerce.PURCHASE purchase = today.columns["EventType"] == commerce.PURCHASE
declared = today.columns["purchaseRevenue"][purchase] declared = {
counts = {"match": 0, "cancelled": 0, "amount_delta": 0} purchase_id[0]: round(revenue[0] * commerce.KOPECKS)
for purchase_id, revenue in zip(
today.columns["purchaseID"][purchase],
today.columns["purchaseRevenue"][purchase],
strict=True,
)
}
counts = {
"match": 0,
"cancelled": 0,
"lost_event": 0,
"duplicate_event": 0,
"amount_delta": 0,
}
for outcome, items_total, revenue in zip( for order_id, order_outcome, event_outcome, items_total in zip(
today.orders.outcome, today.orders.items_total, declared, strict=True today.orders.order_id,
today.orders.outcome,
today.purchase_outcome,
today.orders.items_total,
strict=True,
): ):
if outcome != orders_module.OrderOutcome.PAID: if order_outcome != orders_module.OrderOutcome.PAID:
counts["cancelled"] += 1 counts["cancelled"] += 1
elif items_total != round(revenue[0] * commerce.KOPECKS): elif event_outcome == commerce.EventOutcome.LOST:
counts["lost_event"] += 1
elif event_outcome == commerce.EventOutcome.DUPLICATED:
counts["duplicate_event"] += 1
elif items_total != declared[order_id]:
counts["amount_delta"] += 1 counts["amount_delta"] += 1
else: else:
counts["match"] += 1 counts["match"] += 1
+31 -6
View File
@@ -24,11 +24,13 @@
одной доли перебрасывала бы весь подпоток после себя одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md). (docs/architecture/orders/fate.md).
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит **Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
оплаченным или отменённым, а когда именно это случилось, сказано смещением в изменяемости заказ выходит оплаченным или отменённым, а когда именно это
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных: случилось, сказано смещением в секундах от рождения заказа. Момента, которого
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
бы не отличить от «не оплатили вовсе». «оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
приезжает сразу.
**Состояние на границе суток** — чтение готовой судьбы, а не накопление: **Состояние на границе суток** — чтение готовой судьбы, а не накопление:
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
@@ -62,6 +64,7 @@ _DELIVERY_CUMULATIVE = np.cumsum(
) )
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS) _OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS) _MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
class OrderOutcome(IntEnum): class OrderOutcome(IntEnum):
@@ -103,12 +106,18 @@ class Orders:
outcome: NDArray[np.int64] outcome: NDArray[np.int64]
paid_after: NDArray[np.int64] paid_after: NDArray[np.int64]
cancelled_after: NDArray[np.int64] cancelled_after: NDArray[np.int64]
snapshot_delay: NDArray[np.int64]
def __len__(self) -> int: def __len__(self) -> int:
return self.user_id.size return self.user_id.size
def of_day(seed: int, day: int, purchases: Purchases) -> Orders: def of_day(
seed: int,
day: int,
purchases: Purchases,
assigned_order: NDArray[np.bool_],
) -> Orders:
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги.""" """Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS) rng = day_stream(seed, day, Component.ORDERS)
delivery = _delivery(rng, len(purchases)) delivery = _delivery(rng, len(purchases))
@@ -116,6 +125,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
product, quantity, items_total = _delta(rng, purchases) product, quantity, items_total = _delta(rng, purchases)
created_at = _created_at(rng, purchases) created_at = _created_at(rng, purchases)
discount = _discount(purchases, items_total) discount = _discount(purchases, items_total)
snapshot_delay = _snapshot_delay(rng, assigned_order)
return Orders( return Orders(
day=day, day=day,
order_id=purchases.order_id, order_id=purchases.order_id,
@@ -130,6 +140,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
outcome=outcome, outcome=outcome,
paid_after=paid_after, paid_after=paid_after,
cancelled_after=cancelled_after, cancelled_after=cancelled_after,
snapshot_delay=snapshot_delay,
) )
@@ -166,6 +177,11 @@ def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime6
return status.tolist(), updated return status.tolist(), updated
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
return rows.day + rows.snapshot_delay <= day
def _boundary(day: int) -> np.datetime64: def _boundary(day: int) -> np.datetime64:
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика. """Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
@@ -218,6 +234,15 @@ def _created_at(
) )
def _snapshot_delay(
rng: np.random.Generator,
assigned_order: NDArray[np.bool_],
) -> NDArray[np.int64]:
"""Сколько ранних слепков пропустит заказ; назначенный приезжает сразу."""
delay = pick(rng, _SNAPSHOT_DELAY_CUMULATIVE, assigned_order.size)
return np.where(assigned_order, 0, delay)
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]: def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём. """Момент внутри окна: час по таблице весов и равномерная секунда в нём.
+3 -5
View File
@@ -7,7 +7,7 @@
│ ├── ось → номер дня: когорта этого дня │ ├── ось → номер дня: когорта этого дня
│ └── предыстория → глубина: когорта дня до D0 │ └── предыстория → глубина: когорта дня до D0
└── дни └── дни
└── номер дня → трафик, торговля, заказная сторона, опоздания └── номер дня → трафик, торговля, заказы, маячки счетчика
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7 (зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
@@ -40,10 +40,8 @@ class Component(IntEnum):
# каждого заказа читает из его собственного дня # каждого заказа читает из его собственного дня
# (docs/architecture/orders/fate.md). # (docs/architecture/orders/fate.md).
ORDERS = 2 ORDERS = 2
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех # Маячки счетчика: потеря и повторная отправка событий.
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не BEACONS = 3
# бросает.
LATECOMERS = 3
class _Branch(IntEnum): class _Branch(IntEnum):
@@ -45,7 +45,7 @@ from numpy.typing import NDArray
from clickstream_generator import catalog, schema, world from clickstream_generator import catalog, schema, world
from clickstream_generator.day import Day from clickstream_generator.day import Day
from clickstream_generator.orders import Orders, at_boundary from clickstream_generator.orders import Orders, arrived, at_boundary
_ARRAY_PREFIX = "Array(" _ARRAY_PREFIX = "Array("
@@ -72,9 +72,10 @@ def events(day: Day, limit: int | None = None) -> list[bytes]:
def orders(window: Sequence[Orders], day: int) -> list[bytes]: def orders(window: Sequence[Orders], day: int) -> list[bytes]:
"""Канонические байты слепка дня `day`: по документу JSON на заказ. """Канонические байты слепка дня `day`: по документу JSON на заказ.
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их `window` — заказы дней окна, от раннего дня к позднему. Уже приехавшие
подряд, и порядок строк выходит порядком рождения заказов, он же заказы слепок несёт подряд, поэтому порядок строк остаётся порядком
возрастание `order_id`. Какие это дни, решает `orders.window`. рождения, он же возрастание `order_id`. Какие это дни, решает
`orders.window`.
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
станут `Decimal`, и дробь двоичного числа была бы потерей точности до станут `Decimal`, и дробь двоичного числа была бы потерей точности до
@@ -97,7 +98,8 @@ def orders(window: Sequence[Orders], day: int) -> list[bytes]:
delivery = rows.delivery.tolist() delivery = rows.delivery.tolist()
total = rows.total.tolist() total = rows.total.tolist()
for number, order_id in enumerate(rows.order_id): for number in np.flatnonzero(arrived(rows, day)).tolist():
order_id = rows.order_id[number]
payloads.append( payloads.append(
orjson.dumps( orjson.dumps(
{ {
+26 -9
View File
@@ -169,6 +169,16 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60
# страницу, на которой посетитель его нажал. # страницу, на которой посетитель его нажал.
TRADE_DELAY_SECONDS = (2, 8) TRADE_DELAY_SECONDS = (2, 8)
# Событийная сторона сверки: потерянные маячки purchase и повторная
# отправка пары «просмотр подтверждения + purchase». Замер (28 дней,
# канонический мир, 2026-08-30): дубль 1,89% — в пределах ориентира ~2%;
# потеря 1,78% при броске 3% — так и задумано: назначенные планом покупки
# из броска исключены (мост склейки), часть съедает приоритет отмены;
# наблюдаемая доля принята решением владельца (#194).
LOST_PURCHASE_PERCENT = 3
DUPLICATE_PURCHASE_PERCENT = 2
DUPLICATE_DELAY_SECONDS = (30, 301)
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла. # Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`): # Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
# магнит, обычный, залёживается. # магнит, обычный, залёживается.
@@ -238,8 +248,8 @@ GOAL_PURCHASE_ID = 42150002
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет # обычная курьерская, срочная. Это единственные деньги заказа, которых нет
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду # ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок # считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
# заказной стороны. Числа черновые: калибровка — пересборка эталонного # заказной стороны. Замер (28 дней, канонический мир, 2026-08-30): 35,5% /
# мира, этап 7. # 49,2% / 15,4% — совпадает с весами.
DELIVERY_KOPECKS_WEIGHTS = ( DELIVERY_KOPECKS_WEIGHTS = (
(0, 35), (0, 35),
(29_900, 50), (29_900, 50),
@@ -248,12 +258,13 @@ DELIVERY_KOPECKS_WEIGHTS = (
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке # Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ # `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
# выходит либо оплаченным, либо отменённым. Доли черновые: калибровка — # выходит либо оплаченным, либо отменённым. Замер (28 дней, 2026-08-30):
# пересборка эталонного мира, этап 7. # отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12% отменен
# неоплаченным; ориентир мастер-спеки ~5%.
ORDER_OUTCOME_WEIGHTS = ( ORDER_OUTCOME_WEIGHTS = (
95, # оплачен 95, # оплачен
3, # оплачен, потом отменён 3, # оплачен, потом отменён
2, # отменён неоплаченным 2, # отменен неоплаченным
) )
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов # Когда внутри окна случается момент заказа — оплата или отмена: веса часов
@@ -261,8 +272,8 @@ ORDER_OUTCOME_WEIGHTS = (
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа, # с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в # рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
# первые сутки, дальше затухание до края: # первые сутки, дальше затухание до края:
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса черновые, как и # профиль гаснет, поэтому обрыв окна в данных не виден. Веса зафиксированы
# доли исходов. # вместе с долями исходов — тем же замером выше.
ORDER_MOMENT_HOUR_WEIGHTS = tuple( ORDER_MOMENT_HOUR_WEIGHTS = tuple(
weight weight
for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2)) for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2))
@@ -273,6 +284,12 @@ ORDER_MOMENT_HOUR_WEIGHTS = tuple(
# наличии, и заказ приезжает на строку короче клиентской корзины. Бросается # наличии, и заказ приезжает на строку короче клиентской корзины. Бросается
# доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу # доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу
# терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля # терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Число # выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Замер
# черновое: калибровка — пересборка эталонного мира, этап 7. # (28 дней, 2026-08-30): наблюдаемая доля 1,32% — в пределах ориентира.
ORDER_DELTA_PERCENT = 4 ORDER_DELTA_PERCENT = 4
# На сколько первых дневных слепков заказ опоздает: веса δ = 0, 1, 2.
# Назначенные планом заказы после броска получают 0, чтобы мост склейки
# появился в стартовом мире. Замер (28 дней, 2026-08-30): доля опоздавших
# (δ>0) 8,39% — в пределах ориентира мастер-спеки ~10%.
ORDER_SNAPSHOT_DELAY_WEIGHTS = (90, 5, 5)
+123 -25
View File
@@ -21,7 +21,7 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2 WEEKDAY = 2
WEEK = 7 WEEK = 7
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком — # Окно замера — две недели: доли по уровням спроса меряются на нем целиком —
# на одном дне у самого редкого уровня набирается слишком мало карточек. # на одном дне у самого редкого уровня набирается слишком мало карточек.
FORTNIGHT = 14 FORTNIGHT = 14
@@ -133,15 +133,15 @@ def opened_cards(events: day.Day) -> set[tuple[int, int]]:
) )
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day): def test_only_a_lost_purchase_leaves_a_confirmation_without_purchase(
"""Клиентская сторона честная: подтверждение без покупки — это потеря. weekday: day.Day,
):
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их """Потеря выбрасывает purchase, но оставляет просмотр confirmation."""
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке — заказ ниоткуда.
"""
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist()) bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
assert confirmed_visits(weekday) == bought missing = confirmed_visits(weekday) - bought
lost = int((weekday.purchase_outcome == commerce.EventOutcome.LOST).sum())
assert len(missing) == lost > 0
assert bought <= confirmed_visits(weekday)
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase( def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
@@ -159,7 +159,10 @@ def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
events = day.stream(CANONICAL_SEED, WEEKDAY) events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist()) bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
assert bought assert bought
assert confirmed_visits(events) == bought missing = confirmed_visits(events) - bought
lost = int((events.purchase_outcome == commerce.EventOutcome.LOST).sum())
assert len(missing) == lost
assert bought <= confirmed_visits(events)
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day): def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
@@ -525,13 +528,15 @@ def test_the_goals_repeat_the_trade_events(weekday: day.Day):
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day): def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда.""" """Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE) events = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]] numbers = [cell[0] for cell in events["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "") date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)] expected = [f"{date}-{place:04d}" for place in range(1, len(weekday.orders) + 1)]
assert len(set(numbers)) == len(numbers) # Порча идет после нумерации: потеря оставляет дыру, а дубль
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени. # повторяет готовый номер и не двигает соседей.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"]) assert list(weekday.orders.order_id) == expected
assert set(numbers) <= set(expected)
assert list(events["UTCEventTime"]) == sorted(events["UTCEventTime"])
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue( def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
@@ -599,7 +604,23 @@ def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch.setattr(commerce, "day_stream", spy) monkeypatch.setattr(commerce, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY) day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)] assert asked == [
(CANONICAL_SEED, WEEKDAY, Component.COMMERCE),
(CANONICAL_SEED, WEEKDAY, Component.BEACONS),
]
def test_an_assigned_purchase_is_never_lost(monkeypatch: pytest.MonkeyPatch):
"""Гарантия плана сильнее порчи: мост склейки не теряется."""
monkeypatch.setattr(world, "LOST_PURCHASE_PERCENT", 100)
monkeypatch.setattr(world, "DUPLICATE_PURCHASE_PERCENT", 0)
events = day.stream(CANONICAL_SEED, WEEKDAY)
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
promised = set(audience.client_id[audience.assigned_order].tolist())
bought = set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist())
assert promised
assert bought == promised
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]): def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
@@ -627,6 +648,78 @@ def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
def test_a_duplicate_repeats_the_confirmation_story(weekday: day.Day):
"""Обновление повторяет просмотр и purchase в том же визите."""
purchases = rows_of(weekday, commerce.PURCHASE)
ids_by_order: dict[str, list[int]] = {}
for row, cell in enumerate(purchases["purchaseID"]):
ids_by_order.setdefault(str(cell[0]), []).append(row)
duplicated = [rows for rows in ids_by_order.values() if len(rows) == 2]
expected = int((weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum())
assert len(duplicated) == expected > 0
for pair in duplicated:
first, second = sorted(pair, key=lambda row: purchases["UTCEventTime"][row])
delay = (
(purchases["UTCEventTime"][second] - purchases["UTCEventTime"][first])
.astype("timedelta64[s]")
.astype(int)
)
assert world.DUPLICATE_DELAY_SECONDS[0] <= delay
assert delay < world.DUPLICATE_DELAY_SECONDS[1]
assert delay < world.VISIT_TIMEOUT_SECONDS
assert purchases["VisitID"][first] == purchases["VisitID"][second]
assert purchases["WatchID"][first] != purchases["WatchID"][second]
for name in (
"purchaseID",
"purchaseRevenue",
"productID",
"productQuantity",
"ecommerce",
):
left, right = purchases[name][first], purchases[name][second]
if isinstance(left, np.ndarray):
assert np.array_equal(left, right), name
else:
assert left == right, name
visit = purchases["VisitID"][first]
confirmation = (
(weekday.columns["EventType"] == "pageview")
& (weekday.page == Page.CONFIRMATION)
& (weekday.columns["VisitID"] == visit)
)
views = np.flatnonzero(confirmation)
assert views.size == 2
assert len(set(weekday.columns["WatchID"][views].tolist())) == 2
view_delay = (
(
weekday.columns["UTCEventTime"][views].max()
- weekday.columns["UTCEventTime"][views].min()
)
.astype("timedelta64[s]")
.astype(int)
)
assert view_delay == delay
cookie = purchases["ClientID"][first]
later_visit = (
(weekday.columns["ClientID"] == cookie)
& (weekday.columns["VisitID"] != visit)
& (weekday.columns["UTCEventTime"] > purchases["UTCEventTime"][second])
)
if np.any(later_visit):
gap = (
(
weekday.columns["UTCEventTime"][later_visit].min()
- purchases["UTCEventTime"][second]
)
.astype("timedelta64[s]")
.astype(int)
)
assert gap > world.VISIT_TIMEOUT_SECONDS
def paired_cookies() -> set[int]: def paired_cookies() -> set[int]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём.""" """Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
cookies: set[int] = set() cookies: set[int] = set()
@@ -642,21 +735,26 @@ def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
«Постоянный покупатель» читается только так — как кука, которая ходит «Постоянный покупатель» читается только так — как кука, которая ходит
неделями и покупает не раз: метки в событии нет и не будет. неделями и покупает не раз: метки в событии нет и не будет.
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и Куки двухкуковых пар из счета исключены: их заказы назначил план, и
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются вместе с ними лифт мерил бы гарантию, а не два новых рычага. Даже после
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них событийной порчи оставшиеся помеченные покупают повторно более чем вдвое
против 1,5% прочих — втрое чаще при пороге «вдвое». чаще прочих.
""" """
purchases: dict[int, int] = {} purchases: dict[int, set[str]] = {}
flagged: set[int] = set() flagged: set[int] = set()
for number, events in enumerate(week): for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number) audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist()) flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist(): rows = rows_of(events, commerce.PURCHASE)
purchases[cookie] = purchases.get(cookie, 0) + 1 for cookie, order_id in zip(
rows["ClientID"].tolist(),
(str(cell[0]) for cell in rows["purchaseID"]),
strict=True,
):
purchases.setdefault(cookie, set()).add(order_id)
def repeat_share(cookies: set[int]) -> float: def repeat_share(cookies: set[int]) -> float:
again = sum(1 for cookie in cookies if purchases[cookie] > 1) again = sum(1 for cookie in cookies if len(purchases[cookie]) > 1)
return again / len(cookies) return again / len(cookies)
buyers = set(purchases) buyers = set(purchases)
+3
View File
@@ -68,6 +68,7 @@ def orders(events: day.Day) -> list[list[Any]]:
int(theirs.outcome[number]), int(theirs.outcome[number]),
int(theirs.paid_after[number]), int(theirs.paid_after[number]),
int(theirs.cancelled_after[number]), int(theirs.cancelled_after[number]),
int(theirs.snapshot_delay[number]),
] ]
for number in range(len(theirs)) for number in range(len(theirs))
] ]
@@ -88,6 +89,7 @@ def snapshot(events: day.Day) -> dict[str, Any]:
"page": events.page.tolist(), "page": events.page.tolist(),
"product": events.product.tolist(), "product": events.product.tolist(),
"orders": orders(events), "orders": orders(events),
"purchase_outcome": events.purchase_outcome.tolist(),
} }
@@ -115,6 +117,7 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
page=weekday.page, page=weekday.page,
product=weekday.product, product=weekday.product,
orders=weekday.orders, orders=weekday.orders,
purchase_outcome=weekday.purchase_outcome,
) )
assert snapshot(reordered) != original assert snapshot(reordered) != original
+26 -1
View File
@@ -27,6 +27,9 @@ INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory
# отправляемых; этот дешевле прочих — его окно короче. # отправляемых; этот дешевле прочих — его окно короче.
RUN_DAY = 2 RUN_DAY = 2
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
REPLAY_DAY = 4
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def inventory() -> dict: def inventory() -> dict:
@@ -48,7 +51,15 @@ def test_order_class_counters_cover_only_closed_days(inventory: dict):
rows = [row for row in inventory["days"] if "orders" in row] rows = [row for row in inventory["days"] if "orders" in row]
assert [row["day"] for row in rows] == [0] assert [row["day"] for row in rows] == [0]
assert set(rows[0]["orders"]) == {"match", "cancelled", "amount_delta"} assert set(rows[0]["orders"]) == {
"match",
"cancelled",
"lost_event",
"duplicate_event",
"amount_delta",
}
assert rows[0]["orders"]["lost_event"] > 0
assert rows[0]["orders"]["duplicate_event"] > 0
today = day_module.stream(CANONICAL_SEED, rows[0]["day"]) today = day_module.stream(CANONICAL_SEED, rows[0]["day"])
assert sum(rows[0]["orders"].values()) == len(today.orders) assert sum(rows[0]["orders"].values()) == len(today.orders)
@@ -72,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) row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1)
assert row["sha256"] == sent 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
+63 -18
View File
@@ -58,17 +58,52 @@ def user_id_by_order(events: day.Day) -> dict[str, int]:
) )
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day): def purchase_row_by_order(
"""Заказ — вторая проекция покупки, а не второе порождение. events: day.Day,
) -> tuple[dict[str, NDArray[Any]], dict[str, int]]:
"""Первая строка purchase каждого доехавшего заказа."""
rows = purchases_of(events)
index: dict[str, int] = {}
for row, cell in enumerate(rows["purchaseID"]):
index.setdefault(str(cell[0]), row)
return rows, index
У всякого заказа ровно одно событие `purchase`, и номер у них один:
сойтись двум источникам больше негде — сверка стенда соединяется def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
именно по нему. """Заказ рождается до потери или дубля его событийной проекции."""
"""
events = purchases_of(weekday) events = purchases_of(weekday)
numbers = [cell[0] for cell in events["purchaseID"]] numbers = [cell[0] for cell in events["purchaseID"]]
assert len(numbers) > 100 assert len(numbers) > 100
assert list(weekday.orders.order_id) == numbers lost = {
order_id
for order_id, outcome in zip(
weekday.orders.order_id, weekday.purchase_outcome, strict=True
)
if outcome == commerce.EventOutcome.LOST
}
assert set(numbers) == set(weekday.orders.order_id) - lost
assert len(numbers) - len(set(numbers)) == int(
(weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum()
)
def test_an_assigned_order_never_waits_for_a_later_snapshot(
monkeypatch: pytest.MonkeyPatch,
):
"""Гарантия плана сильнее опоздания: мост склейки приезжает сразу."""
# Все неназначенные заказы опаздывают на два дня. Нулевую задержку после
# такого броска могут получить только заказы, защищённые планом.
monkeypatch.setattr(
orders, "_SNAPSHOT_DELAY_CUMULATIVE", np.array([0, 0, 1], dtype=np.int64)
)
events = day.stream(CANONICAL_SEED, WEEKDAY)
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
promised = set(audience.person_id[audience.assigned_order].tolist())
immediate = set(events.orders.user_id[events.orders.snapshot_delay == 0].tolist())
assert promised
assert immediate == promised
assert (events.orders.snapshot_delay == 2).any()
def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]): def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
@@ -88,16 +123,18 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
""" """
goods = catalog.catalog() goods = catalog.catalog()
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True)) price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
events = purchases_of(weekday) events, row_by_order = purchase_row_by_order(weekday)
money = weekday.orders money = weekday.orders
deltas = 0 deltas = 0
for number, order in enumerate(weekday.orders.order_id): for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0] if weekday.purchase_outcome[number] == commerce.EventOutcome.LOST:
continue
row = row_by_order[order]
client = list( client = list(
zip( zip(
events["productID"][number].tolist(), events["productID"][row].tolist(),
events["productQuantity"][number].tolist(), events["productQuantity"][row].tolist(),
strict=True, strict=True,
) )
) )
@@ -110,7 +147,7 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
) )
# Выручка клиента и `items_total` бэкенда — одно число: в событии # Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках. # оно дробное, у заказа целое в копейках.
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS) revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
if mine == client: if mine == client:
assert money.items_total[number] == revenue assert money.items_total[number] == revenue
continue continue
@@ -136,15 +173,18 @@ def test_the_discount_comes_from_the_coupon_of_the_event(
for today in start_world: for today in start_world:
money = today.orders money = today.orders
events = purchases_of(today) events, row_by_order = purchase_row_by_order(today)
codes = [cell[0] for cell in events["purchaseCoupon"]] for number, order_id in enumerate(money.order_id):
for number, code in enumerate(codes): if today.purchase_outcome[number] == commerce.EventOutcome.LOST:
continue
row = row_by_order[order_id]
code = events["purchaseCoupon"][row][0]
items_total = money.items_total[number] items_total = money.items_total[number]
expected = items_total * percent[code] // 100 if code else 0 expected = items_total * percent[code] // 100 if code else 0
assert money.discount[number] == expected, money.order_id[number] assert money.discount[number] == expected, money.order_id[number]
assert money.discount[number] < items_total assert money.discount[number] < items_total
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS) revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
discounted_delta += int(bool(code) and items_total != revenue) discounted_delta += int(bool(code) and items_total != revenue)
# Иначе проверка не отличила бы новую базу скидки от прежней. # Иначе проверка не отличила бы новую базу скидки от прежней.
@@ -255,8 +295,13 @@ def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True) zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
) )
events = purchases_of(weekday) events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()): by_order = user_id_by_order(weekday)
assert weekday.orders.user_id[number] == person[cookie] for order_id, cookie in zip(
(cell[0] for cell in events["purchaseID"]),
events["ClientID"].tolist(),
strict=True,
):
assert by_order[order_id] == person[cookie]
assert np.all(weekday.orders.user_id > 0) assert np.all(weekday.orders.user_id > 0)
assert weekday.orders.user_id.max() < 2**53 assert weekday.orders.user_id.max() < 2**53
+12 -12
View File
@@ -14,8 +14,8 @@ import pytest
from clickstream_generator import plan, reference, world from clickstream_generator import plan, reference, world
from clickstream_generator.seeds import CANONICAL_SEED from clickstream_generator.seeds import CANONICAL_SEED
# Горизонт эталонного снимка — две недели (спека, раздел 5). # Окно замера плана — две недели: два цикла недельной сезонности.
SNAPSHOT_DAYS = 14 WINDOW_DAYS = 14
@pytest.fixture(autouse=True) @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(): 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() 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(): 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(): def test_the_daily_audience_matches_the_spec_band():
"""6–8 тыс. посетителей в день — правдоподобный средний магазин.""" """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) assert all(6_000 <= size <= 8_000 for size in counters.audience)
def test_the_influx_holds_its_daily_number(): 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) average = sum(counters.new_cookies) / len(counters.new_cookies)
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10 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: def mean(numbers: tuple[int, ...] | list[int]) -> float:
return sum(numbers) / len(numbers) 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] 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] weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean( 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(): def test_the_influx_is_visible_on_the_plan():
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится.""" """Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
week = plan.counters(CANONICAL_SEED, 7) 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 week.visitors < fortnight.visitors
assert fortnight.visitors > 2 * max(fortnight.audience) 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(): def test_the_horizon_is_a_prefix_not_another_world():
"""Продление истории днём N+1 не трогает дни 1…N.""" """Продление истории днём N+1 не трогает дни 1…N."""
week = plan.counters(CANONICAL_SEED, 7) 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.audience[:7] == week.audience
assert fortnight.new_cookies[:7] == week.new_cookies assert fortnight.new_cookies[:7] == week.new_cookies
def test_counters_count_the_same_audience_that_the_day_gets(): def test_counters_count_the_same_audience_that_the_day_gets():
"""Счётчики и дня-функция спрашивают один план — расходиться им негде.""" """Счётчики и дня-функция спрашивают один план — расходиться им негде."""
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS) counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
for day in (0, 7, SNAPSHOT_DAYS - 1): for day in (0, 7, WINDOW_DAYS - 1):
actual = plan.audience(CANONICAL_SEED, day).client_id.size actual = plan.audience(CANONICAL_SEED, day).client_id.size
assert actual == counters.audience[day] assert actual == counters.audience[day]
def test_counters_know_the_pairs_before_a_single_event(): 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 assert counters.pairs > 0
+1 -1
View File
@@ -78,7 +78,7 @@ def test_day_components_do_not_share_randomness():
Component.TRAFFIC, Component.TRAFFIC,
Component.COMMERCE, Component.COMMERCE,
Component.ORDERS, Component.ORDERS,
Component.LATECOMERS, Component.BEACONS,
) )
assert len({int(component) for component in components}) == 4 assert len({int(component) for component in components}) == 4
draws = { draws = {
+64 -2
View File
@@ -128,7 +128,9 @@ def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
) )
if _born(record) == _date(SHORT_WINDOW_DAY): if _born(record) == _date(SHORT_WINDOW_DAY):
assert record["created_at"][:19] + "Z" == bought[record["order_id"]] # Потерянного purchase в трекере нет, но заказ остается.
if record["order_id"] in bought:
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]): def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]):
@@ -174,6 +176,63 @@ def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
assert len(set(numbers)) == len(numbers) assert len(set(numbers)) == len(numbers)
def test_lateness_changes_only_the_rows_present_in_a_snapshot(
days: list[day_module.Day],
):
"""Неопоздавшие строки остаются прежними байтами и в прежнем порядке."""
number = SHORT_WINDOW_DAY
window = [today.orders for today in days[: number + 1]]
eager = serialize.orders(
[
replace(rows, snapshot_delay=np.zeros_like(rows.snapshot_delay))
for rows in window
],
number,
)
actual = serialize.orders(window, number)
actual_ids = {json.loads(payload)["order_id"] for payload in actual}
expected = [
payload for payload in eager if json.loads(payload)["order_id"] in actual_ids
]
assert len(actual) < len(eager)
assert actual == expected
def test_a_two_day_late_order_first_arrives_in_its_lived_state(
days: list[day_module.Day],
):
"""Заказ δ = 2 пропускает D и D+1 и догоняет жизнь, не меняя `created_at`."""
honest = days[0].orders
target = 0
delay = np.zeros_like(honest.snapshot_delay)
delay[target] = 2
cancelled_after = honest.cancelled_after.copy()
cancelled_after[target] = 30 * 3600
late = replace(
honest,
snapshot_delay=delay,
cancelled_after=cancelled_after,
)
order_id = late.order_id[target]
for number in (0, 1):
assert order_id not in {
json.loads(payload)["order_id"]
for payload in serialize.orders([late], number)
}
arrived = {
record["order_id"]: record
for record in map(json.loads, serialize.orders([late], 2))
}[order_id]
created_at = np.datetime_as_string(
late.created_at[target], unit="ms", timezone="UTC"
)
assert arrived["status"] == "cancelled"
assert arrived["created_at"] == created_at
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]): def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`. """Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
@@ -325,7 +384,10 @@ def _purchase_times(today: day_module.Day) -> dict[str, str]:
times = np.datetime_as_string( times = np.datetime_as_string(
today.columns["UTCEventTime"][here], unit="s", timezone="UTC" today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
) )
return dict(zip(numbers, times.tolist(), strict=True)) result: dict[str, str] = {}
for number, moment in zip(numbers, times.tolist(), strict=True):
result.setdefault(number, moment)
return result
def _born(record: dict) -> date: def _born(record: dict) -> date:
+5
View File
@@ -122,6 +122,11 @@ def test_the_trade_event_never_overtakes_the_next_page():
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS) assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
def test_the_duplicate_delay_stays_inside_a_visit_timeout():
assert 0 < min(world.DUPLICATE_DELAY_SECONDS)
assert max(world.DUPLICATE_DELAY_SECONDS) <= world.VISIT_TIMEOUT_SECONDS
def conversion(cart: int, checkout_of_cart: int) -> float: def conversion(cart: int, checkout_of_cart: int) -> float:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки.""" """Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2 return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
@@ -0,0 +1,31 @@
slice_name: Сверка purchase и заказов
description: null
certified_by: null
certification_details: null
viz_type: pivot_table_v2
params:
viz_type: pivot_table_v2
groupbyRows:
- order_day
groupbyColumns:
- mismatch_class
metrics:
- order_count
adhoc_filters:
- expressionType: SIMPLE
subject: order_day
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
aggregateFunction: Sum
metricsLayout: COLUMNS
rowOrder: key_a_to_z
colOrder: key_a_to_z
order_desc: true
row_limit: 10000
valueFormat: SMART_NUMBER
date_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000102
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000002
@@ -0,0 +1,30 @@
slice_name: Выручка по дням и категориям
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_bar
params:
viz_type: echarts_timeseries_bar
x_axis: report_date
metrics:
- revenue
groupby:
- product_category
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
stack: Stack
orientation: vertical
show_legend: true
rich_tooltip: true
show_value: false
y_axis_format: ',.2f'
x_axis_time_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000101
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -0,0 +1,29 @@
slice_name: Посетители и известные пользователи
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_line
params:
viz_type: echarts_timeseries_line
x_axis: report_date
metrics:
- visitors
- known_users
groupby: []
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
seriesType: echarts_timeseries_line
show_legend: true
rich_tooltip: true
show_value: false
y_axis_format: ',d'
x_axis_time_format: smart_date
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000103
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000003
@@ -0,0 +1,105 @@
dashboard_title: Бизнес-сюжеты
description: null
css: null
slug: business-stories
certified_by: null
certification_details: null
published: true
uuid: 19600000-0000-4000-8000-000000000201
position:
DASHBOARD_VERSION_KEY: v2
ROOT_ID:
id: ROOT_ID
type: ROOT
children:
- GRID_ID
GRID_ID:
id: GRID_ID
type: GRID
parents:
- ROOT_ID
children:
- ROW-revenue
- ROW-pvo
- ROW-traffic
ROW-revenue:
id: ROW-revenue
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-revenue
meta:
background: BACKGROUND_TRANSPARENT
CHART-revenue:
id: CHART-revenue
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-revenue
children: []
meta:
chartId: 1
height: 50
width: 12
sliceName: Выручка по дням и категориям
uuid: 19600000-0000-4000-8000-000000000101
ROW-pvo:
id: ROW-pvo
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-pvo
meta:
background: BACKGROUND_TRANSPARENT
CHART-pvo:
id: CHART-pvo
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-pvo
children: []
meta:
chartId: 2
height: 50
width: 12
sliceName: Сверка purchase и заказов
uuid: 19600000-0000-4000-8000-000000000102
ROW-traffic:
id: ROW-traffic
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-traffic
meta:
background: BACKGROUND_TRANSPARENT
CHART-traffic:
id: CHART-traffic
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-traffic
children: []
meta:
chartId: 3
height: 50
width: 12
sliceName: Посетители и известные пользователи
uuid: 19600000-0000-4000-8000-000000000103
metadata:
color_scheme: supersetColors
cross_filters_enabled: false
chart_configuration: {}
expanded_slices: {}
default_filters: '{}'
native_filter_configuration: []
theme_uuid: null
version: 1.0.0
@@ -0,0 +1,106 @@
table_name: daily_traffic_v
main_dttm_col: report_date
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000003
metrics:
- metric_name: known_users
verbose_name: Известные пользователи
metric_type: null
expression: SUM(known_users)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
- metric_name: visitors
verbose_name: Посетители
metric_type: null
expression: SUM(visitors)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
columns:
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: known_users
verbose_name: Известные пользователи
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: visitors
verbose_name: Посетители
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: report_date
verbose_name: Дата
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
@@ -0,0 +1,136 @@
table_name: purchase_vs_orders_v
main_dttm_col: order_day
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000002
metrics:
- metric_name: order_count
verbose_name: Количество заказов
metric_type: null
expression: COUNT(order_id)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
columns:
- column_name: items_total
verbose_name: Сумма позиций заказа
is_dttm: false
is_active: true
type: NULLABLE(DECIMAL(18, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: declared_revenue
verbose_name: Выручка purchase
is_dttm: false
is_active: true
type: NULLABLE(DECIMAL(18, 2))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: status
verbose_name: Статус заказа
is_dttm: false
is_active: true
type: LOWCARDINALITY(NULLABLE(STRING))
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: mismatch_class
verbose_name: Класс расхождения
is_dttm: false
is_active: true
type: LOWCARDINALITY(STRING)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: order_day
verbose_name: День заказа
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: order_id
verbose_name: Заказ
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
@@ -0,0 +1,138 @@
table_name: revenue_daily_v
main_dttm_col: report_date
currency_code_column: null
description: null
default_endpoint: null
offset: 0
cache_timeout: null
catalog: null
schema: dm
sql: null
params: null
template_params: null
filter_select_enabled: true
fetch_values_predicate: null
extra: null
normalize_columns: false
always_filter_main_dttm: false
folders: null
uuid: 19600000-0000-4000-8000-000000000001
metrics:
- metric_name: revenue
verbose_name: Выручка
metric_type: null
expression: SUM(revenue)
description: null
d3format: ',.2f'
currency:
symbol: ₽
symbolPosition: suffix
extra: null
warning_text: null
columns:
- column_name: _load_ts
verbose_name: Время загрузки
is_dttm: true
is_active: true
type: DATETIME64(3, 'UTC')
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: aov
verbose_name: Средний чек
is_dttm: false
is_active: true
type: DECIMAL(18, 2)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: revenue
verbose_name: Выручка
is_dttm: false
is_active: true
type: DECIMAL(18, 2)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: product_category
verbose_name: Категория товара
is_dttm: false
is_active: true
type: LOWCARDINALITY(STRING)
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: _load_id
verbose_name: Запуск загрузки
is_dttm: false
is_active: true
type: STRING
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: orders
verbose_name: Заказы
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: units
verbose_name: Товары
is_dttm: false
is_active: true
type: UINT64
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
- column_name: report_date
verbose_name: Дата
is_dttm: true
is_active: true
type: DATE
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
version: 1.0.0
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
-2
View File
@@ -1,3 +1 @@
version: 1.0.0 version: 1.0.0
type: Database
timestamp: '2026-07-30T00:00:00+00:00'
+8 -2
View File
@@ -2,13 +2,19 @@
-- --
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего -- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону; -- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
-- Python-задача сопоставляет ее с тремя счетчиками закрытого дня из описи. -- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из описи.
SELECT SELECT
order_day AS data_date, order_day AS data_date,
mismatch_class, mismatch_class,
count() AS orders count() AS orders
FROM dm.purchase_vs_orders_v FROM dm.purchase_vs_orders_v
WHERE mismatch_class IN ('match', 'cancelled', 'amount_delta') WHERE mismatch_class IN (
'match',
'cancelled',
'lost_event',
'duplicate_event',
'amount_delta'
)
GROUP BY GROUP BY
data_date, data_date,
mismatch_class mismatch_class