добавлен словарь категориальных значений HAE → коды HealthKit

- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
This commit is contained in:
av
2026-08-04 07:32:19 +03:00
parent eb3fca77ee
commit 1b649ba3d5
40 changed files with 4317 additions and 42 deletions
@@ -0,0 +1,64 @@
# Код HealthKit кладётся реестром рядом, а не полем внутри точки
- Дата: 2026-08-03
- Источник: openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md
## Решение
Стабильный код HealthKit для локализованной строки хранится **отдельной строкой
таблицы `category_value`** с ключом `(метрика, поле, значение)`, а не полем
`value_code` внутри точки, как рисовал `architecture.md`. Словарь и таблица
синонимов живут в бинаре (`internal/healthkit`), а не в базе. Наблюдение входит
в отпечаток витрины, выведенный код — **нет**.
## Почему
Рассматривались три формы, и отвергнутые названы вместе с ценой.
**Поле внутри точки** — отвергнуто. Цитата источника: «Точка хранится
**исходными байтами**; дописать в неё ключ можно только пересериализацией, а она
теряет литерал (`1.0``1`, целые больше 2^53 сдвигаются, невалидный UTF-8 →
U+FFFD) — ровно то, от чего `Point.Raw` защищает. Побайтовая врезка в чужой
JSON — фокус, а не решение. Параллельный массив кодов в `bucket` завёл бы
производную величину в путь слияния и хеширования: правило полноты, тай-брейк и
`content_hash` пришлось бы учить носить код, не давая ему влиять на исход.
Правка на поверхности `critical`-инвариантов ради нуля новых сведений — код есть
**функция** от того, что уже лежит».
**Код нигде не хранится, выводится на чтении** — отвергнуто по одной причине:
«тогда код недостижим ничем, кроме бинаря. Владелец сегодня читает витрину
`sqlite` на хосте (`Read API` ещё нет), а вся задача затевается против того, что
„клиент угадывает словарь“. Реестр без кода сообщает только „такая строка
была“ — это половина ответа».
**Словарь в базе, а не в бинаре** — отвергнуто: «словарь стал бы входом,
которого нет в журнале, и `import + replay` перестал бы задавать состояние
однозначно. `stateOfMind` уже единственная дыра в журнале; вторую заводить
незачем».
**Код вне отпечатка** — обратная сторона того же решения: «Ключ и провенанс —
функция журнала; `code` — функция журнала **и версии словаря в бинаре**. Включи
его в отпечаток, и он перестал бы отвечать на свой единственный вопрос („дал ли
повтор журнала то же состояние“) ровно тогда, когда его задают: всякое
пополнение словаря — а оно объявлено рабочим циклом — давало бы расхождение при
побайтно совпавшем журнале, и человек, принимающий необратимое решение о
подмене базы, читал бы это как дефект».
Prior art: FHIR `ConceptMap` (отображение «чужая система значений → своя») и
`CodeSystem` с `replaced-by` для устаревших имён — те же два отношения,
разведённые по разным сущностям. Форма взята, реализация FHIR отвергнута ценой.
## Последствия
- `+` Инвариант «точки хранятся дословно» не тронут вовсе: точка не меняется ни
байтом, обратное преобразование возможно всегда.
- `+` Пути слияния, тай-брейка и `content_hash` не знают о кодах — правки на
поверхности `critical`-инвариантов не потребовалось.
- `+` Пополнение словаря меняет десяток строк реестра, а не каждый объект с
фазами сна; отпечаток при этом не двигается, потому что код в него не входит.
- `` Потребитель обязан делать соединение по `(метрика, поле, значение)` вместо
чтения одного поля. Форма ответа Read API это скроет, когда он появится.
- `` Код в базе отстаёт от словаря в бинаре для строк, переставших приезжать.
Лечится пересборкой; на сходимость не влияет.
- `` Ключ реестра зафиксирован миграцией `00010`: смена формы ключа стоит
второй миграции и пересборки.
+5 -2
View File
@@ -33,8 +33,11 @@
| Дата | Запись | Статус |
| --- | --- | --- |
Записей пока нет: каталог заведён переездом на канон 2026-08-03. Сырьё для
промоута накоплено — девять архивных изменений в
- [ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom](ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md)
— код HealthKit кладётся реестром рядом со строкой, а не полем внутри точки;
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
Сырьё для промоута накоплено — архивные изменения в
`openspec/changes/archive/`, из них решения с дорогим откатом и намеренные
отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey`
(идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы
+31 -7
View File
@@ -1103,21 +1103,45 @@ HAE отдаёт перечислимые значения строками из
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
но сверить покрытие по этим полям было бы нечем.
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**:
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**
отдельной строкой реестра `category_value`, а не полем внутри точки:
```
value "БДГ" ← как прислал HAE
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю
category_value sleep_analysis / value / "БДГ" → HKCategoryValueSleepAnalysisAsleepREM
точка {"date": …, "value": "БДГ", …} ← не тронута
```
Словарь ключуется парой `(локаль, строка)`; локаль берётся из
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как
список того, что пора добавить в словарь.
Рядом, а не внутри, по трём причинам: точка хранится исходными байтами и
дописать в неё ключ можно только пересериализацией; параллельный массив кодов в
`bucket` завёл бы производную величину в путь слияния и хеширования; пополнение
словаря переписывало бы каждый объект с фазами сна. Обоснование целиком — в
[журнале решений](adr/README.md).
Ключ реестра — `(метрика, поле, значение)`. Словарь при этом ключуется парой
`(локаль, строка)`, локаль берётся из `Accept-Language` (находка 32) и **в ключ
реестра не входит**: заголовков в сыром архиве нет, и ключ с локалью сделал бы
состояние функцией от того, уцелела ли учётная строка. Локаль сужает поиск; её
отсутствие вывода не отменяет, если строка однозначна по всем локалям.
Словарь и таблица синонимов кодов живут в бинаре (`internal/healthkit`), а не в
базе: словарь, наполняемый руками, стал бы входом, которого нет в журнале, и
`import + replay` перестал бы задавать состояние однозначно. Синонимы нужны
потому, что коды тоже не вечны: Apple переименовала `…Asleep` в
`…AsleepUnspecified` и переписывает историю при выгрузке (находка 43).
Для незнакомой строки код пустой — пустота честнее догадки, и перечень таких
строк в реестре есть заявка на пополнение словаря. Счётчик неизвестных строк
уходит в лог свёртки числом; сами строки — данные о здоровье и в лог не
попадают.
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
строку. Обратное преобразование всегда возможно.
Реестр — единица хранения витрины и входит в отпечаток **наблюдением**, но не
выведенным кодом: код производен от словаря в бинаре, а не от журнала, и в
отпечатке он превратил бы всякое пополнение словаря в расхождение при совпавшем
журнале.
### Тренировки и прочие секции
<!-- канон: поведение → openspec/specs/parsing -->
+18
View File
@@ -16,6 +16,24 @@
единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы.
- **Провенанс, входящий в отпечаток, обязан быть явной функцией журнала.**
«Кто первым записал строку» — функция порядка свёртки, а он порядку журнала не
равен: живой приём и пересборка разойдутся при одинаковом журнале. Там, где
провенанс в отпечаток не идёт, слабое правило допустимо и должно быть названо
слабым на месте — иначе его скопируют туда, где оно неверно (`bucket` против
`category_value`).
- **Колонка, производная от бинаря, а не от журнала, в отпечаток не входит.**
Кэш чистой функции (код по словарю, справочное имя) в отпечатке превращает
всякую правку бинаря в расхождение при побайтно совпавшем журнале — и человек,
принимающий по отпечатку необратимое решение о подмене базы, читает это как
дефект. Правильность самой производной проверяют её тесты: это другой вопрос,
и смешение обесценивает оракул сходимости.
- **Граница на число элементов, набираемых из чужого тела, применяется при
накоплении, а не при выдаче.** Накопитель без границы растёт вместе с телом,
а тело контролирует отправитель; отказ по памяти в фоновой горутине не
перехватывается, и перезапуск берёт ту же доставку. Усечение при этом обязано
остаться функцией множества (например, N наименьших ключей), иначе порядок
элементов на проводе решает состав витрины.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
+10
View File
@@ -23,6 +23,16 @@
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел.
- **Значение, попадающее в ключ витрины или в словарь, приёмочный тест берёт из
`testdata`, а не из литерала в тесте.** Литерал, набранный руками, не
воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы
внутри своих строк (находка 24), — и совпадение теста с реализацией доказывает
только согласие автора с самим собой.
- **Утверждение о таблице-константе обходит саму таблицу, а не её видимые
следствия.** Проверка «таблица синонимов плоская», написанная через
экспортированные функции, обходит лишь записи, достижимые из словаря: с
неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения
живут во внутреннем тесте пакета и перебирают саму структуру.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
+52
View File
@@ -46,6 +46,17 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
│ created_at TEXT │ └──────────────────────────┘
│ updated_at TEXT │
└──────────────────────────┘
┌────────────────────────────┐
│ category_value │
│ ───────────────────────── │
│ metric TEXT ┐ │
│ field TEXT ├PK │
│ value TEXT ┘ │
│ code TEXT │
│ first_seen_utc TEXT │
│ first_delivery_id TEXT │
└────────────────────────────┘
```
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
@@ -156,6 +167,47 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
журнала, а не свёрнутая последней. Подробности и обоснование — в
`architecture.md`, раздел «Тренировки и прочие секции».
## `category_value` — реестр категориальных значений
Какие перечислимые строки поток приносил и какой у них стабильный код
HealthKit. HAE отдаёт фазу сна как «БДГ», контекст пульса как «Сидячий образ
жизни», тип тренировки как «В помещении Ходьба» — строками локали телефона, а
родной экспорт Apple говорит кодами; без словаря источники не сходятся
(находка 37). Словарь фаз сна выведен сопоставлением потока с экспортом за тот
же период (находка 43).
| Колонка | Смысл |
|---|---|
| `metric` | имя метрики или секции, **то же**, которым адресуется единица хранения (`sleep_analysis_summary` после разделения схем, `workouts` у тренировок). Второе имя для того же понятия развело бы наблюдение и объект по разным ключам |
| `field` | имя поля внутри точки или сущности дословно как у HAE: `value`, `context`, `name` |
| `value` | строка **дословно**, как прислал HAE. Код приписывается рядом, а не подменяет её: инвариант «точки хранятся дословно» это и означает |
| `code` | канонический код HealthKit. Пустая строка — законное состояние: «словарь этой строки не знает», и перечень таких строк есть заявка на пополнение словаря. **Это кэш**: код производен от словаря в бинаре, а не от журнала, и потому в отпечаток витрины не входит. Строка, переставшая приезжать, держит код прежнего словаря до пересборки |
| `first_seen_utc`, `first_delivery_id` | провенанс **первой** встречи, минимум по журналу `(received_at, id)`. Минимум идемпотентен при повторной свёртке той же доставки; счётчик встреч не идемпотентен и потому не заводится вовсе. Отвечает на вопрос «когда сменился язык телефона», а язык доставки восстанавливается по `delivery.headers` |
Ключ — тройка без локали, и это решение, а не упущение. Локаль приезжает
заголовком `Accept-Language`, а заголовков в сыром архиве нет: они были
заголовками запроса, а не телом. Доставка, восстановленная из осиротевшего
тела, приходит без локали — ключ с локалью положил бы вторую строку на то же
значение, то есть состояние стало бы функцией от того, уцелела ли учётная
строка. Локаль при выводе кода сужает поиск по словарю; её отсутствие вывода не
отменяет, если строка однозначна.
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, а строк
единицы — на живом потоке различных значений по всем трём полям около
одиннадцати. Индексов нет: чтение идёт целиком, в порядке ключа.
Границы разбора не дают доставке положить больше 64 различных значений и
значение длиннее 128 байт (измерено: ~11 значений, самое длинное 36 байт).
Слишком длинное **отбрасывается со счётчиком, а не обрезается** — обрезанная
строка неотличима от настоящей и стала бы самостоятельным ключом; сама точка
при этом хранится целиком.
Data-миграции у таблицы нет и быть не может: коды выводятся из тел, а тела
лежат в архиве. Реестр рабочей витрины наполняется по мере свёртки новых
доставок и целиком — пересборкой. Отсюда первое расхождение отпечатков после
выкатки: оно законно, и отчёт `reindex` называет его ожидаемым классом
«появилась единица хранения».
## Представление данных
- **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`.
+8
View File
@@ -1369,6 +1369,14 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
То есть первую и главную часть словаря не надо составлять вручную — она
выводится сопоставлением потока с экспортом за тот же период.
**Замер покрытия, 2026-08-03.** Прогон всего живого архива (145 доставок) через
разбор с этим словарём даёт **12 различных категориальных строк** по трём полям:
6 фаз сна — все с кодом, 6 без кода (`heart_rate.context` и имена тренировок,
для которых словарь не выводился). То есть шесть выведенных строк покрывают
поток целиком, а не частично: неопознанных фаз сна на корпусе ноль. Заголовков
в архиве нет, поэтому прогон идёт с пустой локалью — и коды всё равно выводятся,
что подтверждает: сопоставление по строке однозначно, пока словарь одноязычен.
## 44. `Correlation` — структурный элемент, и он появился только что
Давление приезжает не записью, а обёрткой из двух записей:
+49
View File
@@ -120,6 +120,13 @@
- **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или
разбора». Единственный раз, когда триаж назвал его отсутствие дырой
покрытия, — это была задача с новым правилом слияния сущностей.
Второй замер (2026-08-03, словарь категориальных значений): триггер сработал
на новом правиле разбора и ключе реестра, проход **окупился** — он независимо
подтвердил замером две находки, до того имевшие только одно измерение (пик
памяти накопителя: 1002 МиБ против 780 на базе; единицы счётчика отброшенных),
и отдельно назвал семь мест, где существующее решение оказалось **лучше** его
собственного. Второе ценно не меньше первого: оно показывает, где проход
соглашается, а не только где спорит.
- **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет
хранимое.
@@ -360,6 +367,48 @@
- **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 78, `design` 3.
Было 11 на коде и 4 на дизайне.
## 2026-08-03 — метка от часов в отпечатке сделала тест функцией секунды прогона [пойман]
- **Где:** `internal/fold/categorical_test.go`, `TestFoldЛокальНеМеняетСостояния`
- **Симптом:** гейт покраснел на одном подтесте из четырёх: «заголовок
`{"Accept-Language":["de"]}` сдвинул отпечаток витрины». Три подтеста прошли.
- **Причина:** тест сравнивал отпечатки четырёх независимых витрин, а метку
приёма доставки брал из `store.Now()`. Провенанс первой встречи входит в
отпечаток реестра — значит отпечаток зависел от того, уложились ли подтесты в
одну секунду. Тест был флаки по построению и краснел бы у того, кто мимо
проходил.
- **Чем воспроизведён:** сам гейт; после замены `store.Now()` на фиксированную
метку — `go test ./internal/fold -count=2` зелёный.
- **Что меняем:** ничего в конвейере — гейт сработал ровно так, как задуман, и
поймал класс, который прошлый раз (2026-08-02, подстрока «5.1» в метке
времени) прожил незамеченным. Правило то же и уже записано в
[conventions/testing.md](conventions/testing.md): величина, зависящая от хода
часов, не участвует в утверждении. Запись здесь — потому что это второй случай
одного класса за два дня, и третий стоит считать сигналом, а не совпадением.
## 2026-08-03 — прогон живого архива красный на master, и это не заметили две задачи подряд [проскочил]
- **Где:** `internal/replay/archive_test.go`, `measureStyles`
- **Симптом:** `task verify:archive` в задаче про словарь категориальных
значений упал на `step_count: противоречащих часов 1 при 22 согласных`.
Проверено прогоном **базовой ревизии** `3df42af` из копии дерева на том же
архиве: те же 2875 объектов, те же 285 координат сна, тот же отказ. Краснота
унаследована, изменением не внесена.
- **Причина:** утверждение «противоречий ноль» — посылка «род измерим», верная
на корпусе, где её снимали. Корпус вырос до 145 доставок, и у `step_count`
появился час, где минутный и часовой слои разошлись. Сама система при этом
ведёт себя правильно: род объявляется только при единогласном свидетельстве,
и `step_count` числится `unknown`.
- **Почему не поймали:** ровно та же причина, что и в записи 2026-08-02, — у
проверки, которую гейт не гоняет, краснота никому не видна. Разница в том, что
тогда протухла константа, а теперь под вопросом сама посылка: противоречие —
это либо дефект правила, либо законное свойство корпуса, и решать это не
прогону.
- **Что меняем:** конвейер — ничего. Решение о том, чем стал `step_count`
(дефект измерения рода или законное противоречие, которое надо печатать, а не
утверждать), принадлежит владельцу и заведено задачей отдельно от этого
изменения. Названо здесь, чтобы третья задача подряд не открывала его заново.
## 2026-08-03 — ответ владельца не превращал задачу в берущуюся [проскочил]
- **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос»
+13 -2
View File
@@ -32,8 +32,10 @@ disabled`, `read auth disabled`), но стартовать не отказыв
значения, метки времени, имена источников и устройств, имена секций, `id`
тренировок и записей, содержимое маршрута.
- **Заголовки доставки** — включая `automation-id`, `automation-aggregation`,
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery` и
участвуют в выводе слоя. Заголовки полуправдивы: `automation-aggregation`
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery`,
участвуют в выводе слоя, а `Accept-Language` — ещё и в выводе кода
категориального значения (тег ограничен по длине и по форме, не тег даёт
пустую локаль). Заголовки полуправдивы: `automation-aggregation`
реальной гранулярности не описывает (разведка, находка 33).
- **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной
координате и 768 МиБ пика кучи на теле 40 МиБ.
@@ -57,6 +59,15 @@ disabled`, `read auth disabled`), но стартовать не отказыв
отчёт, имеет названный предел длины.
- **Ключ сущности**`род секции + id` из HealthKit для `record`, `id` для
`workout`. `id` приходит из тела.
- **Ключ наблюдённого категориального значения**`метрика + поле + значение`.
Значение приходит из тела дословно и уезжает в первичный ключ: предел на него
назван числом (128 байт), число различных значений одной доставки ограничено
(64), и **граница применяется при накоплении, а не при выдаче** — иначе
накопитель растёт вместе с телом, а тело контролирует отправитель (измерено:
миллион различных значений в теле 60 МиБ поднимал пик процесса с 780 до
1002 МиБ). Значение, которое разбор JSON подменил (невалидный UTF-8, одинокий
суррогат), наблюдением не считается вовсе: в ключ обязано попасть то, что
пришло, а не то, что получилось.
- **Файл базы и каталог архива** — из конфига, не из запроса.
## Что разграничивает доступ