store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-04
|
||||
@@ -0,0 +1,218 @@
|
||||
## Context
|
||||
|
||||
Правило слияния точек стоит на двух отношениях: полнота — частичный порядок,
|
||||
тай-брейк — тотальный. Победитель сегодня есть **функция множества** кандидатов
|
||||
координаты, и это записано в спеке хранения отдельным требованием: витрина
|
||||
обязана быть свёрткой журнала, а функция множества коммутативна и потому от
|
||||
порядка свёртки не зависит.
|
||||
|
||||
Решение владельца 2026-08-04 ломает ровно это свойство: «пришедшая побеждает» —
|
||||
не полурешётка. Правило перестаёт быть коммутативным, и исход слияния
|
||||
становится функцией **порядка свёртки**. Задача обязана внести плату целиком:
|
||||
привести порядок свёртки к порядку журнала и доказать равенство «пересборка =
|
||||
приём» оракулом, а не рассуждением.
|
||||
|
||||
Осложняющий факт назван в постановке: `replay` идёт по `(received_at, id)`,
|
||||
живая свёртка — по факту свёртки, и доставка, отложенная занятостью базы, их
|
||||
разводит.
|
||||
|
||||
Схему трогать нельзя: колонок провенанса на точку не заводим, формат `payload`
|
||||
не меняем, миграции нет.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- При равной полноте побеждает точка, пришедшая этой доставкой.
|
||||
- Внутри одной доставки исход по-прежнему решает порядок канонических форм.
|
||||
- Порядок свёртки равен порядку журнала везде, где это достижимо без изменения
|
||||
приёма; недостижимый остаток — наблюдаемый, а не молчащий.
|
||||
- «Пришедшая проиграла сохранённой» считается отдельным счётчиком.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Провенанс на точку, смена формата `payload`, миграция.
|
||||
- Тай-брейк, зависящий от значения точки (вариант «б») или от измеренного рода
|
||||
метрики — род есть функция витрины, и правило слияния, читающее собственную
|
||||
выдачу, повторяет дефект наследования слоя «из будущего»
|
||||
(`docs/review.md`, 2026-08-01).
|
||||
- Удержание порядка на самом приёме — это [journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||||
у неё своё решение владельца и своя очередь.
|
||||
- Пересборка витрины и подмена файла базы — необратимое действие человека.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Прежде своего — prior art
|
||||
|
||||
**CRDT-литература отвечает на вопрос прямо.** Регистр «последняя запись
|
||||
побеждает» (LWW-Register, Shapiro et al., «A comprehensive study of Convergent
|
||||
and Commutative Replicated Data Types», INRIA RR-7506) сходится **только**
|
||||
потому, что метка времени хранится **вместе со значением**: слияние сравнивает
|
||||
две метки, а не «кто пришёл вторым». Без хранимой метки то же правило вырождается
|
||||
в last-writer-wins по порядку применения — а он у реплик разный, и сходимости
|
||||
нет. Ровно это и означает «не полурешётка».
|
||||
|
||||
Взять готовое целиком нельзя: хранимая метка — это колонка провенанса на точку,
|
||||
то есть смена формата `payload` и миграция, которые постановка запрещает.
|
||||
Отвергнуто **с названной причиной**, и причина не «нам не подходит», а «цена
|
||||
выше разрешённой рамки».
|
||||
|
||||
Что из литературы берётся: её же вывод о том, чем платят за отказ от метки.
|
||||
Если состояние не решётка, сходимость обеспечивается **единственным
|
||||
детерминированным порядком применения операций** — это уже не CRDT, а
|
||||
конвейер репликации с журналом (state machine replication: Schneider,
|
||||
«Implementing fault-tolerant services using the state machine approach»,
|
||||
и то же в Raft/Kafka log-compaction). Требование там одно и оно жёсткое: все
|
||||
потребители применяют журнал в одном порядке. Наш журнал есть, порядок в нём
|
||||
определён (`(received_at, id)`), и задача сводится к тому, чтобы **живая свёртка
|
||||
применяла его в том же порядке, что и пересборка**.
|
||||
|
||||
**Прецедент внутри проекта — сильнее внешнего.** Слияние сущностей
|
||||
(`internal/store/entity.go`, `openspec/specs/storage/spec.md`) ту же развилку
|
||||
уже прошло и выбрало **позицию доставки в журнале**, явно отвергнув «побеждает
|
||||
приехавшая» такими словами: «"Побеждает приехавшая" было бы функцией порядка
|
||||
свёртки, а он порядку журнала не равен». Сущность могла себе это позволить —
|
||||
у неё есть колонка провенанса. У точки её нет и не будет, значит равенство
|
||||
порядков обязано быть обеспечено, а не предположено.
|
||||
|
||||
### Решение 1: тай-брейк по происхождению кандидата, затем по канонической форме
|
||||
|
||||
`candidate` получает флаг `incoming` — «эта точка приехала разбираемой
|
||||
доставкой, а не лежала в объекте». Тотальный порядок `pointLess` становится
|
||||
лексикографическим по паре `(не incoming, каноническая форма)`: пришедшая
|
||||
предшествует сохранённой, при равном происхождении решают байты канонической
|
||||
формы.
|
||||
|
||||
Отношение полноты (`pointDominates`) **не трогается**: более полная точка
|
||||
побеждает по-прежнему, и происхождение на это не влияет. Флаг участвует только
|
||||
в тотальном порядке, то есть ровно там, где полнота ответа не дала.
|
||||
|
||||
Механизм `pickBest` остаётся общим с сущностями: он требует от `less` тотального
|
||||
строгого порядка, и пара `(ранг, ключ)` его даёт — ключи различны, потому что
|
||||
совпавшие канонические формы схлопываются до выбора.
|
||||
|
||||
**Схлопывание поднимает флаг.** Если приехавшая точка канонически совпала с
|
||||
сохранённой, выживает сохранённый кандидат (его байты дословны), но флаг
|
||||
`incoming` он получает. Без этого «внутри доставки решают байты» нарушалось бы
|
||||
ровно в том случае, когда одна из точек доставки совпала с сохранённой:
|
||||
сохранённый кандидат проигрывал бы соседу по доставке, которого сам обязан был
|
||||
обойти по байтам. С поднятием флага доставка сравнивается с доставкой, и
|
||||
сохранённые байты остаются в витрине — то есть хеш не двигается и объект не
|
||||
переписывается.
|
||||
|
||||
Альтернатива — двухстадийная схема (сперва победитель внутри доставки, потом
|
||||
он же против сохранённой) — отвергнута: она гасит полноту. Точка, не
|
||||
превзойдённая никем, вылетала бы на внутридоставочном байтовом тай-брейке
|
||||
против точки, которую сохранённая заведомо превосходит. Одна стадия над
|
||||
объединением кандидатов сохраняет инвариант «выигрывает более полная».
|
||||
|
||||
### Решение 2: проход воркера прекращается на первой отложенной доставке
|
||||
|
||||
Сегодня `Worker.Pass` двигает курсор и после отложенной доставки, то есть
|
||||
сворачивает её преемниц раньше неё. При коммутативном правиле это было
|
||||
безразлично; теперь это молчаливое расхождение с пересборкой.
|
||||
|
||||
Проход прекращается на первой доставке с исходом `Deferred`. Следующий проход
|
||||
(сигнал приёма или тик раз в минуту) начинает с начала очереди и берёт её же.
|
||||
|
||||
Опасности «очередь встала навсегда» здесь нет, и это следствие уже принятого
|
||||
определения: `store.Transient` — это **только** занятость базы и отмена
|
||||
снаружи; собственный дедлайн свёртки в него намеренно не входит, и доставка,
|
||||
не уложившаяся в бюджет, получает `failed` и очередь освобождает. Занятость же
|
||||
блокирует запись всем одинаково — проход, перешагнувший занятую доставку, всё
|
||||
равно упёрся бы в ту же занятость на следующей.
|
||||
|
||||
Плата называется вслух: голова очереди блокирует хвост. Раньше проход при
|
||||
занятости базы двигался дальше и мог свернуть хоть что-то. Это осознанный
|
||||
размен «полнота витрины сейчас» на «витрина есть свёртка журнала», и он
|
||||
соответствует тому, как проект уже разрешил тот же спор у сущностей.
|
||||
|
||||
### Решение 3: остаточное окно делается наблюдаемым
|
||||
|
||||
Строка учёта доставки становится видимой воркеру **после** записи тела
|
||||
(измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более
|
||||
ранней меткой может появиться после того, как её преемница уже свёрнута.
|
||||
Барьер этого не ловит: отставшей доставки в момент прохода просто не
|
||||
существует.
|
||||
|
||||
Закрыть окно можно только на приёме — резервированием строки учёта до записи
|
||||
тела либо выдержкой перед свёрткой. И то и другое — предмет отдельной задачи
|
||||
[journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||||
и переигрывать её здесь нельзя.
|
||||
|
||||
**Названо прямо, потому что иначе прочтётся как «закрыто»:** решение владельца
|
||||
в той задаче — вариант (в), повторы при `ErrLayerUnknown`, — порядок журнала
|
||||
**не восстанавливает**. Он лечит невыводимый слой, а доставка всё равно
|
||||
сворачивается после своей преемницы. Значит после его реализации равенство
|
||||
«пересборка = приём» останется условным. Это записано вопросом владельцу в
|
||||
раздел «Вопросы» той задачи вместе с тегом `question`, четвёртым вариантом
|
||||
(сериализовать выпуск ULID с записью тела и вставкой строки) и рекомендацией.
|
||||
|
||||
Что делается вместо: перед свёрткой воркер спрашивает журнал, есть ли доставка
|
||||
**позже** этой, которая уже вышла из очереди. Есть — пишется `WARN` с
|
||||
идентификатором доставки: порядок свёртки разошёлся с порядком журнала, и
|
||||
витрина в этом месте не равна пересборке. Событие перестаёт быть невидимым,
|
||||
а лечится оно `healthlog reindex`.
|
||||
|
||||
Запрос — тот же кортежный предикат `(received_at, id) > (?, ?)`, что уже
|
||||
используется в `PendingDeliveries` и `LastDerivedLayer`, но **не по тому же
|
||||
индексу**, и это названо, потому что первая редакция утверждала обратное.
|
||||
`delivery_pending` — индекс частичный, построен для `parse_status = 'pending'`,
|
||||
а предикат просит `IN ('parsed','partial')`; планировщик берёт
|
||||
`delivery_received_at` (`EXPLAIN QUERY PLAN`: `SEARCH delivery USING INDEX
|
||||
delivery_received_at`).
|
||||
|
||||
Цена измерена, а не объявлена нулевой. В установившемся режиме (очередь пуста
|
||||
или почти пуста) — единицы микросекунд. На **чистой задолженности**, где
|
||||
преемниц в статусе `parsed` нет вовсе, поиск доходит до конца хвоста, и
|
||||
суммарная цена прохода квадратична по длине задолженности: 0,26 с при 2 000
|
||||
доставок, 3,12 с при 8 000, 19,8 с при 20 000. Это меньше 2% от цены самой
|
||||
свёртки (порядка 0,4 с на доставку по прогону живого архива), поэтому индекс не
|
||||
заводится: миграция ради двух процентов — плохой размен, а числа названы, чтобы
|
||||
следующий читатель не принимал решение по слову «дёшево».
|
||||
|
||||
Проверка живёт в **воркере**, а не в свёртке: пересборка зовёт `Player.Play`
|
||||
напрямую и по построению идёт в порядке журнала, а её тишина здесь
|
||||
содержательна.
|
||||
|
||||
### Решение 4: счётчик удержаний
|
||||
|
||||
`MergeStats.PointsHeld` — сколько пришедших точек проиграло сохранённой, плюс
|
||||
`PointsHeldAt` с координатами первых (`Collision`, тот же потолок
|
||||
`maxCollisionsReported`). Считается координата, где победил кандидат **без**
|
||||
флага `incoming`, а кандидаты с флагом были: под новым правилом это возможно
|
||||
ровно тогда, когда сохранённая строго полнее.
|
||||
|
||||
Отдельно от `Overwrites` — потому что `Overwrites` считает столкновение в обе
|
||||
стороны (на корпусе он сработал бы ~84 000 раз) и отличить «оставили новое» от
|
||||
«выбросили новое» по нему нельзя. Ровно тот же довод уже записан для
|
||||
`EntitiesHeld` у сущностей, и именование берётся оттуда же.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Витрина в `./data` расходится с новым правилом** → отпечаток обязан
|
||||
измениться, иначе правило не сработало. Приведение в соответствие —
|
||||
`healthlog reindex` с остановкой сервиса и подменой файла базы, необратимое
|
||||
действие человека. Этим изменением не выполняется.
|
||||
- **Голова очереди блокирует хвост при занятой базе** → занятость и раньше
|
||||
блокировала запись всем; проход возобновляется сигналом приёма или тиком раз
|
||||
в минуту, тела всё это время лежат в архиве и не теряются.
|
||||
- **Остаточное окно конкурентного приёма** → не закрывается, но перестаёт быть
|
||||
молчащим (`WARN`). Закрытие — за [journal-order-on-ingest](../../../docs/tasks/items/journal-order-on-ingest.md),
|
||||
чьё нынешнее решение его не закрывает; вопрос владельцу записан туда же.
|
||||
- **Цена изменения сосредоточена в одной метрике одного слоя** → из 75 494
|
||||
координат, где меняется исход, 71 773 (95%) — `basal_energy_burned` слоя
|
||||
`raw`. Ошибка была массовой по координатам и узкой по метрикам, и число
|
||||
переписываемых объектов растёт соответственно.
|
||||
- **Правило стало функцией порядка, а значит хрупче** → это записывается в
|
||||
спеку хранения нормативно (было «функция множества», стало «функция множества
|
||||
и позиции в журнале»), чтобы следующий автор не восстановил коммутативность
|
||||
«для чистоты» и не откатил починку молча.
|
||||
- **Больше записей в витрину** → повторная доставка той же координаты с другим
|
||||
значением теперь всегда переписывает объект, тогда как байтовый порядок часто
|
||||
давал `unchanged`. Хеш-детектор продолжает гасить точные повторы; рост
|
||||
ограничен долей спорных координат.
|
||||
- **`WARN` о свёртке вне порядка журнала может шуметь при подборе
|
||||
задолженности** → строка пишется одна на доставку и только когда позже неё
|
||||
уже есть свёрнутая; при штатном подборе задолженности в порядке журнала такой
|
||||
доставки нет.
|
||||
@@ -0,0 +1,73 @@
|
||||
## Why
|
||||
|
||||
Тай-брейк при равной полноте точек сегодня — порядок канонических форм, и он
|
||||
решает **98,8% спорных координат** (83 956 из 84 978 при 453 171 координате):
|
||||
полнота отвечает лишь в 1,2% случаев. Лексикографический порядок системно берёт
|
||||
меньшее значение (1 847 из 1 912, находка 49), то есть хранит **устаревшую**
|
||||
версию точки там, где HAE досчитывает задним числом (находка 10).
|
||||
|
||||
У дефекта появился независимый оракул: `task verify:archive` покраснел на
|
||||
`master` без единого коммита, с ростом корпуса. Час `2026-08-03T07:00Z` метрики
|
||||
`step_count` получил четыре доставки с двумя различными значениями; победило
|
||||
меньшее, оно же приехавшее первым, и сверка слоёв объявила метрику мгновенной
|
||||
против 23 согласных часов. `step_count` ушёл в `unknown` — Read API больше не
|
||||
имеет права суммировать шаги.
|
||||
|
||||
Решение владельца от 2026-08-04: **при равной полноте побеждает пришедшая
|
||||
точка**, а не сохранённая. Значение при этом не интерпретируется — правило
|
||||
остаётся структурным.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING (содержимое витрины):** при равной полноте побеждает точка,
|
||||
пришедшая **этой** доставкой, а не сохранённая. Отпечаток витрины обязан
|
||||
измениться; состояние восстанавливается пересборкой из архива.
|
||||
- Порядок канонических форм остаётся тай-брейком **внутри одной доставки**, где
|
||||
провенанс общий и различать нечем.
|
||||
- **Правило слияния точек перестаёт быть функцией множества и становится явной
|
||||
функцией порядка журнала.** Отсюда — плата, которую изменение обязано
|
||||
внести целиком: порядок свёртки приводится к порядку журнала.
|
||||
- Фоновый воркер **прекращает проход на первой отложенной доставке**, а не
|
||||
перешагивает её. Иначе занятость базы переставляет доставки местами, и живая
|
||||
витрина расходится с пересборкой молча.
|
||||
- Свёртка доставки, у которой в журнале уже есть свёрнутая преемница, пишет
|
||||
`WARN`: остаточное окно (конкурентный приём делает строку учёта видимой
|
||||
не в порядке меток) закрыть без изменения приёма нельзя, но молчать о нём
|
||||
нельзя тем более.
|
||||
- Заводится счётчик «пришедшая точка проиграла сохранённой» — отдельно от
|
||||
общего `MergeStats.Overwrites`, который считает столкновения в обе стороны и
|
||||
различить их не даёт.
|
||||
- Схема не трогается: колонок провенанса на точку не заводится, формат
|
||||
`payload` не меняется, миграции нет.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `storage`: тай-брейк при равной полноте точек — пришедшая побеждает
|
||||
сохранённую; порядок канонических форм остаётся только внутри доставки.
|
||||
Требование «победитель есть функция множества точек» заменяется на «функция
|
||||
множества точек и позиции в журнале». Новый счётчик удержаний.
|
||||
- `ingest`: проход воркера прекращается на первой отложенной доставке; свёртка
|
||||
вне порядка журнала становится наблюдаемой.
|
||||
- `reindex`: посылка сходимости («приём шёл последовательно») из оговорки
|
||||
сценария становится названным условием требования — теперь от порядка свёртки
|
||||
зависит не только вывод слоя, но и содержимое точек.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/store/bucket.go` — `mergePoints`, `resolve`, `candidate`,
|
||||
`pointLess`, `MergeStats`.
|
||||
- `internal/store/delivery.go` — запрос «есть ли свёрнутая доставка позже этой».
|
||||
- `internal/replay/worker.go` — барьер на отложенной доставке, `WARN` о свёртке
|
||||
вне порядка журнала.
|
||||
- `internal/fold/fold.go` — проброс нового счётчика в итог свёртки и в лог.
|
||||
- Витрина в `./data`: отпечаток изменится. Пересборка — действие человека при
|
||||
остановленном сервисе, этим изменением не выполняется.
|
||||
- Оракулы: `task verify:archive` (ноль противоречащих часов, `step_count`
|
||||
снова накопительная), тест сходимости «живой приём = пересборка» с отложенной
|
||||
доставкой.
|
||||
@@ -0,0 +1,368 @@
|
||||
# Триаж: tie-break-equal-completeness
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Профиль:** `deep`, режим — линейный (по слову оператора). База диффа
|
||||
`de2001d`.
|
||||
- **Гейт:** зелёный. Непокрытыми остались 9 изменённых строк — все ветки
|
||||
обработки ошибок (`internal/replay/worker.go:272-275`,
|
||||
`internal/store/bucket.go:488,564-565`, `internal/store/delivery.go:266-267`).
|
||||
- **Проходы поимённо, с исходом** (сверено с профилем: `deep` = 7–8 проходов по
|
||||
`docs/review.md`, здесь 7 на коде включая триггерный `reimpl` — расхождения с
|
||||
составом профиля нет):
|
||||
- `gate` — отработал, зелёный, 3 находки о покрытии тестами;
|
||||
- `specs` — отработал, 6 находок; отдельно подтвердил построчно, что
|
||||
переписанные тесты не подгоняют оракул;
|
||||
- `code` — отработал, 0 находок;
|
||||
- `adversary` — отработал, 4 находки + ответ на вопрос журнала (пути значения
|
||||
точки в лог выше `DEBUG` не найдено, 7 враждебных тел);
|
||||
- `ops` — отработал, 4 находки + ответ на вопрос журнала (результат — функция
|
||||
префикса журнала плюс порядка появления строк учёта);
|
||||
- `reimpl` — запускался по триггеру «новое правило слияния», 3 находки; поимённо
|
||||
назвал 6 мест, где существующее решение лучше его собственного, и снял одну
|
||||
чужую находку (стоящая голова очереди не нема: `internal/fold/fold.go:450`
|
||||
пишет `WARN` на каждую отложенную свёртку);
|
||||
- `architecture` — отработал, 2 находки + 2 замечания «дешевле до мерджа»;
|
||||
- до кода — профиль `design` (`specs`, `rubric`, `architecture`): находки
|
||||
отработаны в спеках и коде, повторно не поднимались.
|
||||
- **Вход/выход:** на входе 22 именованных находки семи проходов; после
|
||||
дедупликации, оракулов и отсева — 3 блокирующих, 4 «исправить сейчас»,
|
||||
6 гипотез, 3 promote-кандидата. Выброшено как вкусовщина 3 (названы в
|
||||
границах покрытия).
|
||||
- **Оракулы триажа** (добыты на временных базах, `./tmp/oracle/`, в `./data` не
|
||||
ходил): падающий тест на разрушительное направление слияния; замер
|
||||
`ParsedAfter` на 2 000/8 000/20 000 доставках; `EXPLAIN QUERY PLAN`.
|
||||
- **Разрешённые конфликты проходов:** O1 против R3 — оба замера верны, спор был
|
||||
об интерпретации; разрешён собственным замером (см. пункт 3 «Стоит
|
||||
исправить»). A1 сверен с корпусным замером оркестратора — конструкция
|
||||
воспроизводится, на живом корпусе 0 вхождений.
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. Обеднённая доставка стирает измеренное поле сохранённой точки, и ни один счётчик этого не видит
|
||||
|
||||
- Файл: `internal/store/bucket.go:561-605` (`resolve`, `pointLess`),
|
||||
`internal/canon/canon.go:267-283` (`Relate`)
|
||||
- Severity: critical
|
||||
- Confidence: high
|
||||
- Оракул: падающий тест триажа `tmp/oracle/a1_test.go`
|
||||
(`TestA1ОбеднённаяПришедшаяСтираетПолеБезСчётчика`): сохранено
|
||||
`{date, asleep:7.5, rem:1.2, deep:0.9}`, приехало
|
||||
`{date, asleep:7.4, deep:0.9}` — `Relate` даёт `equal` (значения общего ключа
|
||||
разошлись, разряд полноты гаснет), тай-брейк отдаёт победу пришедшей, в
|
||||
витрине остаётся точка **без `rem`**, `PointsHeld=0`, `Incomparable=0`,
|
||||
`Overwrites=1` (направления не различает). Подтверждает конструкцию
|
||||
`adversary` (A1).
|
||||
- Последствие: нарушение инварианта «Ничего не теряем молча» (`CLAUDE.md`,
|
||||
critical). До изменения этот исход был жребием байтового порядка; теперь он
|
||||
**детерминированно** в пользу обеднённой пришедшей, а новый счётчик
|
||||
`PointsHeld` считает только обратное, безобидное направление. Вероятность
|
||||
низкая: корпусный замер оркестратора — 0 вхождений на 80 129 спорных
|
||||
координат (2 координаты с потерей ключа — ровно 2 несравнимые пары, их
|
||||
считает `Incomparable`). Но порча с низкой вероятностью весит больше
|
||||
гарантированного неудобства, и молчание здесь полное.
|
||||
- Предложение — выбор владельца, оба варианта названы `adversary`:
|
||||
- (а) счётчик разрушительного направления: победитель `incoming`, а у
|
||||
проигравшего сохранённого есть содержательный ключ, которого нет у
|
||||
победителя → счётчик + координата в лог (зеркально `PointsHeld`, сравнение
|
||||
уже посчитанных `fields`, цена ~нулевая). Семантика слияния не меняется;
|
||||
на текущем корпусе счётчик будет нулевым — это и есть утверждение.
|
||||
- (б) сузить «побеждает пришедшая» до случая совпавших множеств
|
||||
содержательных ключей; при несовпавших — побеждает более полная по именам.
|
||||
Меняет правило слияния и текст дельты storage, требует прогона
|
||||
`verify:archive` заново.
|
||||
- Рекомендация триажа: (а) — восстанавливает наблюдаемость без смены
|
||||
семантики, соответствует уже принятому в проекте образцу («объединение
|
||||
полей отложено до счётчика, который заговорит»).
|
||||
- Действие: развилка
|
||||
- Найдено проходом: adversary; сверено с корпусным замером оркестратора
|
||||
|
||||
### 2. Стоящая голова очереди молчит меткой отставания — вопреки MUST дельты, а метка порядка при этом врёт и повторяется
|
||||
|
||||
- Файл: `internal/replay/worker.go:151-211` (`Pass`), `worker.go:184`
|
||||
(`warnOutOfOrder` до свёртки), `worker.go:290-298` (`warnLag`)
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: чтение кода — `warnLag` вызывается только в ветке пустой выборки
|
||||
(`worker.go:174`), а при `Deferred` проход возвращается из середины цикла
|
||||
(`worker.go:206`), не взводя и `startupDone`; значит доставка, которую
|
||||
занятость откладывает проход за проходом, в метку отставания не попадает
|
||||
никогда. Подтверждено живым замером `adversary` (блокировка 22.1 с: строк о
|
||||
задержке 0, об отложенной свёртке 1). Дельта ingest (строки 29-33) обещает
|
||||
дословно: «Стоящая голова очереди молчать MUST NOT… Метка отставания…
|
||||
покрывает этот случай». Обещание не выполнено. Та же ранняя точка выхода
|
||||
делает `warnOutOfOrder` лжецом: он пишется **до** свёртки, при `Deferred`
|
||||
утверждает свёртку, которой не было, и повторяется каждым проходом — против
|
||||
собственного комментария «одна запись на доставку».
|
||||
- Последствие: класс «молчание» — задолженность растёт при занятой базе, а
|
||||
единственный обещанный спекой сигнал о ней не срабатывает (частично прикрыто
|
||||
`WARN "delivery fold deferred"` из `internal/fold/fold.go:450` — этим
|
||||
находка понижена с потенциального critical); плюс ложный `WARN` о свёртке
|
||||
вне порядка, приучающий не верить именно той строке, по которой решают о
|
||||
`reindex`.
|
||||
- Предложение (инлайн, одна зона в `Pass`): вызывать `w.warnLag(ctx, lag)` и на
|
||||
выходе по `Deferred` (метка — одна строка на проход, шквала нет; подавление
|
||||
первого прохода можно сохранить, взводя `startupDone` по завершении первого
|
||||
прохода независимо от исхода); `warnOutOfOrder` писать после свёртки и только
|
||||
при исходе, реально записавшем разбор (не при `Deferred`).
|
||||
- Действие: инлайн
|
||||
- Найдено проходами: specs (F1, F4), adversary (A2), ops (O2) — одна причина:
|
||||
ранний выход `Pass` при `Deferred` не согласован с обеими метками
|
||||
|
||||
### 3. Документы в шести местах продолжают утверждать «победитель — функция множества», и следующий автор откатит починку молча
|
||||
|
||||
- Файл: `docs/architecture.md:937`, `docs/database.md:130`,
|
||||
`docs/conventions/storage.md:38-40`, `internal/store/winner.go:16-19`,
|
||||
`internal/store/entity_test.go:821-828`, `internal/replay/replay_test.go:173-174`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: проверено чтением — `docs/architecture.md:937`: «Победитель — функция
|
||||
множества точек, а не порядка их поступления»; `docs/database.md:130`: «При
|
||||
столкновении выигрывает более полная точка, а не последняя пришедшая»;
|
||||
`docs/conventions/storage.md:38-40` утверждает «порядок свёртки порядку
|
||||
журнала не равен» — ровно то, что это изменение сделало неверным;
|
||||
`winner.go:16-19` ссылается на обещание architecture.md сменить тай-брейк «когда
|
||||
род будет измерен» — дизайн это отверг (Non-Goals). Риск назван самим
|
||||
`design.md`: «чтобы следующий автор не восстановил коммутативность „для
|
||||
чистоты“ и не откатил починку молча» — а шесть мест документации ровно к
|
||||
этому и приглашают.
|
||||
- Последствие: докдрейф на critical-инварианте («Ничего не теряем молча» —
|
||||
формулировка правила столкновения теперь в `CLAUDE.md` другая); строка
|
||||
таблицы двух правил завышает гарантию сущностей (на несравнимых версиях они
|
||||
тоже расходятся).
|
||||
- Предложение: привести все шесть мест к формулировке «функция множества **и
|
||||
позиции в журнале**»; заодно выполнить записанное правило `CLAUDE.md`
|
||||
(«причина отказа от готового решения — промоутом в `docs/adr/`»): причина
|
||||
отказа от LWW-Register/CRDT уже написана в `design.md`, её осталось поднять
|
||||
в ADR — это часть той же работы «документы догоняют правило».
|
||||
- Действие: инлайн
|
||||
- Найдено проходом: architecture (AR1 + замечание про ADR)
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 1. Отчёт пересборки не называет удержанные точки (SHALL дельты), а одно из требований дельты невыполнимо как записано
|
||||
|
||||
- Файл: `cmd/healthlog/reindex_report.go:37-38`;
|
||||
`openspec/changes/tie-break-equal-completeness/specs/reindex/spec.md:30-46`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: чтение — отчёт печатает `Partial, Incomparable, EntitiesHeld,
|
||||
EntitiesDiverging`, но не `PointsHeld`, хотя дельта reindex SHALL требует
|
||||
«называть число точек, удержанных правилом полноты против пришедшей
|
||||
доставки», и число уже лежит в `Report` (`internal/replay/replay.go:214`).
|
||||
Второе: дельта требует «число записей „свёртка вне порядка журнала“ прогон
|
||||
SHALL печатать рядом с отпечатком» — эти записи существуют только в логе
|
||||
живого сервиса, нигде не хранятся, а пересборке та же дельта проверку прямо
|
||||
запрещает (ingest:55). Требование структурно невыполнимо.
|
||||
- Последствие: SHALL дельты нарушен кодом (счётчик — единственный способ
|
||||
увидеть, что правило удержания стало слишком строгим, сходимость отпечатка
|
||||
его не проверяет по построению); невыполнимое SHALL, влитое в спеку, станет
|
||||
вечно красным пунктом для следующего читателя.
|
||||
- Предложение: `PointsHeld` — добавить в строку «слияние:» отчёта (инлайн,
|
||||
одна строка). Невыполнимую величину — переписать в дельте на то, что
|
||||
измеримо (например: «число `failed` печатает прогон; записи „вне порядка“
|
||||
наблюдаются в логе сервиса и в отчёт не входят»). Правка текста дельты —
|
||||
решение не оркестратора.
|
||||
- Действие: развилка (код — инлайн, но формулировка требования дельты требует
|
||||
решения владельца: что именно обязана печатать посылка равенства)
|
||||
- Найдено проходами: specs (F2, F5), reimpl (R1)
|
||||
|
||||
### 2. Сценарий дельты «отложенная занятостью доставка не разводит приём и пересборку» не проверен ни одним оракулом
|
||||
|
||||
- Файл: `openspec/changes/tie-break-equal-completeness/specs/reindex/spec.md:56-62`;
|
||||
тесты: `internal/replay/worker_internal_test.go` (барьер — на подменённой
|
||||
свёртке), `internal/replay/order_test.go` (сходимость — без отложенной)
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: отсутствие теста подтверждено чтением обоих файлов (specs, проверено
|
||||
и триажем): композиция «настоящий `Deferred` × сходимость отпечатков»
|
||||
не проверяется ничем; `task verify:busy` (зелёный) проверяет только «занятость
|
||||
оставляет доставку в очереди», без сверки отпечатков.
|
||||
- Последствие: равенство «пересборка = приём» — ровно то свойство, ради
|
||||
которого изменение существует (инвариант «Хранилище — свёртка по журналу»,
|
||||
critical), — в самом опасном режиме держится на рассуждении, а прецедент
|
||||
2026-08-01 показал, что такие рассуждения расходятся с кодом молча.
|
||||
- Предложение — варианты specs: (а) busy-тест в `internal/replay` под флагом
|
||||
`-healthlog.busy`: журнал с равнополными столкновениями, свёртка под
|
||||
удерживаемой блокировкой, сверка отпечатков живого пути и пересборки;
|
||||
(б) сузить сценарий дельты и записать ограничение в границы. Рекомендация:
|
||||
(а) — цена одного теста против цены молчаливого расхождения витрины.
|
||||
- Действие: развилка
|
||||
- Найдено проходом: specs (F3)
|
||||
|
||||
### 3. design.md утверждает «цена не измеряется» и «тот же индекс» — оба утверждения неверны, замер триажа прилагается
|
||||
|
||||
- Файл: `openspec/changes/tie-break-equal-completeness/design.md:157-159`;
|
||||
`internal/store/delivery.go:256-269`
|
||||
- Severity: minor (после разрешения конфликта O1/R3)
|
||||
- Confidence: high
|
||||
- Оракул: замер триажа `tmp/oracle/parsedafter_test.go` на временной базе:
|
||||
полный проход проверок по чистой задолженности — N=2000: 0.26 с; N=8000:
|
||||
3.12 с; N=20000: 19.8 с (форма квадратичная, экстраполяция N=110 000 → ~10
|
||||
мин, N=330 000 → ~1.5 ч). Установившийся режим — 0.0077 мс на вызов.
|
||||
`EXPLAIN QUERY PLAN`: `SEARCH delivery USING INDEX delivery_received_at` —
|
||||
**не** тот же индекс, что у `PendingDeliveries` (частичный `delivery_pending`
|
||||
требует `parse_status='pending'` и к предикату `IN ('parsed','partial')`
|
||||
неприменим).
|
||||
- Последствие: конфликт O1 (ops) против R3 (reimpl) разрешён: оба замера верны,
|
||||
неверна интерпретация O1 «часами не может разобрать бэклог из-за проверок» —
|
||||
часы даёт сама свёртка (~0.4 с/доставку по verify:archive: при N=20 000 это
|
||||
~2 ч свёртки против 20 с проверок, <2% накладных). Реалистичный потолок
|
||||
задолженности — архив квартала ~27 000 доставок (~300 строк/сутки) → ~35 с
|
||||
проверок. Оптимизация кода не нужна (правка была бы незаказанной); неверные
|
||||
утверждения в design.md — нужно поправить, иначе следующий читатель примет
|
||||
решение по ложному числу.
|
||||
- Предложение: заменить в design.md «цена не измеряется» на измеренные числа
|
||||
(квадратична по задолженности, ~1% от цены свёртки, 7.7 мкс в установившемся
|
||||
режиме) и «по тому же индексу» на `delivery_received_at`. Код не трогать.
|
||||
- Действие: инлайн
|
||||
- Найдено проходами: ops (O1), adversary (A3), reimpl (R3) — одна причина
|
||||
|
||||
### 4. 71 773 координаты живой витрины остаются на старом исходе до ручного `reindex`, и напоминания об этом нет нигде, кроме отчёта ревью
|
||||
|
||||
- Файл: процедура выкладки; `design.md` Risks («Этим изменением не выполняется»)
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: корпусный замер оркестратора — исход меняется на 75 494 координатах
|
||||
(95% — `basal_energy_burned/raw`); `verify:archive` после изменения зелёный с
|
||||
новым отпечатком `03aace91…`, значит рабочая витрина под старым отпечатком с
|
||||
новым правилом **не сойдётся**, пока её не пересоберут.
|
||||
- Последствие: до пересборки живая витрина расходится с тем, что даёт
|
||||
`import + replay` — «состояние, которое даёт пересборка» в этом проекте
|
||||
объявлено внешним поведением (`docs/review.md`). Подмена файла базы —
|
||||
необратимое действие человека (`CLAUDE.md`, спрашивается всегда), оркестратор
|
||||
выполнить его не вправе.
|
||||
- Предложение — готовый вопрос владельцу: «После мерджа витрина расходится с
|
||||
новым правилом на ~75 тыс. координат (95% — basal_energy_burned/raw).
|
||||
(а) выполнить `healthlog reindex` с остановкой сервиса и подменой базы сразу
|
||||
после выкладки; (б) отложить до планового окна, приняв расхождение витрины на
|
||||
этот срок; (в) не пересобирать — витрина сойдётся только по метрикам, которые
|
||||
переприедут доставками.» Рекомендация: (а).
|
||||
- Действие: развилка
|
||||
- Найдено проходами: ops (O4), architecture (замечание про подмену базы)
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **Откат бинаря молча возвращает старое правило слияния** (ops O3, было major
|
||||
confidence medium → остаётся major-гипотезой без оракула). Миграций нет,
|
||||
откат стартует без слова, свёртка идёт по коммутативному правилу, и витрина
|
||||
расходится с пересборкой новым бинарём. Путь не построен и не воспроизведён;
|
||||
частично это плата, уже принятая проектом за «версия базы ниже бинаря — не
|
||||
отказ». Если владелец сочтёт класс важным — это та же развилка, что решалась
|
||||
в задаче про откат релиза.
|
||||
- **`failed`-доставка «в витрину ничего не записала» — посылка предиката
|
||||
`ParsedAfter` держится раскладкой кода, а не проверкой** (adversary). Если
|
||||
когда-нибудь `failed` начнёт писать частично, страж окна ослепнет молча.
|
||||
Оракула нет — гипотеза, потолок major.
|
||||
- **`hasIncomparablePair` не видит отмены и способен продлить свёртку за
|
||||
дедлайн под транзакцией записи** (reimpl R2, замер честный: ≈51 с при ~7000
|
||||
кандидатов на координате). Понижено до гипотезы о масштабе: на живом корпусе
|
||||
кандидатов на координате единицы, путь к ~7000 версий одной координаты не
|
||||
построен; `pickBest` при том же масштабе сам стоит 103 с — чинить пришлось бы
|
||||
оба, то есть это вопрос предела на кандидатов, а не пропущенного `ctx.Err()`.
|
||||
- **Порядок свёртки под конкурентным приёмом не проверяется ни одним тестом с
|
||||
параллельными писателями** (gate-3): `-race` зелёный на последовательных
|
||||
сценариях. Дефекта не построено; остаточное окно конкурентного приёма
|
||||
осознанно вынесено в `journal-order-on-ingest` (ops O5 туда же присоединился).
|
||||
- **Ветки ошибок нового кода не покрыты тестами** (gate-1, gate-2): отказ
|
||||
`ParsedAfter` → `warnOutOfOrder`/`ERROR`, отмена контекста в
|
||||
`resolve`/`pickBest` — 9 строк, названных оркестратором. Поведение веток
|
||||
прямое (залогировать и продолжить / вернуть ошибку), дефекта в них не
|
||||
предъявлено. Закроются заодно с правкой блокирующего пункта 2, если
|
||||
оркестратор добавит тест на `Deferred`-выход.
|
||||
- **Имя метрики из чужого тела уезжает в первичный ключ витрины без предела
|
||||
длины** (adversary A4). Подтверждено конструкцией (512 КиБ в ключе), но
|
||||
дефект предсуществующий, изменением не внесён — этому изменению не
|
||||
предъявляется. Конвенция уже записана (`docs/conventions/storage.md`: «любое
|
||||
значение из чужого JSON, попадающее в ключ… имеет названный предел длины») —
|
||||
значит это не promote, а невыполненное правило: заводится задачей вне этого
|
||||
изменения.
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **Ошибки уровня `store` не проходят через единую границу обрезки** (adversary):
|
||||
сегодня утечку значений в текст ошибки сдерживает дисциплина каждого
|
||||
call-site. Кандидат в `docs/conventions/errors.md`: текст ошибки хранения
|
||||
несёт род и координату, но не значение; проверяется тем же разобранным
|
||||
буфером, что и логи.
|
||||
- **Агрегат счётчиков (`Outcome.Add`, `MergeStats`) суммируется руками —
|
||||
забытая строка молча занулит счётчик** (architecture AR2): кандидат в
|
||||
конвенцию тестирования — рефлексивный тест «каждое числовое поле участвует в
|
||||
Add», или правило «новое поле счётчика приходит вместе со строкой в Add и
|
||||
тестом». Не находка против этого кода: все нынешние поля просуммированы.
|
||||
- **Проверка «в логе нет значения точки» для новых поверхностей вывода**
|
||||
(по мотивам A1/PointsHeldAt): координата в лог идёт через `clipMetric`, но
|
||||
правило «новая поверхность вывода получает тест на утечку» записано только
|
||||
для отчёта пересборки комментарием. Кандидат в `docs/conventions/testing.md`.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Запускалось:** профиль `deep`, линейно: `gate` (зелёный), `specs`, `code`,
|
||||
`adversary`, `ops`, `reimpl` (по триггеру «новое правило слияния»),
|
||||
`architecture`; до кода — профиль `design` (`specs`, `rubric`, `architecture`).
|
||||
Оркестратор дополнительно прогнал `task verify:archive` (до изменения красный —
|
||||
унаследованный `step_count`, после — зелёный, отпечаток `03aace91…`,
|
||||
воспроизведён дважды) и `task verify:busy` (зелёный) — то, что по
|
||||
`docs/review.md` числится «перестали проверять сознательно», в этой задаче
|
||||
прогнано, потому что она триггерная (правило слияния).
|
||||
|
||||
**Не запускалось:** `rubric` на коде — намеренно, только в `design` (его
|
||||
свойства легли приёмочными критериями задачи); других проходов профиля не
|
||||
пропущено.
|
||||
|
||||
**Что запущенные проходы не могли проверить в принципе (из charter'ов):**
|
||||
`specs` судит код против дельт, а не поведение под нагрузкой; `code` — только
|
||||
записанные конвенции; `adversary` назвал свойство без пути — постоянную
|
||||
остановку очереди построить не удалось (`Deferred` только из
|
||||
`ErrBusy`/`Canceled`); `ops` не наблюдал реальную ночную нагрузку и реальный
|
||||
рестарт с большой задолженностью (его замеры — синтетика, как и мои); `reimpl`
|
||||
переигрывал только правило слияния и порядок, не разбор; `architecture` не
|
||||
запускает код; триаж ничего нового не находит по определению — пропуск любого
|
||||
прохода был бы и моим пропуском.
|
||||
|
||||
**Целиком на человеке — два списка, намеренно раздельных.**
|
||||
|
||||
*Не проверит ни один проход:* реальный профиль нагрузки телефона (объём и
|
||||
частота меряются только по факту); поведение HAE за пределами наблюдённого —
|
||||
в том числе пришлёт ли он когда-нибудь ту самую «обеднённую точку с
|
||||
разошедшимся значением» из блокирующего пункта 1 (сегодня — 0 вхождений на
|
||||
корпусе); полнота словаря переводов после обновления iOS; секции, которых
|
||||
поток ещё не приносил (`symptoms`, `ecg`, `heartRateNotifications`,
|
||||
`cycleTracking`, `medications`); история инцидентов; завязка потребителей на
|
||||
текущее поведение витрины; вопрос «нужна ли эта функциональность вообще» —
|
||||
решён владельцем 2026-08-04 (тай-брейк — его выбор), но последствия выбора
|
||||
наблюдаются только эксплуатацией.
|
||||
|
||||
*Перестали проверять сознательно:* `verify:archive`/`verify:busy` вне гейта
|
||||
(в этой задаче прогнаны — см. выше; следующая задача без триггера их не
|
||||
увидит); класс «в Go так не пишут» — не покрыт вовсе после упразднения `idiom`
|
||||
(запись 2026-08-02), к этому диффу поимённая сверка со стайлгайдами не
|
||||
применялась; класс «чего нет в зрелой реализации такого узла» — вне профиля
|
||||
`design`, на коде не проверялся.
|
||||
|
||||
**Документы проекта:** всё, что нужно триажу, на месте — `CLAUDE.md` с
|
||||
инвариантами и severity (веса пунктам присвоены по ним, а не выведены заново),
|
||||
`docs/review.md` с разделами «Типовые ложноположительные» (использован: ни одна
|
||||
находка под шесть записанных классов не подпала, отсев шёл по общим критериям
|
||||
плюс этот раздел) и «Недоступно проверке» (перенесён выше двумя списками),
|
||||
`docs/security.md`, `docs/research/apple-health.md` (находка 54 — по ней
|
||||
`adversary` строил обеднённые тела). Ни один проход недостающего документа не
|
||||
заявил. Единственный документный пробел — не отсутствие, а протухание:
|
||||
`docs/database.md:130` и ещё пять мест из блокирующего пункта 3.
|
||||
|
||||
**Что не влезло в потолок и куда делось:** все понижения названы в «Гипотезах»
|
||||
поимённо, молча не выброшено ничего. Отсеяно как вкусовщина, целиком по трём
|
||||
критериям (не меняет поведения, не влияет на цену следующего изменения, не
|
||||
нарушает записанной конвенции): атрибут `waited_sec` в новом `WARN` (specs F6 —
|
||||
дельта ограничивает состав «без значений точек», и это выполнено; секунды
|
||||
ожидания значением точки не являются); схлопывание `Worker.player`+`fold` в
|
||||
замыкание (architecture); переименования/перестановки из generative-выводов не
|
||||
поступали. Замечания `reimpl` «где существующее решение лучше» — не находки, а
|
||||
подтверждение решений; перечислены в его сыром выводе и сохранены в истории
|
||||
задачи.
|
||||
+130
@@ -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** воркер пробует свернуть её снова
|
||||
+87
@@ -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** проигрываются обе
|
||||
+627
@@ -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** счётчик потери содержания не растёт
|
||||
@@ -0,0 +1,118 @@
|
||||
## 1. Правило слияния точек
|
||||
|
||||
- [x] 1.1 `candidate` получает флаг `incoming`; схлопывание совпавших
|
||||
канонических форм поднимает флаг, а байты оставляет встреченные первыми
|
||||
- [x] 1.2 `pointLess` — лексикографический порядок по паре
|
||||
`(не incoming, каноническая форма)`; `pointDominates` не трогается
|
||||
- [x] 1.3 `resolve` возвращает признак «победил сохранённый при наличии
|
||||
пришедших»; `mergePoints` считает удержания
|
||||
- [x] 1.4 `MergeStats.PointsHeld` и `PointsHeldAt`; проброс через `mergeResult`,
|
||||
`SaveBuckets`, `fold.Stats`, `replay.Outcome`/`Report` и лог свёртки — без
|
||||
значений точек
|
||||
|
||||
## 2. Порядок свёртки равен порядку журнала
|
||||
|
||||
- [x] 2.1 `Worker.Pass` прекращает проход на исходе `Deferred`; доставка,
|
||||
оставшаяся `pending` без такого исхода, курсор двигает
|
||||
- [x] 2.2 `Store.ParsedAfter` — есть ли доставка позже данной в статусе
|
||||
`parsed` или `partial` (кортежный предикат; индекс — `delivery_received_at`,
|
||||
цена измерена и названа в design.md)
|
||||
- [x] 2.3 воркер пишет одну запись `WARN` на доставку о свёртке вне порядка
|
||||
журнала; пересборка эту проверку не выполняет
|
||||
|
||||
## 3. Тесты
|
||||
|
||||
- [x] 3.1 пришедшая побеждает сохранённую при равной полноте
|
||||
- [x] 3.2 столкновение внутри одной доставки решается порядком канонических
|
||||
форм и не зависит от порядка элементов массива
|
||||
- [x] 3.3 повторная присылка канонически совпавшего содержимого не переписывает
|
||||
объект (хеш не двигается)
|
||||
- [x] 3.4 полнота сильнее происхождения; счётчик удержаний растёт
|
||||
- [x] 3.5 сходимость: живой путь через воркер с отложенной занятостью базы
|
||||
доставкой даёт тот же отпечаток, что пересборка
|
||||
- [x] 3.6 проход воркера не перешагивает отложенную доставку
|
||||
- [x] 3.7 `WARN` о свёртке вне порядка журнала пишется, не содержит значений и
|
||||
не пишется на `failed`-преемнице
|
||||
- [x] 3.8 при разошедшихся значениях пустые ключи сохранённой точки её не
|
||||
удерживают (сценарий, парный к 3.4)
|
||||
|
||||
## 4. Документы
|
||||
|
||||
- [x] 4.1 `docs/architecture.md` — «Разрешение столкновений» плюс **все**
|
||||
упоминания правила: инвариант в шапке, «порядок прихода значения не имеет» в
|
||||
разделе синхронизации; таблица двух правил равной полноты (точки против
|
||||
сущностей) с критерием выбора
|
||||
- [x] 4.2 `CLAUDE.md` — формулировка инварианта «ничего не теряем молча»:
|
||||
«выигрывает более полная, при равной полноте — стоящая позже в журнале»
|
||||
- [x] 4.3 `internal/store/entity.go` — комментарии, чей довод («порядок свёртки
|
||||
журналу не равен») этим изменением сужен до окна видимости
|
||||
- [x] 4.4 `docs/research/apple-health.md` — перемер 2026-08-04 с **методом**:
|
||||
ключ со слоем, 155 доставок, 460 995 координат, 80 129 спорных, 981 полнотой,
|
||||
79 148 тай-брейком, 75 494 меняют исход
|
||||
- [x] 4.5 `docs/review.md` — запись о дефекте (посылка оракула умерла с ростом
|
||||
корпуса) и о том, что конвейер её не ловил
|
||||
- [x] 4.7 вопрос владельцу в `docs/tasks/items/journal-order-on-ingest.md`
|
||||
(раздел «Вопросы» + тег `question`): решение (в) порядок журнала не
|
||||
восстанавливает, а цена окна выросла с вывода слоя до значений точек
|
||||
- [x] 4.6 `docs/conventions/` — промоут двух правил: «в проверке на живом
|
||||
корпусе утверждается инвариант, число печатается» и «оракул сходимости
|
||||
называет свою посылку рядом с собой»
|
||||
|
||||
## 5. Оракулы
|
||||
|
||||
- [x] 5.1 `task gate` зелёный
|
||||
- [x] 5.2 `task verify:archive` — ноль противоречащих часов, `step_count`
|
||||
накопительная, отпечаток изменился
|
||||
- [x] 5.3 `task verify:archive` второй раз подряд — тот же отпечаток
|
||||
- [x] 5.4 `task verify:busy` зелёный
|
||||
- [x] 5.5 замер удержаний на живом архиве — число названо, не утверждено
|
||||
|
||||
## Приёмочные критерии из ревью предложения (профиль `design`, проход `rubric`)
|
||||
|
||||
Свойства, по которым судится узел рода «правило слияния версий в
|
||||
хранилище-свёртке по журналу» плюс «упорядочивающий воркер очереди».
|
||||
|
||||
- [x] Р1 исход слияния — функция префикса журнала и только его; запрос воркера
|
||||
«есть ли доставка позже» влияет на лог и MUST NOT влиять на содержимое
|
||||
- [x] Р2 законные расхождения живого пути и пересборки перечислены поимённо, и
|
||||
у оракула названа посылка
|
||||
- [x] Р3 повторная свёртка идемпотентна на суффиксе журнала; свёртка более
|
||||
ранней доставки после более поздней меняет состояние наблюдаемо
|
||||
- [x] Р4 победитель — функция множества кандидатов и их происхождения, но не
|
||||
порядка точек внутри доставки; отношение победы ациклично (проверка
|
||||
перестановками ТРОЙКИ, не пары)
|
||||
- [x] Р5 правило тотально на вырожденном входе: один кандидат, равные
|
||||
канонические формы с разными байтами, не-JSON, несравнимые множества
|
||||
- [x] Р6 победитель сохраняется дословно; цена лишних записей названа числом
|
||||
- [x] Р7 правило структурно: не читает ни значение точки, ни род, ни витрину
|
||||
- [x] Р8 барьер очереди не останавливает поток: названо, что держит очередь и
|
||||
что её освобождает; канонизация и сжатие вне транзакции записи
|
||||
- [x] Р9 наблюдаемость отличает «правило сработало» от «не сработало» и не
|
||||
несёт значений здоровья; тест на утечку разбирает запись, а не ищет
|
||||
подстроку в сыром буфере
|
||||
- [x] Р10 ни одно утверждение теста не пришпилено к числу, производному от
|
||||
размера корпуса: утверждается инвариант, число печатается
|
||||
- [x] Р11 два правила равной полноты (точки и сущности) не расходятся молча:
|
||||
различие механизмов и критерий выбора записаны
|
||||
- [x] Р12 место снапшота Apple в порядке журнала — либо названо, либо явно
|
||||
отложено до задачи импорта (сегодня стадия снапшота пуста)
|
||||
|
||||
## Критерии приёмки задачи
|
||||
|
||||
Приходят из `docs/tasks/items/tie-break-equal-completeness.md`, дословно. Числа
|
||||
в них производны от размера корпуса — по каждому названо, чем именно измерено и
|
||||
что при этом печатается, а не утверждается (`docs/review.md`, 2026-08-02).
|
||||
|
||||
- [x] ни одна метрика не теряет род из-за столкновения равной полноты;
|
||||
`step_count` снова накопительная — оракул: `task verify:archive`, ноль
|
||||
противоречащих часов
|
||||
- [x] живая свёртка и пересборка дают **один** отпечаток витрины — оракул:
|
||||
`healthlog reindex` против живого состояния; это же и есть страховка от
|
||||
потерянной коммутативности
|
||||
- [x] повторный прогон реплея даёт тот же отпечаток — оракул:
|
||||
`task verify:archive`, второй прогон подряд
|
||||
- [x] столкновение **внутри одной доставки** разрешается прежним байтовым
|
||||
порядком — оракул: тест на двух точках одной доставки с равной полнотой
|
||||
- [x] «пришедшая точка проиграла сохранённой» считается отдельно от общего
|
||||
`MergeStats.Overwrites` — оракул: тест плюс прогон на живом архиве, число
|
||||
сходится с замером 2026-08-04
|
||||
@@ -120,19 +120,76 @@
|
||||
порядке журнала — `(received_at, id)`, как он определён capability пересборки.
|
||||
Распараллеливать свёртку MUST NOT.
|
||||
|
||||
Порядок здесь — не удобство отладки, а условие правильности содержимого:
|
||||
тай-брейк при равной полноте точек разрешается в пользу пришедшей доставки, то
|
||||
есть исход слияния есть функция порядка свёртки. Свёрнутая не в порядке журнала
|
||||
доставка возвращает координату к версии, которую источник уже пересчитал, и
|
||||
живая витрина расходится с тем, что даёт пересборка.
|
||||
|
||||
Проход воркера SHALL прекращаться на первой доставке, чей исход свёртки
|
||||
**классифицирован как отложенный** (занятость базы, отмена снаружи), а не
|
||||
перешагивать её. Перешагнув, проход свернул бы её преемниц раньше неё.
|
||||
|
||||
Предикат остановки — именно класс исхода, а не статус доставки. Доставка,
|
||||
оставшаяся `pending` из-за отказа записи самого исхода, курсор двигает и проход
|
||||
не останавливает: иначе проход выбирал бы её бесконечно, свёртка встала бы
|
||||
целиком, а приём продолжал бы отвечать `200`.
|
||||
|
||||
Очередь при этом не встаёт: собственный дедлайн свёртки обстоятельством
|
||||
MUST NOT считаться — доставка, не уложившаяся в бюджет, получает `failed` и
|
||||
очередь освобождает, а занятость базы блокирует запись всем одинаково. Проход
|
||||
возобновляется сигналом приёма или периодическим пробуждением.
|
||||
|
||||
Стоящая голова очереди молчать MUST NOT: у неё нет верхнего предела ожидания, и
|
||||
снаружи она неотличима от здорового потока, потому что приём продолжает отвечать
|
||||
`200`. Метка отставания (см. ниже) SHALL писаться и на выходе прохода по
|
||||
барьеру, а не только на пустой выборке: занятая голова очереди до пустой
|
||||
выборки не пропускает проход НИКОГДА, и метка, привязанная к ней, молчала бы
|
||||
ровно в том состоянии, ради которого заведена. Флаг «первый проход завершён»
|
||||
при этом взводиться MUST NOT — проход до конца очереди не дошёл.
|
||||
|
||||
Достижимая гарантия называется точно: в порядке `(received_at, id)`
|
||||
сворачиваются все доставки, **видимые воркеру** на момент выборки. Доставка,
|
||||
ставшая видимой позже курсора прохода, подбирается следующим проходом;
|
||||
абсолютного порядка при конкурентных приёмах система не обещает.
|
||||
абсолютного порядка при конкурентных приёмах система не обещает, потому что
|
||||
строка учёта становится видимой только после записи тела (измерено 184 мс на
|
||||
62 МиБ).
|
||||
|
||||
Последствие этого предела называется вслух: доставка без плотных метрик,
|
||||
свёрнутая раньше своей предшественницы, слоя не выведет и получит `failed` — то
|
||||
есть её точки в витрину не попадут до пересборки. Живое состояние в этом случае
|
||||
расходится с тем, что даёт `healthlog reindex`. Окно узкое (обе доставки должны
|
||||
приниматься одновременно, и только у автоматизации без плотных метрик), и
|
||||
изменение его сужает, а не открывает: прежде свёртка шла в порядке завершения
|
||||
обработчиков. Устранение предела — отдельный вопрос, оно требует удерживать
|
||||
порядок на самом приёме.
|
||||
Остаточное окно молчать MUST NOT. Перед свёрткой воркер SHALL спрашивать
|
||||
журнал, есть ли доставка **позже** этой по `(received_at, id)`, уже записавшая
|
||||
исход разбора в витрину — то есть в статусе `parsed` или `partial`. Есть —
|
||||
пишется одна запись `WARN` на доставку, с её идентификатором, ожиданием в
|
||||
секундах и без значений точек.
|
||||
|
||||
Спрашивать журнал система SHALL **до** свёртки, а писать запись — **после** и
|
||||
только если свёртка состоялась: после свёртки предикат уже видит саму эту
|
||||
доставку разобранной, а отложенная доставка витрину не трогала, и запись о
|
||||
свёртке вне порядка утверждала бы событие, которого не было, — да ещё
|
||||
повторялась бы каждым проходом, пока голова очереди занята. Это единственное наблюдение, по которому расхождение живой витрины с
|
||||
пересборкой вообще обнаружимо до сверки отпечатков; лечится оно
|
||||
`healthlog reindex`.
|
||||
|
||||
Статус `failed` в предикат входить MUST NOT: такая доставка в витрину ничего не
|
||||
записала, и перестановка относительно неё содержимого не разводит. А
|
||||
`failed` — штатный исход (невыводимый слой), и его учёт превратил бы `WARN` в
|
||||
шум, на который перестают смотреть.
|
||||
|
||||
Пересборка эту проверку выполнять MUST NOT: она идёт в порядке журнала по
|
||||
построению, и её тишина здесь содержательна.
|
||||
|
||||
Проверка эта — **страж окна, а не постоянная часть свёртки**: закрыв порядок на
|
||||
самом приёме, её SHALL снять вместе с окном. Сказано здесь потому, что иначе
|
||||
страж переживёт стерегомое и станет тем, что следующий читатель удалит без
|
||||
объяснения.
|
||||
|
||||
Последствие предела называется вслух: доставка без плотных метрик, свёрнутая
|
||||
раньше своей предшественницы, слоя не выведет и получит `failed` — то есть её
|
||||
точки в витрину не попадут до пересборки. Та же перестановка при равной полноте
|
||||
точек оставляет в витрине версию не той доставки, что стоит в журнале последней.
|
||||
Окно узкое (обе доставки должны приниматься одновременно), и изменение его
|
||||
сужает, а не открывает: прежде проход перешагивал отложенную доставку.
|
||||
Устранение предела — отдельный вопрос, оно требует удерживать порядок на самом
|
||||
приёме.
|
||||
|
||||
Воркер SHALL продвигаться по неразобранным доставкам строго возрастающим
|
||||
курсором в пределах одного прохода. Курсор обязателен для завершимости:
|
||||
@@ -159,6 +216,21 @@
|
||||
- **AND** доставка без плотных метрик наследует слой предшествующей ей по этому
|
||||
порядку доставки той же автоматизации, а не соседа по времени вставки
|
||||
|
||||
#### Scenario: Отложенная доставка держит очередь
|
||||
|
||||
- **GIVEN** в очереди несколько доставок, и свёртка первой из них отложена
|
||||
занятостью базы
|
||||
- **WHEN** воркер делает проход
|
||||
- **THEN** её преемницы в этом проходе не сворачиваются
|
||||
- **AND** следующий проход снова начинает с отложенной доставки
|
||||
|
||||
#### Scenario: Свёртка вне порядка журнала не молчит
|
||||
|
||||
- **GIVEN** доставка стала видимой после того, как её преемница по журналу уже
|
||||
вышла из очереди
|
||||
- **WHEN** воркер сворачивает её
|
||||
- **THEN** система пишет `WARN` с идентификатором доставки и без значений точек
|
||||
|
||||
#### Scenario: Доставка, не записавшая исход, не зацикливает проход
|
||||
|
||||
- **WHEN** свёртка доставки не смогла записать исход разбора и оставила её
|
||||
@@ -170,7 +242,6 @@
|
||||
- **GIVEN** доставка осталась `pending`, и новых доставок не приезжает
|
||||
- **WHEN** наступает очередное периодическое пробуждение
|
||||
- **THEN** воркер пробует свернуть её снова
|
||||
|
||||
### Requirement: Подбор неразобранного при старте — та же операция
|
||||
|
||||
При старте система SHALL сворачивать доставки, числящиеся неразобранными, тем
|
||||
@@ -320,4 +391,3 @@ NOT: факт уходит в `DEBUG`, приём продолжается.
|
||||
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
|
||||
- **AND** запись отличает занятость базы от прочих причин отказа
|
||||
- **AND** тот же отказ по другой причине этого признака не несёт
|
||||
|
||||
|
||||
@@ -26,6 +26,48 @@
|
||||
разбора разошёлся бы с первым молча, а другой предел означал бы, что тело,
|
||||
принятое со `200`, вечно отказывает на каждой пересборке.
|
||||
|
||||
**Условие сходимости SHALL называться требованием, а не оговоркой сценария.**
|
||||
Правило слияния точек разрешает равную полноту в пользу пришедшей доставки, то
|
||||
есть содержимое витрины есть функция **порядка** свёртки, а не только множества
|
||||
доставок. Отпечаток пересборки равен отпечатку накопленной приёмом витрины
|
||||
тогда и только тогда, когда живая свёртка шла в порядке журнала. Прежде от
|
||||
порядка зависел только вывод слоя; теперь от него зависят значения точек, то
|
||||
есть цена нарушения выросла и должна быть названа здесь, а не выведена
|
||||
читателем.
|
||||
|
||||
**Посылка равенства SHALL перечисляться рядом с ним**, а не подразумеваться,
|
||||
потому что оракул, чья посылка не названа, краснеет по причине, к правилу
|
||||
отношения не имеющей, и краснота становится неотличимой от дефекта. Посылок
|
||||
три: живая свёртка шла в порядке журнала; в журнале нет доставок, чью свёртку
|
||||
живой путь провалил, а пересборка проведёт (статус `failed`); за время
|
||||
пересборки новых доставок не приезжало.
|
||||
|
||||
Из этих трёх посылок прогон пересборки SHALL печатать рядом с отпечатком ту,
|
||||
которую он измеряет сам, — число `failed`. Первая посылка прогону
|
||||
**недоступна по построению**: записи «свёртка вне порядка журнала» рождаются
|
||||
только в живом воркере, нигде не хранятся, и пересборке та же спека проверку
|
||||
прямо запрещает. Она проверяется журналом сервиса, и это сказано здесь, чтобы
|
||||
следующий автор не приписал к отпечатку константный ноль, выдав его за
|
||||
подтверждение.
|
||||
|
||||
Равенство «пересборка = приём» SHALL проверяться оракулом, а не рассуждением:
|
||||
журнал, содержащий доставки с равнополными столкновениями, проигранный живым
|
||||
путём (фоновый воркер, в том числе с доставкой, отложенной занятостью базы) и
|
||||
путём пересборки, обязан давать один отпечаток витрины.
|
||||
|
||||
**Место снапшота в порядке журнала этой дельтой не определяется, и это сказано
|
||||
вслух.** Под правилом «побеждает пришедшая» исход столкновения снапшота Apple с
|
||||
точкой HAE зависит от того, какую позицию журнала получит доставка импорта:
|
||||
учтённая сегодняшним временем, она перебила бы все равнополные точки, включая
|
||||
досчитанные задним числом. Стадия снапшота сегодня пуста, поэтому вопрос
|
||||
отложен — но решать его SHALL задача импорта, и явно, а не выбором первого
|
||||
автора.
|
||||
|
||||
Отчёт пересборки SHALL называть два числа про точки — удержанные правилом
|
||||
полноты против пришедшей доставки и потерявшие содержание в пользу пришедшей —
|
||||
рядом с удержанными версиями сущностей и по той же причине: сходимость
|
||||
отпечатка ни одно из них не проверяет по построению.
|
||||
|
||||
#### Scenario: Пересобранная витрина совпадает с накопленной приёмом
|
||||
|
||||
- **GIVEN** рабочая витрина накоплена тем же разбором, приём во время
|
||||
@@ -34,6 +76,14 @@
|
||||
- **WHEN** журнал проигрывается заново с пустой витрины
|
||||
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком накопленной
|
||||
|
||||
#### Scenario: Отложенная занятостью доставка не разводит приём и пересборку
|
||||
|
||||
- **GIVEN** журнал, где одни координаты получают равнополные точки с разными
|
||||
значениями от разных доставок
|
||||
- **AND** свёртка одной из доставок откладывается занятостью базы
|
||||
- **WHEN** тот же журнал проигрывается живым путём и путём пересборки
|
||||
- **THEN** отпечатки витрин совпадают
|
||||
|
||||
#### Scenario: Повторная пересборка ничего не меняет
|
||||
|
||||
- **WHEN** пересборка того же журнала выполняется второй раз
|
||||
@@ -43,7 +93,6 @@
|
||||
|
||||
- **WHEN** в журнале есть доставки со статусом `parsed` и со статусом `pending`
|
||||
- **THEN** проигрываются обе
|
||||
|
||||
### Requirement: Порядок проигрывания задаётся журналом
|
||||
|
||||
Система SHALL проигрывать доставки строго в порядке `(received_at, id)`, а не
|
||||
|
||||
+218
-44
@@ -79,6 +79,14 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
|
||||
полноты, — 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 определяться по
|
||||
@@ -111,37 +119,92 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
|
||||
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
|
||||
лишние ключи там заведомо пусты, объединять в них нечего.
|
||||
|
||||
Если равны и эти множества, а значения различаются, исход MUST быть
|
||||
детерминированным и не зависеть от порядка, в котором доставки дошли до
|
||||
хранилища: свёртка по журналу обязана давать то же состояние, что приём в
|
||||
реальном времени.
|
||||
**Если полнота ответа не дала — равные множества с разными значениями либо
|
||||
несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой
|
||||
доставкой, а не лежавшая в объекте.** Байтовый порядок канонических форм на
|
||||
этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из
|
||||
1 912 (находка 49), то есть системно хранит версию, которую источник уже
|
||||
пересчитал. Ценой этого выбора час `2026-08-03T07:00Z` метрики `step_count`
|
||||
остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной
|
||||
против 23 согласных часов, и род ушёл в `unknown` — Read API потерял право
|
||||
суммировать шаги.
|
||||
|
||||
Победитель MUST быть функцией **множества** точек координаты, а не порядка их
|
||||
поступления. Попарная свёртка этого не даёт: полнота — частичный порядок,
|
||||
тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы
|
||||
(A превосходит B по полноте, B бьёт C тай-брейком, C бьёт A тай-брейком).
|
||||
При таком цикле повторная свёртка одной и той же доставки меняет содержимое
|
||||
объекта, и витрина перестаёт быть функцией журнала. Поэтому система SHALL
|
||||
отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
|
||||
победителя среди оставшихся по тотальному порядку — обе операции зависят
|
||||
только от состава множества.
|
||||
Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для
|
||||
накопительных метрик и неверно для мгновенных, которые источник досчитывает
|
||||
вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а
|
||||
правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса
|
||||
журнала.
|
||||
|
||||
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
|
||||
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
|
||||
не с чем. Детерминизм обеспечивается свойством самих значений (например,
|
||||
порядком канонических форм), а не порядком событий.
|
||||
**Столкновение ВНУТРИ одной доставки 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 выбрать победителя тем же правилом, что
|
||||
и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора
|
||||
доставки, координаты объекта и запись `WARN` без значений точки. Несравнимый
|
||||
набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а
|
||||
координаты попадают в оба списка.
|
||||
|
||||
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимых
|
||||
наборов не встретилось ни разу (0 из 2 897 столкновений, при обоих определениях
|
||||
пустоты), и реализация правила, которое никогда не срабатывает, стоила бы
|
||||
больше, чем счётчик, который скажет, если оно наступит.
|
||||
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые
|
||||
наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое
|
||||
почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно
|
||||
станет массовым.
|
||||
|
||||
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
|
||||
выбирает, а не конструирует. Каноническая форма существует только в момент
|
||||
@@ -163,21 +226,52 @@ TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after a
|
||||
#### Scenario: При равном содержании поля не теряются
|
||||
|
||||
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
|
||||
- **AND** по тем же координатам приезжает точка `{date, qty}` с другим `qty`
|
||||
- **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: Одинаково полные точки с разными значениями
|
||||
|
||||
- **WHEN** по одним координатам приходят две точки с одинаковыми множествами
|
||||
ключей и разными значениями
|
||||
- **THEN** исход определяется детерминированно и не зависит от порядка
|
||||
воспроизведения доставок
|
||||
- **GIVEN** по координатам сохранена точка предыдущей доставки
|
||||
- **WHEN** следующей доставкой приезжает точка с тем же множеством ключей и
|
||||
другим значением
|
||||
- **THEN** в витрине остаётся пришедшая точка
|
||||
|
||||
#### Scenario: Одинаково полные точки внутри одной доставки
|
||||
|
||||
- **WHEN** в одном теле по одним координатам приезжают две точки с одинаковыми
|
||||
множествами ключей и разными значениями
|
||||
- **THEN** остаётся точка с меньшей канонической формой
|
||||
- **AND** исход не зависит от порядка этих точек в массиве
|
||||
|
||||
#### Scenario: Повторная присылка сохранённого содержимого ничего не переписывает
|
||||
|
||||
- **GIVEN** по координатам сохранена точка
|
||||
- **WHEN** следующей доставкой приезжает точка с той же канонической формой и
|
||||
другими байтами
|
||||
- **THEN** в витрине остаются сохранённые байты
|
||||
- **AND** объект не переписывается
|
||||
|
||||
#### Scenario: Полнота сильнее происхождения
|
||||
|
||||
- **GIVEN** по координатам сохранена точка с `Avg`, `Min` и `Max`
|
||||
- **WHEN** следующей доставкой приезжает точка только с `Avg` и теми же
|
||||
значениями общих ключей
|
||||
- **THEN** остаётся сохранённая точка
|
||||
- **AND** счётчик удержаний в итоге разбора доставки растёт
|
||||
|
||||
#### Scenario: Несравнимые множества считаются, а не сливаются
|
||||
|
||||
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
|
||||
ключ с непустым значением, которого нет у другой
|
||||
- **THEN** остаётся ровно одна точка, выбранная детерминированно
|
||||
- **THEN** остаётся ровно одна точка, выбранная тем же правилом
|
||||
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
|
||||
- **AND** система пишет `WARN` с координатами объекта и без значений точки
|
||||
|
||||
@@ -631,13 +725,15 @@ HAE состоят из объектов, а не из чисел.
|
||||
необратимым.
|
||||
|
||||
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||||
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
||||
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
||||
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
||||
а не порядок свёртки.** Порядок свёртки приведён к порядку журнала требованием
|
||||
capability приёма, но равенство это неполное: строка учёта становится видимой
|
||||
воркеру только после записи тела, и при конкурентном приёме остаётся окно, в
|
||||
котором доставка с более ранней меткой сворачивается позже. Она вернула бы
|
||||
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||||
а не от того, кто раньше добрался до базы.
|
||||
в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход
|
||||
зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у
|
||||
сущности гарантия строго сильнее, чем у точки, и держится она на колонке
|
||||
провенанса, которой у точки нет.
|
||||
|
||||
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
|
||||
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
|
||||
@@ -669,12 +765,17 @@ HAE состоят из объектов, а не из чисел.
|
||||
закоммитила последней: исход снова станет функцией порядка коммитов, а не
|
||||
журнала, причём молча.
|
||||
|
||||
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
||||
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
||||
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
||||
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
|
||||
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
||||
версий навсегда, вместе с недосчитанной энергией.
|
||||
Отличие от точки — в **механизме**, а не в намерении, и критерий выбора между
|
||||
ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе
|
||||
предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки —
|
||||
по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их
|
||||
одно: у сущности есть колонка провенанса, у точки её нет и рамка решения
|
||||
владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше.
|
||||
Порядок канонических форм у обеих остался тем же и там же — тай-брейком
|
||||
**внутри одной доставки**, где провенанс общий и различать нечем. Тай-брейк по
|
||||
канонической форме между доставками заморозил бы тренировку на произвольной из
|
||||
версий навсегда, вместе с недосчитанной энергией, а у точки — систематически
|
||||
хранил бы меньшее значение (находка 49).
|
||||
|
||||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||||
победитель среди них SHALL быть **функцией множества версий, а не порядка
|
||||
@@ -1494,3 +1595,76 @@ HealthKit рядом. Значение хранится **дословно**, т
|
||||
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
|
||||
одного кода
|
||||
- **AND** счётчики значений без кода в записи присутствуют
|
||||
### 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** счётчик потери содержания не растёт
|
||||
|
||||
Reference in New Issue
Block a user