Расхождения A–D и опоздания: механика, доли и что обещано #72

Closed
opened 2026-08-07 20:44:25 +03:00 by ddmitry · 3 comments
Owner

Part of #69.

Вопрос

Чем именно генератор порождает четыре класса расхождений и опоздания, что из этого обещано воспроизводимо и что хранит опись мира?

Мастер-спека (раздел 4) даёт механику и ориентиры долей: отмены ~5%, потерянные события ~3%, дельта суммы ~1–2%, дубли ~2%, опоздания ~10%, — и говорит, что точные доли фиксируются при пересборке эталонного мира.

Отдельный вопрос порядка: по страховочному срезу 2 расхождения B и D входят в этап 6 (#8). Но если генератор научится им позже, мир придётся переигрывать — значит механику для них решать здесь, даже если сверка по ним пишется потом.

Part of #69. ## Вопрос Чем именно генератор порождает четыре класса расхождений и опоздания, что из этого обещано воспроизводимо и что хранит опись мира? Мастер-спека (раздел 4) даёт механику и ориентиры долей: отмены ~5%, потерянные события ~3%, дельта суммы ~1–2%, дубли ~2%, опоздания ~10%, — и говорит, что точные доли фиксируются при пересборке эталонного мира. Отдельный вопрос порядка: по страховочному срезу 2 расхождения B и D входят в этап 6 (#8). Но если генератор научится им позже, мир придётся переигрывать — значит механику для них решать здесь, даже если сверка по ним пишется потом.
ddmitry added the wayfinder:grilling label 2026-08-07 20:44:25 +03:00
ddmitry added a new dependency 2026-08-07 20:44:43 +03:00
Author
Owner

Хвост от закрытого #71 — три вещи, которые там решены и сюда приходят готовыми.

Опоздание считается от дня снятия слепка, а не отправки. #71 решил, что даг,
играющий день D, отправляет слепок дня D−1 — ночная выгрузка за вчера. Сдвиг
транспортный, содержимое слепка от него не зависит. Если считать опоздание от дня
отправки, сдвиг удвоится и обещанные «~10% приезжают в слепке D+1/D+2» станут
D+2/D+3.

Вся судьба заказа обязана уложиться в окно K либо не случиться вовсе. Правило
шире момента оплаты: отмена подчиняется тому же пределу. Заказ, отменённый на
восьмой день, в слепок уже не попадёт и навсегда останется в прежнем статусе — а
тогда обещание «за окном заказ неизменяем» окажется ложью, и revenue_daily_v
разойдётся с миром без объяснения.

Появился исход, которого нет в таблице раздела 4 мастер-спеки. Момент оплаты —
бросок по таблице весов, и вес за краем окна означает «не оплачен никогда»: заказ
навсегда в created при доехавшем событии purchase. Суммы сходятся, событие на
месте, выручки за ним нет — по нынешним классам это match. Получает ли этот
случай своё имя в mismatch_class — решать здесь.

Целиком резолюция — в #71.

Хвост от закрытого #71 — три вещи, которые там решены и сюда приходят готовыми. **Опоздание считается от дня снятия слепка, а не отправки.** #71 решил, что даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка за вчера. Сдвиг транспортный, содержимое слепка от него не зависит. Если считать опоздание от дня отправки, сдвиг удвоится и обещанные «~10% приезжают в слепке D+1/D+2» станут D+2/D+3. **Вся судьба заказа обязана уложиться в окно K либо не случиться вовсе.** Правило шире момента оплаты: отмена подчиняется тому же пределу. Заказ, отменённый на восьмой день, в слепок уже не попадёт и навсегда останется в прежнем статусе — а тогда обещание «за окном заказ неизменяем» окажется ложью, и `revenue_daily_v` разойдётся с миром без объяснения. **Появился исход, которого нет в таблице раздела 4 мастер-спеки.** Момент оплаты — бросок по таблице весов, и вес за краем окна означает «не оплачен никогда»: заказ навсегда в `created` при доехавшем событии `purchase`. Суммы сходятся, событие на месте, выручки за ним нет — по нынешним классам это `match`. Получает ли этот случай своё имя в `mismatch_class` — решать здесь. Целиком резолюция — в #71.
ddmitry self-assigned this 2026-08-12 23:00:47 +03:00
Author
Owner

Резолюция

Ревизия 3: два холодных ревью, второе — узкое, по переписанному. Что изменилось
против первой редакции — в конце, разделом «Что поправило ревью».

Рамка. Расхождение — не грязь в данных и не шум, а часть мира: судьба
заказа, решённая при его рождении и уложенная в окно K целиком.

1. Где живут броски

Два подпотока дня:

  • заказная сторона — судьба заказа: исход, моменты, дельта, опоздание;
  • событийная сторона — порча потока: потеря и дубль.

Оба берутся у того дня, которому их предмет принадлежит по рождению. Событиям
это ничего не добавляет — они и так живут в своём дне; заказу добавляет, потому
что слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан читать
из его собственного дня (#71).

Имена DISCREPANCIES и LATECOMERS в seeds.py решения не переживают: они
названы по классам витрины, а компонент называет часть мира. Занимают новые
стороны те же позиции — из мёртвых имён никто не бросает, так что переименование
ничего не сдвигает. Как их звать — при реализации.

Довод за разделение — различимость по описи: правка заказной механики не двигает
хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
видно, какую сторону трогали. Приём в репозитории не новый: хеш каталога лежит в
описи ровно затем, чтобы отличить «правили CSV» от «правили код»
(inventory.py, шапка модуля).

Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.
Соблазн бросать
«какую позицию вычеркнуть» длиной в число заказов с дельтой велик и обходится
дорого: длина броска становится функцией доли, и правка доли перебрасывает весь
подпоток после себя. В генераторе такое место уже есть — _at_least_one бросает
длиной в число опустевших групп, поэтому смена одного процента в
ABANDONED_POSITION_PERCENT перекладывает торговый подпоток целиком. Изоляцию
даёт форма броска, а не число подпотоков.

Отклонено: один компонент на всю судьбу — правка событийной механики молча
меняла бы байты слепка; компонент на класс — пять имён под ручки калибровки,
которые всё равно крутятся разом при пересборке.

2. Путь заказа по статусам

Три исхода, все внутри окна: оплачен; оплачен и отменён; не оплачен и
отменён
.

Инвариант на выходе из окна: заказ либо paid, либо cancelled. created
только промежуточное состояние, живое внутри окна; в последнем слепке заказа его
не бывает. Довод: за окном будущего у заказа нет — paid он уже не станет, — а
заказ, навсегда застрявший в created, это не «висит, отменят позже», а
модельная небрежность, которой в выгрузке живого магазина соответствия нет.

Отсюда снимается вопрос, переданный сюда из #71: исход «навсегда created» не
возникает, и нового значения mismatch_class не нужноcancelled
покрывает обе дороги. (Шестое значение там уже занято: awaiting_order,
мастер-спека §7.)

Форма броска

  1. Исход — таблица долей из трёх строк. Здесь же живёт требование #71 писать
    долю неоплаченных явной строкой.
  2. Момент — таблица целых весов «сколько часов — с каким весом», строки
    0…143, плюс равномерная секунда внутри часа.
  3. Моментов бросается всегда два, на полную длину дня; у одномоментных исходов
    второй выбрасывается. Где их два по существу, ранний считается оплатой
    условной точки отсчёта и правила «отмена после оплаты» не нужно, порядок выходит
    сортировкой. Бросать «два там, где их два» нельзя: длина броска стала бы
    функцией долей исходов, и правка доли переложила бы весь подпоток после себя
    (правило формы, §1).

143 часа — это самый узкий край окна: слепок снимается на границе суток, поэтому
у заказа, рождённого в 23:50, до последнего его слепка 144 часа, а у рождённого
в 00:05 — почти 168. Таблица кончается там, где кончается окно у самого
невезучего, и дальше в ней просто ничего нет: вылезти нечему, сторожа не нужно.
Цена — заказ, рождённый в начале суток, не использует почти сутки своего окна
(167:55 против 143:59:59): его судьба всегда решается раньше края. В хвосте
таблицы веса мизерные, поэтому в данных это не видно.

Это отменяет форму, записанную в #71 («вес за краем окна означает „не оплачен
никогда“»). Причины две. Исход, который тот вес кодировал, в #72 убран: такой
заказ теперь отменяется, и ему нужен второй момент. И край окна не выражается
числом часов — одна и та же строка почасовой таблицы для одного заказа внутри
окна, для другого за краем, отчего доля неоплаченных стала бы функцией часа
покупки: купил вечером — реже платишь. Наклона этого никто не выбирал, а менти
нашёл бы его первым же разрезом по часу.

Из #71 остаётся нетронутым: момент — бросок, а не правило; целые веса; хвост
меряется в часах, но тянется на дни; секунда внутри часа, чтобы
updated_at − created_at не давал точных равенств (урок правки #50); и «правила
усечения не нужно ни в каком виде» — теперь оно выполняется строже прежнего.

Доли и что из этого видно

Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как она делится
между двумя дорогами — строки таблицы исходов. Точные числа — калибровка этапа 7;
проверок вида «отмен должно быть от 4 до 6 процентов» не заводим.

Побочный урок, ради которого стоит держать обе дороги: в dds.order они
неразличимы — там последняя версия, — а различает их история слепков в
ods.order_snapshot. «Почему для одних отмен выручка убыла, а для других нет» —
вопрос, ответ на который оправдывает слой сырья ровно тем, ради чего тот заведён.

Различает не всегда, и обещать лабе полное различение нельзя. Слепок — состояние
на границе суток, поэтому paid виден только у заказов, между оплатой и отменой
которых лежит полночь; оплатили и отменили в один день — обе дороги в сырье
выглядят одинаково. То же у сильно опоздавших: приехав уже терминальным, заказ не
покажет, был ли он оплачен (§5). Насколько велика эта доля, решает калибровка
хвоста моментов.

Отклонено: отмена только после оплаты (строгий префикс цепочки мастер-спеки) —
неоплаченному заказу тогда некуда деться, кроме как остаться брошенным;
мгновенная отмена при рождении — «дыхание» окна на отменах исчезает; чтение
«мир живёт дальше, а окно просто не показывает поздние изменения»
— мастер-спека
держит заказ за окном неизменяемым, а не «изменяемым, но не вывозимым»;
отмен нет вовсе — это страховочный срез 1, он в резерве.

3. Дельта суммы (класс C)

Механика — вычеркнутая позиция: товара не оказалось в наличии, позицию сняли.
У заказа на одну позицию меньше, чем в клиентских массивах, а items_total
меньше на её стоимость.

Момента у неё нет: заказ приезжает урезанным во всех своих слепках. Легенда
держится сама — первый слепок снимается на границе суток, когда склад заказ уже
собрал. Дышать внутри окна умеют статусы, и второй механики дыхания урок класса C
не покупает.

Заказ из одной позиции дельты не получает — пустых заказов не бывает. Позиция
выбирается равновероятно: корреляция со спросом (в жизни вылетает ходовое)
отклонена, потому что на доле 1–2% за восемь дней она статистически
ненаблюдаема — менти платит за неё таблицей чисел мира, а увидеть не может ничем.

Доводы за вычёркивание: остаток видно без микроскопа — это сотни рублей, они
торчат в витрине сверки сами; расхождение объясняется сравнением позиций, то есть
разбором вложенного JSON и ARRAY JOIN, ровно тем навыком, ради которого позиции
на стенде разбираются; история рассказывается словами без легенды про генератор.

Отклонено: переоценка позиции — дельта в десятки рублей против сотен, её надо
захотеть заметить; другое количество — то же самое, слабее; чистая дельта без
истории
— тупик, объяснить нечем; врёт клиент, а не бэкенд — заказ у нас
проекция той же корзины (#71), и чтобы соврал клиент, править пришлось бы событие
после того, как заказ от него отпочковался.

4. Событийная сторона: потеря (B) и дубль (D)

Глубина порчи у них разная, и разводит их не вкус, а счёт.

  • Потеря точечная. Уходит строка purchase, просмотр /confirmation
    остаётся: события уезжают разными запросами, потерять один и сохранить другой —
    обычное дело. И это единственный вариант, при котором потеря видна со стороны
    трекера
    : до страницы подтверждения дошли сто человек, покупок девяносто семь.
  • Дубль сюжетный — обновление страницы шлёт и просмотр, и покупку. Довод не
    в связности легенды, а в том, что точечный дубль ломает урок соседнего класса:
    просмотров осталось бы сто, покупок 100 − 3 + 2 = 99, и разрыв воронки, на
    котором держится потеря, сжался бы с трёх процентов до одного. При сюжетном
    просмотров 102 против 99 покупок — разрыв снова равен доле потерь.

Назначенные планом покупки не теряются. dds.identity_map строится из моста
«событие purchase ↔ заказ», и мастер-спека §5 держит на этом лабу склейки:
каждый двухкуковый покупатель обязан сделать по заказу с каждой куки, иначе вторая
кука в карту не попадает. Выброшенное событие уносит куку из карты, а опись хранит
число пар — счётчик разошёлся бы с картой по построению, и это отказ лабы, а не
расхождение в данных. Гарантия плана старше расхождения; менти различить не может.

И назначенные планом заказы не опаздывают — та же дыра с другого конца:
опоздавший заказ у края горизонта не попадает ни в один снятый слепок, и кука
выпадает из карты так же надёжно, как от потерянного события.

Дубль случается там, где для него есть место. Соседние визиты куки разведены
ровно на таймаут плюс торговый хвост (day.py, _starts), и запас над таймаутом
у прижатых пар — секунды. Дубль через полминуты увёл бы паузу под тридцать минут,
и сборка сессий у менти разошлась бы с VisitID — сломался бы эталон, ради
которого VisitID в потоке и лежит. Правило честно по легенде: кто открыл новый
визит, старую вкладку не обновляет. Наблюдаемая доля дублей от этого чуть ниже
брошенной, как и у прочих классов.

Прочее об этих двух:

  • Задержка дубля — тем же приёмом, что задержка торгового события
    (TRADE_DELAY_SECONDS): секунды-минуты, короче таймаута визита, поэтому
    VisitID у дубля тот же.
  • purchaseID, суммы и позиции у дубля один в один — в этом и весь бизнес-дубль:
    посчитал наивно, удвоил выручку.
  • Полночь режет дубль парой, просмотр вместе с покупкой. День-функция уже
    держит это правило для подтверждения (day.py: «полночь забирает подтверждение
    вместе с торговым хвостом или не забирает ни того, ни другого»), и по той же
    причине: просмотр без покупки — подделка под класс B.
  • Обе порчи случаются после присвоения номеров. Про потерю это уже обещано
    комментарием в _seal; дублю то же нужно по обратной причине — ему требуется
    копия уже присвоенного номера.

Отклонено: обе точечные — дубль затирает урок потери; обе сюжетные — потеря
перестаёт быть видна со стороны трекера.

5. Опоздание

Заказ прячется от ранних слепков: created_at честный — момент покупки, — но
в слепках дней d…d+δ−1 строки нет, а с d+δ она появляется в том состоянии, до
которого заказ дожил.

  • Задержка — таблица весов по дням, и строк в ней ровно три: 0 (не опаздывает)
    на подавляющем весе, 1 и 2. Это и есть «D+1/D+2» мастер-спеки, буква
    в букву.
  • Дальше таблица не идёт, и дело не только в букве: заказ с задержкой δ = 6
    приехал бы ровно в одном слепке, и обещание «пропущенный день ничего не ломает,
    следующий слепок самовосстанавливает» (мастер-спека §2, ADR 0008) на нём
    перестало бы быть верным — потеряли эту пачку, потеряли заказ насовсем. При
    δ ≤ 2 у всякого заказа слепков не меньше пяти.
  • Опоздавший приезжает уже пожившим. Отменённый на второй день и опоздавший на
    третий приедет в первом же своём слепке как cancelledcreated его хранилище
    не увидит никогда. Так и выглядит выгрузка, догоняющая жизнь.
  • Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже.

Одно следствие для переливки, но нового в нём нет: партиции всех дней окна
перестраиваются, а не только последнего. Это уже так из-за «дыхания» статусов;
опоздание лишь добавляет, что у дня меняется ещё и состав, а не только суммы.

Отклонено: сдвиг created_atdds.order партиционируется по
toDate(created_at), заказ уехал бы в чужой день, и «выручка дня D» перестала бы
отвечать покупкам дня D, то есть сломалась бы та самая сверка, ради которой всё
строится.

6. Пересечения классов и что хранит опись

Броски независимы, пересечения выходят арифметикой сами, и приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
решается только у выживших покупок (дублировать потерянное нечего) и назначенные
планом покупки не теряются. Зависимости «оплата — отмена» среди них нет: обе
дороги перечислены строками таблицы исходов, а не выведены одна из другой.

Вариант «один класс на заказ» отклонён: приоритет в SQL стал бы мёртвой веткой,
которую менти читает как живую.

Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные
числа
. Часть заказов забирают победители по приоритету, часть у класса C
недоступна с самого начала (заказы из одной позиции — их больше половины).

Что кладётся в опись:

  • Счётчики — наблюдаемых классов, после приоритета, посчитанные по итоговой
    судьбе заказов дня. Мастер-спека просит их как самопроверку лабы сверки; сойтись
    с запросом менти они могут только на днях, у которых окно закрылось, а таких при
    N сыгранных днях ровно N − 7: на стартовом мире (восемь дней) один,
    на эталонном (четырнадцать) семь. Считается это из сдвига #71 — прогон дня D
    отправляет слепок дня D−1, поэтому последний отправленный слепок несёт день N−2,
    а день d закрывается слепком d+6.
  • Единица счёта — заказ, и опись называет её словом. Как витрина сверки
    складывает строки — дело этапа 4; опись на её форму не опирается.
  • У слепка появляется своя строка с хешем байтов. Байты слепка не покрыты
    хешами дней ни при каком раскладе подпотоков — это второй артефакт мира, и до сих
    пор опись о нём не знала ничего. Побайтовое обещание на слепок дано в #71
    («переснимается и даёт те же байты»), опись начинает его сторожить.

Разделение труда: хеши сторожат мир, счётчики служат лабе. Порогов нет: ни
«отмен от 4 до 6 процентов», ни сторожа assert момент <= окно — правило окна
выражено длиной таблицы.

Расхождения с принятым

Вносятся тем же коммитом, что и спека второго источника (заметки карты #69).

Мастер-спека:

  1. §2: цепочка created → paid → cancelled допускает прямой переход
    created → cancelled — неоплаченный заказ отменяют. Статусов по-прежнему три.
  2. §4: у класса C названа механика — вычеркнутая позиция; ориентир «отмены
    ~5%» читается как доля отменённых вообще, обе дороги вместе.
  3. §4 и §7: «amount_delta — необъяснённый остаток» читать как «не
    объяснённый приведением к сравнимой базе»; сравнением позиций он как раз
    объясняется, и на этом стоит урок класса C.
  4. §8: опись получает строку на слепок с хешем байтов, а счётчики классов —
    наблюдаемые, после приоритета, и считаются заказами.

Спека генератора, §2: перечисление компонентов дня («трафик, торговые события,
расхождения, опоздания») меняется — расхождения и опоздания уступают место
заказной и событийной сторонам.

Резолюция #71: форма броска момента оплаты («вес за краем окна означает „не
оплачен никогда“») отменена, причины — в §2.

CONTEXT.md: у термина «Слепок» строка «один заказ приезжает в стольких
слепках, сколько дней окна он прожил» перестаёт быть верной для опоздавших; у
«Описи мира» — «по строке на день» дополняется строкой на слепок. Плюс новый
термин «судьба заказа», несущий уже вторую резолюцию подряд.

Швы соседям

  • #81. Хеш слепка вычислим только после того, как решён формат записи на
    проводе; там же нужен канонический порядок строк внутри слепка — иначе
    побайтовое обещание не на чем держать.
  • #74. Строк-слепков в описи столько, сколько слепков отправляет стартовый
    мир, — это решается там. И там же ждёт находка холодного ревью, которую #72
    закрыть не может: при сдвиге #71 слепок последнего сыгранного дня не
    отправляется, а у 18 из 73 пар стартового мира поздний назначенный заказ
    падает как раз на этот день (замерено на каноническом зерне). Карта склейки их
    не увидит, хотя счётчик пар описи считает — он смотрит только на горизонт
    (plan.py, np.all(pair_order_days < days)), про слепки не зная ничего. Либо
    стартовый мир дошлёт последний слепок, либо счётчик пар научится спрашивать про
    слепки.
  • #80. Расхождения — норма мира, а не брак: вычеркнутая позиция приезжает
    валидной строкой, потерянное событие не приезжает вовсе. Разговор про контур
    проверок качества для расхождений (туман карты #69) естественнее вести там.
  • Этапы 4 и 6 (#6, #8). Механика решена здесь целиком, включая B и D; сверка
    по ним пишется там, мир переигрывать не придётся — ради чего тикет и заводился.
    Форму строк витрины сверки резолюция за них не решает.
  • Этап 7 (#9). При пересборке эталонного мира правки потребуют только числа:
    доли исходов, таблица моментов, доли классов, таблица задержки опоздания и
    следом счётчики описи. Устройство не потребует.

Что поправило ревью

Холодное ревью в двух линзах (дефекты и связность; уместность и вычитание) против
первой ревизии:

  • Форма броска судьбы переделана — §2. Прежняя (почасовая таблица от рождения
    с условной точкой отсчёта у отмены) не выражала инвариант окна: край окна
    меряется сутками, а таблица часами.
  • Снят ложный довод «правка порога броски не сдвигает»: порог кормит
    следующий бросок переменного размера. На его месте — правило формы в §1.
  • Найдены три дыры: потеря ломала гарантию двухкуковых пар, дубль склеивал
    визиты, у вычёркивания позиции не был назван момент.
  • Хвост опоздания укорочен до второго-третьего дня — иначе рвётся обещание
    про пропущенный день.
  • Уточнены ссылки на код (_seal обещает про потерю, не про дубль), число
    значений mismatch_class (шестое занято awaiting_order) и границы: форму строк
    витрины сверки решает этап 4, а не эта резолюция.

Второе ревью, узкое, по переписанному (граница окна выведена независимо и сошлась,
оба новых правила в коде осуществимы — assigned_order уже лежит в
plan.DayAudience, а покупка всегда последнее событие своего визита):

  • Правило про назначенные покупки расширено на опоздание — оно закрывало одну
    дверь из двух: заказ у края горизонта рвёт карту склейки не только потерянным
    событием, но и опозданием.
  • Моменты бросаются всегда парой, иначе §2 нарушал бы правило формы из §1.
  • Уточнено, где две дороги отмены не различаются: слепок снимается на границе
    суток, поэтому оплата и отмена в один день в сырье неразличимы.
  • Число дней с закрытым окном исправлено на N − 7 (было «все, кроме последних
    шести» — верно ни для стартового мира, ни для эталонного).
  • Таблица задержки названа явно: строки 0, 1, 2. Таблица моментов — строки 0…143,
    а не «длиной 143 часа»: единица там решает.
## Резолюция *Ревизия 3: два холодных ревью, второе — узкое, по переписанному. Что изменилось против первой редакции — в конце, разделом «Что поправило ревью».* **Рамка.** Расхождение — не грязь в данных и не шум, а часть мира: судьба заказа, решённая при его рождении и уложенная в окно K целиком. ### 1. Где живут броски Два подпотока дня: - **заказная сторона** — судьба заказа: исход, моменты, дельта, опоздание; - **событийная сторона** — порча потока: потеря и дубль. Оба берутся у того дня, которому их предмет принадлежит по рождению. Событиям это ничего не добавляет — они и так живут в своём дне; заказу добавляет, потому что слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан читать из его собственного дня (#71). Имена `DISCREPANCIES` и `LATECOMERS` в `seeds.py` решения не переживают: они названы по классам витрины, а компонент называет часть мира. Занимают новые стороны те же позиции — из мёртвых имён никто не бросает, так что переименование ничего не сдвигает. Как их звать — при реализации. Довод за разделение — различимость по описи: правка заказной механики не двигает хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам видно, какую сторону трогали. Приём в репозитории не новый: хеш каталога лежит в описи ровно затем, чтобы отличить «правили CSV» от «правили код» (`inventory.py`, шапка модуля). **Правило формы, без которого разделение не работает: броски заказной стороны делаются на полную длину дня, а не на отобранных заказах.** Соблазн бросать «какую позицию вычеркнуть» длиной в число заказов с дельтой велик и обходится дорого: длина броска становится функцией доли, и правка доли перебрасывает весь подпоток после себя. В генераторе такое место уже есть — `_at_least_one` бросает длиной в число опустевших групп, поэтому смена одного процента в `ABANDONED_POSITION_PERCENT` перекладывает торговый подпоток целиком. Изоляцию даёт форма броска, а не число подпотоков. Отклонено: *один компонент на всю судьбу* — правка событийной механики молча меняла бы байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые всё равно крутятся разом при пересборке. ### 2. Путь заказа по статусам Три исхода, все внутри окна: **оплачен**; **оплачен и отменён**; **не оплачен и отменён**. **Инвариант на выходе из окна: заказ либо `paid`, либо `cancelled`.** `created` — только промежуточное состояние, живое внутри окна; в последнем слепке заказа его не бывает. Довод: за окном будущего у заказа нет — `paid` он уже не станет, — а заказ, навсегда застрявший в `created`, это не «висит, отменят позже», а модельная небрежность, которой в выгрузке живого магазина соответствия нет. Отсюда снимается вопрос, переданный сюда из #71: исход «навсегда `created`» не возникает, и **нового значения `mismatch_class` не нужно** — `cancelled` покрывает обе дороги. (Шестое значение там уже занято: `awaiting_order`, мастер-спека §7.) #### Форма броска 1. **Исход** — таблица долей из трёх строк. Здесь же живёт требование #71 писать долю неоплаченных явной строкой. 2. **Момент** — таблица целых весов «сколько часов — с каким весом», строки **0…143**, плюс равномерная секунда внутри часа. 3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных исходов второй выбрасывается. Где их два по существу, **ранний считается оплатой** — условной точки отсчёта и правила «отмена после оплаты» не нужно, порядок выходит сортировкой. Бросать «два там, где их два» нельзя: длина броска стала бы функцией долей исходов, и правка доли переложила бы весь подпоток после себя (правило формы, §1). 143 часа — это самый узкий край окна: слепок снимается на границе суток, поэтому у заказа, рождённого в 23:50, до последнего его слепка 144 часа, а у рождённого в 00:05 — почти 168. Таблица кончается там, где кончается окно у самого невезучего, и дальше в ней просто ничего нет: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в начале суток, не использует почти сутки своего окна (167:55 против 143:59:59): его судьба всегда решается раньше края. В хвосте таблицы веса мизерные, поэтому в данных это не видно. **Это отменяет форму, записанную в #71** («вес за краем окна означает „не оплачен никогда“»). Причины две. Исход, который тот вес кодировал, в #72 убран: такой заказ теперь отменяется, и ему нужен второй момент. И край окна не выражается числом часов — одна и та же строка почасовой таблицы для одного заказа внутри окна, для другого за краем, отчего доля неоплаченных стала бы функцией часа покупки: купил вечером — реже платишь. Наклона этого никто не выбирал, а менти нашёл бы его первым же разрезом по часу. Из #71 остаётся нетронутым: момент — бросок, а не правило; целые веса; хвост меряется в часах, но тянется на дни; секунда внутри часа, чтобы `updated_at − created_at` не давал точных равенств (урок правки #50); и «правила усечения не нужно ни в каком виде» — теперь оно выполняется строже прежнего. #### Доли и что из этого видно Ориентир мастер-спеки ~5% читается как доля **отменённых вообще**; как она делится между двумя дорогами — строки таблицы исходов. Точные числа — калибровка этапа 7; проверок вида «отмен должно быть от 4 до 6 процентов» не заводим. Побочный урок, ради которого стоит держать обе дороги: в `dds.order` они неразличимы — там последняя версия, — а различает их история слепков в `ods.order_snapshot`. «Почему для одних отмен выручка убыла, а для других нет» — вопрос, ответ на который оправдывает слой сырья ровно тем, ради чего тот заведён. Различает не всегда, и обещать лабе полное различение нельзя. Слепок — состояние на границе суток, поэтому `paid` виден только у заказов, между оплатой и отменой которых лежит полночь; оплатили и отменили в один день — обе дороги в сырье выглядят одинаково. То же у сильно опоздавших: приехав уже терминальным, заказ не покажет, был ли он оплачен (§5). Насколько велика эта доля, решает калибровка хвоста моментов. Отклонено: *отмена только после оплаты* (строгий префикс цепочки мастер-спеки) — неоплаченному заказу тогда некуда деться, кроме как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на отменах исчезает; *чтение «мир живёт дальше, а окно просто не показывает поздние изменения»* — мастер-спека держит заказ за окном **неизменяемым**, а не «изменяемым, но не вывозимым»; *отмен нет вовсе* — это страховочный срез 1, он в резерве. ### 3. Дельта суммы (класс C) Механика — **вычеркнутая позиция**: товара не оказалось в наличии, позицию сняли. У заказа на одну позицию меньше, чем в клиентских массивах, а `items_total` меньше на её стоимость. **Момента у неё нет: заказ приезжает урезанным во всех своих слепках.** Легенда держится сама — первый слепок снимается на границе суток, когда склад заказ уже собрал. Дышать внутри окна умеют статусы, и второй механики дыхания урок класса C не покупает. Заказ из одной позиции дельты не получает — пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со спросом (в жизни вылетает ходовое) отклонена, потому что на доле 1–2% за восемь дней она статистически ненаблюдаема — менти платит за неё таблицей чисел мира, а увидеть не может ничем. Доводы за вычёркивание: остаток видно без микроскопа — это сотни рублей, они торчат в витрине сверки сами; расхождение объясняется сравнением позиций, то есть разбором вложенного JSON и `ARRAY JOIN`, ровно тем навыком, ради которого позиции на стенде разбираются; история рассказывается словами без легенды про генератор. Отклонено: *переоценка позиции* — дельта в десятки рублей против сотен, её надо захотеть заметить; *другое количество* — то же самое, слабее; *чистая дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд* — заказ у нас проекция той же корзины (#71), и чтобы соврал клиент, править пришлось бы событие после того, как заказ от него отпочковался. ### 4. Событийная сторона: потеря (B) и дубль (D) Глубина порчи у них разная, и разводит их не вкус, а счёт. - **Потеря точечная.** Уходит строка `purchase`, просмотр `/confirmation` остаётся: события уезжают разными запросами, потерять один и сохранить другой — обычное дело. И это единственный вариант, при котором потеря **видна со стороны трекера**: до страницы подтверждения дошли сто человек, покупок девяносто семь. - **Дубль сюжетный** — обновление страницы шлёт и просмотр, и покупку. Довод не в связности легенды, а в том, что точечный дубль ломает урок соседнего класса: просмотров осталось бы сто, покупок 100 − 3 + 2 = 99, и разрыв воронки, на котором держится потеря, сжался бы с трёх процентов до одного. При сюжетном просмотров 102 против 99 покупок — разрыв снова равен доле потерь. **Назначенные планом покупки не теряются.** `dds.identity_map` строится из моста «событие `purchase` ↔ заказ», и мастер-спека §5 держит на этом лабу склейки: каждый двухкуковый покупатель обязан сделать по заказу с каждой куки, иначе вторая кука в карту не попадает. Выброшенное событие уносит куку из карты, а опись хранит число пар — счётчик разошёлся бы с картой по построению, и это отказ лабы, а не расхождение в данных. Гарантия плана старше расхождения; менти различить не может. **И назначенные планом заказы не опаздывают** — та же дыра с другого конца: опоздавший заказ у края горизонта не попадает ни в один снятый слепок, и кука выпадает из карты так же надёжно, как от потерянного события. **Дубль случается там, где для него есть место.** Соседние визиты куки разведены ровно на таймаут плюс торговый хвост (`day.py`, `_starts`), и запас над таймаутом у прижатых пар — секунды. Дубль через полминуты увёл бы паузу под тридцать минут, и сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID` в потоке и лежит. Правило честно по легенде: кто открыл новый визит, старую вкладку не обновляет. Наблюдаемая доля дублей от этого чуть ниже брошенной, как и у прочих классов. Прочее об этих двух: - Задержка дубля — тем же приёмом, что задержка торгового события (`TRADE_DELAY_SECONDS`): секунды-минуты, короче таймаута визита, поэтому `VisitID` у дубля тот же. - `purchaseID`, суммы и позиции у дубля один в один — в этом и весь бизнес-дубль: посчитал наивно, удвоил выручку. - **Полночь режет дубль парой**, просмотр вместе с покупкой. День-функция уже держит это правило для подтверждения (`day.py`: «полночь забирает подтверждение вместе с торговым хвостом или не забирает ни того, ни другого»), и по той же причине: просмотр без покупки — подделка под класс B. - Обе порчи случаются после присвоения номеров. Про потерю это уже обещано комментарием в `_seal`; дублю то же нужно по обратной причине — ему требуется копия уже присвоенного номера. Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* — потеря перестаёт быть видна со стороны трекера. ### 5. Опоздание Заказ **прячется от ранних слепков**: `created_at` честный — момент покупки, — но в слепках дней d…d+δ−1 строки нет, а с d+δ она появляется в том состоянии, до которого заказ дожил. - Задержка — таблица весов по дням, и строк в ней ровно три: `0` (не опаздывает) на подавляющем весе, `1` и `2`. Это и есть «D+1/D+2» мастер-спеки, буква в букву. - **Дальше таблица не идёт**, и дело не только в букве: заказ с задержкой δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный день ничего не ломает, следующий слепок самовосстанавливает» (мастер-спека §2, ADR 0008) на нём перестало бы быть верным — потеряли эту пачку, потеряли заказ насовсем. При δ ≤ 2 у всякого заказа слепков не меньше пяти. - **Опоздавший приезжает уже пожившим.** Отменённый на второй день и опоздавший на третий приедет в первом же своём слепке как `cancelled` — `created` его хранилище не увидит никогда. Так и выглядит выгрузка, догоняющая жизнь. - Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Одно следствие для переливки, но нового в нём нет: партиции всех дней окна перестраиваются, а не только последнего. Это уже так из-за «дыхания» статусов; опоздание лишь добавляет, что у дня меняется ещё и состав, а не только суммы. Отклонено: *сдвиг `created_at`* — `dds.order` партиционируется по `toDate(created_at)`, заказ уехал бы в чужой день, и «выручка дня D» перестала бы отвечать покупкам дня D, то есть сломалась бы та самая сверка, ради которой всё строится. ### 6. Пересечения классов и что хранит опись **Броски независимы**, пересечения выходят арифметикой сами, и приоритет мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль решается только у выживших покупок (дублировать потерянное нечего) и назначенные планом покупки не теряются. Зависимости «оплата — отмена» среди них нет: обе дороги перечислены строками таблицы исходов, а не выведены одна из другой. Вариант «один класс на заказ» отклонён: приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую. Следствие для калибровки: **брошенная доля и наблюдаемая в сверке — разные числа**. Часть заказов забирают победители по приоритету, часть у класса C недоступна с самого начала (заказы из одной позиции — их больше половины). Что кладётся в опись: - **Счётчики — наблюдаемых классов, после приоритета**, посчитанные по итоговой судьбе заказов дня. Мастер-спека просит их как самопроверку лабы сверки; сойтись с запросом менти они могут только на днях, у которых окно закрылось, а таких при N сыгранных днях ровно **N − 7**: на стартовом мире (восемь дней) один, на эталонном (четырнадцать) семь. Считается это из сдвига #71 — прогон дня D отправляет слепок дня D−1, поэтому последний отправленный слепок несёт день N−2, а день d закрывается слепком d+6. - **Единица счёта — заказ**, и опись называет её словом. Как витрина сверки складывает строки — дело этапа 4; опись на её форму не опирается. - **У слепка появляется своя строка с хешем байтов.** Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй артефакт мира, и до сих пор опись о нём не знала ничего. Побайтовое обещание на слепок дано в #71 («переснимается и даёт те же байты»), опись начинает его сторожить. Разделение труда: **хеши сторожат мир, счётчики служат лабе.** Порогов нет: ни «отмен от 4 до 6 процентов», ни сторожа `assert момент <= окно` — правило окна выражено длиной таблицы. ## Расхождения с принятым Вносятся тем же коммитом, что и спека второго источника (заметки карты #69). **Мастер-спека:** 1. **§2**: цепочка `created → paid → cancelled` допускает прямой переход `created → cancelled` — неоплаченный заказ отменяют. Статусов по-прежнему три. 2. **§4**: у класса C названа механика — вычеркнутая позиция; ориентир «отмены ~5%» читается как доля отменённых вообще, обе дороги вместе. 3. **§4 и §7**: «`amount_delta` — необъяснённый остаток» читать как «не объяснённый приведением к сравнимой базе»; сравнением позиций он как раз объясняется, и на этом стоит урок класса C. 4. **§8**: опись получает строку на слепок с хешем байтов, а счётчики классов — наблюдаемые, после приоритета, и считаются заказами. **Спека генератора, §2**: перечисление компонентов дня («трафик, торговые события, расхождения, опоздания») меняется — расхождения и опоздания уступают место заказной и событийной сторонам. **Резолюция #71**: форма броска момента оплаты («вес за краем окна означает „не оплачен никогда“») отменена, причины — в §2. **`CONTEXT.md`**: у термина «Слепок» строка «один заказ приезжает в стольких слепках, сколько дней окна он прожил» перестаёт быть верной для опоздавших; у «Описи мира» — «по строке на день» дополняется строкой на слепок. Плюс новый термин «судьба заказа», несущий уже вторую резолюцию подряд. ## Швы соседям - **#81.** Хеш слепка вычислим только после того, как решён формат записи на проводе; там же нужен канонический порядок строк внутри слепка — иначе побайтовое обещание не на чем держать. - **#74.** Строк-слепков в описи столько, сколько слепков отправляет стартовый мир, — это решается там. И там же ждёт находка холодного ревью, которую #72 закрыть не может: при сдвиге #71 слепок последнего сыгранного дня не отправляется, а у **18 из 73** пар стартового мира поздний назначенный заказ падает как раз на этот день (замерено на каноническом зерне). Карта склейки их не увидит, хотя счётчик пар описи считает — он смотрит только на горизонт (`plan.py`, `np.all(pair_order_days < days)`), про слепки не зная ничего. Либо стартовый мир дошлёт последний слепок, либо счётчик пар научится спрашивать про слепки. - **#80.** Расхождения — норма мира, а не брак: вычеркнутая позиция приезжает валидной строкой, потерянное событие не приезжает вовсе. Разговор про контур проверок качества для расхождений (туман карты #69) естественнее вести там. - **Этапы 4 и 6 (#6, #8).** Механика решена здесь целиком, включая B и D; сверка по ним пишется там, мир переигрывать не придётся — ради чего тикет и заводился. Форму строк витрины сверки резолюция за них не решает. - **Этап 7 (#9).** При пересборке эталонного мира правки потребуют только числа: доли исходов, таблица моментов, доли классов, таблица задержки опоздания и следом счётчики описи. Устройство не потребует. ## Что поправило ревью Холодное ревью в двух линзах (дефекты и связность; уместность и вычитание) против первой ревизии: - **Форма броска судьбы переделана** — §2. Прежняя (почасовая таблица от рождения с условной точкой отсчёта у отмены) не выражала инвариант окна: край окна меряется сутками, а таблица часами. - **Снят ложный довод** «правка порога броски не сдвигает»: порог кормит следующий бросок переменного размера. На его месте — правило формы в §1. - **Найдены три дыры**: потеря ломала гарантию двухкуковых пар, дубль склеивал визиты, у вычёркивания позиции не был назван момент. - **Хвост опоздания укорочен** до второго-третьего дня — иначе рвётся обещание про пропущенный день. - Уточнены ссылки на код (`_seal` обещает про потерю, не про дубль), число значений `mismatch_class` (шестое занято `awaiting_order`) и границы: форму строк витрины сверки решает этап 4, а не эта резолюция. Второе ревью, узкое, по переписанному (граница окна выведена независимо и сошлась, оба новых правила в коде осуществимы — `assigned_order` уже лежит в `plan.DayAudience`, а покупка всегда последнее событие своего визита): - **Правило про назначенные покупки расширено на опоздание** — оно закрывало одну дверь из двух: заказ у края горизонта рвёт карту склейки не только потерянным событием, но и опозданием. - **Моменты бросаются всегда парой**, иначе §2 нарушал бы правило формы из §1. - Уточнено, где две дороги отмены **не** различаются: слепок снимается на границе суток, поэтому оплата и отмена в один день в сырье неразличимы. - Число дней с закрытым окном исправлено на N − 7 (было «все, кроме последних шести» — верно ни для стартового мира, ни для эталонного). - Таблица задержки названа явно: строки 0, 1, 2. Таблица моментов — строки 0…143, а не «длиной 143 часа»: единица там решает.
Author
Owner

Поправка после решения «Форма записи слепка на проводе»

Горячее ревью решения о формате слепка
нашло ошибку нейминга в этой резолюции. Агенту, который будет собирать решения
карты в единую спецификацию, нельзя переносить фразы, где created_at назван
«моментом покупки», updated_at - created_at читается как длительность
бизнес-перехода, а toDate(created_at) — как бизнес-день заказа.

Каноническое чтение после решения о формате слепка:

  • created_at и updated_at — время создания и последнего изменения строки по
    часам базы источника;
  • бизнес-время покупки живёт отдельно в purchase.UTCEventTime;
  • синхронная запись может физически совпасть с бизнес-событием, но смысл полей
    от этого не меняется;
  • toDate(created_at) — день создания строки источника, не доказательство дня
    бизнес-события.

Поправка меняет только смысл временных полей. Механика судеб заказов, статусов,
опозданий и расхождений из резолюции остаётся в силе.

## Поправка после решения «Форма записи слепка на проводе» Горячее ревью [решения о формате слепка](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81) нашло ошибку нейминга в этой резолюции. Агенту, который будет собирать решения карты в единую спецификацию, нельзя переносить фразы, где `created_at` назван «моментом покупки», `updated_at - created_at` читается как длительность бизнес-перехода, а `toDate(created_at)` — как бизнес-день заказа. Каноническое чтение после решения о формате слепка: - `created_at` и `updated_at` — время создания и последнего изменения строки по часам базы источника; - бизнес-время покупки живёт отдельно в `purchase.UTCEventTime`; - синхронная запись может физически совпасть с бизнес-событием, но смысл полей от этого не меняется; - `toDate(created_at)` — день создания строки источника, не доказательство дня бизнес-события. Поправка меняет только смысл временных полей. Механика судеб заказов, статусов, опозданий и расхождений из резолюции остаётся в силе.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: ddmitry/clickstream-data-platform#72