добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-03
|
||||
@@ -0,0 +1,272 @@
|
||||
## Context
|
||||
|
||||
Точки хранятся дословно: `bucket.payload` — исходные байты точек, сжатые gzip;
|
||||
`content_hash` — детектор изменений по канонической форме этих же байтов.
|
||||
Пересборка обязана давать то же состояние (`import + replay`), и единственный
|
||||
её оракул — отпечаток витрины.
|
||||
|
||||
Локаль приезжает заголовком `Accept-Language` **доставки** (находка 32,
|
||||
измеренное значение — `ru`). Часовой объект при этом собирается из точек многих
|
||||
доставок, а из провенанса у него только `first_delivery_id`. Значит локаль
|
||||
точки после слияния восстановить нечем — вывод кода обязан происходить в
|
||||
разборе, пока доставка ещё цела.
|
||||
|
||||
Словарь фаз сна выведен измерением (находка 43) и переписыванию не подлежит.
|
||||
Коды HealthKit при этом сами не вечны: те же записи сна приезжают как
|
||||
`…Asleep` в экспорте 2021 года и как `…AsleepUnspecified` в экспорте 2026-го.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Рядом с локализованной строкой появляется стабильный код HealthKit, выведенный,
|
||||
а не угаданный.
|
||||
- Незнакомая строка видна, а не молчит.
|
||||
- Переименование кода самой Apple не раскалывает историю.
|
||||
- Состояние остаётся детерминированной свёрткой по журналу.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Словарь для `heart_rate.context` и типов тренировок. Он не выведен: экспорт
|
||||
Apple хранит контекст пульса метаданным-числом, а не `HKCategoryValue*`, и
|
||||
сопоставление с «Сидячий образ жизни» было бы догадкой. Поля объявляются
|
||||
категориальными **сейчас**, чтобы их строки попали в реестр с пустым кодом —
|
||||
это и есть заявка на будущий вывод.
|
||||
- Показ реестра в `/stats` и в Read API: ни того, ни другого ещё нет
|
||||
(`stats-endpoint`, `read-api-points`).
|
||||
- Импорт родного экспорта Apple. Синонимы кодов заводятся ради него, но
|
||||
разбирать экспорт эта задача не начинает. Отсюда прямое следствие для
|
||||
приёмки: сверка «поток против экспорта на живой базе» этой задачей
|
||||
недостижима — оракула нет, и это названо в отчёте, а не обойдено.
|
||||
- Отдача кода в ответе чтения. Форма ответа — забота Read API; хранилище отдаёт
|
||||
строку и реестр, по которому код сопоставляется.
|
||||
- Обслуживание реестра: удаления строк нет, реестр накопителен. Строка,
|
||||
единственные доставки которой выпали из журнала ретеншеном, переживёт их в
|
||||
рабочей витрине и не появится в пересобранной. Это тот же класс, что и
|
||||
«верхние слои за периоды с удалёнными доставками» из инварианта, и он
|
||||
называется вслух, а не досчитывается.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Код кладётся в реестр наблюдённых значений, а не внутрь точки
|
||||
|
||||
Архитектура рисовала `value` и `value_code` соседними полями одной записи. Форма
|
||||
принята другая — **таблица `category_value`, ключ `(metric, field, value)`**, а
|
||||
точка не меняется вовсе.
|
||||
|
||||
Рассмотрены три формы, компромисс каждой назван:
|
||||
|
||||
**(1) Код — поле внутри точки.** Отвергнута. Точка хранится **исходными
|
||||
байтами**; дописать в неё ключ можно только пересериализацией, а она теряет
|
||||
литерал (`1.0` → `1`, целые больше 2^53 сдвигаются, невалидный UTF-8 →
|
||||
U+FFFD) — ровно то, от чего `Point.Raw` защищает. Побайтовая врезка в чужой
|
||||
JSON — фокус, а не решение. Параллельный массив кодов в `bucket` завёл бы
|
||||
производную величину в путь слияния и хеширования: правило полноты, тай-брейк и
|
||||
`content_hash` пришлось бы учить носить код, не давая ему влиять на исход.
|
||||
Правка на поверхности `critical`-инвариантов ради нуля новых сведений — код есть
|
||||
**функция** от того, что уже лежит. Плюс пополнение словаря переписывало бы
|
||||
каждый объект с фазами сна.
|
||||
|
||||
**(2) Код нигде не хранится, выводится на чтении.** Отвергнута, но по одной
|
||||
причине и с названной ценой: тогда код недостижим ничем, кроме бинаря. Владелец
|
||||
сегодня читает витрину `sqlite` на хосте (`Read API` ещё нет), а вся задача
|
||||
затевается против того, что «клиент угадывает словарь». Реестр без кода
|
||||
сообщает только «такая строка была» — это половина ответа. Цена отказа
|
||||
названа в решении 6: код объявлен **кэшем** и из отпечатка исключён, поэтому
|
||||
недостатки материализации (отпечаток как функция версии бинаря, пересборка
|
||||
после каждого пополнения) не наступают.
|
||||
|
||||
**(3) Реестр с материализованным кодом.** Принята. Что теряется: потребитель
|
||||
делает соединение по `(metric, field, value)` вместо чтения одного поля, а
|
||||
код в базе может отставать от словаря в бинаре ровно для тех строк, которые
|
||||
перестали приезжать. Отставание лечится пересборкой и не влияет на сходимость.
|
||||
|
||||
Prior art: FHIR `ConceptMap` (отображение «чужая система значений → своя») и
|
||||
`CodeSystem` с `replaced-by` для устаревших имён — ровно два наших отношения,
|
||||
разведённые по разным сущностям. Форму берём, реализацию FHIR — нет: она стоит
|
||||
дороже всего проекта.
|
||||
|
||||
### 2. Словарь и синонимы — код бинаря, реестр — база
|
||||
|
||||
`internal/healthkit` держит две таблицы Go: `(локаль, строка) → код` и
|
||||
`алиас → канонический код`. В базе — только наблюдённое.
|
||||
|
||||
Почему не строки в миграции: словарь есть **знание об Apple**, выведенное
|
||||
измерением, и меняться ему положено вместе с бинарём и через ревью. Данные,
|
||||
засеянные миграцией, живут в двух местах сразу (в файле миграции и в базе), и
|
||||
после первой правки словаря они расходятся молча — база помнит засев, бинарь
|
||||
знает новое.
|
||||
|
||||
Почему не таблица в базе, наполняемая руками: словарь стал бы входом, которого
|
||||
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно.
|
||||
`stateOfMind` уже единственная дыра в журнале; вторую заводить незачем.
|
||||
|
||||
Это решение нормируется требованием, а не остаётся в дизайне: иначе следующий
|
||||
автор заведёт рядом ту самую таблицу словаря, наполняемую руками, — и обоснование
|
||||
против неё останется в архиве изменения, куда он не пойдёт.
|
||||
|
||||
### 3. Локаль сужает поиск и в ключ реестра не входит
|
||||
|
||||
Локаль нормализуется по правилу lookup RFC 4647: первый тег списка, подтеги
|
||||
отсекаются, регистр свёрнут (`RU-ru,ru;q=0.9` → `ru`). Свёртка регистра
|
||||
обязательна — теги BCP 47 регистронезависимы, и без неё `RU` и `ru` были бы
|
||||
разными языками.
|
||||
|
||||
Разрешение кода идёт тремя разрядами:
|
||||
|
||||
```
|
||||
1. (локаль, строка) есть в словаре → её код
|
||||
2. локали нет или пары нет, но строка known
|
||||
во всех локалях даёт ОДИН код → этот код
|
||||
3. иначе → пустой код
|
||||
```
|
||||
|
||||
Второй разряд — не поблажка, а требование пересобираемости. Заголовки запроса в
|
||||
сыром архиве **не лежат**: они были заголовками запроса, а не телом. Доставка,
|
||||
восстановленная из осиротевшего тела (`replay.adopt`), приезжает без
|
||||
`Accept-Language`, и правило «нет локали — нет кода» сделало бы состояние
|
||||
функцией от того, уцелела ли строка учёта, — то есть сломало бы `import +
|
||||
replay` на ровном месте.
|
||||
|
||||
**В ключ реестра локаль не идёт, и это та же причина, доведённая до конца.**
|
||||
Ключ, содержащий локаль, оставляет ту же зависимость этажом ниже: усыновлённая
|
||||
доставка положила бы вторую строку с пустой локалью, и раздел реестра в
|
||||
отпечатке разошёлся бы вне всякого названного класса. Ключ по
|
||||
`(метрика, поле, значение)` — функция одних тел.
|
||||
|
||||
Что теряется: «на каком языке приехала эта строка» больше не колонка. Ответ
|
||||
остаётся достижим по ссылке — `first_delivery_id` → `delivery.headers`, ровно
|
||||
тем же приёмом, каким провенанс устроен у часового объекта. Побочная выгода:
|
||||
у тройки-ключа неоднозначности не бывает по построению, поэтому читателю
|
||||
нечего разрешать и нечего угадывать.
|
||||
|
||||
Своя нормализация, а не `golang.org/x/text/language`: измеренное значение
|
||||
заголовка — `ru`, а x/text тянет в статический бинарь таблицы CLDR ради разбора
|
||||
одной строки. Разборщика `Accept-Language` в стандартной библиотеке нет.
|
||||
Отвергнуто по цене, не по качеству — если появится согласование весов `q`,
|
||||
решение стоит пересмотреть.
|
||||
|
||||
### 4. Синонимы — плоская карта в один шаг
|
||||
|
||||
`Canonical(код)` — одно чтение карты `алиас → канонический код`. Зовётся и
|
||||
внутри `Code(...)`, поэтому «две формы сходятся в один код» верно по
|
||||
построению, а не по дисциплине автора словаря. Направление одно — старое имя к
|
||||
новому: `HKCategoryValueSleepAnalysisAsleep` →
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified` (находка 43).
|
||||
|
||||
Цепочек и ограничения глубины нет намеренно. Инвариант таблицы — «ни одно
|
||||
значение не является ключом» — проверяется тестом по таблице целиком, и при нём
|
||||
цепочка длиннее одного шага невозможна по построению. Рантайм-обход был бы
|
||||
подстраховкой поверх подстраховки: у него нет ни одного вызывающего сценария,
|
||||
зато он рождает новый вырожденный случай — «что вернуть при превышении глубины».
|
||||
|
||||
### 5. Какие поля категориальны — знание HAE, какой у них код — знание HealthKit
|
||||
|
||||
Список пар `(метрика/секция, поле)` живёт в `internal/hae`: имена `value`,
|
||||
`context`, `name` принадлежат формату HAE. В ключ реестра идёт **то же** имя
|
||||
метрики, которым адресуется часовой объект: разделение схем под одним именем
|
||||
(`sleep_analysis` → `sleep_analysis_summary`) тогда проходит по обеим единицам
|
||||
хранения одновременно и лечится одной пересборкой.
|
||||
|
||||
Словарь живёт в `internal/healthkit`: он понадобится импорту родного экспорта,
|
||||
который про HAE не знает ничего. Пакет `healthkit` не зависит ни от чего
|
||||
внутреннего.
|
||||
|
||||
Поля читаются **тем же** разбором заголовка точки, что и метки, — второго
|
||||
прохода по данным нет. Значения берутся `json.RawMessage` и принимаются только
|
||||
как JSON-строка: объяви поле `string`, и точка, у которой `value` пришло числом,
|
||||
перестала бы разбираться вовсе — новый путь потери данных ради удобства
|
||||
структуры.
|
||||
|
||||
Имя тренировки при этом берётся из уже разобранного заголовка сущности, а не из
|
||||
её сырых байт. `softString` превращает значение не того типа в `""`, отличить
|
||||
«имени не было» от «имя приехало числом» на нём нечем — и не нужно: пустая
|
||||
строка категориальным значением не считается, так что оба случая дают один
|
||||
исход, и он верный. Второй разбор сущности стоил бы полного прохода по
|
||||
мегабайтному маршруту ради поля в её заголовке.
|
||||
|
||||
Пустая строка исключена и сама по себе: она несла бы вечную единицу в счётчике
|
||||
строк без кода, а сказать о данных ей нечего.
|
||||
|
||||
### 6. Реестр — единица хранения; в отпечаток идёт наблюдение, а не код
|
||||
|
||||
Реестр входит в отпечаток витрины отдельным разделом (`c`). Иначе
|
||||
недетерминированный upsert прошёл бы мимо единственного оракула сходимости.
|
||||
|
||||
**В раздел идут ключ и провенанс, но не `code`.** Ключ и провенанс — функция
|
||||
журнала; `code` — функция журнала **и версии словаря в бинаре**. Включи его в
|
||||
отпечаток, и он перестал бы отвечать на свой единственный вопрос («дал ли
|
||||
повтор журнала то же состояние») ровно тогда, когда его задают: всякое
|
||||
пополнение словаря — а оно объявлено рабочим циклом — давало бы расхождение
|
||||
при побайтно совпавшем журнале, и человек, принимающий необратимое решение о
|
||||
подмене базы, читал бы это как дефект. Правильность вывода кода проверяется
|
||||
тестами словаря, а не оракулом сходимости журнала: это разные вопросы, и
|
||||
смешивать их — способ обесценить оракул.
|
||||
|
||||
Прецедент цены назван: как с `workout`/`record` (миграция 00007), первая сверка
|
||||
после выкатки покажет расхождение, потому что рабочая витрина получит реестр
|
||||
только с пересворачиванием. Отчёт `reindex` обязан назвать это ожидаемым
|
||||
классом и напечатать счётчик строк реестра «до и после» — иначе расхождение
|
||||
безадресно: числа объектов, тренировок и записей не изменятся.
|
||||
|
||||
Провенанс — **только первая встреча** (`first_seen_utc`, `first_delivery_id`,
|
||||
минимум по журналу `(received_at, id)`). Минимум идемпотентен при повторной
|
||||
свёртке той же доставки; счётчик встреч не идемпотентен и потому не заводится
|
||||
вовсе.
|
||||
|
||||
Границы, потому что тело контролирует отправитель целиком:
|
||||
|
||||
```
|
||||
maxCategoricalValues 64 различных значения на доставку измерено ~11
|
||||
maxCategoricalValueLen 128 байт на значение измерено 36 («Сидячий образ жизни»)
|
||||
```
|
||||
|
||||
Числа названы здесь, а не «по аналогии с 32/64 у непокрытых секций»: там имена
|
||||
секций короткие и латинские, здесь — русские фразы в UTF-8, и предел в 32 байта
|
||||
отбросил бы две из трёх измеренных строк контекста пульса.
|
||||
|
||||
Слишком длинное значение **отбрасывается со счётчиком, а не обрезается**:
|
||||
обрезанная строка неотличима от настоящей и попала бы в ключ реестра как
|
||||
самостоятельное значение. Точка при этом хранится целиком — теряется запись в
|
||||
реестре, а не данные.
|
||||
|
||||
При переполнении границы числа уцелевший набор — **функция множества, а не
|
||||
порядка элементов на проводе**: наблюдения сортируются по
|
||||
`(метрика, поле, значение)` и берутся первые `maxCategoricalValues`. Иначе то
|
||||
же содержимое, переприсланное в другом порядке ключей (порядок у HAE
|
||||
нестабилен, находка 2), давало бы другой реестр — и расхождение вышло бы как
|
||||
«пересборка не сошлась», без адреса.
|
||||
|
||||
### 7. Запись реестра идёт в транзакции слияния
|
||||
|
||||
Реестр обновляется тем же `store.Merge`, что и объекты: доставка либо свёрнута
|
||||
целиком, либо не свёрнута. Отдельный вызов дал бы состояние «точки легли,
|
||||
реестр нет», которое повторная свёртка не чинит — хеш содержимого сойдётся, и
|
||||
объекты переписываться не станут. Строк на доставку единицы, удержание
|
||||
блокировки не растёт.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Строки локали — данные о здоровье** («Сидячий образ жизни» — контекст
|
||||
пульса) → в лог уходят только счётчики; значения и коды не попадают в атрибуты
|
||||
свёртки ни на каком уровне. Проверяется тем же приёмом, что и значения точек:
|
||||
разбором записи, а не поиском подстроки в сыром буфере.
|
||||
- **Отправитель раздувает реестр** тысячей различных «фаз сна» → границы на
|
||||
число и длину, отброшенное считается, уцелевшее детерминировано.
|
||||
- **Первая сверка отпечатков после выкатки разойдётся** → названо в миграции, в
|
||||
спеке, в отчёте `reindex` отдельным классом и счётчиком.
|
||||
- **Код в базе отстаёт от словаря в бинаре** для строк, переставших приезжать →
|
||||
осознанная цена материализации (решение 1, форма 2); лечится пересборкой,
|
||||
сходимость не задевает, потому что `code` вне отпечатка.
|
||||
- **Словарь покрывает только фазы сна** → `heart_rate.context` и имена
|
||||
тренировок попадут в реестр с пустым кодом. Счётчик неизвестных строк поэтому
|
||||
ненулевой в установившемся режиме, и как сигнал «появилось новое» он не
|
||||
годится; сигналом служит **новая строка в реестре**, а не ненулевой счётчик.
|
||||
- **Словарь набирался литералами Go, а сверяться будет с байтами тела** →
|
||||
приёмочный тест берёт строку **из пакета `testdata`**, а не из константы
|
||||
теста. Прецедент: Apple шлёт неразрывные пробелы внутри строк (находка 24);
|
||||
в наблюдённых категориальных полях их сегодня нет (проверено `grep` по
|
||||
`testdata`), но литерал, набранный руками, эту проверку не заменяет.
|
||||
- **Реестр не сравнивается с усечённым журналом** → при подрезке архива строки,
|
||||
чьи доставки выпали, останутся в рабочей витрине и не появятся в
|
||||
пересобранной. Названо в Non-Goals; ретеншена архива пока нет.
|
||||
@@ -0,0 +1,66 @@
|
||||
## Why
|
||||
|
||||
HAE отдаёт перечислимые значения строками локали телефона («БДГ», «Сидячий
|
||||
образ жизни», «В помещении Ходьба»), а родной экспорт Apple — кодами HealthKit
|
||||
(`HKCategoryValueSleepAnalysisAsleepREM`). Источники несопоставимы (находка 37),
|
||||
и на этой сверке стоит устаревание нижнего слоя. Плюс два молчащих отказа:
|
||||
клиент вынужден угадывать словарь, а смена языка телефона расколет историю так,
|
||||
что по координатному ключу это неотличимо от изменения данных.
|
||||
|
||||
Словарь фаз сна уже выведен сопоставлением потока с экспортом за тот же период
|
||||
(находка 43) — составлять его руками не нужно, нужно завести механизм.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Разбор извлекает **категориальные значения** объявленных полей HAE
|
||||
(`sleep_analysis.value`, `heart_rate.context`, `workouts.name`) и выводит для
|
||||
каждого стабильный код HealthKit по словарю `(локаль, строка) → код`. Локаль
|
||||
берётся из `Accept-Language` доставки (находка 32).
|
||||
- Строка **не трогается**: она остаётся в точке дословно, код кладётся рядом —
|
||||
отдельной строкой реестра, а не полем внутри точки (обоснование — design.md).
|
||||
Ключ реестра — `(метрика, поле, значение)`; локаль сужает поиск по словарю, но
|
||||
в ключ не входит: её нет в сыром архиве, и ключ с ней сделал бы состояние
|
||||
функцией от того, уцелела ли строка учёта.
|
||||
- Незнакомая строка даёт **пустой** код, а не догадку, и попадает в счётчик
|
||||
доставки и в реестр строкой с пустым кодом — это и есть список того, что пора
|
||||
добавить в словарь.
|
||||
- Переименование кода самой Apple (`…Asleep` → `…AsleepUnspecified`, находка 43)
|
||||
не раскалывает историю: эквивалентность имён держит таблица синонимов, обе
|
||||
формы сходятся в один канонический код.
|
||||
- Витрина получает таблицу `category_value` (миграция `00010`): наблюдённые
|
||||
категориальные значения с выведенным кодом и провенансом первой встречи. Её
|
||||
наблюдение входит в отпечаток витрины — как всякая единица хранения; **код в
|
||||
отпечаток не входит**: он функция не журнала, а версии словаря в бинаре.
|
||||
- Отчёт `reindex` учится считать строки реестра и называть «появившуюся единицу
|
||||
хранения» ожидаемым классом расхождения — иначе первая сверка после выкатки
|
||||
безадресна, а по ней принимается необратимое решение о подмене базы.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- новых capability нет: поведение ложится в разбор и хранение -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `parsing`: разбор выделяет категориальные значения объявленных полей, выводит
|
||||
код по словарю и синонимам, считает неразобранные словарём строки; отсутствие
|
||||
или неизвестность локали не даёт догадки и не отменяет вывода кода, если
|
||||
строка однозначна.
|
||||
- `storage`: реестр наблюдённых категориальных значений — единица хранения
|
||||
витрины: детерминированный по журналу upsert, участие наблюдения в отпечатке
|
||||
при исключении из него выведенного кода, происхождение словаря.
|
||||
- `reindex`: счётчик строк реестра в отчёте и ожидаемый класс расхождения
|
||||
«появилась единица хранения».
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/healthkit` (новый пакет): словарь `(локаль, строка) → код` и таблица
|
||||
синонимов кодов. Знание о HealthKit, общее с будущим импортом родного
|
||||
экспорта Apple.
|
||||
- `internal/hae`: `Meta.Locale`, извлечение категориальных значений из точек и
|
||||
сущностей, счётчики; `Point.Raw` и `Entity.Raw` не меняются.
|
||||
- `internal/store`: миграция `00010`, таблица `category_value`, upsert внутри
|
||||
транзакции слияния, отпечаток.
|
||||
- `internal/fold`: локаль из заголовков доставки, счётчик в логе свёртки.
|
||||
- `docs/database.md` — описание таблицы (без него гейт красный),
|
||||
`docs/architecture.md` — раздел «Категориальные значения».
|
||||
- Оракул `task verify:archive` обязателен: правило разбора меняется.
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# Триаж чекпоинта кода — `slovar-kategorialnyh-znachenij`
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Профиль:** `deep`, режим последовательный. База диффа `3df42afecade8ba9af7ad3d8b7a09a6f0f619574`, код в рабочем дереве `/home/av/projects/private/healthlog/tmp/wt-categorical` (не закоммичен).
|
||||
- **Гейт:** ЗЕЛЁНЫЙ (`task gate BASE=3df42af`, дважды, exit 0). Покрытие диффа после исправлений G1/G2 — 85% (17 непокрытых из 114).
|
||||
- **`task verify:archive`:** КРАСНЫЙ, но **унаследованно** — отказ `step_count: противоречащих часов 1 при 22 согласных` воспроизводится на базовой ревизии `3df42af` (проверено прогоном копии дерева базы на том же архиве). Сходимость (повтор журнала даёт тот же отпечаток) держится. По CLAUDE.md («Общий станок») красный `verify:archive` врывается в спринт — это отдельная задача владельцу, не находка этого изменения. `task verify:busy` — зелёный.
|
||||
- **Проходы поимённо** (сверено с составом профиля `deep`, стадии 0–5; расхождений с профилем нет):
|
||||
- `review-gate` (стадия 0) — отработал, 4 находки; G1 и G2 исправлены по ходу.
|
||||
- `review-specs` (стадия 1) — отработал, 4 находки; S3 исправлена по ходу.
|
||||
- `review-code` (стадия 1) — отработал, 1 находка.
|
||||
- `review-adversary` (стадия 2) — отработал, 4 находки; A1 (critical) исправлена по ходу.
|
||||
- `review-ops` (стадия 2) — отработал, 3 находки.
|
||||
- `review-reimpl` (стадия 3, по триггеру «новое правило разбора и идентичности» — триггер корректен) — отработал, независимая реализация написана, 3 находки.
|
||||
- `review-architecture` (стадия 4) — отработал, 2 находки.
|
||||
- `review-triage` (стадия 5) — этот отчёт.
|
||||
- **Исправления по ходу проверены триажем, а не приняты на слово:**
|
||||
- A1/S3: `internal/replay/archive_test.go` — печатаются только числа («фаз сна без кода N из M») и имена метрик/полей; ни `v.Value`, ни `v.Code` в `t.Errorf` больше нет (чтение строк 185–236). Подтверждено.
|
||||
- G1: `TestMergeCategoriesКонкурентноНеПортитПровенанс` существует (`internal/store/category_test.go:229`), прогнан триажем под `-race` — зелёный. Подтверждено.
|
||||
- G2: в `TestFoldЛокальНеМеняетСостояния` есть кейсы пустой строки заголовков, битого JSON и пустого списка; прогнан — зелёный. Подтверждено.
|
||||
- **Счёт находок:** на вход 21 (по проходам), из них 4 закрывают 3 уже исправленные причины; после дедупликации по причине — 12 живых причин. В отчёте: 2 блокера, 4 «сейчас», 4 гипотезы, 2 promote. Ничего не выброшено молча.
|
||||
- **Дедупликация:** S2=A2=R3 (одна причина: инкремент `dropped` до дедупликации), C1=O1=R2 (одна причина: нет ветки в `logResult`), O3=R1 (одна причина: `seen` без границы до `result()`). Согласие трёх проходов поднимает приоритет этих причин, но не confidence — под всеми проходами одна модель; confidence даёт оракул, и он у всех трёх есть.
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. Заявленный спекой страж плоскости словаря ничего не сторожит: неплоская таблица пройдёт тест зелёным, а `Code` вернёт неканонический код
|
||||
|
||||
- Файл: internal/healthkit/healthkit_test.go:48-68 (тест), internal/healthkit/healthkit.go:63-65 (таблица)
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: воспроизведено проходом specs — в копии пакета добавлены две записи, где значение одной является ключом другой: `TestSynonymsТаблицаПлоская` PASS, `Canonical(Old)` возвращает промежуточный код. Триаж сверил с текущим кодом: тест обходит шесть измеренных строк и один алиас через экспортированные `Code`/`Canonical`, саму карту `synonyms` не обходит — запись, не достижимая из словаря, под утверждение не попадает.
|
||||
- Последствие: дельта (specs/parsing/spec.md:179, сценарий :194-198) требует дословно «ни одно её значение не встречается среди её ключей» и этим обменом оправдала отсутствие рантайм-обхода цепочек. Обмена не произошло: защиты нет ни в рантайме, ни в тесте. Следующее переименование HealthKit (находка 43 разведки: уже случалось) добавляется строкой в таблицу; если новый канонический код совпадёт со старым ключом, в реестре осядет код, которого в экспорте Apple нет, — и ни один оракул не поймает: код в отпечаток не входит намеренно, а `verify:archive` проверяет каноничность тем же `Canonical`, то есть согласием функции с самой собой.
|
||||
- Предложение: внутренний тест `package healthkit`, обход самой карты: `for _, to := range synonyms { if _, ok := synonyms[to]; ok { t.Errorf(...) } }`. Существующий внешний тест оставить — он проверяет другое (неподвижность точек для измеренных строк).
|
||||
- Найдено проходом: review-specs (S1)
|
||||
- Действие: инлайн
|
||||
|
||||
### 2. Единственный сигнал «HAE изменил форму категориального поля» отдаёт величину в двух несовместимых единицах: 200 вхождений одной строки неотличимы от 64 отброшенных различных
|
||||
|
||||
- Файл: internal/hae/categorical.go:103-112 (`add`: `c.dropped++` на каждое вхождение, до попадания в `seen`) и :161-165 (`result`: второе слагаемое — уже различные)
|
||||
- Severity: major (поднято с minor: спека нормирует счётчики через «различные» и объясняет почему — сломанное требование дельта-спеки)
|
||||
- Confidence: high
|
||||
- Оракул: измерено дважды независимо (adversary: тело с 200 точками и одной длинной строкой → `ОТБРОШЕНО 200` при одном различном значении; reimpl: 1000 точек, одна непомерная строка → `CategoricalDropped = 840`). Триаж сверил с кодом: инкремент действительно стоит до дедупликации, ветка переполнения складывает в тот же счётчик различные.
|
||||
- Последствие: `categoricals_dropped` — единственный чекпоинт дрейфа формы в свёртке — завышен на порядок числа точек; между доставками не сравнить, порог не построить. После мерджа семантика уедет в лог и в привычку оператора.
|
||||
- Предложение: считать по различным — отдельное множество `tooLong` (или счётчик по ключу), `dropped = len(tooLong) + переполнение`; тест: одно длинное значение в десяти точках → `dropped == 1`.
|
||||
- Найдено проходом: review-specs (S2) = review-adversary (A2) = review-reimpl (R3)
|
||||
- Действие: инлайн
|
||||
|
||||
---
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 3. Первая же строка, получившая разные коды в двух локалях, сделает колонку `code` функцией порядка свёртки: живой приём и replay разойдутся при побайтно совпавшем журнале
|
||||
|
||||
- Файл: internal/healthkit/healthkit.go:29-33,106-116 (`byValue`, комментарий-страж); internal/store/category.go:54-55 (`code = excluded.code` безусловно)
|
||||
- Severity: major
|
||||
- Confidence: medium (пути сегодня нет — словарь одноязычный, проверено прогоном `Code(l,v) == Code("",v)` для пяти локалей на 10 измеренных строках)
|
||||
- Оракул: механизм построен по коду; отсутствие сегодняшнего пути измерено. Единственный страж — комментарий healthkit.go:31-33, утверждающий «вторая локаль ничего не ломает», что верно ровно до первого пересечения строк между локалями.
|
||||
- Последствие: расхождение живой витрины с пересобранной по колонке, сознательно исключённой из отпечатка, — его не увидит ни отпечаток, ни счётчик строк. Это латентный путь к нарушению «Хранилище — свёртка по журналу» (critical в CLAUDE.md), но без построенного сегодняшнего пути — major.
|
||||
- Предложение: тест словаря «ни одна строка не встречается в двух локалях с разными кодами» — это и есть условие корректности `byValue`; дешевле и бьёт в причину (вариант прохода). Ложится рядом с тестом плоскости из блокера 1.
|
||||
- Найдено проходом: review-adversary (A3)
|
||||
- Действие: инлайн
|
||||
|
||||
### 4. Взрывной рост словаря телефона владелец не увидит: `categoricals_dropped` тонет в INFO, хотя тот же файл эскалирует тот же класс события до WARN
|
||||
|
||||
- Файл: internal/fold/fold.go:304-346 (`logResult`: ветки `case st.CategoricalDropped > 0` нет; прецедент — `case st.UncoveredDropped > 0` на :319)
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: чтение switch (триаж сверил — ветки нет) + docs/conventions/logging.md:16 («WARN — владельцу, "может стать проблемой"») + собственный прецедент того же файла с тем же обоснованием («в INFO оно тонуло»). Дополнение reimpl: соответствие «доставка → отброшено» живёт в ротируемом логе (max-file: 3, max-size: 10m) — к моменту сверки отпечатков причину уже не восстановить.
|
||||
- Последствие: границы подобраны по измерению (~11 значений, максимум 36 байт), попадание в границу — само по себе аномалия; молчащий отказ — второй по весу класс. `/stats` нет, отчёт reindex переносит только счётчик строк.
|
||||
- Предложение: ветка `case st.CategoricalDropped > 0` → WARN, симметрично `UncoveredDropped`. `CategoricalUnknown` не эскалировать — штатно ненулевой.
|
||||
- Найдено проходом: review-code (C1) = review-ops (O1) = review-reimpl (R2)
|
||||
- Действие: инлайн
|
||||
|
||||
### 5. Отчёт reindex, запущенный спустя дни после выкатки, объявит расхождение по реестру непонятным — и приучит оператора игнорировать «отпечатки РАЗОШЛИСЬ»
|
||||
|
||||
- Файл: cmd/healthlog/reindex_report.go:85-110 (условие `sourceCategories == 0 && replay.Categories > 0`)
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: воспроизведено вызовом `writeReport` с `sourceCategories:5, replay.Categories:11` при совпавших остальных счётчиках — «отпечатки РАЗОШЛИСЬ» со стандартным списком причин, реестр не упомянут. Триаж сверил условие в коде — ловит только `== 0`.
|
||||
- Последствие: промежуточное состояние (`0 < sourceCategories < replay.Categories`) — штатный сценарий: воркер наполняет реестр по новым доставкам, редкий тип тренировки не встретился, а прогон по CLAUDE.md запускается перед следующей задачей, то есть спустя дни. Отчёт — оракул, по которому принимается необратимое решение о подмене базы; научить оператора игнорировать его строку дороже самого расхождения.
|
||||
- Предложение: условие на диапазон — `sourceCategories < replay.Categories` при совпавших остальных единицах. Безусловную пометку не печатать: reimpl отдельно отметил, что условная печать — сильная сторона текущего решения.
|
||||
- Найдено проходом: review-ops (O2)
|
||||
- Действие: инлайн
|
||||
|
||||
### 6. Состязательное тело в пределах лимита приёма поднимает пик процесса на ~222 МиБ: накопитель наблюдений растёт с числом точек, а не с пределом 64
|
||||
|
||||
- Файл: internal/hae/categorical.go:83-112 (`add` кладёт в `seen` без проверки числа), :148-168 (граница 64 применяется только в `result()`)
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: измерено дважды независимо (ops: +77–98 МиБ на теле 48.4 МиБ с уникальным `context`; reimpl: HeapSys 1002 МиБ против 778–787 на базе, тело 60 МиБ / 1.14 млн различных значений, три прогона на каждом дереве).
|
||||
- Последствие: тело 60 МиБ проходит предел приёма (64 МиБ); docker-compose лимита памяти не ставит; OOM в горутине свёртки `recover()` не ловит, `restart: unless-stopped` поднимает процесс, первый проход берёт ту же доставку из архива — неустранимый цикл перезапуска, приём стоит, новые доставки телефон не перешлёт («поток не останавливается»). Вероятность низкая (свой телефон такое тело не шлёт; security.md исключает злонамеренное исчерпание), но потеря новых доставок необратима — потому в «сейчас», а не в гипотезы.
|
||||
- Предложение: ограничивать `seen` порогом 64 на вставке. Осторожно: наивное «перестать добавлять после 64» сделает результат функцией порядка — держать 64 наименьших ключа (отсортированный срез с двоичной вставкой; рабочий вариант и тест «переполнение не зависит от порядка» написаны проходом reimpl). Взаимодействует с блокером 2: счётчик после обеих правок считает различные.
|
||||
- Найдено проходом: review-ops (O3) = review-reimpl (R1)
|
||||
- Действие: инлайн
|
||||
|
||||
---
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **A4 (adversary, minor→гипотеза): предел имени метрики в тексте ошибки держится на форме карты `pointCategoricalFields`, а не на проверке.** Оракула нет — вход, роняющий `mergeCategories`, построить не удалось; сегодня незнакомая метрика даёт пустой список полей и до `fmt.Errorf` не доезжает. Станет актуальным при первом правиле вида «поле по префиксу имени». Дешёвая профилактика — `clipSection` для `v.Metric`/`v.Field` — на усмотрение оркестратора, требования нет.
|
||||
- **G4 (gate, minor, унаследовано): ветки ошибок чтения БД непокрыты.** Не этой задачи: идентично непокрыты у всего семейства (`CountDeliveries` и родня), fault-injection в проекте не делается нигде. Закрывать классом целиком отдельной задачей, если владелец сочтёт нужным.
|
||||
- **AR2 (architecture, minor, не влезло в потолок — оракул есть): «провенанс первой встречи» живёт двумя правилами** (`mergeCategories` — явный минимум по журналу; `writeBucket` — неявный «первый записавший»). Факт двух правил доказан сверкой upsert'ов, путь к последствию — medium (автор следующей единицы скопирует паттерн bucket для единицы с провенансом в отпечатке). Рекомендация — вариант (б) прохода: одна фраза-комментарий у `writeBucket`, почему здесь допустим слабый провенанс и где образец сильного. Дешевле буквы (а) и достаточно.
|
||||
- **S4 (specs, minor, не влезло в потолок — оракул есть, и это развилка): невалидный UTF-8 ляжет в первичный ключ реестра подменёнными байтами (U+FFFD), а критерий tasks.md:114-117 «невалидный UTF-8» отмечен [x] без теста.** Измерено: байт 0xFF → `s = "�Во сне"`, не равно пришедшим байтам, при этом сама точка хранится дословно — расходится только строка реестра (пересобираемая). Вероятность мала (HAE шлёт корректный JSON), но по CLAUDE.md «сделана = критерии проверены поимённо», а этот не проверен. Вопрос в файл задачи: (а) `utf8.ValidString` в `jsonString` — наблюдение не снимается, «пустота честнее догадки», плюс строка в спеку и табличный тест; (б) снять формулировку из критерия. Цена (а) — ~5 строк и тест; цена (б) — честность списка. Действие: развилка.
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **G3 (gate): шаг `cover` гейта без `-coverpkg=./...` занижает покрытие для кода, вызываемого из чужих тестов** (82% против фактических 85% на этом диффе; `DeliveryForParse` показывает 0.0%, будучи покрытым из fold/replay). Механизируемо — правка `scripts/gate.py`, не находка ревью этого изменения.
|
||||
- **AR1 (architecture): кадрирование строк отпечатка (`длина:поле` с признаком раздела) написано вручную третий раз** (`fingerprintCategories`, `fingerprintBuckets`, `fingerprintRows`); дисциплина держится тремя комментариями. Кандидат в конвенцию storage.md: «новая единица хранения подключается к отпечатку через общий помощник кадрирования», рефакторинг — вместе с пятой единицей или отдельной задачей. Пропущенная длина перед одним полем — молчащий отказ оракула, по которому принимается необратимая подмена базы, поэтому правило стоит записать до того, как появится пятая копия.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Прогон.** Профиль `deep`, последовательный режим; запущены все проходы профиля: `review-gate`, `review-specs`, `review-code`, `review-adversary`, `review-ops`, `review-reimpl` (по триггеру — корректно), `review-architecture`, плюс этот триаж. Непущенных проходов нет; расхождения состава с профилем нет (обязательный вопрос `triage` из docs/review.md — закрыт).
|
||||
|
||||
**Красный `task verify:archive` — унаследованный.** Отказ `step_count: противоречащих часов 1 при 22 согласных` воспроизводится на базе `3df42af` на том же архиве; сходимость держится; `verify:busy` зелёный. По CLAUDE.md красный общий станок врывается в спринт — это долг владельцу вне этого изменения; изменение его не вносило и не чинит.
|
||||
|
||||
**Не влезло в потолок 7 (названо, не выброшено):** S4 и AR2 — с оракулами, ушли в гипотезы с рекомендациями; G3, AR1 — в promote; G4, A4 — в гипотезы.
|
||||
|
||||
**Отсев вкусовщины:** выброшенных находок нет — проходы вкусовщины не выпустили (кандидаты вроде «печатать класс безусловно» отсеяны самими проходами). Ни одна находка не совпала с «Типовыми ложноположительными» docs/review.md; раздел существует и применялся.
|
||||
|
||||
**Чего каждый запущенный проход не мог проверить в принципе:**
|
||||
- `review-gate` — только механизируемое; смысл тестов и полноту утверждений не судит.
|
||||
- `review-specs` — соответствие кода спеке, но не спеки — реальности формата HAE; границы спеки, домысленные реализацией, перечислены им поимённо (момент применения границы 64, единица счёта, невалидный UTF-8, NUL в ключе, строка реестра после подрезки архива — последняя остаётся открытой: ретеншена ещё нет, «MUST называться» негде проверить).
|
||||
- `review-code` — только прозаические конвенции; поведение не исполняет.
|
||||
- `review-adversary` — не судит полноту словаря переводов и профиль нагрузки; рост реестра как DoS сознательно не поднят (security.md исключает исчерпание ресурсов как злонамеренное).
|
||||
- `review-ops` — деградация `mergeCategories` под удерживаемой блокировкой отдельно не измерялась (по порядку много меньше канонизации — осталось гипотезой).
|
||||
- `review-reimpl` — сверяет решения, не требования; его независимая реализация разделяет априорные той же модели.
|
||||
- `review-architecture` — код не исполняет, судит границы и направления.
|
||||
- триаж — ничего нового не находит по определению; пропуск любого прохода был бы и его пропуском.
|
||||
|
||||
**Осталось целиком на человеке — два списка из docs/review.md, не сливать:**
|
||||
|
||||
*Не проверит ни один проход:* реальный профиль нагрузки (телефон шлёт молча, объём меряется по факту); поведение HAE за пределами наблюдённого; **полнота словаря переводов после обновления iOS** — прямо релевантно этой задаче: словарь одноязычный и заведомо неполон, ветка «фаз сна без кода» в `verify:archive` существует ровно поэтому; секции, которых поток не приносил (`symptoms`, `ecg`, `heartRateNotifications`, `cycleTracking`, `medications`).
|
||||
|
||||
*Перестали проверять сознательно:* `verify:archive`/`verify:busy` вне гейта — гоняет человек или оркестратор перед изменением разбора/идентичности/слияния (в этом прогоне: archive красный унаследованно, busy зелёный); класс «в Go так не пишут» — не покрыт вовсе после упразднения `idiom` (запись 2026-08-02); класс «чего нет в зрелой реализации такого узла» — вне профиля `design`.
|
||||
|
||||
*Общее, что не покрывает ни один конвейер:* история инцидентов, поведение под реальным потоком, поведение внешних систем в их версиях (переименования HealthKit — находка 43 — случатся снова), завязка потребителей на текущее поведение (Read API и MCP ещё не написаны), вопрос «нужна ли эта функциональность вообще» (закрыт триажем дизайна, не кода).
|
||||
|
||||
**Документы проекта:** всех хватило — `CLAUDE.md` с инвариантами и severity при них, `docs/review.md` с типовыми ложноположительными, журналом и обоими списками «недоступно проверке», `docs/security.md` с периметром, `docs/conventions/` поимённо, `docs/research/apple-health.md` с измерениями формата. Отсутствующих документов ни один проход не назвал; деградации не было.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Триаж ревью: `slovar-kategorialnyh-znachenij` (профиль design)
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Профиль/режим:** `design` (ревью предложения до кода), последовательный.
|
||||
Состав профиля — specs + rubric + architecture — **совпадает с запущенным**,
|
||||
расхождений нет.
|
||||
- **Гейт:** не запускался — кода нет, на профиле design гонять нечего.
|
||||
- **Проходы поимённо:**
|
||||
- `review-specs` (дизайн до кода) — отработал, 8 находок (7 содержательных + 1 nit, уже исправленный);
|
||||
- `review-rubric` (фаза 1) — отработал, 8 находок + 12 свойств-кандидатов в приёмочные критерии;
|
||||
- `review-architecture` (на предложении) — отработал, 3 находки;
|
||||
- `review-gate` — не запускался (кода нет);
|
||||
- `review-code`, `review-adversary`, `review-ops`, `review-reimpl` — не запускались (вне состава профиля design).
|
||||
- **Находок на входе:** 19. После дедупликации по причине (5 слияний) и
|
||||
отсева: **3 блокера + 4 «исправить сейчас»**, 3 гипотезы, 5 promote,
|
||||
4 доказанных minor-переполнения потолка названы в границах покрытия.
|
||||
- **Отдельно для оркестратора:** 12 свойств рубрики (файл сырого вывода
|
||||
rubric, раздел «Рубрика») по регламенту design-прогона переносятся
|
||||
приёмочными критериями в `tasks.md` — это штатный шаг, не находка.
|
||||
- Совпадение находок между проходами (материализация кода — specs+architecture;
|
||||
критерий №1 — specs+rubric; локаль — rubric+architecture) учтено как
|
||||
приоритет, не как подтверждение: подтверждением служат только добытые оракулы.
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. Отчёт пересборки скажет «отпечатки не совпали», не назвав причины, — человек примет по безадресному расхождению необратимое решение о подмене базы
|
||||
|
||||
- Файл: `openspec/changes/slovar-kategorialnyh-znachenij/specs/storage/spec.md:74-87`; дельты `specs/reindex/spec.md` в change нет; шага в `tasks.md` нет
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: действующая спека `openspec/specs/reindex/spec.md:325-327` дословно — «Счётчики „до и после“ SHALL покрывать **каждую единицу хранения витрины**: часовые объекты, тренировки и записи»; `:363-365` — ожидаемые классы расхождения SHALL называться отдельно; сценарий `:401-405` перечисляет три единицы **закрытым списком**. Дельта storage объявляет реестр единицей хранения и разделом `c` отпечатка, но ни счётчика строк реестра, ни новых ожидаемых классов не заказывает. Проверено чтением обеих спек.
|
||||
- Последствие: дизайн сам гарантирует расхождение первой сверки после выкатки (`spec.md:84-87`) и обещает, что «отчёт `reindex` показывает его человеку», — но отчёт по своей спеке этого не умеет: счётчики объектов/тренировок/записей не изменятся, расхождение будет безадресным, а тест закрытого списка останется зелёным. Второй, вечный класс — расхождение после каждого пополнения словаря (пока код материализован, см. блокер 3).
|
||||
- Предложение: добавить в change дельту `specs/reindex/spec.md` (MODIFIED «Отчёт, оракул и исход команды»): счётчик строк реестра «до и после» + ожидаемый класс «покрыт реестр категориальных значений» (+ класс «изменился только код» — если блокер 3 решится в пользу материализации), шаг в `tasks.md`, `reindex` в Modified Capabilities.
|
||||
- Действие: инлайн (счётчик и класс «новая единица» нужны при любом исходе блокера 3; класс «пополнение словаря» — только при варианте с материализацией)
|
||||
- Найдено проходами: specs, rubric (дубль по причине, слит)
|
||||
|
||||
### 2. Локаль в ключе реестра не переживает жизненный цикл доставки: усыновлённое тело рождает вторую строку с пустой локалью и ложный сигнал «появилось новое»
|
||||
|
||||
- Файл: `specs/storage/spec.md:9-11` (ключ); `specs/parsing/spec.md:91-127` (нормализация без свёртки регистра и без значения ключа для «заголовка нет»); `design.md:102-108`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: построенный путь по существующему коду — `internal/replay/replay.go:378-400`: `adopt()` собирает `store.Delivery` **без заголовков** («Заголовков в архиве нет вовсе» — комментарий там же; то же в `openspec/specs/reindex/spec.md:95-98`). Значит доставка, восстановленная из осиротевшего тела (штатный случай, станет частым с ретеншеном), даёт ключ `(метрика, поле, "", «Во сне»)` ≠ `(…, "ru", «Во сне»)` живой витрины → раздел `c` отпечатка расходится вне всякого названного класса. Разряд 2 правила вывода чинит **код**, но не **ключ** — та самая зависимость от «уцелела ли строка учёта», которую дизайн сам объявил недопустимой (`design.md:104-106`). Вдобавок спека не сворачивает регистр локали (BCP 47: теги регистронезависимы; `RU` и `ru` дадут два ключа) и нигде не называет, что пишется в ключ при отсутствии заголовка.
|
||||
- Последствие: постоянное расхождение отпечатков после первого же усыновления; ослабление собственного сигнала дизайна «новая строка в реестре — сигнал» (усыновление рождает ложные новые строки); потенциальный раскол одного языка на несколько ключей — раскол истории на нашей стороне, ключ миграции 00010 фиксируется навсегда.
|
||||
- Предложение: развилка, решить до фиксации миграции. (а) Убрать локаль из ключа: реестр ключуется `(метрика, поле, значение)`, локальная неоднозначность и так выражается пустым кодом на уровне вывода; цена — теряется «когда сменился язык телефона» по строке. (б) Оставить локаль в ключе: тогда объявить в спеке свёртку регистра, выделенное значение ключа для «заголовка не было», записать строку с пустой локалью законным наблюдением и законным классом расхождения после усыновления (уехать в дельту `reindex` из блокера 1). Вариант (а) дешевле и убирает весь класс; вариант (б) сохраняет провенанс локали ценой трёх оговорок.
|
||||
- Действие: развилка
|
||||
- Найдено проходами: rubric, architecture (две находки об одной причине, слиты; сюда же граница №1 прохода specs)
|
||||
|
||||
### 3. Отпечаток витрины перестаёт быть функцией журнала — он становится функцией «журнал + версия словаря в бинаре», и форма без этой связки в design.md не рассматривалась
|
||||
|
||||
- Файл: `specs/storage/spec.md:54-56` («код обновляется при каждой встрече… по текущему словарю») и `:89-93` (код входит в отпечаток); `design.md:57-60` против `design.md:139-147`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: собственное положение дизайна — `design.md:59-60`: «код есть **функция** от того, что уже лежит» (этим доводом отвергнут код в пути слияния и хеша). Цепочка из текста спеки: код лежит в реестре → код в отпечатке (сценарий «Разошедшийся реестр меняет отпечаток») → код выводится словарём из бинаря → отпечаток = f(журнал, бинарь). Non-Goal снимает отдачу кода в ответе чтения, то есть внутри change у материализованной колонки нет ни одного потребителя, кроме тестов. Раздел Decisions разбирает «не поле в точке», «не миграция», «не таблица руками» — формы «код не хранится, выводится на чтении» среди рассмотренных нет.
|
||||
- Последствие: (1) после каждого пополнения словаря рабочая и пересобранная витрины расходятся отпечатком при побайтно совпавшем журнале — а пополнение объявлено регулярным циклом (словарь покрывает только фазы сна); (2) строки, переставшие приезжать, держат устаревший код до полной пересборки и **подмены базы человеком** — необратимого действия; (3) «обновление при каждой встрече» гарантирует свежесть ровно не тем строкам, ради которых словарь пополняли.
|
||||
- Предложение: развилка, решить до фиксации миграции 00010 и формата раздела `c`. (а) Реестр хранит только наблюдение (значение + провенанс), код — функция на чтении тем же `healthkit.Code`: исчезают upsert кода, зависимость отпечатка от бинаря и пересборка при пополнении; цена — сверка с экспортом уходит из чистого SQL в код, критерий приёмки №1 в текущей формулировке опереться на колонку не сможет (он и так неисполним — см. пункт 4). (б) Оставить материализацию: тогда записать в design.md рассмотренную и отвергнутую форму (а) с причиной, назвать зависимость отпечатка от версии словаря вслух и завести класс «изменился только код» в дельте `reindex` (блокер 1).
|
||||
- Действие: развилка
|
||||
- Найдено проходами: specs, architecture (одна причина, слита)
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 4. Первый критерий приёмки проверить нечем: задача либо не будет объявлена сделанной, либо галка встанет непроверенной
|
||||
|
||||
- Файл: `tasks.md:66-70`; `docs/tasks/items/categorical-value-dictionary.md:42-45`; `design.md:37-38`
|
||||
- Severity: major
|
||||
- Confidence: high
|
||||
- Оракул: CLAUDE.md, «Работа» — «критерии приёмки задачи проверены поимённо». Критерий требует «запрос на живой базе за период с известным перекрытием, ноль несопоставимых строк», а Non-Goal `design.md:37-38` прямо снимает импорт родного экспорта: кодов экспорта в живой базе нет и после change не появится. Прецедент разобран самой постановкой: соседний критерий про `/stats` снят 2026-08-03 ровно с этой формулировкой («вешать приёмку на несуществующий оракул значит либо блокировать задачу, либо принять её непроверенной») — этот критерий имеет тот же дефект. «Ноль несопоставимых строк» вдобавок — утверждение о растущем корпусе (прецедент журнала 2026-08-02).
|
||||
- Предложение: развилка (правится постановка, не спека). (а) Переформулировать на исполнимое: «каждая из шести измеренных строк фаз сна (находка 43), прочитанная из живого архива, даёт непустой канонический код, и множество кодов совпадает с множеством кодов сна экспорта 2026-08» — проверяется тестом на testdata + `verify:archive`. (б) Снять критерий с датированной пометкой по образцу критерия `/stats`, оставив сверку с экспортом задаче импорта. (в) Назвать внешний ручной оракул — скрипт сверки реестра с XML экспорта, гоняет человек.
|
||||
- Действие: развилка
|
||||
- Найдено проходами: specs, rubric (одна причина, слита)
|
||||
|
||||
### 5. Требование границ недоопределено: чисел нет, правило уцелевшего набора не объявлено, теста в плане нет
|
||||
|
||||
- Файл: `specs/parsing/spec.md:164-188`; `tasks.md:27-28` (шаг 2.5 без шага теста; 2.6 границ не упоминает)
|
||||
- Severity: major
|
||||
- Confidence: high (отсутствие чисел и теста), medium (последствие про порядок)
|
||||
- Оракул: соседнее действующее требование называет пределы числом («не больше 32 имён и не больше 64 байт на имя») — образец в том же проекте; измеренные длины (находка 37): «Сидячий образ жизни» — 36 байт UTF-8, «В помещении Ходьба» — 34, то есть предел, выбранный «по аналогии» с 32 байтами, отбросит измеренные строки. `docs/conventions/storage.md:20`: правило выбора — «функция множества версий либо явно функция порядка журнала — третьего состояния нет»; сценарий «их ровно предел» не говорит, какие именно: уцелевший набор становится необъявленной функцией порядка точек в теле. (Уточнение триажа к находке rubric: сходимость `import + replay` это **не** ломает — тела в журнале дословны и переигрываются в том же порядке; ломается сравнимость реестров между переприсылками одного содержимого и однозначность теста.)
|
||||
- Предложение: инлайн. Назвать оба числа в спеке рядом с измерением (длина ≥ 64 байт с запасом под измеренные 36); объявить уцелевший набор функцией множества (например, минимальные по байтам значения с тай-брейком по полю) либо явно функцией порядка тела; добавить в 2.6 тест на обе границы и на «тот же набор строк в переставленном теле даёт тот же результат».
|
||||
- Действие: инлайн
|
||||
- Найдено проходами: specs, rubric + граница №2 specs (одна причина — требование границ, слиты)
|
||||
|
||||
### 6. Ограничение глубины цепочек синонимов — мёртвая машинерия: собственный тест таблицы делает цепочку длиннее одного шага невозможной
|
||||
|
||||
- Файл: `specs/parsing/spec.md:142-145` и сценарий `:158-162`; `design.md:117-124`; `tasks.md:8-9` (шаг 1.2)
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: логическая цепочка из текста самой спеки: сценарий требует «ни один канонический код сам не является алиасом» — при этом инварианте всякий алиас разрешается ровно за один шаг, цепочек и неподвижных точек глубже одного шага не существует по построению. Спека одновременно декларирует «цикл ловится тестом, а не обходится в рантайме» и заказывает рантайм-обход (ограничение глубины) — внутреннее противоречие. Это класс «что опытный человек отсюда удалил бы» (вопрос 5 к architecture в docs/review.md).
|
||||
- Предложение: инлайн. `Canonical` — одно чтение плоской карты; тест таблицы проверяет «ни одно значение не является ключом». Сократить формулировку требования («цепочка» → «алиас разрешается за один шаг, таблица плоская по построению»), поправить `tasks.md` 1.2 и `design.md` §4.
|
||||
- Действие: инлайн
|
||||
- Найдено проходом: architecture
|
||||
|
||||
### 7. design.md ссылается на требование, которого в дельте нет: потребитель реестра выберет один код там, где реестр говорит «неразрешимо»
|
||||
|
||||
- Файл: `design.md:64-68` («читатель обязан считать его неразрешённым… Это названо требованием»); `specs/storage/spec.md` — такого требования нет
|
||||
- Severity: minor
|
||||
- Confidence: high
|
||||
- Оракул: прямая сверка текстов. Ближайшее требование (`specs/parsing/spec.md:110-111`) нормирует разрешение расхождения локалей **внутри разбора**; поведение читателя при соединении по `(метрика, поле, значение)` не нормировано нигде.
|
||||
- Последствие: следующий автор (Read API) возьмёт первую строку или `MIN(code)` — ровно та догадка, ради запрета которой заведён пустой код.
|
||||
- Предложение: инлайн — добавить в дельту storage требование со сценарием «две строки с разными кодами на одно значение — читатель обязан трактовать код как неразрешённый», либо убрать из design.md слова «названо требованием». (Если блокер 2 решится вариантом (а) — требование становится ещё короче.)
|
||||
- Действие: инлайн
|
||||
- Найдено проходом: specs
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **Известная строка молча получит пустой код из-за невидимых символов (NBSP)** — понижено major→minor. Оракул добывался и дал отрицательный результат: `grep -RP '\xc2\xa0' internal/hae/testdata/` — **ни одного вхождения**; находка 24 (`docs/research/apple-health.md:531-553`) касается имён устройств (`source`), а не категориальных полей change. Сценарии спеки уже требуют разбор реального пакета из testdata, так что несовпадение литерала словаря с живыми байтами упало бы громко на приёмочном тесте, а не молча. Остаточный риск — будущие пополнения словаря строками, не представленными в testdata; закрывается promote-кандидатом 1. (rubric)
|
||||
- **Реестр монотонен: строка, чьи доставки удалены ретеншеном, останется в живой витрине и исчезнет в пересобранной** — Confidence: low (ретеншена архива ещё нет, поведение построено на двух допущениях). По правилу «low — не выше minor». Дешёвая профилактика: одна фраза в дельте storage — реестр накопительный и при усечённом журнале исключается из сравнения, либо строки законно теряемы (родственно инварианту CLAUDE.md о верхних слоях за периоды с удалёнными доставками). Решается заодно с дельтой `reindex` из блокера 1. (rubric)
|
||||
- **Ключ реестра завязан на имя метрики, которое каталог собирается расщепить (`sleep_analysis` → `…_summary`, находка 38)** — Confidence: low, расщепление не запланировано этим change. Дешёвая профилактика: фраза «в ключ идёт то же имя метрики, которым адресуется часовой объект». (rubric)
|
||||
|
||||
## Promote candidates
|
||||
|
||||
1. `docs/conventions/testing.md`: значение, попадающее в ключ витрины или в словарь, приёмочный тест берёт из пакета `testdata`, а не из литерала в тесте (прецеденты: находка 24, журнал 2026-08-02 про флаки-подстроку).
|
||||
2. `docs/conventions/storage.md`: правило «функция множества либо явно функция порядка» распространить с выбора между версиями на усечение по границе (прецедент `f8200f7`).
|
||||
3. `docs/conventions/storage.md`: колонка витрины, производная от бинаря, входит в отпечаток только вместе со счётчиком отчёта `reindex`, различающим её расхождение (следствие блокеров 1 и 3 — при любом исходе развилки).
|
||||
4. `docs/conventions/storage.md`: значение из заголовка запроса, попадающее в ключ витрины, свёрнуто по регистру и имеет объявленное значение для «заголовка не было» (следствие блокера 2 — при исходе (б)).
|
||||
5. Правило учёта задач (`av-dev-pm`): приёмочный критерий не вешается на оракул, которого ещё нет; прецедентов уже два — снятый критерий `/stats` и пункт 4 этого отчёта.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Запущено (профиль `design`, последовательный режим):** `review-specs` (дизайн до кода), `review-rubric` (фаза 1), `review-architecture` (на предложении). Состав совпадает с профилем, расхождений нет.
|
||||
|
||||
**Не запускалось и почему:**
|
||||
- `review-gate` — кода нет, гейт на профиле design не гоняется; состояние гейта не измерено.
|
||||
- `review-code`, `review-adversary`, `review-ops`, `review-reimpl` — вне состава профиля design. **Заметка вперёд:** на чекпоинте кода это изменение попадает под триггеры `deep` (миграция схемы, `internal/store`, `internal/fold`) и под триггер `reimpl` («новое правило идентичности/разбора» — ключ и upsert реестра, извлечение категориальных значений); плюс перед реализацией обязательны `task verify:archive` и `task verify:busy` руками человека или оркестратора (CLAUDE.md, «Гейт») — они уже стоят шагами 6.2–6.3 плана.
|
||||
|
||||
**Что запущенные проходы не могли проверить в принципе (из charter'ов):** specs — форму решения, эксплуатацию сверх спеки, правильность самой постановки; rubric — рантайм, межмодульные связи, ничего не запускал; architecture — внутренности будущей реализации, производительность, соответствие кода спеке. Общее для профиля: реализации нет, ни один тест не падал и не мог упасть — все оракулы здесь документные, кодовые или логические.
|
||||
|
||||
**Не влезло в потолок 7 (доказано, но minor; ничего не выброшено молча):**
|
||||
- тренировка без `name` даёт наблюдение с пустой строкой — `softString` в `internal/hae/entity.go:49-73` глушит тип; записать в дельту «пустая строка категориальным значением не считается» (specs);
|
||||
- три формулировки одного ключа: разбор «поле + строка» (`specs/parsing/spec.md:31-33`), реестр — четвёрка, план 2.4 — пятёрка; привести к одному (specs);
|
||||
- утверждения тестов 6.2 не защищены от чисел, растущих с корпусом, — добавить в 6.2 строку «утверждаются свойства, числа — в `t.Logf`» (rubric; правило уже записано журналом 2026-08-02);
|
||||
- происхождение словаря (только код бинаря, не миграция и не ручная таблица) не нормировано ни одним требованием — одна строка требования в дельте storage; заодно `tasks.md` 3.5 «чтение реестра» спекой не заказано (specs).
|
||||
- Вкусовщины среди выброшенного нет; ни одна находка не попала в «Типовые ложноположительные» `docs/review.md` (проверено всеми тремя проходами и триажем; ближайший кандидат — нормализация локали — не трогает дословность данных Apple: локаль — заголовок, не тело).
|
||||
|
||||
**Целиком на человеке (docs/review.md, «Недоступно проверке» — два списка раздельно):**
|
||||
- *Не проверит ни один проход:* реальный профиль нагрузки (телефон шлёт молча); поведение HAE за пределами наблюдённого; полнота словаря переводов после обновления iOS — прямо задевает этот change: словарь фаз сна выведен по одной локали `ru` и одной версии iOS; секции, которых поток не приносил (`symptoms`, `ecg`, `heartRateNotifications`, `cycleTracking`, `medications`); поведение внешних систем в их версиях; завязка будущих потребителей (Read API, MCP) на текущее поведение; вопрос «нужна ли функциональность вообще» — постановка принята как данность, кроме пункта 4.
|
||||
- *Перестали проверять сознательно:* `task verify:archive` и `task verify:busy` вне гейта (гоняет человек/оркестратор — для этой задачи обязательны, см. выше); класс «в Go так не пишут» — не покрыт никем после упразднения `idiom` (для этого change станет актуален на чекпоинте кода); класс «чего нет в зрелой реализации такого узла» — вне профиля design (реестры наблюдённых значений в зрелых системах несут пути обслуживания — экспорт, чистку, — здесь это никем не оценивалось).
|
||||
|
||||
**Документы проекта:** дефицита нет — все входы на месте и прочитаны: инварианты CLAUDE.md (severity находок взят из них, не выведен заново), `docs/review.md` (типовые ложноположительные — раздел есть и применён; журнал — прецеденты 2026-08-01, 2026-08-02 ×2, `f8200f7` использованы как оракулы), `docs/research/apple-health.md` (находки 24, 32, 37, 43 сверены), `docs/database.md`, `docs/conventions/{storage,testing}.md`, `docs/architecture.md`. Единственная оговорка: rubric не открывал `docs/security.md` и `docs/passport.md` (бюджет) — периметр безопасности на этом прогоне сверен только проходом specs через требование «значения не в логи».
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Категориальные значения получают стабильный код
|
||||
|
||||
Разбор SHALL выделять из тела **категориальные значения** — строки объявленных
|
||||
полей, у которых значение перечислимо, — и выводить для каждого стабильный код
|
||||
HealthKit по словарю `(локаль, строка) → код`.
|
||||
|
||||
Объявленных полей три, и все три измерены на живом потоке (находка 37):
|
||||
|
||||
```
|
||||
sleep_analysis value «БДГ», «Основная», «Глубокий», «Во сне», «В кровати», «Бодрствование»
|
||||
heart_rate context «Не задано», «Сидячий образ жизни», «Активен»
|
||||
workouts name «В помещении Ходьба», «На улице Ходьба»
|
||||
```
|
||||
|
||||
Список полей SHALL быть объявлен явно, а не выведен из формы значения. Строк в
|
||||
точке много (`date`, `start`, `source`), и «всякая строка категориальна» завело
|
||||
бы в реестр метки времени и имена устройств.
|
||||
|
||||
Именем в наблюдении SHALL быть **то же** имя метрики или секции, которым
|
||||
адресуется единица хранения: `sleep_analysis_summary` после разделения схем,
|
||||
`workouts` для тренировок. Второе имя для того же понятия развело бы наблюдение
|
||||
и объект по разным ключам.
|
||||
|
||||
Исходная строка MUST оставаться в содержимом точки или сущности **дословно и
|
||||
нетронутой**: код добавляется рядом, отдельной единицей, и обратное
|
||||
преобразование остаётся возможным всегда. Содержимое точки MUST NOT
|
||||
пересериализовываться ради кода.
|
||||
|
||||
Значение SHALL читаться из исходных байтов и приниматься только как непустая
|
||||
JSON-строка. Значение другого рода (число, объект, `null`), отсутствие поля и
|
||||
пустая строка категориальным значением не считаются; точка при этом MUST
|
||||
разбираться как обычно — новых путей потери точки требование не заводит. Пустая
|
||||
строка исключена намеренно: сказать о данных ей нечего, а в счётчике строк без
|
||||
кода она сидела бы вечно.
|
||||
|
||||
Результат разбора SHALL нести множество различных наблюдённых категориальных
|
||||
значений — по одному элементу на тройку `(метрика или секция, поле, строка)`, в
|
||||
детерминированном порядке. Повтор одной строки в тысяче точек даёт один элемент.
|
||||
Ключ наблюдения совпадает с ключом реестра: два разных ключа на одно понятие
|
||||
разошлись бы при первом же поле с одинаковым именем у двух метрик.
|
||||
|
||||
#### Scenario: Фаза сна из реального пакета получает код
|
||||
|
||||
- **WHEN** разбирается пакет HAE из `testdata`, где `sleep_analysis.value`
|
||||
равно «Во сне», а локаль доставки — `ru`
|
||||
- **THEN** содержимое точки в результате разбора совпадает с исходными байтами
|
||||
тела, включая строку «Во сне»
|
||||
- **AND** результат разбора несёт наблюдение
|
||||
`sleep_analysis / value / «Во сне»` с кодом
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
|
||||
#### Scenario: Имя тренировки попадает в наблюдённые значения
|
||||
|
||||
- **WHEN** разбирается пакет с тренировкой «В помещении Ходьба»
|
||||
- **THEN** результат разбора несёт наблюдение `workouts / name / «В помещении
|
||||
Ходьба»`
|
||||
- **AND** содержимое тренировки не изменено
|
||||
|
||||
#### Scenario: Повтор строки не задваивает наблюдение
|
||||
|
||||
- **WHEN** в доставке тысяча точек `heart_rate` с одинаковым `context`
|
||||
- **THEN** в результате разбора это одно наблюдение
|
||||
|
||||
#### Scenario: Значение не-строка точку не роняет
|
||||
|
||||
- **WHEN** у точки `sleep_analysis` поле `value` пришло числом
|
||||
- **THEN** точка разобрана как обычно и её содержимое сохранено дословно
|
||||
- **AND** наблюдения из неё не выводится
|
||||
|
||||
#### Scenario: Тренировка без имени наблюдения не даёт
|
||||
|
||||
- **WHEN** у тренировки нет поля `name` либо оно пришло пустой строкой
|
||||
- **THEN** наблюдения из неё не выводится
|
||||
- **AND** счётчик значений без кода не растёт
|
||||
|
||||
### Requirement: Незнакомая строка даёт пустой код и попадает в счётчик
|
||||
|
||||
Разбор MUST NOT угадывать код: строка, которой словарь не знает, SHALL получать
|
||||
**пустой** код. Пустота честнее догадки — по коду сверяются с экспортом Apple, и
|
||||
неверный код неотличим от верного до тех пор, пока по нему не примут решение.
|
||||
|
||||
Разбор SHALL считать различные наблюдения, для которых код не выведен, и
|
||||
отдавать счётчик вызывающему. Считаются **различные** наблюдения, а не их
|
||||
вхождения: вопрос, на который счётчик отвечает, — «сколько строк ждёт словаря»,
|
||||
а не «сколько точек их несло».
|
||||
|
||||
Счётчик ненулевой в установившемся режиме — словарь покрывает только фазы сна, а
|
||||
контекст пульса и имена тренировок объявлены категориальными заранее. Сигналом
|
||||
«появилось новое» служит поэтому **новая строка в реестре**, а не ненулевой
|
||||
счётчик; требование называет это, чтобы счётчик не читали как тревогу.
|
||||
|
||||
Незнакомая строка MUST NOT влиять на исход разбора: доставка разбирается,
|
||||
точки сохраняются, статус не меняется.
|
||||
|
||||
#### Scenario: Выдуманная фаза сна не роняет разбор
|
||||
|
||||
- **WHEN** в теле встречается `sleep_analysis.value` со строкой, которой в
|
||||
словаре нет
|
||||
- **THEN** разбор завершается без ошибки и точка сохраняется дословно
|
||||
- **AND** её наблюдение имеет пустой код
|
||||
- **AND** счётчик значений без кода равен единице
|
||||
|
||||
#### Scenario: Известная и неизвестная строки в одной доставке
|
||||
|
||||
- **WHEN** доставка несёт и «Во сне», и незнакомую строку в том же поле
|
||||
- **THEN** у первой код выведен, у второй пуст
|
||||
- **AND** счётчик значений без кода равен единице
|
||||
|
||||
### Requirement: Локаль сужает поиск, но её отсутствие не отменяет вывода
|
||||
|
||||
Локаль доставки SHALL браться из заголовка `Accept-Language` и нормализоваться
|
||||
по правилу lookup RFC 4647: берётся первый тег списка, подтеги отсекаются,
|
||||
регистр свёрнут (`RU-ru,ru;q=0.9` → `ru`). Свёртка регистра обязательна: теги
|
||||
BCP 47 регистронезависимы, и без неё `RU` и `ru` были бы разными языками.
|
||||
|
||||
Вывод кода SHALL идти тремя разрядами:
|
||||
|
||||
1. пара `(локаль, строка)` есть в словаре — её код;
|
||||
2. локали нет либо пары нет, но строка известна в других локалях и **все** они
|
||||
дают один код — этот код;
|
||||
3. иначе — пустой код.
|
||||
|
||||
Второй разряд обязателен, а не удобен. Заголовки запроса в сыром архиве не
|
||||
лежат: они были заголовками запроса, а не телом. Доставка, восстановленная из
|
||||
осиротевшего тела, приезжает без `Accept-Language`, и правило «нет локали — нет
|
||||
кода» сделало бы состояние функцией от того, уцелела ли строка учёта, — то есть
|
||||
сломало бы `import + replay`.
|
||||
|
||||
Локаль MUST NOT входить в ключ наблюдения. Ключ, содержащий локаль, оставляет ту
|
||||
же зависимость этажом ниже: усыновлённая доставка положила бы вторую строку с
|
||||
пустой локалью, и раздел реестра в отпечатке разошёлся бы вне всякого названного
|
||||
класса. На каком языке приехала строка, восстанавливается по доставке
|
||||
провенанса — тем же приёмом, каким устроен провенанс часового объекта.
|
||||
|
||||
Расхождение локалей MUST разрешаться пустым кодом, а не выбором: если одна и та
|
||||
же строка в разных локалях означает разные коды, выбирать не из чего.
|
||||
|
||||
#### Scenario: Локаль с подтегом, весами и в верхнем регистре приводится к базовому тегу
|
||||
|
||||
- **WHEN** доставка пришла с `Accept-Language: RU-ru,ru;q=0.9,en;q=0.8`
|
||||
- **THEN** локалью доставки считается `ru`
|
||||
- **AND** фаза сна «Во сне» получает свой код
|
||||
|
||||
#### Scenario: Доставка без заголовка локали код всё равно получает
|
||||
|
||||
- **WHEN** у доставки заголовка `Accept-Language` нет вовсе
|
||||
- **THEN** «Во сне» получает тот же код, что и при локали `ru`
|
||||
- **AND** наблюдение имеет тот же ключ, что и при локали `ru`
|
||||
|
||||
#### Scenario: Незнакомая локаль знакомой строки код не отменяет
|
||||
|
||||
- **WHEN** доставка пришла с локалью, которой в словаре нет, а строка известна в
|
||||
единственной локали
|
||||
- **THEN** код выведен
|
||||
|
||||
#### Scenario: Мусор в заголовке разбор не роняет
|
||||
|
||||
- **WHEN** заголовок пуст, состоит из `*`, из пробелов или из сотни тегов
|
||||
- **THEN** разбор завершается без паники, локаль либо выведена, либо пуста
|
||||
- **AND** коды выводятся вторым разрядом
|
||||
|
||||
### Requirement: Переименование кода самой Apple не раскалывает историю
|
||||
|
||||
Система SHALL держать таблицу синонимов кодов HealthKit — соответствие
|
||||
устаревшего имени каноническому — и приводить к каноническому имени **всякий**
|
||||
выведенный код, а не только код, пришедший из экспорта Apple.
|
||||
|
||||
Основание измерено (находка 43): те же 338 записей сна экспортированы как
|
||||
`HKCategoryValueSleepAnalysisAsleep` в 2021 году и как
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified` в 2026-м — Apple переименовала
|
||||
значение и переписывает историю при выгрузке. Код устойчивее локализованной
|
||||
строки, но не абсолютен.
|
||||
|
||||
Приведение SHALL применяться и к словарю, поэтому «две формы сходятся в один
|
||||
код» верно по построению. Таблица SHALL быть **плоской**: ни одно её значение не
|
||||
является ключом, поэтому алиас разрешается ровно за один шаг. Инвариант
|
||||
проверяется тестом по таблице целиком; рантайм-обхода цепочек и ограничения
|
||||
глубины у системы быть MUST NOT — у них нет ни одного достижимого сценария, зато
|
||||
есть собственный вырожденный случай.
|
||||
|
||||
#### Scenario: Устаревшее и новое имя дают один код
|
||||
|
||||
- **WHEN** канонизируются `HKCategoryValueSleepAnalysisAsleep` и
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
- **THEN** обе формы дают `HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
|
||||
#### Scenario: Код без синонима остаётся собой
|
||||
|
||||
- **WHEN** канонизируется `HKCategoryValueSleepAnalysisAsleepREM`
|
||||
- **THEN** результат равен исходному коду
|
||||
|
||||
#### Scenario: Таблица синонимов плоская
|
||||
|
||||
- **WHEN** проверяется таблица синонимов целиком
|
||||
- **THEN** ни одно её значение не встречается среди её ключей
|
||||
|
||||
### Requirement: Границы наблюдённых категориальных значений
|
||||
|
||||
Число различных наблюдений одной доставки MUST быть ограничено **64**, а длина
|
||||
значения — **128 байт**; отброшенное SHALL считаться. Тело контролирует
|
||||
отправитель целиком: без границы одна доставка кладёт в витрину сколько угодно
|
||||
строк.
|
||||
|
||||
Граница числа SHALL применяться **при накоплении, а не при выдаче**. Накопитель
|
||||
без границы растёт по числу различных строк тела, а их контролирует
|
||||
отправитель: измерено — тело в 60 МиБ из миллиона различных значений (предел
|
||||
приёма 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита памяти у
|
||||
контейнера нет, отказ по памяти в фоновой горутине свёртки не перехватывается,
|
||||
а перезапуск берёт ту же доставку из архива: приём стоит, телефон доставку не
|
||||
перешлёт.
|
||||
|
||||
Счётчик отброшенного считает **вхождения**, а не различные значения, и единица
|
||||
MUST быть названа. Различные здесь не считаются не по недосмотру: отброшенный
|
||||
ключ нигде не запоминается, а запомнить его значило бы вернуть тот самый
|
||||
неограниченный рост. Счётчик отвечает на «сколько раз сработала граница»; на
|
||||
«какие строки ждут словаря» отвечает реестр.
|
||||
|
||||
Числа названы измерением, а не аналогией: на живом потоке различных значений по
|
||||
всем трём полям около одиннадцати, самое длинное — «Сидячий образ жизни»,
|
||||
36 байт UTF-8. Предел в 32 байта, взятый по аналогии с именами непокрытых
|
||||
секций, отбросил бы две из трёх измеренных строк контекста пульса: там имена
|
||||
короткие и латинские, здесь — русские фразы.
|
||||
|
||||
Значение длиннее предела SHALL **отбрасываться со счётчиком, а не обрезаться**.
|
||||
Обрезанная строка неотличима от настоящей и попала бы в реестр самостоятельным
|
||||
значением; настоящая строка при этом остаётся в точке целиком — теряется
|
||||
наблюдение, а не данные.
|
||||
|
||||
При переполнении границы числа уцелевший набор SHALL быть **функцией множества
|
||||
наблюдений, а не порядка элементов на проводе**: наблюдения упорядочиваются по
|
||||
ключу и берутся первые. Иначе то же содержимое, переприсланное в другом порядке
|
||||
ключей (порядок у HAE нестабилен, находка 2), давало бы другой реестр.
|
||||
|
||||
#### Scenario: Слишком много различных строк
|
||||
|
||||
- **WHEN** доставка несёт различных наблюдений больше предела
|
||||
- **THEN** в результате разбора их ровно предел
|
||||
- **AND** счётчик отброшенных наблюдений положителен
|
||||
- **AND** все точки доставки сохранены
|
||||
|
||||
#### Scenario: Уцелевший набор не зависит от порядка точек в теле
|
||||
|
||||
- **WHEN** два тела несут одно и то же множество категориальных строк в разном
|
||||
порядке, и обоим не хватает предела
|
||||
- **THEN** уцелевшие наблюдения у них совпадают
|
||||
|
||||
#### Scenario: Непомерно длинное значение отброшено целиком
|
||||
|
||||
- **WHEN** `sleep_analysis.value` длиннее предела длины
|
||||
- **THEN** наблюдения из него не выводится, счётчик отброшенных положителен
|
||||
- **AND** содержимое точки сохранено дословно и целиком
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Отчёт, оракул и исход команды
|
||||
|
||||
Система SHALL завершать пересборку отчётом, который несёт счётчики
|
||||
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
|
||||
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
|
||||
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
|
||||
или нет.
|
||||
|
||||
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
|
||||
часовые объекты, тренировки, записи и строки реестра категориальных значений.
|
||||
Отпечаток отвечает «да/нет» за витрину целиком, поэтому единица, которой нет в
|
||||
счётчиках, делает расхождение безадресным: человек увидит «не совпало» при
|
||||
неизменившемся числе объектов и не отличит появление двадцати семи тренировок от
|
||||
пропажи двух. Перечень пополняется **тем же изменением**, которое заводит
|
||||
единицу хранения: список закрытый, и единица, не внесённая в него, молчит ровно
|
||||
там, где расхождение впервые становится заметным.
|
||||
|
||||
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
|
||||
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
|
||||
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
|
||||
дефект там, где его нет. Отдельно называть человеку следует только нештатные
|
||||
отказы.
|
||||
|
||||
Отложенная доставка (занятость базы, отмена работы снаружи) SHALL считаться
|
||||
нештатной **для пересборки**, хотя для фоновой свёртки она штатна: пересборка
|
||||
идёт в свежий файл при единственном писателе, и такая доставка в собранной
|
||||
витрине просто отсутствует — вместе с теми, кто наследовал от неё слой. Классы
|
||||
при этом общие с фоновой свёрткой: второй классификатор разошёлся бы с первым
|
||||
молча.
|
||||
|
||||
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
|
||||
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
|
||||
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
|
||||
поймала прошлый дефект наследования слоя.
|
||||
|
||||
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
|
||||
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
|
||||
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
|
||||
сломанном правиле.
|
||||
|
||||
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
|
||||
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
|
||||
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
|
||||
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
|
||||
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
|
||||
|
||||
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
|
||||
пересобранной витрины и число доставок после прогона не измеряются вовсе —
|
||||
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
|
||||
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
|
||||
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
|
||||
разбором новая секция**, **появившаяся единица хранения**) SHALL называться
|
||||
отдельно от самого факта расхождения.
|
||||
|
||||
Класс «покрыта новая секция» назван потому, что первый прогон после такого
|
||||
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
|
||||
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
|
||||
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
|
||||
оракул ровно тогда, когда по нему принимается необратимое решение. Класс
|
||||
«появилась единица хранения» — та же причина в общем виде: реестр категориальных
|
||||
значений пуст у витрины, свёрнутой прежним бинарём, и первая сверка после
|
||||
выкатки расходится по нему одному, при неизменившихся объектах и сущностях.
|
||||
|
||||
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
|
||||
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
|
||||
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
|
||||
выводится, — штатный исход.
|
||||
|
||||
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
|
||||
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
|
||||
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
|
||||
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
|
||||
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
|
||||
выполнивший напечатанную процедуру, заменил бы витрину пустой.
|
||||
|
||||
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
|
||||
MUST NOT: отпечаток берёт содержимое хешем. Строки реестра при этом счётом, а не
|
||||
перечислением: наблюдённое значение — данные о здоровье наравне со значением
|
||||
точки. Ограничение относится к отчёту в
|
||||
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
|
||||
столкновения (метрика, слой, час) разрешены явно.
|
||||
|
||||
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
|
||||
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
|
||||
молчит минутами, и зависший неотличим от идущего.
|
||||
|
||||
#### Scenario: Отчёт сравнивает отпечатки
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
|
||||
- **AND** прямо называет, совпали они или нет
|
||||
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
|
||||
|
||||
#### Scenario: Счётчики покрывают все единицы хранения
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
|
||||
тренировок, записей и строк реестра категориальных значений
|
||||
|
||||
#### Scenario: Пустой реестр рабочей витрины назван ожидаемым классом
|
||||
|
||||
- **WHEN** рабочая витрина свёрнута прежним бинарём и строк реестра не имеет, а
|
||||
пересобранная их получила
|
||||
- **THEN** отчёт называет «появилась единица хранения» ожидаемым классом
|
||||
расхождения
|
||||
- **AND** печатает число строк реестра «до и после»
|
||||
|
||||
#### Scenario: Расхождение отпечатков не является отказом
|
||||
|
||||
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
|
||||
хотя бы одна доставка свёрнута
|
||||
- **THEN** команда завершается успешно, а расхождение названо в отчёте
|
||||
|
||||
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
|
||||
|
||||
- **WHEN** в архиве не нашлось ни одного тела
|
||||
- **THEN** команда завершается ненулевым кодом
|
||||
- **AND** процедуры подмены не печатает
|
||||
|
||||
#### Scenario: Ни одна доставка не свернулась
|
||||
|
||||
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
|
||||
- **THEN** команда завершается ненулевым кодом
|
||||
- **AND** процедуры подмены не печатает
|
||||
|
||||
#### Scenario: Приезд доставок за время прогона отменяет подмену
|
||||
|
||||
- **WHEN** число доставок в рабочей базе за время прогона изменилось
|
||||
- **THEN** отчёт называет разницу
|
||||
- **AND** процедуры подмены не печатает
|
||||
|
||||
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
|
||||
|
||||
- **WHEN** прогон отменён
|
||||
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
|
||||
числа доставок
|
||||
|
||||
#### Scenario: Рабочей базы нет вовсе
|
||||
|
||||
- **WHEN** файла рабочей базы не существует
|
||||
- **THEN** пересборка идёт по одним подобранным телам
|
||||
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
|
||||
восстанавливаются
|
||||
|
||||
#### Scenario: Отчёт не раскрывает данных о здоровье
|
||||
|
||||
- **WHEN** отчёт напечатан
|
||||
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств,
|
||||
ни наблюдённых категориальных строк
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Реестр наблюдённых категориальных значений
|
||||
|
||||
Хранилище SHALL держать реестр категориальных значений, которые приносил поток:
|
||||
одна строка на тройку `(метрика или секция, поле, значение)`, с выведенным кодом
|
||||
HealthKit рядом. Значение хранится **дословно**, тем же текстом, каким пришло.
|
||||
|
||||
Локаль в ключ MUST NOT входить. Она живёт в заголовке доставки, которого нет в
|
||||
сыром архиве, — ключ с локалью сделал бы состояние функцией от того, уцелела ли
|
||||
строка учёта: усыновлённая доставка положила бы вторую строку с пустой локалью.
|
||||
Язык, на котором приехала строка, восстанавливается по доставке провенанса.
|
||||
|
||||
Реестр SHALL нести выведенный код и провенанс **первой** встречи — метку приёма
|
||||
и идентификатор доставки, в которой строка появилась впервые по порядку журнала.
|
||||
Первая встреча отвечает на вопрос «когда сменился язык телефона»; счётчик встреч
|
||||
не заводится вовсе — он не идемпотентен при повторной свёртке той же доставки, а
|
||||
значит сделал бы состояние зависящим от числа прогонов.
|
||||
|
||||
Пустой код — законное состояние строки: он означает «словарь этой строки не
|
||||
знает», и перечень таких строк есть заявка на пополнение словаря.
|
||||
|
||||
Словарь, по которому выводится код, SHALL жить в бинаре, а не в таблице базы и
|
||||
не в строках миграции. Таблица, наполняемая руками, стала бы входом, которого
|
||||
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно;
|
||||
данные, засеянные миграцией, живут в двух местах сразу и расходятся с бинарём
|
||||
молча после первой же правки словаря.
|
||||
|
||||
Строки реестра SHALL писаться **той же транзакцией**, что и точки доставки.
|
||||
Доставка — единица свёртки; частичное состояние «точки легли, реестр нет»
|
||||
повторная свёртка не чинит: хеш содержимого сойдётся, и объекты переписываться
|
||||
не станут.
|
||||
|
||||
Обслуживания у реестра нет: строки не удаляются. Строка, единственные доставки
|
||||
которой выпали из журнала подрезкой архива, переживёт их в рабочей витрине и не
|
||||
появится в пересобранной. Это тот же класс, что «верхние слои за периоды с
|
||||
удалёнными доставками», и он MUST называться, а не досчитываться.
|
||||
|
||||
#### Scenario: Фаза сна попадает в реестр с кодом
|
||||
|
||||
- **WHEN** свёрнута доставка с фазой сна «Во сне» и локалью `ru`
|
||||
- **THEN** в реестре есть строка `sleep_analysis / value / «Во сне»` с кодом
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
- **AND** содержимое точки в часовом объекте не изменилось
|
||||
|
||||
#### Scenario: Та же строка без заголовка локали даёт ту же строку реестра
|
||||
|
||||
- **WHEN** та же строка приехала доставкой без `Accept-Language`
|
||||
- **THEN** второй строки в реестре не появляется
|
||||
|
||||
#### Scenario: Строка без кода в реестре видна
|
||||
|
||||
- **WHEN** свёрнута доставка с `heart_rate.context`, которого словарь не знает
|
||||
- **THEN** в реестре есть строка с этим значением и пустым кодом
|
||||
|
||||
#### Scenario: Отказ слияния не оставляет строк реестра
|
||||
|
||||
- **WHEN** слияние доставки отказало
|
||||
- **THEN** реестр не содержит значений этой доставки
|
||||
|
||||
### Requirement: Реестр детерминирован по журналу
|
||||
|
||||
Повторная свёртка той же доставки MUST оставлять реестр без изменений, а
|
||||
провенанс первой встречи MUST быть **минимумом** по порядку журнала
|
||||
(`received_at`, затем идентификатор доставки), а не значением последней записи.
|
||||
|
||||
Проигрывание журнала в любом порядке доставок, дающем тот же префикс, SHALL
|
||||
приводить реестр к тому же состоянию. Реестр — свёртка по журналу, как и всё
|
||||
остальное в витрине.
|
||||
|
||||
Выведенный код SHALL обновляться при каждой встрече строки: словарь живёт в
|
||||
бинаре, и пересборка обязана давать код по текущему словарю, а не по тому,
|
||||
который действовал при первой свёртке. Строка, переставшая приезжать, держит код
|
||||
прежнего словаря до пересборки — это осознанная цена материализации, и на
|
||||
сходимость она не влияет, потому что код в отпечаток не входит.
|
||||
|
||||
#### Scenario: Повторная свёртка ничего не меняет
|
||||
|
||||
- **WHEN** одна и та же доставка свёрнута дважды
|
||||
- **THEN** строки реестра и их провенанс совпадают с состоянием после первой
|
||||
свёртки
|
||||
|
||||
#### Scenario: Провенанс — самая ранняя доставка
|
||||
|
||||
- **WHEN** одна строка приехала сперва поздней доставкой, затем ранней
|
||||
- **THEN** провенансом остаётся ранняя по журналу
|
||||
|
||||
#### Scenario: Порядок свёртки на реестр не влияет
|
||||
|
||||
- **WHEN** три доставки свёрнуты во всех шести порядках
|
||||
- **THEN** реестр и его раздел отпечатка совпадают у всех шести прогонов
|
||||
|
||||
#### Scenario: Пересборка даёт тот же реестр
|
||||
|
||||
- **WHEN** журнал проигран в свежую витрину
|
||||
- **THEN** реестр пересобранной витрины совпадает с реестром исходной
|
||||
|
||||
### Requirement: Отпечаток покрывает наблюдение реестра, но не выведенный код
|
||||
|
||||
Отпечаток витрины SHALL покрывать реестр категориальных значений отдельным
|
||||
разделом: ключ и провенанс первой встречи. Иначе недетерминированная запись
|
||||
реестра прошла бы мимо единственного оракула сходимости.
|
||||
|
||||
Выведенный код в отпечаток входить MUST NOT. Ключ и провенанс — функция журнала;
|
||||
код — функция журнала **и версии словаря в бинаре**. Включённый в отпечаток, он
|
||||
заставил бы всякое пополнение словаря давать расхождение при побайтно совпавшем
|
||||
журнале, а пополнение объявлено рабочим циклом. Человек, принимающий по
|
||||
отпечатку необратимое решение о подмене базы, читал бы это как дефект.
|
||||
Правильность вывода кода проверяется тестами словаря — это другой вопрос, и
|
||||
смешение обесценило бы оракул.
|
||||
|
||||
Раздел SHALL быть отличим от прочих признаком впереди строки, а поля переменной
|
||||
длины SHALL идти с длиной впереди: два разных состояния витрины не имеют права
|
||||
дать один отпечаток.
|
||||
|
||||
Цена названа вслух: у витрины, свёрнутой до этого изменения, реестр пуст, и
|
||||
первая же сверка отпечатков после выкатки покажет расхождение. Это законное
|
||||
расхождение, а не дефект; отчёт пересборки обязан назвать его ожидаемым классом
|
||||
и напечатать счётчик строк реестра.
|
||||
|
||||
#### Scenario: Разошедшееся наблюдение меняет отпечаток
|
||||
|
||||
- **WHEN** у двух витрин совпадают объекты и сущности, но в реестре одной есть
|
||||
строка, которой нет в другой
|
||||
- **THEN** отпечатки различны
|
||||
|
||||
#### Scenario: Расхождение только по коду отпечаток не двигает
|
||||
|
||||
- **WHEN** две витрины несут те же строки реестра с тем же провенансом, но
|
||||
разными кодами
|
||||
- **THEN** отпечатки совпадают
|
||||
|
||||
#### Scenario: Значение с разделителем границу поля не подделывает
|
||||
|
||||
- **WHEN** значение содержит признак раздела, цифры и нулевой байт
|
||||
- **THEN** отпечаток отличается от отпечатка витрины с другим разбиением тех же
|
||||
байтов по полям
|
||||
|
||||
#### Scenario: Пустой реестр отпечаток не ломает
|
||||
|
||||
- **WHEN** в витрине нет ни одной строки реестра
|
||||
- **THEN** отпечаток считается и совпадает с отпечатком такой же витрины без
|
||||
реестра
|
||||
|
||||
### Requirement: Значения категориальных строк не попадают в логи
|
||||
|
||||
Сами наблюдённые строки и выведенные коды MUST NOT попадать в записи лога выше
|
||||
`DEBUG`; в журнал SHALL уходить только счётчики — сколько значений наблюдалось и
|
||||
сколько осталось без кода. Основание: строки категориальных значений — данные о
|
||||
здоровье, контекст пульса и фаза сна описывают человека не меньше, чем число.
|
||||
|
||||
Проверка отсутствия строки в логе SHALL разбирать запись и сравнивать значения
|
||||
полей, а не искать подстроку в сыром буфере: служебная метка времени содержит
|
||||
произвольные цифры, и поиск по буферу делает тест флаки по построению.
|
||||
|
||||
#### Scenario: Свёртка доставки со сном не пишет строк в лог
|
||||
|
||||
- **WHEN** свёрнута доставка с фазами сна и контекстом пульса
|
||||
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
|
||||
одного кода
|
||||
- **AND** счётчики значений без кода в записи присутствуют
|
||||
@@ -0,0 +1,145 @@
|
||||
# Задачи
|
||||
|
||||
## 1. Словарь HealthKit — `internal/healthkit`
|
||||
|
||||
- [x] 1.1 Новый пакет без внутренних зависимостей: словарь `(локаль, строка) → код`
|
||||
с выведенными фазами сна (находка 43) и **плоская** таблица синонимов кодов
|
||||
(`…Asleep` → `…AsleepUnspecified`).
|
||||
- [x] 1.2 `Canonical(код)` — одно чтение карты, без обхода цепочек и ограничения
|
||||
глубины: плоскость таблицы держит тест, а не рантайм.
|
||||
- [x] 1.3 `Code(локаль, строка)` — три разряда: точная пара, однозначность по
|
||||
всем локалям, пустой код.
|
||||
- [x] 1.4 Нормализация локали по lookup RFC 4647: первый тег, подтеги отсечены,
|
||||
регистр свёрнут.
|
||||
- [x] 1.5 Тесты: обе формы синонима сходятся; таблица плоская (ни одно значение
|
||||
не является ключом); неизвестная строка — пустой код; табличный тест
|
||||
нормализации (`ru`, `RU`, `ru-RU`, `RU-ru,ru;q=0.9`, ` ru `, `*`, пусто,
|
||||
мусор, сто тегов).
|
||||
|
||||
## 2. Разбор — `internal/hae`
|
||||
|
||||
- [x] 2.1 `Meta.Locale`; объявленный список категориальных полей
|
||||
(`sleep_analysis.value`, `heart_rate.context`, `workouts.name`).
|
||||
- [x] 2.2 Извлечение значений тем же разбором заголовка точки; поля —
|
||||
`json.RawMessage`, принимается только непустая JSON-строка.
|
||||
- [x] 2.3 Имя тренировки — из уже разобранного заголовка сущности. `softString`
|
||||
превращает значение не того типа в `""`, а пустая строка наблюдением не
|
||||
считается: «имени не было» и «имя приехало числом» дают один исход, и он
|
||||
верный. Второго разбора мегабайтного маршрута не заводим.
|
||||
- [x] 2.4 `Result.Categoricals` — детерминированное множество наблюдений
|
||||
`(метрика, поле, строка, код)`, ключ тот же, что у реестра; счётчики
|
||||
`CategoricalUnknown` и `CategoricalDropped`.
|
||||
- [x] 2.5 Границы: `maxCategoricalValues = 64`, `maxCategoricalValueLen = 128`;
|
||||
длинное отбрасывается, не обрезается; уцелевший набор — функция множества
|
||||
(сортировка по ключу, затем усечение).
|
||||
- [x] 2.6 Тесты на реальных пакетах `testdata` (`sparse_sleep.json`,
|
||||
`handmade_edge.json`, `workout_route.json`, `raw.json`): строка берётся
|
||||
**из пакета**, а не из литерала теста; побайтное равенство `Point.Raw`
|
||||
фрагменту тела; обе границы; порядок точек на результат не влияет.
|
||||
|
||||
## 3. Хранилище — `internal/store`
|
||||
|
||||
- [x] 3.1 Миграция `00010_category_value.sql` — таблица `category_value`,
|
||||
ключ `(metric, field, value)`, `WITHOUT ROWID`, с обоснованием в
|
||||
комментарии.
|
||||
- [x] 3.2 Upsert внутри транзакции `Merge`: код обновляется всегда, провенанс —
|
||||
минимум по `(received_at, id)`.
|
||||
- [x] 3.3 `DeliveryForParse` отдаёт заголовки доставки.
|
||||
- [x] 3.4 Раздел реестра в `Fingerprint` (признак `c`, поля с длиной впереди);
|
||||
**`code` в раздел не входит**.
|
||||
- [x] 3.5 Чтение реестра и его счёт — для отчёта пересборки и тестов.
|
||||
- [x] 3.6 Тесты: идемпотентность повторной свёртки, минимум провенанса, шесть
|
||||
порядков трёх доставок дают один реестр, влияние наблюдения на отпечаток,
|
||||
независимость отпечатка от кода, пустой реестр, значение с разделителем и
|
||||
нулевым байтом.
|
||||
|
||||
## 4. Свёртка — `internal/fold`
|
||||
|
||||
- [x] 4.1 Локаль из `Accept-Language` заголовков доставки.
|
||||
- [x] 4.2 Счётчики в `Stats` и в единственном логирующем чекпоинте; строк и
|
||||
кодов в логе нет.
|
||||
- [x] 4.3 Тест на отсутствие строк и кодов в логе — разбор записи, а не поиск в
|
||||
сыром буфере.
|
||||
|
||||
## 5. Пересборка — `cmd/healthlog`
|
||||
|
||||
- [x] 5.1 Счётчик строк реестра «до и после» в отчёте `reindex`.
|
||||
- [x] 5.2 Ожидаемый класс расхождения «появилась единица хранения».
|
||||
- [x] 5.3 Тест отчёта: пустой реестр рабочей витрины против непустого
|
||||
пересобранной.
|
||||
|
||||
## 6. Документы
|
||||
|
||||
- [x] 6.1 `docs/database.md` — таблица `category_value` (без правки гейт красный).
|
||||
- [x] 6.2 `docs/architecture.md` — раздел «Категориальные значения» приводится в
|
||||
соответствие с принятой формой хранения, тем же коммитом, что и миграция.
|
||||
|
||||
## 7. Оракулы
|
||||
|
||||
- [x] 7.1 `task gate` зелёный.
|
||||
- [x] 7.2 `task verify:archive` на живом архиве основного репозитория
|
||||
(`ARCHIVE=…/data/raw`) — правило разбора изменено. Новые утверждения
|
||||
проверяют **свойства**, а не числа, производные от размера корпуса
|
||||
(число строк реестра и счётчик строк без кода растут с потоком); числа
|
||||
печатаются `t.Logf`.
|
||||
- [x] 7.3 `task verify:busy` — свёртка трогает транзакцию слияния.
|
||||
|
||||
## Приёмочные критерии
|
||||
|
||||
### Из постановки задачи
|
||||
|
||||
- [x] фаза сна из потока («БДГ») и из экспорта Apple
|
||||
(`HKCategoryValueSleepAnalysisAsleepREM`) за один период сопоставляются
|
||||
напрямую — оракул: запрос на живой базе за период с известным перекрытием,
|
||||
ноль несопоставимых строк.
|
||||
**Оракул недостижим в рамках этой задачи:** импорт родного экспорта Apple
|
||||
объявлен Non-Goal, кодов экспорта в живой базе нет и не появится. Снимается
|
||||
настолько, насколько возможно: все шесть измеренных строк фаз сна
|
||||
(находка 43), прочитанные из живого архива, дают непустой канонический код,
|
||||
и множество выведенных кодов совпадает с множеством кодов сна из находки 43.
|
||||
Полная сверка принадлежит задаче `apple-export-import`; расхождение названо
|
||||
в отчёте, а не обойдено.
|
||||
- [x] строка сохранена **дословно**, код лежит рядом отдельным полем — оракул:
|
||||
тест разбора на реальном пакете HAE из `internal/hae/testdata`
|
||||
- [x] незнакомая строка даёт пустой код, разбор не падает, а событие попадает в
|
||||
счётчик — оракул: тест на выдуманной фазе сна плюс проверка счётчика
|
||||
- [x] переименование кода самой Apple (`…Asleep` → `…AsleepUnspecified`,
|
||||
находка 43) не раскалывает историю — оракул: тест на паре синонимов, обе
|
||||
формы сходятся в один код
|
||||
- [x] повторный прогон живого архива даёт то же состояние — оракул:
|
||||
`task verify:archive`
|
||||
|
||||
### Из ревью предложения (рубрика профиля `design`)
|
||||
|
||||
- [x] **Дословность и живучесть точки.** `Point.Raw`/`Entity.Raw` побайтно равны
|
||||
фрагменту тела; значение не-строка, отсутствие поля, пустая строка,
|
||||
невалидный UTF-8 — точка разбирается как обычно, наблюдения не выводится.
|
||||
*Оракул:* тесты на четырёх пакетах `testdata` + табличный тест форм.
|
||||
- [x] **Реестр — функция префикса журнала, а не порядка свёртки.** Три доставки
|
||||
во всех шести порядках дают один реестр и один отпечаток; повторная
|
||||
свёртка не меняет ни одной колонки. *Оракул:* тест перестановок + тест
|
||||
повтора.
|
||||
- [x] **Уцелевший при переполнении набор — функция множества.** *Оракул:* два
|
||||
тела с одним множеством строк и разным порядком точек дают один результат.
|
||||
- [x] **Отпечаток инъективен по наблюдению и слеп к коду.** Различие в значении,
|
||||
ключе, числе строк — меняет отпечаток; различие только в коде — не меняет;
|
||||
значение с разделителем и цифрами границу поля не подделывает. *Оракул:*
|
||||
тест с враждебной строкой + тест «пустой реестр».
|
||||
- [x] **Расхождение «появилась единица хранения» отличимо машиной.** *Оракул:*
|
||||
тест отчёта `reindex`: класс назван, счётчик напечатан, прогон не красный.
|
||||
- [x] **Ключ восстановим из того, что переживает доставку.** Локали в ключе нет;
|
||||
доставка без `Accept-Language` даёт ту же строку реестра. *Оракул:* тест
|
||||
без заголовка + `task verify:archive` (архив заголовков не несёт).
|
||||
- [x] **Нормализация локали не расщепляет язык вариантами тега.** *Оракул:*
|
||||
табличный тест на девяти входах, включая `RU` и `*`.
|
||||
- [x] **Словарь опознаёт байты живого потока, а не литерал теста.** *Оракул:*
|
||||
приёмочный тест берёт строку из пакета `testdata`.
|
||||
- [x] **Словарь и синонимы — чистая функция без изменяемого состояния.**
|
||||
*Оракул:* тесты по таблицам целиком + `go test -race` (шаг гейта).
|
||||
- [x] **Границы названы числом, отброшенное считается, рост реестра назван.**
|
||||
*Оракул:* тесты обеих границ + число строк реестра живого архива в отчёте
|
||||
задачи.
|
||||
- [x] **Утверждения на живом корпусе — свойства, не числа от размера корпуса.**
|
||||
*Оракул:* `task verify:archive` дважды подряд + чтение утверждений.
|
||||
- [x] **Ни одна строка и ни один код не выходят выше `DEBUG`.** *Оракул:* тест
|
||||
по разобранной записи лога; `task verify:busy`.
|
||||
@@ -704,3 +704,254 @@ SHALL называть **тип** встреченного токена и см
|
||||
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
|
||||
- **AND** смещение не меняется, если то же значение сделать длиннее
|
||||
|
||||
### Requirement: Категориальные значения получают стабильный код
|
||||
|
||||
Разбор SHALL выделять из тела **категориальные значения** — строки объявленных
|
||||
полей, у которых значение перечислимо, — и выводить для каждого стабильный код
|
||||
HealthKit по словарю `(локаль, строка) → код`.
|
||||
|
||||
Объявленных полей три, и все три измерены на живом потоке (находка 37):
|
||||
|
||||
```
|
||||
sleep_analysis value «БДГ», «Основная», «Глубокий», «Во сне», «В кровати», «Бодрствование»
|
||||
heart_rate context «Не задано», «Сидячий образ жизни», «Активен»
|
||||
workouts name «В помещении Ходьба», «На улице Ходьба»
|
||||
```
|
||||
|
||||
Список полей SHALL быть объявлен явно, а не выведен из формы значения. Строк в
|
||||
точке много (`date`, `start`, `source`), и «всякая строка категориальна» завело
|
||||
бы в реестр метки времени и имена устройств.
|
||||
|
||||
Именем в наблюдении SHALL быть **то же** имя метрики или секции, которым
|
||||
адресуется единица хранения: `sleep_analysis_summary` после разделения схем,
|
||||
`workouts` для тренировок. Второе имя для того же понятия развело бы наблюдение
|
||||
и объект по разным ключам.
|
||||
|
||||
Исходная строка MUST оставаться в содержимом точки или сущности **дословно и
|
||||
нетронутой**: код добавляется рядом, отдельной единицей, и обратное
|
||||
преобразование остаётся возможным всегда. Содержимое точки MUST NOT
|
||||
пересериализовываться ради кода.
|
||||
|
||||
Значение SHALL читаться из исходных байтов и приниматься только как непустая
|
||||
JSON-строка. Значение другого рода (число, объект, `null`), отсутствие поля и
|
||||
пустая строка категориальным значением не считаются; точка при этом MUST
|
||||
разбираться как обычно — новых путей потери точки требование не заводит. Пустая
|
||||
строка исключена намеренно: сказать о данных ей нечего, а в счётчике строк без
|
||||
кода она сидела бы вечно.
|
||||
|
||||
Результат разбора SHALL нести множество различных наблюдённых категориальных
|
||||
значений — по одному элементу на тройку `(метрика или секция, поле, строка)`, в
|
||||
детерминированном порядке. Повтор одной строки в тысяче точек даёт один элемент.
|
||||
Ключ наблюдения совпадает с ключом реестра: два разных ключа на одно понятие
|
||||
разошлись бы при первом же поле с одинаковым именем у двух метрик.
|
||||
|
||||
#### Scenario: Фаза сна из реального пакета получает код
|
||||
|
||||
- **WHEN** разбирается пакет HAE из `testdata`, где `sleep_analysis.value`
|
||||
равно «Во сне», а локаль доставки — `ru`
|
||||
- **THEN** содержимое точки в результате разбора совпадает с исходными байтами
|
||||
тела, включая строку «Во сне»
|
||||
- **AND** результат разбора несёт наблюдение
|
||||
`sleep_analysis / value / «Во сне»` с кодом
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
|
||||
#### Scenario: Имя тренировки попадает в наблюдённые значения
|
||||
|
||||
- **WHEN** разбирается пакет с тренировкой «В помещении Ходьба»
|
||||
- **THEN** результат разбора несёт наблюдение `workouts / name / «В помещении
|
||||
Ходьба»`
|
||||
- **AND** содержимое тренировки не изменено
|
||||
|
||||
#### Scenario: Повтор строки не задваивает наблюдение
|
||||
|
||||
- **WHEN** в доставке тысяча точек `heart_rate` с одинаковым `context`
|
||||
- **THEN** в результате разбора это одно наблюдение
|
||||
|
||||
#### Scenario: Значение не-строка точку не роняет
|
||||
|
||||
- **WHEN** у точки `sleep_analysis` поле `value` пришло числом
|
||||
- **THEN** точка разобрана как обычно и её содержимое сохранено дословно
|
||||
- **AND** наблюдения из неё не выводится
|
||||
|
||||
#### Scenario: Тренировка без имени наблюдения не даёт
|
||||
|
||||
- **WHEN** у тренировки нет поля `name` либо оно пришло пустой строкой
|
||||
- **THEN** наблюдения из неё не выводится
|
||||
- **AND** счётчик значений без кода не растёт
|
||||
|
||||
### Requirement: Незнакомая строка даёт пустой код и попадает в счётчик
|
||||
|
||||
Разбор MUST NOT угадывать код: строка, которой словарь не знает, SHALL получать
|
||||
**пустой** код. Пустота честнее догадки — по коду сверяются с экспортом Apple, и
|
||||
неверный код неотличим от верного до тех пор, пока по нему не примут решение.
|
||||
|
||||
Разбор SHALL считать различные наблюдения, для которых код не выведен, и
|
||||
отдавать счётчик вызывающему. Считаются **различные** наблюдения, а не их
|
||||
вхождения: вопрос, на который счётчик отвечает, — «сколько строк ждёт словаря»,
|
||||
а не «сколько точек их несло».
|
||||
|
||||
Счётчик ненулевой в установившемся режиме — словарь покрывает только фазы сна, а
|
||||
контекст пульса и имена тренировок объявлены категориальными заранее. Сигналом
|
||||
«появилось новое» служит поэтому **новая строка в реестре**, а не ненулевой
|
||||
счётчик; требование называет это, чтобы счётчик не читали как тревогу.
|
||||
|
||||
Незнакомая строка MUST NOT влиять на исход разбора: доставка разбирается,
|
||||
точки сохраняются, статус не меняется.
|
||||
|
||||
#### Scenario: Выдуманная фаза сна не роняет разбор
|
||||
|
||||
- **WHEN** в теле встречается `sleep_analysis.value` со строкой, которой в
|
||||
словаре нет
|
||||
- **THEN** разбор завершается без ошибки и точка сохраняется дословно
|
||||
- **AND** её наблюдение имеет пустой код
|
||||
- **AND** счётчик значений без кода равен единице
|
||||
|
||||
#### Scenario: Известная и неизвестная строки в одной доставке
|
||||
|
||||
- **WHEN** доставка несёт и «Во сне», и незнакомую строку в том же поле
|
||||
- **THEN** у первой код выведен, у второй пуст
|
||||
- **AND** счётчик значений без кода равен единице
|
||||
|
||||
### Requirement: Локаль сужает поиск, но её отсутствие не отменяет вывода
|
||||
|
||||
Локаль доставки SHALL браться из заголовка `Accept-Language` и нормализоваться
|
||||
по правилу lookup RFC 4647: берётся первый тег списка, подтеги отсекаются,
|
||||
регистр свёрнут (`RU-ru,ru;q=0.9` → `ru`). Свёртка регистра обязательна: теги
|
||||
BCP 47 регистронезависимы, и без неё `RU` и `ru` были бы разными языками.
|
||||
|
||||
Вывод кода SHALL идти тремя разрядами:
|
||||
|
||||
1. пара `(локаль, строка)` есть в словаре — её код;
|
||||
2. локали нет либо пары нет, но строка известна в других локалях и **все** они
|
||||
дают один код — этот код;
|
||||
3. иначе — пустой код.
|
||||
|
||||
Второй разряд обязателен, а не удобен. Заголовки запроса в сыром архиве не
|
||||
лежат: они были заголовками запроса, а не телом. Доставка, восстановленная из
|
||||
осиротевшего тела, приезжает без `Accept-Language`, и правило «нет локали — нет
|
||||
кода» сделало бы состояние функцией от того, уцелела ли строка учёта, — то есть
|
||||
сломало бы `import + replay`.
|
||||
|
||||
Локаль MUST NOT входить в ключ наблюдения. Ключ, содержащий локаль, оставляет ту
|
||||
же зависимость этажом ниже: усыновлённая доставка положила бы вторую строку с
|
||||
пустой локалью, и раздел реестра в отпечатке разошёлся бы вне всякого названного
|
||||
класса. На каком языке приехала строка, восстанавливается по доставке
|
||||
провенанса — тем же приёмом, каким устроен провенанс часового объекта.
|
||||
|
||||
Расхождение локалей MUST разрешаться пустым кодом, а не выбором: если одна и та
|
||||
же строка в разных локалях означает разные коды, выбирать не из чего.
|
||||
|
||||
#### Scenario: Локаль с подтегом, весами и в верхнем регистре приводится к базовому тегу
|
||||
|
||||
- **WHEN** доставка пришла с `Accept-Language: RU-ru,ru;q=0.9,en;q=0.8`
|
||||
- **THEN** локалью доставки считается `ru`
|
||||
- **AND** фаза сна «Во сне» получает свой код
|
||||
|
||||
#### Scenario: Доставка без заголовка локали код всё равно получает
|
||||
|
||||
- **WHEN** у доставки заголовка `Accept-Language` нет вовсе
|
||||
- **THEN** «Во сне» получает тот же код, что и при локали `ru`
|
||||
- **AND** наблюдение имеет тот же ключ, что и при локали `ru`
|
||||
|
||||
#### Scenario: Незнакомая локаль знакомой строки код не отменяет
|
||||
|
||||
- **WHEN** доставка пришла с локалью, которой в словаре нет, а строка известна в
|
||||
единственной локали
|
||||
- **THEN** код выведен
|
||||
|
||||
#### Scenario: Мусор в заголовке разбор не роняет
|
||||
|
||||
- **WHEN** заголовок пуст, состоит из `*`, из пробелов или из сотни тегов
|
||||
- **THEN** разбор завершается без паники, локаль либо выведена, либо пуста
|
||||
- **AND** коды выводятся вторым разрядом
|
||||
|
||||
### Requirement: Переименование кода самой Apple не раскалывает историю
|
||||
|
||||
Система SHALL держать таблицу синонимов кодов HealthKit — соответствие
|
||||
устаревшего имени каноническому — и приводить к каноническому имени **всякий**
|
||||
выведенный код, а не только код, пришедший из экспорта Apple.
|
||||
|
||||
Основание измерено (находка 43): те же 338 записей сна экспортированы как
|
||||
`HKCategoryValueSleepAnalysisAsleep` в 2021 году и как
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified` в 2026-м — Apple переименовала
|
||||
значение и переписывает историю при выгрузке. Код устойчивее локализованной
|
||||
строки, но не абсолютен.
|
||||
|
||||
Приведение SHALL применяться и к словарю, поэтому «две формы сходятся в один
|
||||
код» верно по построению. Таблица SHALL быть **плоской**: ни одно её значение не
|
||||
является ключом, поэтому алиас разрешается ровно за один шаг. Инвариант
|
||||
проверяется тестом по таблице целиком; рантайм-обхода цепочек и ограничения
|
||||
глубины у системы быть MUST NOT — у них нет ни одного достижимого сценария, зато
|
||||
есть собственный вырожденный случай.
|
||||
|
||||
#### Scenario: Устаревшее и новое имя дают один код
|
||||
|
||||
- **WHEN** канонизируются `HKCategoryValueSleepAnalysisAsleep` и
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
- **THEN** обе формы дают `HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
|
||||
#### Scenario: Код без синонима остаётся собой
|
||||
|
||||
- **WHEN** канонизируется `HKCategoryValueSleepAnalysisAsleepREM`
|
||||
- **THEN** результат равен исходному коду
|
||||
|
||||
#### Scenario: Таблица синонимов плоская
|
||||
|
||||
- **WHEN** проверяется таблица синонимов целиком
|
||||
- **THEN** ни одно её значение не встречается среди её ключей
|
||||
|
||||
### Requirement: Границы наблюдённых категориальных значений
|
||||
|
||||
Число различных наблюдений одной доставки MUST быть ограничено **64**, а длина
|
||||
значения — **128 байт**; отброшенное SHALL считаться. Тело контролирует
|
||||
отправитель целиком: без границы одна доставка кладёт в витрину сколько угодно
|
||||
строк.
|
||||
|
||||
Граница числа SHALL применяться **при накоплении, а не при выдаче**. Накопитель
|
||||
без границы растёт по числу различных строк тела, а их контролирует
|
||||
отправитель: измерено — тело в 60 МиБ из миллиона различных значений (предел
|
||||
приёма 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита памяти у
|
||||
контейнера нет, отказ по памяти в фоновой горутине свёртки не перехватывается,
|
||||
а перезапуск берёт ту же доставку из архива: приём стоит, телефон доставку не
|
||||
перешлёт.
|
||||
|
||||
Счётчик отброшенного считает **вхождения**, а не различные значения, и единица
|
||||
MUST быть названа. Различные здесь не считаются не по недосмотру: отброшенный
|
||||
ключ нигде не запоминается, а запомнить его значило бы вернуть тот самый
|
||||
неограниченный рост. Счётчик отвечает на «сколько раз сработала граница»; на
|
||||
«какие строки ждут словаря» отвечает реестр.
|
||||
|
||||
Числа названы измерением, а не аналогией: на живом потоке различных значений по
|
||||
всем трём полям около одиннадцати, самое длинное — «Сидячий образ жизни»,
|
||||
36 байт UTF-8. Предел в 32 байта, взятый по аналогии с именами непокрытых
|
||||
секций, отбросил бы две из трёх измеренных строк контекста пульса: там имена
|
||||
короткие и латинские, здесь — русские фразы.
|
||||
|
||||
Значение длиннее предела SHALL **отбрасываться со счётчиком, а не обрезаться**.
|
||||
Обрезанная строка неотличима от настоящей и попала бы в реестр самостоятельным
|
||||
значением; настоящая строка при этом остаётся в точке целиком — теряется
|
||||
наблюдение, а не данные.
|
||||
|
||||
При переполнении границы числа уцелевший набор SHALL быть **функцией множества
|
||||
наблюдений, а не порядка элементов на проводе**: наблюдения упорядочиваются по
|
||||
ключу и берутся первые. Иначе то же содержимое, переприсланное в другом порядке
|
||||
ключей (порядок у HAE нестабилен, находка 2), давало бы другой реестр.
|
||||
|
||||
#### Scenario: Слишком много различных строк
|
||||
|
||||
- **WHEN** доставка несёт различных наблюдений больше предела
|
||||
- **THEN** в результате разбора их ровно предел
|
||||
- **AND** счётчик отброшенных наблюдений положителен
|
||||
- **AND** все точки доставки сохранены
|
||||
|
||||
#### Scenario: Уцелевший набор не зависит от порядка точек в теле
|
||||
|
||||
- **WHEN** два тела несут одно и то же множество категориальных строк в разном
|
||||
порядке, и обоим не хватает предела
|
||||
- **THEN** уцелевшие наблюдения у них совпадают
|
||||
|
||||
#### Scenario: Непомерно длинное значение отброшено целиком
|
||||
|
||||
- **WHEN** `sleep_analysis.value` длиннее предела длины
|
||||
- **THEN** наблюдения из него не выводится, счётчик отброшенных положителен
|
||||
- **AND** содержимое точки сохранено дословно и целиком
|
||||
|
||||
@@ -323,10 +323,13 @@
|
||||
или нет.
|
||||
|
||||
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
|
||||
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
|
||||
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
|
||||
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
|
||||
отличит появление двадцати семи тренировок от пропажи двух.
|
||||
часовые объекты, тренировки, записи и строки реестра категориальных значений.
|
||||
Отпечаток отвечает «да/нет» за витрину целиком, поэтому единица, которой нет в
|
||||
счётчиках, делает расхождение безадресным: человек увидит «не совпало» при
|
||||
неизменившемся числе объектов и не отличит появление двадцати семи тренировок от
|
||||
пропажи двух. Перечень пополняется **тем же изменением**, которое заводит
|
||||
единицу хранения: список закрытый, и единица, не внесённая в него, молчит ровно
|
||||
там, где расхождение впервые становится заметным.
|
||||
|
||||
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
|
||||
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
|
||||
@@ -362,13 +365,17 @@
|
||||
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
|
||||
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
|
||||
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
|
||||
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
|
||||
разбором новая секция**, **появившаяся единица хранения**) SHALL называться
|
||||
отдельно от самого факта расхождения.
|
||||
|
||||
Класс «покрыта новая секция» назван потому, что первый прогон после такого
|
||||
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
|
||||
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
|
||||
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
|
||||
оракул ровно тогда, когда по нему принимается необратимое решение.
|
||||
оракул ровно тогда, когда по нему принимается необратимое решение. Класс
|
||||
«появилась единица хранения» — та же причина в общем виде: реестр категориальных
|
||||
значений пуст у витрины, свёрнутой прежним бинарём, и первая сверка после
|
||||
выкатки расходится по нему одному, при неизменившихся объектах и сущностях.
|
||||
|
||||
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
|
||||
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
|
||||
@@ -383,7 +390,9 @@
|
||||
выполнивший напечатанную процедуру, заменил бы витрину пустой.
|
||||
|
||||
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
|
||||
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
|
||||
MUST NOT: отпечаток берёт содержимое хешем. Строки реестра при этом счётом, а не
|
||||
перечислением: наблюдённое значение — данные о здоровье наравне со значением
|
||||
точки. Ограничение относится к отчёту в
|
||||
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
|
||||
столкновения (метрика, слой, час) разрешены явно.
|
||||
|
||||
@@ -402,7 +411,15 @@ SHALL идти в поток ошибок, а не смешиваться с о
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
|
||||
тренировок и записей
|
||||
тренировок, записей и строк реестра категориальных значений
|
||||
|
||||
#### Scenario: Пустой реестр рабочей витрины назван ожидаемым классом
|
||||
|
||||
- **WHEN** рабочая витрина свёрнута прежним бинарём и строк реестра не имеет, а
|
||||
пересобранная их получила
|
||||
- **THEN** отчёт называет «появилась единица хранения» ожидаемым классом
|
||||
расхождения
|
||||
- **AND** печатает число строк реестра «до и после»
|
||||
|
||||
#### Scenario: Расхождение отпечатков не является отказом
|
||||
|
||||
@@ -444,7 +461,8 @@ SHALL идти в поток ошибок, а не смешиваться с о
|
||||
#### Scenario: Отчёт не раскрывает данных о здоровье
|
||||
|
||||
- **WHEN** отчёт напечатан
|
||||
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
|
||||
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств,
|
||||
ни наблюдённых категориальных строк
|
||||
|
||||
### Requirement: Отказ на одной доставке не останавливает пересборку
|
||||
|
||||
@@ -497,4 +515,3 @@ SHALL идти в поток ошибок, а не смешиваться с о
|
||||
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
|
||||
теряет содержание сохранённой
|
||||
- **THEN** отчёт пересборки называет число удержанных версий больше нуля
|
||||
|
||||
|
||||
@@ -1335,3 +1335,162 @@ MUST NOT, а закрывать базу по выходу одной из дв
|
||||
- **THEN** база не закрывается, а запись лога называет этап, не указывая
|
||||
виновной горутины
|
||||
|
||||
### Requirement: Реестр наблюдённых категориальных значений
|
||||
|
||||
Хранилище SHALL держать реестр категориальных значений, которые приносил поток:
|
||||
одна строка на тройку `(метрика или секция, поле, значение)`, с выведенным кодом
|
||||
HealthKit рядом. Значение хранится **дословно**, тем же текстом, каким пришло.
|
||||
|
||||
Локаль в ключ MUST NOT входить. Она живёт в заголовке доставки, которого нет в
|
||||
сыром архиве, — ключ с локалью сделал бы состояние функцией от того, уцелела ли
|
||||
строка учёта: усыновлённая доставка положила бы вторую строку с пустой локалью.
|
||||
Язык, на котором приехала строка, восстанавливается по доставке провенанса.
|
||||
|
||||
Реестр SHALL нести выведенный код и провенанс **первой** встречи — метку приёма
|
||||
и идентификатор доставки, в которой строка появилась впервые по порядку журнала.
|
||||
Первая встреча отвечает на вопрос «когда сменился язык телефона»; счётчик встреч
|
||||
не заводится вовсе — он не идемпотентен при повторной свёртке той же доставки, а
|
||||
значит сделал бы состояние зависящим от числа прогонов.
|
||||
|
||||
Пустой код — законное состояние строки: он означает «словарь этой строки не
|
||||
знает», и перечень таких строк есть заявка на пополнение словаря.
|
||||
|
||||
Словарь, по которому выводится код, SHALL жить в бинаре, а не в таблице базы и
|
||||
не в строках миграции. Таблица, наполняемая руками, стала бы входом, которого
|
||||
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно;
|
||||
данные, засеянные миграцией, живут в двух местах сразу и расходятся с бинарём
|
||||
молча после первой же правки словаря.
|
||||
|
||||
Строки реестра SHALL писаться **той же транзакцией**, что и точки доставки.
|
||||
Доставка — единица свёртки; частичное состояние «точки легли, реестр нет»
|
||||
повторная свёртка не чинит: хеш содержимого сойдётся, и объекты переписываться
|
||||
не станут.
|
||||
|
||||
Обслуживания у реестра нет: строки не удаляются. Строка, единственные доставки
|
||||
которой выпали из журнала подрезкой архива, переживёт их в рабочей витрине и не
|
||||
появится в пересобранной. Это тот же класс, что «верхние слои за периоды с
|
||||
удалёнными доставками», и он MUST называться, а не досчитываться.
|
||||
|
||||
#### Scenario: Фаза сна попадает в реестр с кодом
|
||||
|
||||
- **WHEN** свёрнута доставка с фазой сна «Во сне» и локалью `ru`
|
||||
- **THEN** в реестре есть строка `sleep_analysis / value / «Во сне»` с кодом
|
||||
`HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||||
- **AND** содержимое точки в часовом объекте не изменилось
|
||||
|
||||
#### Scenario: Та же строка без заголовка локали даёт ту же строку реестра
|
||||
|
||||
- **WHEN** та же строка приехала доставкой без `Accept-Language`
|
||||
- **THEN** второй строки в реестре не появляется
|
||||
|
||||
#### Scenario: Строка без кода в реестре видна
|
||||
|
||||
- **WHEN** свёрнута доставка с `heart_rate.context`, которого словарь не знает
|
||||
- **THEN** в реестре есть строка с этим значением и пустым кодом
|
||||
|
||||
#### Scenario: Отказ слияния не оставляет строк реестра
|
||||
|
||||
- **WHEN** слияние доставки отказало
|
||||
- **THEN** реестр не содержит значений этой доставки
|
||||
|
||||
### Requirement: Реестр детерминирован по журналу
|
||||
|
||||
Повторная свёртка той же доставки MUST оставлять реестр без изменений, а
|
||||
провенанс первой встречи MUST быть **минимумом** по порядку журнала
|
||||
(`received_at`, затем идентификатор доставки), а не значением последней записи.
|
||||
|
||||
Проигрывание журнала в любом порядке доставок, дающем тот же префикс, SHALL
|
||||
приводить реестр к тому же состоянию. Реестр — свёртка по журналу, как и всё
|
||||
остальное в витрине.
|
||||
|
||||
Выведенный код SHALL обновляться при каждой встрече строки: словарь живёт в
|
||||
бинаре, и пересборка обязана давать код по текущему словарю, а не по тому,
|
||||
который действовал при первой свёртке. Строка, переставшая приезжать, держит код
|
||||
прежнего словаря до пересборки — это осознанная цена материализации, и на
|
||||
сходимость она не влияет, потому что код в отпечаток не входит.
|
||||
|
||||
#### Scenario: Повторная свёртка ничего не меняет
|
||||
|
||||
- **WHEN** одна и та же доставка свёрнута дважды
|
||||
- **THEN** строки реестра и их провенанс совпадают с состоянием после первой
|
||||
свёртки
|
||||
|
||||
#### Scenario: Провенанс — самая ранняя доставка
|
||||
|
||||
- **WHEN** одна строка приехала сперва поздней доставкой, затем ранней
|
||||
- **THEN** провенансом остаётся ранняя по журналу
|
||||
|
||||
#### Scenario: Порядок свёртки на реестр не влияет
|
||||
|
||||
- **WHEN** три доставки свёрнуты во всех шести порядках
|
||||
- **THEN** реестр и его раздел отпечатка совпадают у всех шести прогонов
|
||||
|
||||
#### Scenario: Пересборка даёт тот же реестр
|
||||
|
||||
- **WHEN** журнал проигран в свежую витрину
|
||||
- **THEN** реестр пересобранной витрины совпадает с реестром исходной
|
||||
|
||||
### Requirement: Отпечаток покрывает наблюдение реестра, но не выведенный код
|
||||
|
||||
Отпечаток витрины SHALL покрывать реестр категориальных значений отдельным
|
||||
разделом: ключ и провенанс первой встречи. Иначе недетерминированная запись
|
||||
реестра прошла бы мимо единственного оракула сходимости.
|
||||
|
||||
Выведенный код в отпечаток входить MUST NOT. Ключ и провенанс — функция журнала;
|
||||
код — функция журнала **и версии словаря в бинаре**. Включённый в отпечаток, он
|
||||
заставил бы всякое пополнение словаря давать расхождение при побайтно совпавшем
|
||||
журнале, а пополнение объявлено рабочим циклом. Человек, принимающий по
|
||||
отпечатку необратимое решение о подмене базы, читал бы это как дефект.
|
||||
Правильность вывода кода проверяется тестами словаря — это другой вопрос, и
|
||||
смешение обесценило бы оракул.
|
||||
|
||||
Раздел SHALL быть отличим от прочих признаком впереди строки, а поля переменной
|
||||
длины SHALL идти с длиной впереди: два разных состояния витрины не имеют права
|
||||
дать один отпечаток.
|
||||
|
||||
Цена названа вслух: у витрины, свёрнутой до этого изменения, реестр пуст, и
|
||||
первая же сверка отпечатков после выкатки покажет расхождение. Это законное
|
||||
расхождение, а не дефект; отчёт пересборки обязан назвать его ожидаемым классом
|
||||
и напечатать счётчик строк реестра.
|
||||
|
||||
#### Scenario: Разошедшееся наблюдение меняет отпечаток
|
||||
|
||||
- **WHEN** у двух витрин совпадают объекты и сущности, но в реестре одной есть
|
||||
строка, которой нет в другой
|
||||
- **THEN** отпечатки различны
|
||||
|
||||
#### Scenario: Расхождение только по коду отпечаток не двигает
|
||||
|
||||
- **WHEN** две витрины несут те же строки реестра с тем же провенансом, но
|
||||
разными кодами
|
||||
- **THEN** отпечатки совпадают
|
||||
|
||||
#### Scenario: Значение с разделителем границу поля не подделывает
|
||||
|
||||
- **WHEN** значение содержит признак раздела, цифры и нулевой байт
|
||||
- **THEN** отпечаток отличается от отпечатка витрины с другим разбиением тех же
|
||||
байтов по полям
|
||||
|
||||
#### Scenario: Пустой реестр отпечаток не ломает
|
||||
|
||||
- **WHEN** в витрине нет ни одной строки реестра
|
||||
- **THEN** отпечаток считается и совпадает с отпечатком такой же витрины без
|
||||
реестра
|
||||
|
||||
### Requirement: Значения категориальных строк не попадают в логи
|
||||
|
||||
Сами наблюдённые строки и выведенные коды MUST NOT попадать в записи лога выше
|
||||
`DEBUG`; в журнал SHALL уходить только счётчики — сколько значений наблюдалось и
|
||||
сколько осталось без кода. Основание: строки категориальных значений — данные о
|
||||
здоровье, контекст пульса и фаза сна описывают человека не меньше, чем число.
|
||||
|
||||
Проверка отсутствия строки в логе SHALL разбирать запись и сравнивать значения
|
||||
полей, а не искать подстроку в сыром буфере: служебная метка времени содержит
|
||||
произвольные цифры, и поиск по буферу делает тест флаки по построению.
|
||||
|
||||
#### Scenario: Свёртка доставки со сном не пишет строк в лог
|
||||
|
||||
- **WHEN** свёрнута доставка с фазами сна и контекстом пульса
|
||||
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
|
||||
одного кода
|
||||
- **AND** счётчики значений без кода в записи присутствуют
|
||||
|
||||
Reference in New Issue
Block a user