store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
@@ -0,0 +1,130 @@
## MODIFIED Requirements
### Requirement: Свёртку ведёт один фоновый воркер в порядке журнала
Свёртку принятых доставок SHALL вести одна горутина, обрабатывающая доставки в
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
Распараллеливать свёртку MUST NOT.
Порядок здесь — не удобство отладки, а условие правильности содержимого:
тай-брейк при равной полноте точек разрешается в пользу пришедшей доставки, то
есть исход слияния есть функция порядка свёртки. Свёрнутая не в порядке журнала
доставка возвращает координату к версии, которую источник уже пересчитал, и
живая витрина расходится с тем, что даёт пересборка.
Проход воркера SHALL прекращаться на первой доставке, чей исход свёртки
**классифицирован как отложенный** (занятость базы, отмена снаружи), а не
перешагивать её. Перешагнув, проход свернул бы её преемниц раньше неё.
Предикат остановки — именно класс исхода, а не статус доставки. Доставка,
оставшаяся `pending` из-за отказа записи самого исхода, курсор двигает и проход
не останавливает: иначе проход выбирал бы её бесконечно, свёртка встала бы
целиком, а приём продолжал бы отвечать `200`.
Очередь при этом не встаёт: собственный дедлайн свёртки обстоятельством
MUST NOT считаться — доставка, не уложившаяся в бюджет, получает `failed` и
очередь освобождает, а занятость базы блокирует запись всем одинаково. Проход
возобновляется сигналом приёма или периодическим пробуждением.
Стоящая голова очереди молчать MUST NOT: у неё нет верхнего предела ожидания, и
снаружи она неотличима от здорового потока, потому что приём продолжает отвечать
`200`. Метка отставания (см. ниже) SHALL писаться и на выходе прохода по
барьеру, а не только на пустой выборке: занятая голова очереди до пустой
выборки не пропускает проход НИКОГДА, и метка, привязанная к ней, молчала бы
ровно в том состоянии, ради которого заведена. Флаг «первый проход завершён»
при этом взводиться MUST NOT — проход до конца очереди не дошёл.
Достижимая гарантия называется точно: в порядке `(received_at, id)`
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
ставшая видимой позже курсора прохода, подбирается следующим проходом;
абсолютного порядка при конкурентных приёмах система не обещает, потому что
строка учёта становится видимой только после записи тела (измерено 184 мс на
62 МиБ).
Остаточное окно молчать MUST NOT. Перед свёрткой воркер SHALL спрашивать
журнал, есть ли доставка **позже** этой по `(received_at, id)`, уже записавшая
исход разбора в витрину — то есть в статусе `parsed` или `partial`. Есть —
пишется одна запись `WARN` на доставку, с её идентификатором, ожиданием в
секундах и без значений точек.
Спрашивать журнал система SHALL **до** свёртки, а писать запись — **после** и
только если свёртка состоялась: после свёртки предикат уже видит саму эту
доставку разобранной, а отложенная доставка витрину не трогала, и запись о
свёртке вне порядка утверждала бы событие, которого не было, — да ещё
повторялась бы каждым проходом, пока голова очереди занята. Это единственное наблюдение, по которому расхождение живой витрины с
пересборкой вообще обнаружимо до сверки отпечатков; лечится оно
`healthlog reindex`.
Статус `failed` в предикат входить MUST NOT: такая доставка в витрину ничего не
записала, и перестановка относительно неё содержимого не разводит. А
`failed` — штатный исход (невыводимый слой), и его учёт превратил бы `WARN` в
шум, на который перестают смотреть.
Пересборка эту проверку выполнять MUST NOT: она идёт в порядке журнала по
построению, и её тишина здесь содержательна.
Проверка эта — **страж окна, а не постоянная часть свёртки**: закрыв порядок на
самом приёме, её SHALL снять вместе с окном. Сказано здесь потому, что иначе
страж переживёт стерегомое и станет тем, что следующий читатель удалит без
объяснения.
Последствие предела называется вслух: доставка без плотных метрик, свёрнутая
раньше своей предшественницы, слоя не выведет и получит `failed` — то есть её
точки в витрину не попадут до пересборки. Та же перестановка при равной полноте
точек оставляет в витрине версию не той доставки, что стоит в журнале последней.
Окно узкое (обе доставки должны приниматься одновременно), и изменение его
сужает, а не открывает: прежде проход перешагивал отложенную доставку.
Устранение предела — отдельный вопрос, оно требует удерживать порядок на самом
приёме.
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
курсором в пределах одного прохода. Курсор обязателен для завершимости:
доставка, у которой не удалось записать даже исход разбора, остаётся `pending`,
и проход без курсора выбирал бы её бесконечно.
Приём SHALL будить воркер после того, как доставка учтена. Потеря сигнала
отказом быть MUST NOT: доставка от этого не перестаёт числиться `pending`.
Помимо сигнала воркер SHALL просыпаться периодически — иначе доставка,
оставшаяся `pending` по причине выше, ждала бы следующей доставки, а ночью
телефон молчит часами.
Отказ отдельного прохода воркер SHALL переживать: отказ выборки пишется `ERROR`
и прекращает проход, но не цикл. Отмена работы снаружи отказом при этом
считаться MUST NOT — штатная остановка не должна писать `ERROR`. Воркер, умерший
от временного отказа базы, остановил бы свёртку до конца жизни процесса, пока
приём продолжал бы отвечать `200`.
#### Scenario: Видимые доставки сворачиваются в порядке журнала
- **GIVEN** несколько доставок числятся `pending` до начала прохода
- **WHEN** воркер делает проход
- **THEN** он сворачивает их в порядке `(received_at, id)`
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
порядку доставки той же автоматизации, а не соседа по времени вставки
#### Scenario: Отложенная доставка держит очередь
- **GIVEN** в очереди несколько доставок, и свёртка первой из них отложена
занятостью базы
- **WHEN** воркер делает проход
- **THEN** её преемницы в этом проходе не сворачиваются
- **AND** следующий проход снова начинает с отложенной доставки
#### Scenario: Свёртка вне порядка журнала не молчит
- **GIVEN** доставка стала видимой после того, как её преемница по журналу уже
вышла из очереди
- **WHEN** воркер сворачивает её
- **THEN** система пишет `WARN` с идентификатором доставки и без значений точек
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
`pending`
- **THEN** проход воркера завершается, а не выбирает её повторно
#### Scenario: Доставка без входящего потока всё равно подбирается
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
- **WHEN** наступает очередное периодическое пробуждение
- **THEN** воркер пробует свернуть её снова
@@ -0,0 +1,87 @@
## MODIFIED Requirements
### Requirement: Пересборка витрины проигрыванием журнала
Система SHALL уметь собрать витрину заново, проиграв журнал целиком:
`import(снапшот) + replay(доставки)`. Стадия снапшота в этой дельте пуста —
пересборка из архива есть вырожденный случай с пустым снапшотом, — и отдельной
операции «пересборка из архива» рядом с импортом экспорта заводить MUST NOT.
Проигрываться SHALL **все** доставки журнала, а не только те, чей
`parse_status` говорит о неразобранности. Отбор по учётному статусу сделал бы
результат функцией предыдущего прогона, а не журнала; дешевизну повторного
проигрывания обеспечивает хеш-детектор объекта, а не пропуск доставок.
Пересборка собственного разбора и собственного слияния иметь MUST NOT: она
зовёт тот же код, что и приём, по идентификатору доставки, и тело читает из
архива тем же путём, с тем же пределом размера распакованного тела. Второй путь
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
принятое со `200`, вечно отказывает на каждой пересборке.
**Условие сходимости SHALL называться требованием, а не оговоркой сценария.**
Правило слияния точек разрешает равную полноту в пользу пришедшей доставки, то
есть содержимое витрины есть функция **порядка** свёртки, а не только множества
доставок. Отпечаток пересборки равен отпечатку накопленной приёмом витрины
тогда и только тогда, когда живая свёртка шла в порядке журнала. Прежде от
порядка зависел только вывод слоя; теперь от него зависят значения точек, то
есть цена нарушения выросла и должна быть названа здесь, а не выведена
читателем.
**Посылка равенства SHALL перечисляться рядом с ним**, а не подразумеваться,
потому что оракул, чья посылка не названа, краснеет по причине, к правилу
отношения не имеющей, и краснота становится неотличимой от дефекта. Посылок
три: живая свёртка шла в порядке журнала; в журнале нет доставок, чью свёртку
живой путь провалил, а пересборка проведёт (статус `failed`); за время
пересборки новых доставок не приезжало.
Из этих трёх посылок прогон пересборки SHALL печатать рядом с отпечатком ту,
которую он измеряет сам, — число `failed`. Первая посылка прогону
**недоступна по построению**: записи «свёртка вне порядка журнала» рождаются
только в живом воркере, нигде не хранятся, и пересборке та же спека проверку
прямо запрещает. Она проверяется журналом сервиса, и это сказано здесь, чтобы
следующий автор не приписал к отпечатку константный ноль, выдав его за
подтверждение.
Равенство «пересборка = приём» SHALL проверяться оракулом, а не рассуждением:
журнал, содержащий доставки с равнополными столкновениями, проигранный живым
путём (фоновый воркер, в том числе с доставкой, отложенной занятостью базы) и
путём пересборки, обязан давать один отпечаток витрины.
**Место снапшота в порядке журнала этой дельтой не определяется, и это сказано
вслух.** Под правилом «побеждает пришедшая» исход столкновения снапшота Apple с
точкой HAE зависит от того, какую позицию журнала получит доставка импорта:
учтённая сегодняшним временем, она перебила бы все равнополные точки, включая
досчитанные задним числом. Стадия снапшота сегодня пуста, поэтому вопрос
отложен — но решать его SHALL задача импорта, и явно, а не выбором первого
автора.
Отчёт пересборки SHALL называть два числа про точки — удержанные правилом
полноты против пришедшей доставки и потерявшие содержание в пользу пришедшей —
рядом с удержанными версиями сущностей и по той же причине: сходимость
отпечатка ни одно из них не проверяет по построению.
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
накопления шёл последовательно, и за время пересборки новых доставок не
приезжало
- **WHEN** журнал проигрывается заново с пустой витрины
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
#### Scenario: Отложенная занятостью доставка не разводит приём и пересборку
- **GIVEN** журнал, где одни координаты получают равнополные точки с разными
значениями от разных доставок
- **AND** свёртка одной из доставок откладывается занятостью базы
- **WHEN** тот же журнал проигрывается живым путём и путём пересборки
- **THEN** отпечатки витрин совпадают
#### Scenario: Повторная пересборка ничего не меняет
- **WHEN** пересборка того же журнала выполняется второй раз
- **THEN** отпечаток витрины не меняется
#### Scenario: Разобранная доставка проигрывается наравне с неразобранной
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
- **THEN** проигрываются обе
@@ -0,0 +1,627 @@
## 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** счётчик потери содержания не растёт