Files
av b278501a6e store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
2026-08-04 11:16:24 +03:00

56 KiB
Raw Permalink Blame History

MODIFIED Requirements

Requirement: Разрешение столкновений по полноте

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

Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями, не несущими содержания, побеждала бы настоящее измерение с двумя полями и стирала бы его безвозвратно.

Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат (0.65%); из них 981 различаются набором полей — это и есть область правила полноты, — 1 916 несут равные наборы и разные значения, где исход решает тай-брейк, а несравнимых наборов ноль.

Перемер 2026-08-04 на выросшем корпусе (155 доставок, 460 995 координат, настоящий ключ со слоем) уточнил соотношение и сделал тай-брейк главным разрядом правила, а не крайним: спорных координат 80 129, полнота отбрасывает кого-то в 981 из них (1,2%), остальные 79 148 (98,8%) уходят в тай-брейк. Смена тай-брейка меняет исход на 75 494 координатах, из них 71 773 — basal_energy_burned слоя raw, то есть посекундная развёртка HAE, которую Read API суммировать и так не имеет права.

Пустым значением MUST считаться null, пустая строка, число, равное нулю (в любой записи), пустой объект и пустой массив: поле без содержания не делает точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по разобранному значению, а не по байтам: 0, 0.0, 0e0, -0 и { } — та же пустота, что 0 и {}.

false пустотой MUST NOT считаться: для булева поля это одно из двух значений, а не отсутствие содержания (isIndoor: false — тренировка на улице).

Поле source в множество не входит — оно нестабильно и переписывается задним числом (находка 36), так что о полноте измерения ничего не говорит.

Содержимое, которое не разбирается как JSON-объект, SHALL нести пустое множество ключей: так оно проигрывает любой точке с содержанием и не загрязняет наблюдение о несравнимых наборах.

Надмножество побеждает только тогда, когда оно несёт то же содержание: значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до канонической формы). Иначе точки несут разные измерения, и надмножество имён о полноте не говорит ничего — такая пара MUST разрешаться как равнополная. Без этого условия точка {date, qty:0.001, p1:null, p2:null} вытесняла бы {date, qty:72.5}, то есть точка, где ни одно значение не измерение, стирала бы измерение — ровно то, ради отрицания чего правило переписано.

Если множества ключей с непустым значением равны и значения совпали, система SHALL сравнить множества всех ключей, кроме source, и оставить строгое надмножество. Без этого разряда правило теряло бы поля там, где заведено их беречь: точка {date, qty:10, Min:0, Max:0} и точка {date, qty:10} несут одинаковое содержание, и Min с Max исчезли бы из витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть: лишние ключи там заведомо пусты, объединять в них нечего.

Если полнота ответа не дала — равные множества с разными значениями либо несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой доставкой, а не лежавшая в объекте. Байтовый порядок канонических форм на этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из 1 912 (находка 49), то есть системно хранит версию, которую источник уже пересчитал. Ценой этого выбора час 2026-08-03T07:00Z метрики step_count остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной против 23 согласных часов, и род ушёл в unknown — Read API потерял право суммировать шаги.

Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для накопительных метрик и неверно для мгновенных, которые источник досчитывает вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса журнала.

Столкновение ВНУТРИ одной доставки SHALL разрешаться порядком канонических форм: провенанс у таких точек общий, различать их нечем, а порядок элементов в JSON-массиве от HAE нестабилен. Точка, канонически совпавшая с сохранённой, SHALL считаться пришедшей — иначе сохранённый кандидат проигрывал бы соседу по доставке, которого обязан был обойти по байтам, и «внутри доставки решают байты» нарушалось бы ровно тогда, когда доставка ничего не изменила. Побеждает при этом сохранённое содержимое дословно: хеш не двигается, объект не переписывается. Тем же правилом схлопываются две точки одного тела с совпавшей канонической формой — в витрине остаются байты встреченной первой. Выбор между ними ненаблюдаем по построению: различие при совпавшей канонической форме это порядок ключей или последний разряд double, и хеш содержимого объекта считается по канонической форме, а не по байтам.

Цена нового тай-брейка называется вслух и ограничивается разрядом, на котором он работает. Когда значения общих содержательных ключей разошлись, полнота ответа не даёт по построению — «надмножество имён о полноте не говорит ничего», см. выше, — и пришедшая точка побеждает, даже если сохранённая несла сверх того ключи без содержания (Min:0, Max:0). Такие ключи по определению пустоты этого же требования содержания не несут, и второй разряд их бережёт только там, где содержание совпало. Прежний байтовый порядок сохранял их случайно — по тому, что каноническая форма с ключом Max сортируется раньше формы с одним date, — и рассчитывать на такую защиту было нельзя.

Класс входа, на котором правило ведёт себя хуже прежнего, называется здесь. Две автоматизации, чьи наборы метрик пересекаются, наполняют одну координату разными значениями (находка 14); прежний тай-брейк давал на ней устойчивый исход, новый — чередование по последней доставке, то есть перезапись объекта и WARN на каждой доставке. Защита остаётся операционной («наборы метрик между автоматизациями не пересекать»), система её не проверяет, и это записано, чтобы следующий разбор не искал причину заново.

Победитель SHALL быть функцией множества точек координаты и их происхождения (доставка или витрина), а не порядка элементов внутри доставки. Попарная свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы (A превосходит B по полноте, B бьёт C тай-брейком, C бьёт A тай-брейком). При таком цикле повторная свёртка одной и той же доставки меняет содержимое объекта. Поэтому система SHALL отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать победителя среди оставшихся по тотальному порядку — сперва происхождение, затем каноническая форма.

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

Сравнение по received_at для тай-брейка не годится: у сохранённой точки нет провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с чем. Заводить его это изменение SHALL NOT: колонка провенанса на точку означала бы смену формата содержимого объекта, миграцию и рост нижнего слоя, а «пришедшая побеждает» даёт тот же исход, пока порядок свёртки равен порядку журнала. Запрет этот бюджетный, а не принципиальный: он назван решением владельца от 2026-08-04 и снимается тем же порядком. Если окно конкурентного приёма закрыть на приёме не удастся, провенанс (на объект, не на точку) остаётся единственным ходом, и спека обязана это допускать, а не запрещать вечно.

Если множества несравнимы — каждое несёт ключ с непустым значением, которого нет у другой, — система SHALL выбрать победителя тем же правилом, что и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора доставки, координаты объекта и запись WARN без значений точки. Несравнимый набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а координаты попадают в оба списка.

Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно станет массовым.

Победителем SHALL оставаться одна из пришедших точек дословно: правило выбирает, а не конструирует. Каноническая форма существует только в момент сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое число вместо присланного.

Scenario: Бедная точка не стирает поля богатой

  • WHEN сохранена точка с Avg, Min, Max и context
  • AND по тем же координатам приезжает точка только с Avg, Min и Max
  • THEN сохранённая точка остаётся с context

Scenario: Поля без содержания полноты не добавляют

  • WHEN сохранена точка с пятью полями, значения которых 0, {} и []
  • AND по тем же координатам приезжает точка с date и ненулевым qty
  • THEN остаётся точка с date и qty

Scenario: При равном содержании поля не теряются

  • WHEN сохранена точка {date, qty, Min:0, Max:0}
  • AND по тем же координатам приезжает точка {date, qty} с тем же qty
  • THEN остаётся точка с Min и Max

Scenario: При разошедшихся значениях пустые ключи не удерживают точку

  • WHEN сохранена точка {date, qty:10, Min:0, Max:0}
  • AND по тем же координатам следующей доставкой приезжает точка {date, qty:12}
  • THEN остаётся пришедшая точка, а Min и Max в витрине не остаются
  • AND счётчик перезаписей растёт

Scenario: Одинаково полные точки с разными значениями

  • GIVEN по координатам сохранена точка предыдущей доставки
  • WHEN следующей доставкой приезжает точка с тем же множеством ключей и другим значением
  • THEN в витрине остаётся пришедшая точка

Scenario: Одинаково полные точки внутри одной доставки

  • WHEN в одном теле по одним координатам приезжают две точки с одинаковыми множествами ключей и разными значениями
  • THEN остаётся точка с меньшей канонической формой
  • AND исход не зависит от порядка этих точек в массиве

Scenario: Повторная присылка сохранённого содержимого ничего не переписывает

  • GIVEN по координатам сохранена точка
  • WHEN следующей доставкой приезжает точка с той же канонической формой и другими байтами
  • THEN в витрине остаются сохранённые байты
  • AND объект не переписывается

Scenario: Полнота сильнее происхождения

  • GIVEN по координатам сохранена точка с Avg, Min и Max
  • WHEN следующей доставкой приезжает точка только с Avg и теми же значениями общих ключей
  • THEN остаётся сохранённая точка
  • AND счётчик удержаний в итоге разбора доставки растёт

Scenario: Несравнимые множества считаются, а не сливаются

  • WHEN по одним координатам приходят две точки, каждая из которых несёт ключ с непустым значением, которого нет у другой
  • THEN остаётся ровно одна точка, выбранная тем же правилом
  • AND счётчик несравнимых наборов в итоге разбора доставки растёт
  • AND система пишет WARN с координатами объекта и без значений точки

Scenario: Содержимое, которое не разбирается в объект

  • WHEN по координатам сталкиваются точка с непустыми полями и содержимое, не разбирающееся как JSON-объект
  • THEN остаётся точка с полями
  • AND счётчик несравнимых наборов не растёт

Столкновением SHALL считаться расхождение канонических форм, а не байтов. Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.

Scenario: Столкновение с различием содержимого оставляет след

  • WHEN по одним координатам сохраняется точка, каноническая форма которой отличается от уже сохранённой
  • THEN система пишет запись уровня WARN без значений точки
  • AND запись несёт координаты объекта: метрику, слой и час
  • AND увеличивает счётчик перезаписей в итоге разбора доставки

Scenario: Дребезг сериализации столкновением не считается

  • WHEN та же точка приезжает с другим порядком ключей или отличаясь последним разрядом числа
  • THEN счётчик перезаписей не растёт и WARN не пишется

Без этого следа допущение «меньше полей не значит новее» не получит ни одного наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с экспортом Apple — то есть месяцами.

Requirement: Замена версии сущности не теряет содержания

Сущность с собственным id SHALL замещаться целиком, а не сливаться по полям: она приезжает повторно, пока источник её досчитывает. Замер на живом архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва добавились stepCadence и stepCount вместе с изменившимся рядом activeEnergy, затем при том же наборе полей досчитались totalEnergy и basalEnergy.

Замещение MUST быть условным: приехавшая версия побеждает, если не теряет содержания сохранённой. Порядок разбора:

1. хеш канонического содержимого совпал        → содержимое не пишется,
                                                  провенанс поднимается до
                                                  более поздней позиции журнала
2. содержание приехавшей покрывает сохранённую
   и сверх того                                → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой    → остаётся сохранённая,
                                                  счётчик + WARN
4. содержание сравнимо, наборы равны           → версия из более поздней
                                                  доставки журнала
5. наборы несравнимы                           → остаётся сохранённая,
                                                  счётчик + WARN

Содержание сравнивается множествами ключей и формой их значений — но не значениями. Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение включения, когда значения общих содержательных ключей разошлись, а у сущности они расходятся всегда — источник её досчитывает. Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.

Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню содержимого:

  1. каждый ключ сохранённой с непустым значением есть у приехавшей и тоже непуст;
  2. при равенстве множеств содержательных ключей — каждый ключ сохранённой, включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и с тем же условием: иначе ключ с пустым значением исчезает по жребию тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
  3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST быть объект; где массив — массив. Версия, подменившая объект или массив скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и null-ов той же длины признаётся равным настоящей тренировке и выигрывает тай-брейк журнала;
  4. верхнеуровневый массив не теряет ни длины, ни содержательных элементов: усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из [null,null,null] не теряет и длины — притом что маршрут это 95% содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и опустошение элементов — законные признаки «приехало меньше».

Содержательность элемента ряда SHALL определяться той же пустотой, что и содержательность поля точки: null, пустая строка, ноль в любой записи, пустой объект, пустой массив; false содержателен. Второй словарь пустоты в проекте завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из настоящих нулей ([0,0,0]) считается лишённым содержания, поэтому версия с таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону — правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды HAE состоят из объектов, а не из чисел.

Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии. Ключ, содержания не несущий, проверяется только на присутствие (условие 2): формы у пустоты нет, и требовать её сохранения означало бы отличать [] от 0 там, где ни то, ни другое ничего не несёт.

Предел правила называется вслух и не закрывается: сокращение внутри элемента ряда (точка маршрута без altitude при непустом элементе и той же длине) не ловится ничем, кроме сверки с телом в архиве.

Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые множества ключей — то же правило, что для точки: такая версия проигрывает любой версии с содержанием и не загрязняет наблюдение о несравнимых наборах.

Единственная причина повторной присылки — доезжающий маршрут, то есть рост: обратного за 44 доставленные копии не случилось ни разу. Но восстановление требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не необратимым.

Тай-брейк при равных наборах — позиция доставки в журнале (received_at, id), а не порядок свёртки. Порядок свёртки приведён к порядку журнала требованием capability приёма, но равенство это неполное: строка учёта становится видимой воркеру только после записи тела, и при конкурентном приёме остаётся окно, в котором доставка с более ранней меткой сворачивается позже. Она вернула бы витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча, в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у сущности гарантия строго сильнее, чем у точки, и держится она на колонке провенанса, которой у точки нет.

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

Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же доставка, свёрнутая повторно) ничего не меняют.

Слово «провенанс» у сущности и у часового объекта означает разное, и это называется вслух: у объекта хранится доставка, создавшая его, и она не поднимается никогда; у сущности — доставка, чья версия лежит сейчас, и она поднимается до максимума по журналу среди версий с этим содержимым. Причина в том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.

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

Отличие от точки — в механизме, а не в намерении, и критерий выбора между ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки — по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их одно: у сущности есть колонка провенанса, у точки её нет и рамка решения владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше. Порядок канонических форм у обеих остался тем же и там же — тай-брейком внутри одной доставки, где провенанс общий и различать нечем. Тай-брейк по канонической форме между доставками заморозил бы тренировку на произвольной из версий навсегда, вместе с недосчитанной энергией, а у точки — систематически хранил бы меньшее значение (находка 49).

Версии одного ключа внутри одной доставки позициями не различаются, и победитель среди них SHALL быть функцией множества версий, а не порядка элементов массива: сперва отбрасываются строго покрытые кем-то из остальных, среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает «покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого опустошило бы множество, потеряв обе. Порядок при этом обязан быть тотальным до конца: при совпавших канонических формах решает минимум исходных байтов — иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом содержимом. Попарная свёртка здесь неверна ровно так же, как она была неверна для точек: покрытие — частичный порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение победы, при котором [A,B,C] и [B,C,A] дают разных победителей, а порядок элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии SHALL до сравнения с сохранённой.

Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL считаться симметрично и тоже быть функцией множества: считаются кандидаты, чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют — победитель ложится в витрину целиком, — и одно число на два события отвечало бы ни на одно. На счётчик удержаний опирается единственный контроль того, что правило покрытия не стало слишком строгим; примесь делает его неотличимым от шума.

Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя. Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не значит ничего. Побайтовое различие при совпавшей канонической форме событием MUST NOT считаться — порядок ключей в JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что счётчик по байтам срабатывал бы на измеренной норме потока. Различие содержимого при совпадающих множествах ключей и длинах массивов считаться SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.

Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для точек: на живом потоке событие не наступало, и вместо реализации заведено наблюдение.

Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается счётчиком и WARN.

Scenario: Доехавший маршрут замещает тренировку без маршрута

  • WHEN та же тренировка приезжает повторно, добавив route
  • THEN в хранилище лежит версия с маршрутом

Scenario: Досчитанные значения при том же наборе полей побеждают

  • WHEN та же тренировка приезжает повторно с тем же набором полей и изменившимися значениями, доставкой с более поздней позицией журнала
  • THEN в хранилище лежит приехавшая версия

Scenario: Версия из более ранней доставки не откатывает витрину

  • WHEN две доставки несут одну тренировку с равными наборами полей, и свёрнута сперва более поздняя по журналу, затем более ранняя
  • THEN в хранилище лежит версия из более поздней доставки
  • AND тот же исход даёт свёртка в обратном порядке

Scenario: Обеднённая версия сохранённую не затирает

  • WHEN та же тренировка приезжает повторно без route, который был у сохранённой, и с изменившимися значениями общих полей
  • THEN в хранилище остаётся сохранённая версия
  • AND факт учитывается счётчиком и записью WARN с идентификатором тренировки

Scenario: Усечённый маршрут сохранённый не затирает

  • WHEN та же тренировка приезжает повторно с тем же набором полей, но route короче сохранённого
  • THEN в хранилище остаётся сохранённая версия
  • AND факт учитывается тем же счётчиком

Scenario: Маршрут из пустых элементов сохранённый не затирает

  • WHEN та же тренировка приезжает повторно с route той же длины, все элементы которого пусты (null либо пустой объект)
  • THEN в хранилище остаётся сохранённая версия с координатами маршрута
  • AND факт учитывается тем же счётчиком

Scenario: Скелет из скаляров сохранённую тренировку не затирает

  • WHEN та же тренировка приезжает повторно, где каждый вложенный объект заменён числом, а каждый массив — массивом той же длины из null
  • THEN в хранилище остаётся сохранённая версия
  • AND факт учитывается тем же счётчиком

Scenario: Ключ с пустым значением не исчезает по жребию

  • WHEN та же тренировка приезжает повторно без ключа, значение которого у сохранённой было пустым, при совпадающих содержательных ключах
  • THEN в хранилище остаётся сохранённая версия
  • AND факт учитывается тем же счётчиком удержаний

Scenario: Пустой ключ не запирает законный досчёт

  • WHEN у сохранённой версии есть ключ с пустым значением, а приехавшая его не несёт, но приносит содержательный ключ, которого у сохранённой не было
  • THEN приехавшая замещает сохранённую
  • AND счётчик удержаний не растёт

Scenario: Две версии одной сущности в одном теле

  • WHEN тело содержит два элемента секции с одним id
  • THEN исход не зависит от их порядка в массиве
  • AND счётчик различающихся версий тоже не зависит от их порядка

Scenario: Три версии одной сущности в одном теле

  • WHEN тело содержит три элемента секции с одним id, из которых один покрывает второй, а третий несравним с обоими
  • THEN победитель одинаков при любой перестановке этих трёх элементов

Scenario: Две версии разного содержания при равной длине массивов

  • WHEN тело содержит два элемента секции с одним id, содержимое которых различается, но множества ключей и длины верхнеуровневых массивов совпадают
  • THEN факт учитывается счётчиком различающихся версий

Scenario: Разные байты при совпавшей канонической форме событием не считаются

  • WHEN тело содержит два элемента секции с одним id, различающихся только порядком ключей либо записью числа
  • THEN счётчик различающихся версий не растёт
  • AND в хранилище лежат одни и те же байты при любой перестановке элементов

Scenario: Повторная присылка обновляет провенанс

  • WHEN та же сущность приезжает повторно с тем же содержимым доставкой, стоящей в журнале позже сохранённой
  • THEN содержимое не переписывается
  • AND провенанс сущности указывает на более позднюю доставку

Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому

  • WHEN журнал несёт содержимое A, затем B, затем снова A, и доставка с B свёрнута последней
  • THEN содержимое сущности и отпечаток витрины совпадают со свёрткой того же журнала в его порядке

Scenario: Составной ключ не даёт коллизии отпечатка

  • WHEN две витрины различаются только тем, где проходит граница между родом и идентификатором записи
  • THEN отпечатки не совпадают

Scenario: Несравнимые наборы полей не объединяются

  • WHEN приехавшая версия несёт содержательный ключ, которого нет у сохранённой, и теряет содержательный ключ, который у сохранённой есть
  • THEN в хранилище остаётся сохранённая версия
  • AND факт учитывается тем же счётчиком

Scenario: Повторная свёртка того же журнала состояния не меняет

  • WHEN те же доставки сворачиваются повторно в том же порядке
  • THEN содержимое сущностей не меняется

ADDED Requirements

Requirement: Проигрыш пришедшей точки считается отдельно

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

Счётчик SHALL быть отдельным от счётчика перезаписей. Перезаписи считают столкновение в обе стороны (на корпусе 2026-08-04 они сработали бы около 84 000 раз), и отличить по ним «оставили пришедшую» от «выбросили пришедшую» нельзя — то есть единственное событие, ради наблюдения за которым правило и переписано, остаётся невидимым.

Под новым правилом пришедшая точка проигрывает ровно тогда, когда сохранённая строго полнее. То есть счётчик меряет одно направление правила полноты — то, в котором оно спорит с журналом; случай «пришедшая строго полнее» им не считается, потому что там полнота и журнал согласны. Молчащий счётчик означает, что правило полноты перестало спорить вовсе, и это событие для разбора, а не отказ.

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

Значений точек счётчик и его координаты содержать MUST NOT — данные о здоровье чувствительнее токенов.

Scenario: Удержание сохранённой точки видно в итоге доставки

  • GIVEN по координатам сохранена точка, строго более полная, чем пришедшая
  • WHEN доставка сворачивается
  • THEN счётчик удержаний растёт
  • AND координаты объекта попадают в список удержаний
  • AND счётчик остаётся нулевым, когда побеждает пришедшая точка

Requirement: Потеря содержания пришедшей точкой считается и не молчит

Система SHALL считать отдельным счётчиком координаты, где победителем оказалась пришедшая точка, а у проигравшей был ключ с непустым значением, которого у победительницы нет. Координаты таких объектов SHALL попадать в лог, и система SHALL писать WARN — без значений точек.

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

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

Запрещать такое слияние система SHALL NOT: измерено — 2 координаты из 80 129 спорных на живом корпусе, и обе те же, что дают несравнимые наборы. Событие обратимо пересборкой, пока жив архив, поэтому вместо запрета — наблюдение, тем же решением и по той же причине, по какой отложено объединение полей.

Значений точек ни счётчик, ни координаты, ни запись содержать MUST NOT.

Scenario: Пришедшая точка унесла содержательный ключ сохранённой

  • GIVEN сохранена точка {date, asleep:7.5, rem:1.2}
  • WHEN следующей доставкой приезжает точка {date, asleep:7.4}
  • THEN в витрине остаётся пришедшая точка
  • AND счётчик потери содержания растёт, а счётчик удержаний — нет
  • AND система пишет WARN с координатами объекта и без значений точек

Scenario: Пришедшая точка ничего не унесла

  • WHEN множества ключей у сохранённой и пришедшей совпадают, а значения различаются
  • THEN счётчик потери содержания не растёт