- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
375 lines
36 KiB
Markdown
375 lines
36 KiB
Markdown
## Context
|
||
|
||
Семь находок дозапущенных проходов ревью (`tmp/triage-late.md`) по коммиту
|
||
`f8200f7`. Каждая имеет прогнанный падающий оракул; оракулы переезжают обычными
|
||
тестами пакетов, `tmp/` в `.gitignore` и жить в нём им нельзя.
|
||
|
||
Ограничения, которые задача не выбирает, а наследует:
|
||
|
||
- **Свёртка обязана быть функцией журнала.** Живая витрина и `reindex` обязаны
|
||
сходиться отпечатком; всё, что зависит от порядка свёртки или от порядка
|
||
элементов на проводе, — дефект по определению.
|
||
- **Тренировка приезжает повторно, пока источник её досчитывает** (26 копий,
|
||
три различных содержимых на живом архиве), и значения между копиями
|
||
расходятся **всегда**. Поэтому правило полноты точек к сущностям неприменимо,
|
||
и `Covers` существует отдельно от `Relate`.
|
||
- **Маршрут — 95% веса тренировки**, и в экспорте Apple его нет вовсе.
|
||
Затирание маршрута необратимо: `reindex` проиграет журнал и получит то же.
|
||
- **Цена канонизации измерена**: тело 40 МиБ → пик кучи 768.3 МиБ; 63 МиБ →
|
||
блокировка удерживается 5.019 с при `busy_timeout` 5000.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Обеднённая версия сущности не замещает сохранённую и не пропадает из счётчика.
|
||
- Победитель — функция множества версий: и внутри доставки, и между доставками.
|
||
- Провенанс сущности отражает победителя по журналу, а не первую свёрнутую копию.
|
||
- Поле не той формы стоит одного поля, а не сущности; пропуск виден в базе.
|
||
- Откат бинаря поверх новой схемы отказывает, а не стартует молча.
|
||
- Каноническая форма сущности считается один раз и вне транзакции.
|
||
- Диагностика разбора не несёт значений из тела.
|
||
|
||
**Non-Goals:**
|
||
|
||
- Хранение сущности с `id` и неразобранной меткой (NULL-метка) — требует схемы
|
||
и правил чтения витрины.
|
||
- Пределы на размер одной сущности и суммарный размер секции, потоковый расчёт
|
||
формы и хеша — новая политика, а не правка.
|
||
- Объединение полей несравнимых версий — отвергнуто там же и по той же причине,
|
||
что для точек.
|
||
- Поэлементная сверка **содержимого** элементов ряда (точка маршрута без
|
||
`altitude`) — см. «Риски».
|
||
|
||
## Decisions
|
||
|
||
### 1. `Covers` получает второй разряд, запрет вырождения формы и счёт содержательных элементов
|
||
|
||
Сегодня `Covers(g)` требует от `f` лишь наличия каждого содержательного ключа
|
||
`g` и длины верхнеуровневого массива не меньше. Этого хватает, чтобы «скелет»
|
||
(`{"maxHeartRate":1,"heartRateData":[null,null]}` против настоящей тренировки)
|
||
признался равным настоящей и выиграл тай-брейк журнала.
|
||
|
||
Правило дополняется тремя проверками, все — по верхнему уровню:
|
||
|
||
1. **Второй разряд: ключи без содержания.** Каждый ключ `g` (включая пустые)
|
||
обязан быть у `f`. Тот же стандарт записан для точек в `canon.Relate`:
|
||
«иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
|
||
`Max` исчезли бы из витрины по жребию тай-брейка». Отличие от `Relate`:
|
||
там второй разряд применяется только при равенстве первого, здесь включение
|
||
всех ключей требуется безусловно. Это эквивалентно при равном первом разряде
|
||
и строже, когда первый разряд неравен, — а строже здесь и нужно: `Covers`
|
||
отвечает «не потеряем ли содержания».
|
||
2. **Запрет вырождения формы.** Покрывающая версия не может подменить объект
|
||
или массив скаляром: если `g[k]` — объект, `f[k]` обязан быть объектом; если
|
||
массив — массивом. Обратное (скаляр у `g`, объект у `f`) разрешено: `f`
|
||
богаче формой. Стоимость — O(ключей), первый байт литерала.
|
||
3. **Содержательные элементы массива.** Рядом с длиной считается число
|
||
**непустых** элементов. `[null,null,null]` и `[{},{},{}]` сохраняют длину, но
|
||
не несут ничего, а именно так выглядит затёртый маршрут.
|
||
|
||
Про третью проверку важно, что она **не добавляет обхода**: `arrayLen` уже
|
||
сегодня декодирует каждый элемент массива в выбрасываемый `json.RawMessage`,
|
||
чтобы посчитать их количество. Добавляется `isEmpty` по литералу элемента —
|
||
проверка первого байта и, для чисел, `strconv`; в дерево значений элемент не
|
||
разворачивается. Поэтому цена, названная триажем («полный обход маршрута на
|
||
каждое сравнение»), уже уплачена, и решение её не умножает.
|
||
|
||
**Отвергнуто:** сверка содержимого *внутри* элемента ряда (набор ключей у
|
||
каждой точки маршрута). Вот она обход действительно умножила бы — на 768 МиБ
|
||
пика, измеренных пунктом 4, — и остаётся названным пределом (см. «Риски»).
|
||
|
||
**Отношение остаётся частичным порядком** — конъюнкция включений множеств и
|
||
нестрогих неравенств по каждому общему ключу транзитивна. На это опирается
|
||
решение 2.
|
||
|
||
### 2. Победитель внутри доставки — минимум канонической формы среди непревзойдённых
|
||
|
||
Попарная свёртка `pickWithinDelivery` нетранзитивна: полнота — частичный
|
||
порядок, тай-брейк — тотальный, и вместе они образуют цикл. Стандарт для точек
|
||
записан в `architecture.md` («победитель — функция множества точек, а не порядка
|
||
их поступления») и реализован в `store.resolve`; для сущностей он применяется
|
||
дословно: собрать версии ключа, отбросить строго покрытые, среди оставшихся
|
||
взять минимум канонической формы.
|
||
|
||
Порядок обязан быть тотальным **до конца**. У точек кандидаты с равной
|
||
канонической формой схлопываются ещё до сравнения, поэтому минимум единственен;
|
||
у сущностей схлопывания нет, и при двух версиях, различающихся только порядком
|
||
ключей или дребезгом последнего разряда (измеренная норма HAE), «минимум формы»
|
||
неединственен — в хранилище лёг бы тот элемент, что стоял в массиве раньше.
|
||
Поэтому последним разрядом сравнения идут исходные байты, ровно тем же
|
||
движением и по той же причине, что записана в `canon.Less`.
|
||
|
||
«Строго покрыта» определено явно: покрыта другой версией и сама её не покрывает.
|
||
Покрытие — предпорядок, две версии могут покрывать друг друга взаимно, и наивное
|
||
«выбросить всё, что кем-то покрыто» опустошило бы множество, потеряв обе.
|
||
|
||
Счётчик «в одном теле приехали две версии одного ключа с разным содержанием»
|
||
становится функцией множества тем же движением: считаются кандидаты, чья
|
||
каноническая форма отличается от формы победителя.
|
||
|
||
Здесь требование постановки исполнено **по канонической форме, а не по байтам**,
|
||
и это сказано прямо, потому что постановка говорит «при равном содержании и
|
||
разных байтах обязан считать `differs=true`». Цель постановки — чтобы счётчик
|
||
перестал молчать на двух настоящих разных версиях — достигается: случай оракула
|
||
(маршрут против маршрута из `null`) считается. Побайтовый вариант отвергнут
|
||
записанным инвариантом: порядок ключей в JSON от HAE нестабилен и дребезг
|
||
последнего разряда тоже, ради чего канонизация и заведена, — счётчик по байтам
|
||
срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
|
||
понадобился бы. Детерминизм при этом обеспечен не счётчиком, а тай-брейком по
|
||
байтам выше.
|
||
|
||
`pickWithinDelivery` исчезает: она была парной формой того, что теперь делает
|
||
множество.
|
||
|
||
Отбор «максимальные элементы плюс минимум по тотальному порядку» существует в
|
||
проекте для точек (`store.resolve`) и объявлен стандартом в `architecture.md`.
|
||
Второй рукописный экземпляр — ровно то, чем был `pickWithinDelivery`, и он
|
||
разошёлся со стандартом нетранзитивностью. Поэтому механизм выносится в общего
|
||
помощника, а точки и сущности становятся двумя его вызовами с разными
|
||
отношениями: `architecture.md` уже обещает смену тай-брейка точек, когда род
|
||
метрики будет измерен, то есть правка одного экземпляра при живом втором
|
||
запланирована заранее.
|
||
|
||
### 3. Провенанс обновляется при совпавшем хеше
|
||
|
||
Совпал хеш — содержимое то же, писать нечего. Но провенанс
|
||
(`delivery_id`/`delivery_received_at`) остаётся от первой свёрнутой копии, а не
|
||
от победителя журнала. Следствия два: провенанс устаревает гарантированно на
|
||
каждой из ~26 повторных присылок, и при возврате содержимого к прежнему
|
||
(A→B→A) живая витрина расходится с `reindex` — тай-брейк пункта 4 правила
|
||
сравнивает позиции, а сохранённая позиция неверна.
|
||
|
||
Решение: при совпавшем хеше сравнить позиции журнала и, если сохранённая
|
||
раньше приехавшей, обновить **только** колонки провенанса. Провенанс становится
|
||
максимумом по журналу среди версий с этим содержимым, то есть функцией
|
||
множества.
|
||
|
||
`updated_at` при этом не двигается — и это отдельное решение, а не экономия.
|
||
Тренировка приезжает до двадцати шести раз, и бамп метки на каждой сделал бы её
|
||
меткой **касания строки**, а не изменения содержимого. Потребитель запроса «что
|
||
изменилось с момента X» — естественного для коллектора и уже заказанного Read
|
||
API — получил бы двадцать шесть ложных изменений, неотличимых от настоящего
|
||
досчёта, и выяснилось бы это после того, как потребитель написан. Провенанс
|
||
несёт собственную метку (время приёма своей доставки), и для тай-брейка её
|
||
достаточно.
|
||
|
||
Счётчик записанных сущностей такое обновление **не** увеличивает: он считает
|
||
содержимое витрины, и его сравнимость с прежними замерами важнее, чем учёт
|
||
обновления. Отпечаток витрины провенанса не включает, поэтому сходимость
|
||
`reindex` от этого решения не зависит — она зависит от него косвенно, через
|
||
тай-брейк.
|
||
|
||
Слово «провенанс» после этого означает у сущности не то, что у часового
|
||
объекта: у объекта хранится доставка, **создавшая** его, и она не поднимается.
|
||
Асимметрия законная — у объекта нет замещения версии целиком, — но записана в
|
||
спеке явно, иначе читатель перенесёт смысл с одного на другое.
|
||
|
||
### 4. Каноническая форма считается один раз, вне транзакции
|
||
|
||
Комментарий `bucket.go` утверждает, что канонизация вынесена наружу; фактически
|
||
`analyze()` зовётся из `compareEntities` **внутри** `inTx`, а его результат
|
||
пишется в **копию** элемента среза и не переживает даже одной попытки.
|
||
|
||
Ключевое наблюдение: `canon.Hash(raw)` уже считает полную каноническую форму и
|
||
**выбрасывает** её, а `analyze()` считает ту же форму заново. То есть дорогая
|
||
часть и так платится на каждой версии, в `prepareEntities`, вне транзакции.
|
||
|
||
Решение: `canon` отдаёт форму и хеш **одним проходом** (`FormAndHash`, внутри —
|
||
запись в `io.MultiWriter(буфер, sha256)`, той же формой, какой уже написан
|
||
`HashAll`), `newEntityVersion` зовёт его один раз и держит результат вместе с
|
||
`canon.Analyze`. Ленивость, `analyze()` и флаг `parsed` уходят.
|
||
|
||
Отдельный вызов `HashForm(form []byte)` отвергнут: он вводит контракт
|
||
очерёдности («сперва `Form`, потом `HashForm`»), где передача сырых байт вместо
|
||
формы даёт правдоподобный, но неверный хеш, — а компилятор такую подмену не
|
||
ловит.
|
||
|
||
Баланс работы назван честно, потому что он не односторонний:
|
||
|
||
- на пути **разошедшегося хеша** (одна доставка из сорока четырёх) — минус одна
|
||
полная канонизация: сегодня форма считается дважды;
|
||
- на пути **совпавшего хеша** (сорок три из сорока четырёх) — плюс один мелкий
|
||
разбор `json.Unmarshal` в `map[string]json.RawMessage`, то есть проход по телу
|
||
и копия каждого верхнеуровневого значения. Сегодня на этом пути `analyze()` не
|
||
зовётся вовсе.
|
||
|
||
Плюс оценивается величиной входа, минус — величиной входа с константой развёртки
|
||
в дерево значений, так что суммарно решение не дороже. Но «строго меньше» было
|
||
бы неправдой, и приёмка меряет пик кучи до и после, а не верит рассуждению.
|
||
|
||
Разбор **сохранённой** версии остаётся внутри транзакции: её содержимое читается
|
||
оттуда же и только когда хеш разошёлся (одна доставка из сорока четырёх).
|
||
Убрать это можно лишь оптимистичным чтением до транзакции с перепроверкой
|
||
внутри — это уже пределы размера и потоковый расчёт, то есть остаток.
|
||
|
||
### 5. Страж версии схемы переезжает в `Open`
|
||
|
||
`OpenForRead` сверяет версию схемы и отказывает при расхождении; `Open`
|
||
мигрирует безусловно. Поэтому старый бинарь поверх схемы 7 стартует молча,
|
||
незнакомые секции игнорирует, а доставки за окно отката помечает разобранными —
|
||
и ничто не намекает, что для этого окна нужен `reindex`.
|
||
|
||
Асимметрия у `Open` законная: версия базы **ниже** версии бинаря — это ровно то,
|
||
ради чего миграции существуют. Отказ ставится на «версия базы **выше** версии
|
||
бинаря». Асимметрия относится только к `Open`: `OpenForRead` сохраняет строгое
|
||
равенство, как требует спека пересборки, — иначе `reindex` начал бы читать
|
||
рабочую базу схемы старее бинаря по колонкам, которых там нет. В одно место
|
||
выносится **чтение** версии, а не сравнение.
|
||
|
||
Чтение берётся у самого goose (`Provider.GetVersions` отдаёт и текущую версию
|
||
базы, и целевую), потому что имя таблицы учёта, имя колонки и правило «максимум
|
||
= текущая версия» принадлежат ему: рукописная копия его приватной схемы
|
||
разошлась бы при обновлении зависимости — и не отказом, а тем, что страж
|
||
перестал бы ловить. Если окажется, что на соединении только для чтения этот путь
|
||
требует записи или отдаёт лишнюю задержку (SQLite-диалект goose не умеет
|
||
`TableExists`, поэтому на отсутствующей таблице уходит в повторы), копия
|
||
допустима, но одной функцией и с названной причиной — и тогда «таблицы нет»
|
||
распознаётся структурно (`sqlite_master`) и `NULL` читается как `NULL`
|
||
(`sql.NullInt64`), а не по тексту ошибки драйвера: сообщения драйвера не
|
||
контракт, это уже записанное правило проекта.
|
||
|
||
**Цена отказа названа, потому что она реальна.** Страж останавливает сервис
|
||
целиком, а телефон шлёт непрерывно и молча: доставка, не попавшая в архив, в
|
||
журнал не попадает вовсе. Взвешено так: откат бинаря — действие оператора,
|
||
который в этот момент рядом и видит crash-loop сразу; дыры плотных метрик за
|
||
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
|
||
`stateOfMind` — у него доставки HAE единственный источник, — и это цена решения.
|
||
Она меньше цены молчания: молчаливый старт портит витрину за всё окно отката, а
|
||
узнать об этом неоткуда, и после ретеншена тел чинить будет нечем. Отвергнуты:
|
||
деградированный режим «принимать и архивировать, свёртку не начинать» (сохраняет
|
||
оба инварианта, но заводит режим, о существовании которого надо помнить, и
|
||
правила его видимости) и отказ только воркеру свёртки (требует доказать, что
|
||
старый бинарь корректно пишет учёт в новую схему, — доказательства нет).
|
||
|
||
### 6. Мягкий заголовок сущности
|
||
|
||
`entityHead` держит `ID`/`Name`/`Date`/`Start`/`End` типизированными строками, и
|
||
сущность теряется целиком при смене типа любого из пяти. Причина названа точно,
|
||
потому что от неё зависит выбор решения: роняет не `encoding/json`, а строка
|
||
`entity.go:51-53`, где любая ошибка разбора считается фатальной. Сам
|
||
`json.Unmarshal` «skips that field and completes the unmarshaling as best it
|
||
can» и возвращает `*UnmarshalTypeError`.
|
||
|
||
Отсюда напрашивается трёхстрочная альтернатива — `errors.As(err, &ute)` и
|
||
продолжить с уже заполненным заголовком. Она **отвергается**, и по названной
|
||
причине: та же документация тут же оговаривает — «it's not guaranteed that all
|
||
the remaining fields following the problematic one will be unmarshaled». Разбор,
|
||
построенный на дозаполнении, перестал бы быть функцией тела: одна и та же
|
||
тренировка давала бы разный заголовок в зависимости от порядка ключей на
|
||
проводе, а он у HAE нестабилен.
|
||
|
||
Решение — штатная точка расширения `encoding/json`: тип `softString` с
|
||
`UnmarshalJSON`, который на нестроковом значении ничего не пишет и возвращает
|
||
`nil`. Теги остаются декларативными, ручных извлечений нет, гарантия полная.
|
||
Заодно сохраняется различение счётчиков: элемент, который сам не объект, даёт
|
||
ошибку **верхнего** уровня и по-прежнему уходит в «не разобралось как объект», а
|
||
не в «нет `id`».
|
||
|
||
`id` при этом остаётся требованием, а не полем: без него сущность не адресуема.
|
||
Число вместо строки в `id` — это сменившаяся форма идентификатора, и
|
||
превращать `42` в `"42"` значило бы придумать идентичность за источник. Такая
|
||
сущность пропускается прежним счётчиком.
|
||
|
||
`start`, приехавший не строкой, на `date` **не** откатывается. Мягкое чтение
|
||
объявляет непонятое значение отсутствующим, а фолбэк `start → date` существует
|
||
для сущностей, у которых `start` не прислан вовсе; композиция этих двух правил
|
||
подставила бы метку другого момента времени, неотличимую от настоящей и ничем не
|
||
считаемую. Поэтому нестроковый `start` — это неразбираемая метка.
|
||
|
||
Граница правила названа вслух: оно закрывает смену **типа**, но не смену
|
||
**формата строки**, а наблюдался именно дрейф формата дат. Тренировка с датой в
|
||
незнакомом формате по-прежнему теряется целиком — теперь со счётчиком в базе, —
|
||
и закрыть это может только хранение сущности с неразобранной меткой, вынесенное
|
||
остатком.
|
||
|
||
**Пропуски становятся видны в базе.** Миграция `00008` добавляет доставке
|
||
колонку `skipped_entities`; свёртка пишет туда сумму трёх счётчиков пропуска
|
||
сущностей. Причина не в отчётности: ретеншен решает «что потеряется, если тело
|
||
удалить», по базе, и сегодня получает ответ «терять нечего» ровно там, где
|
||
теряется тренировка с маршрутом.
|
||
|
||
Статус доставки от пропуска сущности **не** меняется: `partial` определён
|
||
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
|
||
расхождение, которое спека запрещает явно.
|
||
|
||
### 7. Диагностика разбора без значений из тела
|
||
|
||
`fmt.Errorf("… встречено %v", tok)` подставляет токен целиком: тело 8 МиБ даёт
|
||
текст ошибки 8 МиБ, который уходит атрибутом `error` на уровень `WARN`.
|
||
Инвариант «тела запросов только на `DEBUG` и с обрезкой» нарушен буквально.
|
||
|
||
Ошибка называет **тип токена** и `dec.InputOffset()`. Смещение полезнее
|
||
значения: по нему место в теле находится в архиве, а значение из тела в логе не
|
||
имеет права быть в принципе.
|
||
|
||
## Три формы решения главного узла и компромисс каждой
|
||
|
||
Главный узел — глубина сравнения содержания при слиянии версий сущности.
|
||
Рассматривались три, и выбор записан не по умолчанию:
|
||
|
||
1. **Поэлементная сверка содержимого рядов** (у каждого элемента маршрута
|
||
сравнивать набор ключей). Ловит всё, включая точку маршрута без `altitude`.
|
||
Компромисс: полный обход маршрута с материализацией каждого элемента на
|
||
каждое сравнение — умножение уже измеренных 768 МиБ пика; плюс пересмотр
|
||
правила слияния целиком. Отвергнута ценой.
|
||
2. **Второй разряд + запрет вырождения формы + счёт содержательных элементов**
|
||
(выбрана). Ловит скелет из скаляров, обнулённый ряд и исчезающий пустой ключ.
|
||
Компромисс: строже правила точек, поэтому чаще удерживает; событие видно
|
||
счётчиком, но контроль требует вывести счётчик в отчёт пересборки — иначе
|
||
мера «сходимость `verify:archive`» его не увидит по построению. Стоимость —
|
||
O(ключей) плюс `isEmpty` на элементах в уже существующем обходе.
|
||
3. **Принять предел, оставить только наблюдаемость** (счётчик по различию байт,
|
||
предел записан в `architecture.md`). Компромисс: маршрут продолжает теряться
|
||
необратимо при обеднённой версии, а восстановить его после ретеншена тел
|
||
неоткуда — в экспорте Apple маршрута нет. Отвергнута последствием.
|
||
|
||
Форма (2) принята владельцем в постановке; здесь она не переоткрывается, а
|
||
уточняется недостающими определениями (пустота элемента, строгость покрытия,
|
||
тотальность порядка) и получает контроль, которого у неё не было.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- **Порча внутри элемента ряда не ловится** (точка маршрута без `altitude`:
|
||
длина та же, элемент непуст, форма не выродилась) → предел записывается в
|
||
`architecture.md` рядом с описанием `Covers` так же прямо, как он записан в
|
||
комментарии кода. Закрыть его может только сверка с телом в архиве, а тело
|
||
живёт до ретеншена.
|
||
- **Второй разряд `Covers` строже прежнего правила** и может удержать версию,
|
||
которая раньше замещала: досчёт, потерявший ключ с пустым значением, теперь
|
||
проигрывает. Мера контроля — **не** сходимость `verify:archive`: живой приём и
|
||
пересборка пользуются одним правилом и одинаково сойдутся на одинаково
|
||
замороженной версии, то есть слишком строгое правило выглядело бы идеальной
|
||
сходимостью. Контроль — счётчик удержаний, выведенный в отчёт пересборки, и
|
||
замер его значения на живом архиве.
|
||
- **`isEmpty` считает ноль пустотой**, и это переносится на элементы ряда: ряд
|
||
настоящих нулей будет выглядеть опустошённым → ошибка направлена в безопасную
|
||
сторону (удерживаем, а не затираем) и видна счётчиком; наблюдённые ряды HAE
|
||
состоят из объектов. Записано в спеку, потому что после мерджа это часть
|
||
правила слияния навсегда.
|
||
- **Мягкий заголовок принимает больше входов**, то есть сущности, ранее
|
||
уходившие в `SkippedEntityMalformed`, начнут попадать в витрину → это
|
||
изменение разбора, и по правилу «покрыли — пересверните» такие доставки надо
|
||
пересворачивать. Замер на живом архиве сделан **до** утверждения формулировок:
|
||
118 тел, пропусков `noID=0 noTime=0 malformed=0`, то есть пересворачивать
|
||
нечего, и data-миграции нет.
|
||
- **Мягкий заголовок увеличивает долю тел, доходящих до канонизации**: сущность,
|
||
раньше отсекавшаяся на разборе заголовка почти бесплатно, теперь канонизуется
|
||
целиком, и худший случай по памяти становится достижим на входах, которые до
|
||
него не доходили → предел на размер сущности из задачи-остатка перестаёт быть
|
||
желательным и становится **обязательным условием**; записано в её теле.
|
||
- **Обновление провенанса при совпавшем хеше — дополнительная запись** там, где
|
||
раньше её не было: ~26 повторных присылок на тренировку → запись касается трёх
|
||
колонок без `payload`, то есть не трогает самое тяжёлое; счётчик записанных
|
||
сущностей и `updated_at` не двигаются, и сравнимость замеров сохраняется.
|
||
- **Страж версии схемы отказывает при старте** — сервис не поднимется на базе
|
||
из будущего, то есть приём останавливается, а телефон не перешлёт → цена
|
||
взвешена выше, в решении 5, вместе с отвергнутыми альтернативами; в
|
||
`architecture.md` уезжает эксплуатационный контракт: как это выглядит
|
||
(crash-loop контейнера) и чем лечится (возврат бинаря).
|
||
- **Пункт 5 правила (несравнимые наборы) остаётся функцией порядка
|
||
проигрывания**, и второй разряд `Covers` делает этот исход чаще → приёмочный
|
||
критерий сходимости сформулирован условно (перестановка даёт один отпечаток
|
||
при нулевом счётчике несравнимых), а сам предел записан в `architecture.md`
|
||
как единственная точка, где витрина не является функцией множества доставок.
|