From 1b649ba3d5d5810c56959b023202c184a3504ede Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 3 Aug 2026 21:23:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20HAE=20=E2=86=92=20=D0=BA=D0=BE=D0=B4=D1=8B=20HealthKit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала --- cmd/healthlog/reindex.go | 14 +- cmd/healthlog/reindex_report.go | 23 + cmd/healthlog/reindex_test.go | 106 +++++ ...26-08-03-kod-ryadom-so-strokoj-reestrom.md | 64 +++ docs/adr/README.md | 7 +- docs/architecture.md | 38 +- docs/conventions/storage.md | 18 + docs/conventions/testing.md | 10 + docs/database.md | 52 +++ docs/research/apple-health.md | 8 + docs/review.md | 49 +++ docs/security.md | 15 +- internal/fold/categorical_test.go | 277 ++++++++++++ internal/fold/fold.go | 89 +++- internal/hae/categorical.go | 257 +++++++++++ internal/hae/categorical_test.go | 404 ++++++++++++++++++ internal/hae/hae.go | 68 ++- internal/healthkit/healthkit.go | 183 ++++++++ internal/healthkit/healthkit_test.go | 138 ++++++ internal/healthkit/tables_internal_test.go | 69 +++ internal/replay/archive_test.go | 86 ++++ internal/replay/replay.go | 15 +- internal/store/bucket.go | 79 +++- internal/store/category.go | 150 +++++++ internal/store/category_test.go | 285 ++++++++++++ internal/store/delivery.go | 9 +- internal/store/entity.go | 4 + .../store/migrations/00010_category_value.sql | 67 +++ .../.openspec.yaml | 2 + .../design.md | 272 ++++++++++++ .../proposal.md | 66 +++ .../review/code-triage.md | 140 ++++++ .../review/design-triage.md | 138 ++++++ .../specs/parsing/spec.md | 253 +++++++++++ .../specs/reindex/spec.md | 151 +++++++ .../specs/storage/spec.md | 161 +++++++ .../tasks.md | 145 +++++++ openspec/specs/parsing/spec.md | 251 +++++++++++ openspec/specs/reindex/spec.md | 37 +- openspec/specs/storage/spec.md | 159 +++++++ 40 files changed, 4317 insertions(+), 42 deletions(-) create mode 100644 docs/adr/ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md create mode 100644 internal/fold/categorical_test.go create mode 100644 internal/hae/categorical.go create mode 100644 internal/hae/categorical_test.go create mode 100644 internal/healthkit/healthkit.go create mode 100644 internal/healthkit/healthkit_test.go create mode 100644 internal/healthkit/tables_internal_test.go create mode 100644 internal/store/category.go create mode 100644 internal/store/category_test.go create mode 100644 internal/store/migrations/00010_category_value.sql create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/proposal.md create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/code-triage.md create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/design-triage.md create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/parsing/spec.md create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/reindex/spec.md create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/tasks.md diff --git a/cmd/healthlog/reindex.go b/cmd/healthlog/reindex.go index 5bb9f2a..dbf91e7 100644 --- a/cmd/healthlog/reindex.go +++ b/cmd/healthlog/reindex.go @@ -179,9 +179,14 @@ type report struct { // единица, которой нет в счётчиках, делает расхождение безадресным. sourceWorkouts int64 sourceRecords int64 - sourceBefore int64 - sourceAfter int64 - sourceMissing bool + // sourceCategories — то же «было» для реестра категориальных значений. + // Перечень единиц хранения закрытый, и он пополняется ТЕМ ЖЕ изменением, + // которое заводит единицу: не внесённая сюда, она молчит ровно там, где + // расхождение впервые становится заметным. + sourceCategories int64 + sourceBefore int64 + sourceAfter int64 + sourceMissing bool } // rebuild собирает витрину в промежуточный файл и переименовывает его в файл @@ -237,6 +242,9 @@ func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger if rep.sourceRecords, err = src.CountRecords(ctx); err != nil { return canceledOr(rep, err, stopped) } + if rep.sourceCategories, err = src.CountCategoryValues(ctx); err != nil { + return canceledOr(rep, err, stopped) + } } removeDB(t.partial) diff --git a/cmd/healthlog/reindex_report.go b/cmd/healthlog/reindex_report.go index 6e56dc8..67704ed 100644 --- a/cmd/healthlog/reindex_report.go +++ b/cmd/healthlog/reindex_report.go @@ -66,6 +66,7 @@ func writeReport(w io.Writer, r report) { p(" объектов: %d", r.replay.Buckets) p(" тренировок: %d", r.replay.Workouts) p(" записей: %d", r.replay.Records) + p(" строк реестра категориальных значений: %d", r.replay.Categories) p("") p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath) p("не восстанавливаются: в архиве их нет.") @@ -76,6 +77,8 @@ func writeReport(w io.Writer, r report) { p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets) p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts) p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records) + p(" строк реестра категориальных значений: было %d, стало %d", + r.sourceCategories, r.replay.Categories) p("") p(" отпечаток рабочей: %s", r.sourcePrint) p(" отпечаток пересобранной: %s", r.replay.Fingerprint) @@ -89,6 +92,26 @@ func writeReport(w io.Writer, r report) { p(" ожидаемые причины: исправленный разбор; покрытая разбором новая") p(" секция (её единиц хранения в рабочей базе нет по построению);") p(" признак sealed не переносится (правила его выставления ещё нет)") + if r.sourceCategories < r.replay.Categories { + // Класс назван отдельно от факта расхождения: реестр появился + // вместе с бинарём, и у витрины, свёрнутой прежним, его нет по + // построению. Не назвав это, отчёт приучает человека + // игнорировать расхождение — то есть обесценивает оракул ровно + // там, где по нему принимается необратимое решение. + // + // Условие — НЕПОЛНОТА, а не пустота. Между выкаткой и прогоном + // проходят дни: воркер успевает набрать частые значения (фазы + // сна, контекст пульса) и не успевает редкие — имя тренировки, + // которая с тех пор не повторялась. Проверка «в рабочей базе + // реестра нет вовсе» такое состояние не ловила бы, и человек + // получил бы безадресное «разошлись» при совпавших числах + // объектов, тренировок и записей. + p(" РЕЕСТР НЕПОЛОН: строк категориальных значений в рабочей базе %d,", + r.sourceCategories) + p(" в пересобранной %d — реестр наполняется по мере свёртки, а целиком", + r.replay.Categories) + p(" его даёт только пересборка. Расхождение объясняется этим и лечится ею же") + } if partialJournal { p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может") p(" объясняться этим, а не разбором") diff --git a/cmd/healthlog/reindex_test.go b/cmd/healthlog/reindex_test.go index c87a414..1488a38 100644 --- a/cmd/healthlog/reindex_test.go +++ b/cmd/healthlog/reindex_test.go @@ -337,3 +337,109 @@ func TestОтчётВсегдаНазываетУдержанныеВерсии( } } } + +// Реестр категориальных значений — четвёртая единица хранения витрины, и у +// витрины, свёрнутой прежним бинарём, его нет по построению. Расхождение +// отпечатков по нему одному законно, и отчёт обязан назвать это классом, а не +// оставить человека с безадресным «не совпало»: числа объектов, тренировок и +// записей при этом не меняются вовсе, а решение о подмене базы необратимо. +func TestОтчётНазываетПоявившийсяРеестр(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeReport(&buf, report{ + replay: replay.Report{ + Bodies: 116, + Outcome: replay.Outcome{Folded: 116}, + Buckets: 2049, Workouts: 2, Records: 2, Categories: 11, + Fingerprint: "aaaa", + }, + target: "/data/healthlog.db.rebuild", + dbPath: "/data/healthlog.db", + sourcePrint: "bbbb", + sourceBuckets: 2049, + sourceWorkouts: 2, + sourceRecords: 2, + sourceCategories: 0, + sourceBefore: 116, + sourceAfter: 116, + }) + out := buf.String() + + for _, want := range []string{ + "строк реестра категориальных значений: было 0, стало 11", + "РЕЕСТР НЕПОЛОН", + "лечится ею же", + } { + if !strings.Contains(out, want) { + t.Errorf("отчёт не содержит %q:\n%s", want, out) + } + } + + // Сами строки реестра — данные о здоровье наравне со значением точки: + // отчёт отвечает счётом, а не перечислением. + for _, forbidden := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} { + if strings.Contains(out, forbidden) { + t.Errorf("отчёт содержит наблюдённую строку %q", forbidden) + } + } +} + +// Реестр рабочей витрины непуст, но неполон — штатное состояние через сутки +// после выкатки: частые значения воркер набрал, редкое имя тренировки с тех пор +// не повторялось. Класс обязан называться и здесь, иначе человек получит +// безадресное «разошлись» при совпавших числах объектов, тренировок и записей — +// и научится игнорировать строку, по которой принимает необратимое решение. +func TestОтчётНазываетНеполныйРеестр(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeReport(&buf, report{ + replay: replay.Report{ + Bodies: 116, + Outcome: replay.Outcome{Folded: 116}, + Buckets: 2049, Workouts: 2, Records: 2, Categories: 11, + Fingerprint: "aaaa", + }, + target: "/data/healthlog.db.rebuild", + dbPath: "/data/healthlog.db", + sourcePrint: "bbbb", + sourceBuckets: 2049, + sourceWorkouts: 2, + sourceRecords: 2, + sourceCategories: 5, + sourceBefore: 116, + sourceAfter: 116, + }) + out := buf.String() + for _, want := range []string{"РЕЕСТР НЕПОЛОН", "в рабочей базе 5", "в пересобранной 11"} { + if !strings.Contains(out, want) { + t.Errorf("отчёт не содержит %q:\n%s", want, out) + } + } +} + +// Совпавший реестр отдельным классом не объявляется: иначе строка звучала бы +// при каждом прогоне и перестала бы что-либо значить. +func TestОтчётНеОбъявляетРеестрПоявившимсяБезПричины(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + writeReport(&buf, report{ + replay: replay.Report{ + Bodies: 10, + Outcome: replay.Outcome{Folded: 10}, + Buckets: 5, Categories: 11, Fingerprint: "aaaa", + }, + target: "/data/healthlog.db.rebuild", + dbPath: "/data/healthlog.db", + sourcePrint: "bbbb", + sourceBuckets: 4, + sourceCategories: 11, + sourceBefore: 10, + sourceAfter: 10, + }) + if out := buf.String(); strings.Contains(out, "РЕЕСТР НЕПОЛОН") { + t.Errorf("класс объявлен при совпавшем реестре рабочей витрины:\n%s", out) + } +} diff --git a/docs/adr/ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md b/docs/adr/ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md new file mode 100644 index 0000000..e78a29a --- /dev/null +++ b/docs/adr/ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md @@ -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`: смена формы ключа стоит + второй миграции и пересборки. diff --git a/docs/adr/README.md b/docs/adr/README.md index 855d0e8..822655f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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` (подмену базы diff --git a/docs/architecture.md b/docs/architecture.md index 4bc21b5..0fe3de6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). + +Для незнакомой строки код пустой — пустота честнее догадки, и перечень таких +строк в реестре есть заявка на пополнение словаря. Счётчик неизвестных строк +уходит в лог свёртки числом; сами строки — данные о здоровье и в лог не +попадают. Дословность инварианта не нарушена: код **приписывается**, а не подменяет строку. Обратное преобразование всегда возможно. +Реестр — единица хранения витрины и входит в отпечаток **наблюдением**, но не +выведенным кодом: код производен от словаря в бинаре, а не от журнала, и в +отпечатке он превратил бы всякое пополнение словаря в расхождение при совпавшем +журнале. + ### Тренировки и прочие секции diff --git a/docs/conventions/storage.md b/docs/conventions/storage.md index 6b90873..612679c 100644 --- a/docs/conventions/storage.md +++ b/docs/conventions/storage.md @@ -16,6 +16,24 @@ единица, которой нет в счётчиках, делает расхождение безадресным: человек видит «не совпало» при неизменившемся числе объектов и принимает по этому необратимое решение о подмене базы. +- **Провенанс, входящий в отпечаток, обязан быть явной функцией журнала.** + «Кто первым записал строку» — функция порядка свёртки, а он порядку журнала не + равен: живой приём и пересборка разойдутся при одинаковом журнале. Там, где + провенанс в отпечаток не идёт, слабое правило допустимо и должно быть названо + слабым на месте — иначе его скопируют туда, где оно неверно (`bucket` против + `category_value`). +- **Колонка, производная от бинаря, а не от журнала, в отпечаток не входит.** + Кэш чистой функции (код по словарю, справочное имя) в отпечатке превращает + всякую правку бинаря в расхождение при побайтно совпавшем журнале — и человек, + принимающий по отпечатку необратимое решение о подмене базы, читает это как + дефект. Правильность самой производной проверяют её тесты: это другой вопрос, + и смешение обесценивает оракул сходимости. +- **Граница на число элементов, набираемых из чужого тела, применяется при + накоплении, а не при выдаче.** Накопитель без границы растёт вместе с телом, + а тело контролирует отправитель; отказ по памяти в фоновой горутине не + перехватывается, и перезапуск берёт ту же доставку. Усечение при этом обязано + остаться функцией множества (например, N наименьших ключей), иначе порядок + элементов на проводе решает состав витрины. - Правило выбора между двумя версиями одних данных объявляется либо **функцией множества версий**, либо явно **функцией порядка журнала** — третьего состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является: diff --git a/docs/conventions/testing.md b/docs/conventions/testing.md index 1c9b560..0e3d843 100644 --- a/docs/conventions/testing.md +++ b/docs/conventions/testing.md @@ -23,6 +23,16 @@ архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение без числа не отличается от предположения, а цена ошибки здесь — необратимое решение о судьбе тел. +- **Значение, попадающее в ключ витрины или в словарь, приёмочный тест берёт из + `testdata`, а не из литерала в тесте.** Литерал, набранный руками, не + воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы + внутри своих строк (находка 24), — и совпадение теста с реализацией доказывает + только согласие автора с самим собой. +- **Утверждение о таблице-константе обходит саму таблицу, а не её видимые + следствия.** Проверка «таблица синонимов плоская», написанная через + экспортированные функции, обходит лишь записи, достижимые из словаря: с + неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения + живут во внутреннем тесте пакета и перебирают саму структуру. - **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока находится в ней сама: проверка на «5.1» краснела примерно раз на сотню diff --git a/docs/database.md b/docs/database.md index 4f61069..cc6afd0 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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`. diff --git a/docs/research/apple-health.md b/docs/research/apple-health.md index 187dbad..4fed28a 100644 --- a/docs/research/apple-health.md +++ b/docs/research/apple-health.md @@ -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` — структурный элемент, и он появился только что Давление приезжает не записью, а обёрткой из двух записей: diff --git a/docs/review.md b/docs/review.md index 73df842..81cd3b9 100644 --- a/docs/review.md +++ b/docs/review.md @@ -120,6 +120,13 @@ - **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или разбора». Единственный раз, когда триаж назвал его отсутствие дырой покрытия, — это была задача с новым правилом слияния сущностей. + Второй замер (2026-08-03, словарь категориальных значений): триггер сработал + на новом правиле разбора и ключе реестра, проход **окупился** — он независимо + подтвердил замером две находки, до того имевшие только одно измерение (пик + памяти накопителя: 1002 МиБ против 780 на базе; единицы счётчика отброшенных), + и отдельно назвал семь мест, где существующее решение оказалось **лучше** его + собственного. Второе ценно не меньше первого: оно показывает, где проход + соглашается, а не только где спорит. - **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет хранимое. @@ -360,6 +367,48 @@ - **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 7–8, `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 — ответ владельца не превращал задачу в берущуюся [проскочил] - **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос» diff --git a/docs/security.md b/docs/security.md index 01b346d..9f33d94 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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, одинокий + суррогат), наблюдением не считается вовсе: в ключ обязано попасть то, что + пришло, а не то, что получилось. - **Файл базы и каталог архива** — из конфига, не из запроса. ## Что разграничивает доступ diff --git a/internal/fold/categorical_test.go b/internal/fold/categorical_test.go new file mode 100644 index 0000000..dba06ea --- /dev/null +++ b/internal/fold/categorical_test.go @@ -0,0 +1,277 @@ +package fold_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/archive" + "git.vakhrushev.me/av/healthlog/internal/fold" + "git.vakhrushev.me/av/healthlog/internal/store" +) + +// Тело с обеими наблюдаемыми формами: фаза сна, которую словарь знает, и +// контекст пульса, которого он не знает. +const categoricalBody = `{"data":{"metrics":[` + + `{"name":"sleep_analysis","units":"hr","data":[` + + `{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` + + `"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"Во сне"}]},` + + `{"name":"heart_rate","units":"count/min","data":[` + + `{"date":"2025-06-05 10:00:00 +0300","Avg":62,"context":"Сидячий образ жизни"}]}` + + `]}}` + +// receivedAt — фиксированная метка приёма. +// +// Не `store.Now()`: провенанс первой встречи входит в отпечаток витрины, и +// метка от часов сделала бы сравнение отпечатков между подтестами функцией +// того, в одну ли секунду они успели отработать. Ровно так этот тест и покраснел +// на гейте — у трёх подтестов из четырёх метки совпали, у четвёртого нет. +var receivedAt = time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC) + +func deliverWithHeaders(t *testing.T, arch *archive.Archive, st *store.Store, id, headers string, body []byte) { + t.Helper() + + at := receivedAt + rawPath, err := arch.Write(id, at, body) + if err != nil { + t.Fatalf("запись в архив: %v", err) + } + err = st.CreateDelivery(context.Background(), store.Delivery{ + ID: id, + ReceivedAt: at, + AutomationID: "auto-1", + Aggregation: "Hours", + Bytes: int64(len(body)), + SHA256: "-", + RawPath: rawPath, + Headers: headers, + ParseStatus: store.ParsePending, + }) + if err != nil { + t.Fatalf("запись доставки: %v", err) + } +} + +func foldService(t *testing.T, log *slog.Logger) (*fold.Service, *archive.Archive, *store.Store) { + t.Helper() + + dir := t.TempDir() + arch, err := archive.New(filepath.Join(dir, "raw")) + if err != nil { + t.Fatalf("архив: %v", err) + } + st, err := store.Open(filepath.Join(dir, "healthlog.db")) + if err != nil { + t.Fatalf("база: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return fold.New(arch, st, 0, log), arch, st +} + +func TestFoldКладётНаблюденияВРеестр(t *testing.T) { + t.Parallel() + + f, arch, st := foldService(t, slog.New(slog.DiscardHandler)) + deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody)) + + stats, err := f.Fold(context.Background(), "d1") + if err != nil { + t.Fatalf("свёртка: %v", err) + } + if stats.Categoricals != 2 { + t.Errorf("наблюдений %d, ожидалось 2", stats.Categoricals) + } + if stats.CategoricalUnknown != 1 { + t.Errorf("строк без кода %d, ожидалась 1 (контекст пульса)", stats.CategoricalUnknown) + } + + got, err := st.CategoryValues(context.Background()) + if err != nil { + t.Fatalf("чтение реестра: %v", err) + } + if len(got) != 2 { + t.Fatalf("строк реестра %d: %+v", len(got), got) + } + if got[1].Value != "Во сне" || got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" { + t.Errorf("фаза сна в реестре: %+v", got[1]) + } + if got[0].Value != "Сидячий образ жизни" || got[0].Code != "" { + t.Errorf("контекст пульса в реестре: %+v", got[0]) + } + if got[1].FirstDeliveryID != "d1" { + t.Errorf("провенанс %q, ожидался d1", got[1].FirstDeliveryID) + } +} + +// Локаль сужает поиск по словарю, но в ключ не входит и вывода не отменяет: +// заголовков в сыром архиве нет, и доставка, восстановленная из осиротевшего +// тела, обязана дать то же состояние. +func TestFoldЛокальНеМеняетСостояния(t *testing.T) { + t.Parallel() + + cases := []struct{ name, headers string }{ + {"измеренный заголовок потока", `{"Accept-Language":["ru"]}`}, + {"подтег, веса и регистр", `{"accept-language":["RU-ru,ru;q=0.9,en;q=0.8"]}`}, + {"заголовка нет вовсе", `{}`}, + {"незнакомая локаль", `{"Accept-Language":["de"]}`}, + // Пустая строка и битый JSON — не вычурность: заголовков в сыром архиве + // нет вовсе, и доставка, восстановленная из осиротевшего тела, приезжает + // ровно так. Оба обязаны дать пустую локаль и то же состояние. + {"заголовков нет вовсе", ``}, + {"заголовки не разбираются", `не json`}, + {"заголовок пустым списком", `{"Accept-Language":[]}`}, + } + + var want string + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f, arch, st := foldService(t, slog.New(slog.DiscardHandler)) + deliverWithHeaders(t, arch, st, "d1", c.headers, []byte(categoricalBody)) + if _, err := f.Fold(context.Background(), "d1"); err != nil { + t.Fatalf("свёртка: %v", err) + } + got, err := st.CategoryValues(context.Background()) + if err != nil { + t.Fatalf("чтение реестра: %v", err) + } + if len(got) != 2 { + t.Fatalf("строк реестра %d: %+v", len(got), got) + } + if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" { + t.Errorf("код фазы сна %q — локаль отменила вывод", got[1].Code) + } + // Отпечаток не должен зависеть от заголовка вовсе: ключ реестра — + // функция одних тел. + fp, err := st.Fingerprint(context.Background()) + if err != nil { + t.Fatalf("отпечаток: %v", err) + } + if want == "" { + want = fp + } else if fp != want { + t.Errorf("заголовок %s сдвинул отпечаток витрины", c.headers) + } + }) + } +} + +// Строки категориальных значений — данные о здоровье наравне со значением +// точки: «Сидячий образ жизни» описывает человека. В журнал уходят только +// счётчики. +// +// Запись РАЗБИРАЕТСЯ, служебное `time` выбрасывается, и поиск идёт в остатке: +// метка времени содержит произвольные цифры, и поиск по сырому буферу делает +// такой тест флаки по построению (запись 2026-08-02 в docs/review.md). +func TestFoldНеПишетКатегориальныхСтрокВЛог(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + f, arch, st := foldService(t, log) + deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody)) + if _, err := f.Fold(context.Background(), "d1"); err != nil { + t.Fatalf("свёртка: %v", err) + } + + logged := strings.TrimSpace(buf.String()) + if logged == "" { + t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит") + } + + var folded map[string]any + for line := range strings.SplitSeq(logged, "\n") { + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + t.Fatalf("строка лога не JSON: %v", err) + } + delete(rec, "time") + clean, err := json.Marshal(rec) + if err != nil { + t.Fatalf("запись лога не сериализуется: %v", err) + } + for _, secret := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} { + if strings.Contains(string(clean), secret) { + t.Errorf("в логе оказалось %q:\n%s", secret, clean) + } + } + if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "delivery folded") { + folded = rec + } + } + + if folded == nil { + t.Fatalf("записи об исходе свёртки нет:\n%s", logged) + } + for _, attr := range []string{"categoricals", "categoricals_unknown", "categoricals_dropped"} { + if _, ok := folded[attr]; !ok { + t.Errorf("в записи нет счётчика %q: %v", attr, folded) + } + } + if folded["categoricals_unknown"] != float64(1) { + t.Errorf("счётчик строк без кода %v, ожидалась 1", folded["categoricals_unknown"]) + } +} + +// Срабатывание границы наблюдений обязано подниматься до WARN: границы +// подобраны по измерению, поэтому попадание в них — аномалия, а не режим, и в +// INFO оно утонуло бы среди штатных доставок раз в пять минут. +func TestFoldПревышениеГраницыДаётWarn(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) + + f, arch, st := foldService(t, log) + + // Одно непомерно длинное значение — граница длины, а не числа: тело + // остаётся маленьким, а событие то же. + long := strings.Repeat("я", 200) + body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` + + `{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` + + `"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"` + long + `"}]}]}}` + deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(body)) + + stats, err := f.Fold(context.Background(), "d1") + if err != nil { + t.Fatalf("свёртка: %v", err) + } + if stats.CategoricalDropped == 0 { + t.Fatal("граница не сработала — тест проверяет не то") + } + + var rec map[string]any + for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") { + var r map[string]any + if err := json.Unmarshal([]byte(line), &r); err != nil { + t.Fatalf("строка лога не JSON: %v", err) + } + if msg, _ := r["msg"].(string); strings.HasPrefix(msg, "delivery folded") { + rec = r + } + } + if rec == nil { + t.Fatalf("записи об исходе свёртки нет:\n%s", buf.String()) + } + if rec["level"] != "WARN" { + t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String()) + } + if rec["msg"] != "delivery folded, categorical values dropped by limit" { + t.Errorf("сообщение %v не называет событие", rec["msg"]) + } + + // И ни одного байта самой строки: она из тела доставки. + delete(rec, "time") + clean, err := json.Marshal(rec) + if err != nil { + t.Fatalf("запись лога не сериализуется: %v", err) + } + if strings.Contains(string(clean), "яяя") { + t.Errorf("в логе оказалось значение из тела:\n%s", clean) + } +} diff --git a/internal/fold/fold.go b/internal/fold/fold.go index d9f2971..30b98f6 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -9,6 +9,7 @@ package fold import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -18,6 +19,7 @@ import ( "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/hae" + "git.vakhrushev.me/av/healthlog/internal/healthkit" "git.vakhrushev.me/av/healthlog/internal/store" ) @@ -90,6 +92,16 @@ type Stats struct { Uncovered []string // UncoveredDropped — сколько имён отброшено границей списка. UncoveredDropped int + + // Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось; + // CategoricalUnknown — сколько из них словарь не знает; + // CategoricalDropped — сколько отброшено границами разбора. + // + // Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна + // описывают человека не меньше, чем число. + Categoricals int + CategoricalUnknown int + CategoricalDropped int } // ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и @@ -144,6 +156,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err parsed, err := hae.Parse(body, hae.Meta{ Aggregation: d.Aggregation, FallbackLayer: hae.Layer(fallback), + Locale: localeOf(d.Headers), }) if err != nil { // Список непокрытых секций переживает отказ: доставка, у которой не @@ -169,6 +182,9 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed stats.Layer = string(parsed.Layer) stats.LayerMismatch = parsed.LayerMismatch + stats.Categoricals = len(parsed.Categoricals) + stats.CategoricalUnknown = parsed.CategoricalUnknown + stats.CategoricalDropped = parsed.CategoricalDropped merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{ ID: d.ID, @@ -256,6 +272,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { // построчный разбор логов. Содержимого секций здесь нет. "uncovered", st.Uncovered, "uncovered_dropped", st.UncoveredDropped, + // Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов: + // «Сидячий образ жизни» — это контекст пульса, то есть данные о + // здоровье. Какие именно строки ждут словаря, отвечает реестр в базе. + "categoricals", st.Categoricals, + "categoricals_unknown", st.CategoricalUnknown, + "categoricals_dropped", st.CategoricalDropped, } if len(st.Collisions) > 0 { attrs = append(attrs, "collisions", formatCollisions(st.Collisions)) @@ -298,6 +320,19 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { // Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь, // а границу выбило больше тридцати двух. s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...) + case st.CategoricalDropped > 0: + // Тот же класс события и та же причина эскалации, что у списка секций: + // границы подобраны по измерению (на живом потоке около одиннадцати + // различных значений, самое длинное 36 байт), поэтому попадание в них — + // уже аномалия, а не режим. В INFO оно утонуло бы: поток идёт раз в пять + // минут, а `/stats` ещё нет. Соответствие «доставка → отброшено» живёт + // только здесь: лог ротируется, и к следующей сверке отпечатков причину + // уже не восстановить. + // + // `CategoricalUnknown` намеренно НЕ эскалируется: он штатно ненулевой — + // словарь покрывает только фазы сна, — и постоянный WARN обесценил бы + // уровень. + s.log.WarnContext(ctx, "delivery folded, categorical values dropped by limit", attrs...) case st.Incomparable > 0: // Выше перезаписей намеренно: несравнимый набор полей — событие реже и // информативнее, на живом потоке не случавшееся ни разу. Признаки при @@ -479,12 +514,60 @@ func toIncoming(parsed hae.Result) store.Incoming { }) } return store.Incoming{ - Points: points, - Workouts: toEntities(parsed.Workouts), - Records: toEntities(parsed.Records), + Points: points, + Workouts: toEntities(parsed.Workouts), + Records: toEntities(parsed.Records), + Categories: toCategories(parsed.Categoricals), } } +func toCategories(in []hae.Categorical) []store.CategoryValue { + if len(in) == 0 { + return nil + } + out := make([]store.CategoryValue, 0, len(in)) + for _, c := range in { + out = append(out, store.CategoryValue{ + Metric: c.Metric, + Field: c.Field, + Value: c.Value, + Code: c.Code, + }) + } + return out +} + +// localeOf достаёт язык доставки из сохранённых заголовков запроса. +// +// Отдельной колонки под язык нет намеренно: заголовки уже лежат целиком, и +// вторая копия того же факта разошлась бы с первой при первой же правке приёма. +// +// Отсутствие заголовка, неразбираемый JSON и любая другая неожиданность дают +// пустую локаль, а не отказ. Пустая локаль законна и вывода кода не отменяет: +// заголовков в сыром архиве нет вовсе, поэтому доставка, восстановленная из +// осиротевшего тела, приезжает без них — и обязана дать то же состояние. +// +// Имя заголовка ищется без учёта регистра: `http.Header` канонизирует его при +// приёме, но в базе лежит то, что было записано, и правило регистра не наше. +func localeOf(headers string) string { + if headers == "" { + return "" + } + var h map[string][]string + if err := json.Unmarshal([]byte(headers), &h); err != nil { + return "" + } + for name, values := range h { + if !strings.EqualFold(name, acceptLanguageHeader) || len(values) == 0 { + continue + } + return healthkit.Locale(values[0]) + } + return "" +} + +const acceptLanguageHeader = "Accept-Language" + func toEntities(in []hae.Entity) []store.IncomingEntity { if len(in) == 0 { return nil diff --git a/internal/hae/categorical.go b/internal/hae/categorical.go new file mode 100644 index 0000000..1fa6e81 --- /dev/null +++ b/internal/hae/categorical.go @@ -0,0 +1,257 @@ +package hae + +import ( + "encoding/json" + "sort" + "strings" + "unicode/utf8" + + "git.vakhrushev.me/av/healthlog/internal/healthkit" +) + +// Categorical — наблюдённое категориальное значение: строка перечислимого поля +// и выведенный для неё код HealthKit. +// +// Это НАБЛЮДЕНИЕ, а не копия данных: сама строка остаётся в содержимом точки +// дословно, а наблюдение говорит «такое значение поток приносил, и вот его +// стабильный код». Пустой код означает «словарь этой строки не знает» — и +// перечень таких строк есть заявка на пополнение словаря. +type Categorical struct { + // Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица + // хранения (`sleep_analysis_summary` после разделения схем, `workouts` у + // тренировок). Второе имя для того же понятия развело бы наблюдение и объект + // по разным ключам. + Metric string + // Field — имя поля внутри точки или сущности, дословно как у HAE. + Field string + // Value — строка, как прислал HAE. + Value string + // Code — канонический код HealthKit либо пустая строка. + Code string +} + +// Имена категориальных полей — дословно как у HAE. +const ( + fieldValue = "value" + fieldContext = "context" + fieldName = "name" +) + +// heartRateMetric — имя метрики пульса у HAE. +const heartRateMetric = "heart_rate" + +// pointCategoricalFields — какие поля ТОЧКИ несут перечислимое значение. +// +// Список объявлен явно, а не выведен из формы значения: строк в точке много +// (`date`, `start`, `source`), и правило «всякая строка категориальна» завело бы +// в реестр метки времени и имена устройств. Состав измерен на живом потоке +// (находка 37); новое поле — одна строка здесь и запись в словаре. +var pointCategoricalFields = map[string][]string{ + sleepMetric: {fieldValue}, + heartRateMetric: {fieldContext}, +} + +// Границы наблюдений одной доставки. +// +// Тело контролирует отправитель целиком: без границы одна доставка кладёт в +// витрину сколько угодно строк, а строки эти уезжают в первичный ключ. +// +// Числа названы измерением, а не аналогией с 32/64 у имён непокрытых секций: +// там имена короткие и латинские, здесь — русские фразы в UTF-8. На живом +// потоке различных значений по всем трём полям около одиннадцати, самое длинное +// — «Сидячий образ жизни», 36 байт (находка 37). Предел в 32 байта отбросил бы +// две из трёх измеренных строк контекста пульса. +const ( + maxCategoricalValues = 64 + maxCategoricalValueLen = 128 +) + +// categoricalKey — ключ наблюдения. Совпадает с ключом реестра в витрине: два +// разных ключа на одно понятие разошлись бы при первом же поле с одинаковым +// именем у двух метрик. +type categoricalKey struct { + metric string + field string + value string +} + +// categoricals — сборщик наблюдений одной доставки. +// +// Дедупликация обязательна: `context` повторяется в каждой точке пульса, и без +// множества доставка на две тысячи точек дала бы две тысячи одинаковых +// наблюдений. Локаль хранится здесь, а не в наблюдении: она сужает поиск по +// словарю и в ключ не входит — заголовков в сыром архиве нет, и ключ с локалью +// сделал бы состояние функцией от того, уцелела ли учётная строка. +// +// Набор ограничен ПРИ ВСТАВКЕ, а не при выдаче, и это измеренное решение, а не +// аккуратность. Накопитель без границы растёт по числу РАЗЛИЧНЫХ строк тела, а +// их контролирует отправитель: тело в 60 МиБ из миллиона различных значений +// (предел приёма — 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита +// памяти у контейнера нет, OOM в фоновой горутине свёртки не перехватывается, а +// `restart: unless-stopped` поднимает процесс — и первый же проход берёт ту же +// доставку из архива. Приём при этом стоит, а телефон доставку не перешлёт. +// Правило то же, что уже действует в этом пакете для имён непокрытых секций. +// +// Держатся 64 НАИМЕНЬШИХ ключа: усечение остаётся функцией МНОЖЕСТВА, а не +// порядка элементов на проводе. Порядок ключей у HAE нестабилен (находка 2), и +// «первые 64 по ходу разбора» давали бы разный реестр на переприсланном том же +// содержимом — расхождение вышло бы как «пересборка не сошлась», без адреса. +// +// Срез, а не куча: элементов 64, вставка двоичным поиском стоит дешевле +// поддержания инварианта кучи, а отсортированный срез заодно и есть готовый +// ответ `result`. +type categoricals struct { + locale string + // seen — членство, kept — те же ключи в порядке возрастания. Две структуры + // на одно множество: карта отвечает «видели ли», срез — «кто наибольший», + // и оба вопроса задаются на каждое вхождение. + seen map[categoricalKey]struct{} + kept []categoricalKey + dropped int +} + +func newCategoricals(locale string) *categoricals { + return &categoricals{ + locale: locale, + seen: make(map[categoricalKey]struct{}, maxCategoricalValues), + kept: make([]categoricalKey, 0, maxCategoricalValues), + } +} + +// less задаёт порядок ключей — он же порядок выдачи и он же правило усечения. +func (a categoricalKey) less(b categoricalKey) bool { + if a.metric != b.metric { + return a.metric < b.metric + } + if a.field != b.field { + return a.field < b.field + } + return a.value < b.value +} + +// add записывает наблюдение, если строка на него годится. +// +// Пустая строка наблюдением не считается: сказать о данных ей нечего, а в +// счётчике строк без кода она сидела бы вечно — тренировка без `name` даёт +// ровно её (мягкое чтение заголовка сущности превращает значение не того типа в +// пустую строку). +// +// Слишком длинное значение ОТБРАСЫВАЕТСЯ, а не обрезается: обрезанная строка +// неотличима от настоящей и попала бы в ключ реестра самостоятельным значением. +// Сама точка при этом хранится целиком — теряется наблюдение, а не данные. +func (c *categoricals) add(metric, field, value string) { + if value == "" { + return + } + if len(value) > maxCategoricalValueLen { + c.dropped++ + return + } + + key := categoricalKey{metric: metric, field: field, value: value} + if _, ok := c.seen[key]; ok { + return + } + + if len(c.kept) >= maxCategoricalValues { + // Набор полон. Ключ больше наибольшего удержанного — он и есть + // отброшенный; иначе вытесняем наибольший, а отброшенным становится он. + last := c.kept[len(c.kept)-1] + if !key.less(last) { + c.dropped++ + return + } + delete(c.seen, last) + c.kept = c.kept[:len(c.kept)-1] + c.dropped++ + } + + at := sort.Search(len(c.kept), func(i int) bool { return key.less(c.kept[i]) }) + c.kept = append(c.kept, categoricalKey{}) + copy(c.kept[at+1:], c.kept[at:]) + c.kept[at] = key + c.seen[key] = struct{}{} +} + +// addPoint снимает с точки объявленные для её метрики поля. +// +// Значение читается из уже разобранного заголовка точки и принимается только +// как JSON-строка: объяви поле `string` в самом заголовке — и точка, у которой +// `value` пришло числом, перестала бы разбираться вовсе. Это был бы новый путь +// потери данных ради удобства структуры. +func (c *categoricals) addPoint(metric string, head *pointHead) { + for _, field := range pointCategoricalFields[metric] { + var raw *json.RawMessage + switch field { + case fieldValue: + raw = head.Value + case fieldContext: + raw = head.Context + } + if raw == nil { + continue + } + if s, ok := jsonString(*raw); ok { + c.add(metric, field, s) + } + } +} + +// result отдаёт наблюдения доставки: отсортированные, усечённые границей и с +// выведенными кодами. +// +// Порядок и усечение — функция МНОЖЕСТВА, а не порядка элементов на проводе; за +// это отвечает add, здесь набор уже готов. +// +// Код выводится здесь, а не при добавлении: словарь зовётся по разу на +// РАЗЛИЧНОЕ удержанное значение, а не по разу на точку. На теле в миллион +// точек это 64 обращения к карте вместо миллиона. +func (c *categoricals) result() (out []Categorical, unknown, dropped int) { + dropped = c.dropped + + out = make([]Categorical, 0, len(c.kept)) + for _, k := range c.kept { + code := healthkit.Code(c.locale, k.value) + if code == "" { + unknown++ + } + out = append(out, Categorical{Metric: k.metric, Field: k.field, Value: k.value, Code: code}) + } + return out, unknown, dropped +} + +// jsonString читает значение как строку JSON, не считая строкой ничего другого. +// +// Проверка первого байта — та же дисциплина, что в jsonNumber: полагаться на +// тип-приёмник значило бы получить разное поведение от невидимой детали, а +// молчаливое приведение числа к строке выдумало бы за источник значение, +// которого он не присылал. +func jsonString(raw json.RawMessage) (string, bool) { + if len(raw) == 0 || raw[0] != '"' { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", false + } + // Значение, которое `encoding/json` ЗАМЕНИЛ, наблюдением не считается. + // + // Ошибки он на этом не даёт: негодную последовательность — сырой байт 0xFF, + // одинокий суррогат `\ud800` — он молча меняет на U+FFFD, и строка на выходе + // оказывается валидным UTF-8, но уже не равной пришедшим байтам (измерено: + // 0xFF даёт "\uFFFDВо сне", `utf8.ValidString` отвечает true). Проверять + // поэтому надо не годность результата, а его НЕТРОНУТОСТЬ. + // + // Реестр требует хранить значение дословно; подменённое осело бы в первичном + // ключе таблицы, у которой нет обслуживания, и кода не получило бы никогда. + // Точка при этом хранится целиком — теряется наблюдение, а не данные, и это + // та же цена, что у непомерной длины. + // + // Цена правила названа: настоящий U+FFFD в значении тоже не станет + // наблюдением. Перечислимые значения HealthKit — слова человеческого языка, + // символа замены в них не бывает, а ошибка направлена в безопасную сторону. + if strings.ContainsRune(s, utf8.RuneError) { + return "", false + } + return s, true +} diff --git a/internal/hae/categorical_test.go b/internal/hae/categorical_test.go new file mode 100644 index 0000000..ad809fe --- /dev/null +++ b/internal/hae/categorical_test.go @@ -0,0 +1,404 @@ +package hae_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "git.vakhrushev.me/av/healthlog/internal/hae" +) + +// Строки берутся ИЗ пакета testdata, а не из литерала теста: словарь набран +// литералами Go, а сверяться ему предстоит с байтами тела. Apple шлёт внутри +// своих строк неразрывные пробелы (находка 24), и литерал, набранный руками, +// такую подмену не воспроизводит — совпадение теста с реализацией доказывало бы +// только согласие автора с самим собой. +func TestParseКатегориальныеЗначенияРеальногоПакета(t *testing.T) { + t.Parallel() + + body := readTestdata(t, "sparse_sleep.json") + res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + want := []hae.Categorical{ + {Metric: "heart_rate", Field: "context", Value: "Не задано", Code: ""}, + {Metric: "sleep_analysis", Field: "value", Value: "В кровати", Code: "HKCategoryValueSleepAnalysisInBed"}, + {Metric: "sleep_analysis", Field: "value", Value: "Во сне", Code: "HKCategoryValueSleepAnalysisAsleepUnspecified"}, + } + assertCategoricals(t, res.Categoricals, want) + + // Строка, из которой выведен код, обязана остаться в точке ДОСЛОВНО: код + // приписывается, а не подменяет. + assertRawContains(t, res.Points, "sleep_analysis", `"value": "Во сне"`) + + // Контекст пульса словарём не покрыт намеренно (см. healthkit): его строка + // видна реестром с пустым кодом и считается счётчиком. + if res.CategoricalUnknown != 1 { + t.Errorf("строк без кода %d, ожидалась 1", res.CategoricalUnknown) + } + if res.CategoricalDropped != 0 { + t.Errorf("отброшено %d, ожидался ноль", res.CategoricalDropped) + } +} + +// Тысяча точек с одним контекстом даёт одно наблюдение: без дедупликации +// доставка пульса за сутки положила бы в витрину тысячи одинаковых строк. +func TestParseПовторСтрокиНеЗадваиваетНаблюдение(t *testing.T) { + t.Parallel() + + body := readTestdata(t, "raw.json") + res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + + if len(res.Points) < 10 { + t.Fatalf("в пакете %d точек — тест потерял смысл", len(res.Points)) + } + seen := make(map[string]int) + for _, c := range res.Categoricals { + seen[c.Metric+"/"+c.Field+"/"+c.Value]++ + } + for key, n := range seen { + if n != 1 { + t.Errorf("наблюдение %s встретилось %d раз", key, n) + } + } + t.Logf("точек %d, различных наблюдений %d", len(res.Points), len(res.Categoricals)) +} + +func TestParseИмяТренировкиПопадаетВНаблюдения(t *testing.T) { + t.Parallel() + + body := readTestdata(t, "workout_route.json") + res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if len(res.Workouts) == 0 { + t.Fatal("в пакете нет тренировок — тест потерял смысл") + } + + var names []string + for _, c := range res.Categoricals { + if c.Metric == "workouts" && c.Field == "name" { + if c.Code != "" { + t.Errorf("имя тренировки %q получило код %q — словарь их не покрывает", c.Value, c.Code) + } + names = append(names, c.Value) + } + } + if len(names) == 0 { + t.Fatal("имена тренировок в наблюдения не попали") + } + // Содержимое сущности не изменено: имя остаётся в нём дословно. + for _, w := range res.Workouts { + if !bytes.Contains(w.Raw, []byte(`"name"`)) { + t.Errorf("в содержимом тренировки %s нет ключа name", w.ID) + } + } +} + +// Метрика, не объявленная категориальной, наблюдений не даёт, даже если у её +// точек есть поле `value`: список объявлен явно, а не выведен из формы. +func TestParseНеобъявленноеПолеНаблюденийНеДаёт(t *testing.T) { + t.Parallel() + + body := readTestdata(t, "handmade_edge.json") + res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + for _, c := range res.Categoricals { + if c.Metric == "interval_points" { + t.Errorf("метрика interval_points дала наблюдение %q, хотя категориальной не объявлена", c.Value) + } + } +} + +func TestParseФормыКатегориальногоЗначения(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + value string + want string + unknown int + }{ + {"известная строка", `"Во сне"`, "Во сне", 0}, + {"незнакомая строка", `"Полудрёма"`, "Полудрёма", 1}, + {"число значением не считается", `123`, "", 0}, + {"null значением не считается", `null`, "", 0}, + {"объект значением не считается", `{"a":1}`, "", 0}, + {"массив значением не считается", `[1]`, "", 0}, + {"пустая строка значением не считается", `""`, "", 0}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + body := sleepBody(c.value) + res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + // Точка живёт при любой форме значения: категориальное поле не + // заводит нового пути её потери. + if len(res.Points) != 1 { + t.Fatalf("точек %d, ожидалась одна (счётчики: malformed %d, no_time %d)", + len(res.Points), res.SkippedMalformed, res.SkippedNoTime) + } + if !bytes.Contains(res.Points[0].Raw, []byte(c.value)) { + t.Errorf("содержимое точки %s не несёт исходного значения %s", res.Points[0].Raw, c.value) + } + + var got string + if len(res.Categoricals) == 1 { + got = res.Categoricals[0].Value + } else if len(res.Categoricals) > 1 { + t.Fatalf("наблюдений %d, ожидалось не больше одного", len(res.Categoricals)) + } + if got != c.want { + t.Errorf("наблюдение %q, ожидалось %q", got, c.want) + } + if res.CategoricalUnknown != c.unknown { + t.Errorf("строк без кода %d, ожидалось %d", res.CategoricalUnknown, c.unknown) + } + }) + } +} + +// Локаль сужает поиск, но её отсутствие вывода не отменяет: заголовков в сыром +// архиве нет, и усыновлённое тело обязано дать то же состояние. +func TestParseЛокальНаКлючНаблюденияНеВлияет(t *testing.T) { + t.Parallel() + + body := readTestdata(t, "sparse_sleep.json") + + withLocale, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор с локалью: %v", err) + } + without, err := hae.Parse(body, hae.Meta{FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор без локали: %v", err) + } + + assertCategoricals(t, without.Categoricals, withLocale.Categoricals) +} + +// Уцелевший при переполнении набор — функция МНОЖЕСТВА, а не порядка элементов +// на проводе: порядок ключей у HAE нестабилен, и то же содержимое, +// переприсланное иначе, не имеет права дать другой реестр. +func TestParseГраницаЧислаНаблюденийНеЗависитОтПорядка(t *testing.T) { + t.Parallel() + + values := make([]string, 0, 200) + for i := range 200 { + values = append(values, fmt.Sprintf("фаза-%03d", i)) + } + forward := hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute} + + res, err := hae.Parse(sleepBodyMany(values), forward) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if len(res.Categoricals) != 64 { + t.Fatalf("наблюдений %d, ожидался предел 64", len(res.Categoricals)) + } + if res.CategoricalDropped != len(values)-64 { + t.Errorf("отброшено %d, ожидалось %d", res.CategoricalDropped, len(values)-64) + } + if len(res.Points) != len(values) { + t.Errorf("точек %d, ожидалось %d: граница теряет наблюдение, а не данные", len(res.Points), len(values)) + } + + reversed := make([]string, len(values)) + for i, v := range values { + reversed[len(values)-1-i] = v + } + back, err := hae.Parse(sleepBodyMany(reversed), forward) + if err != nil { + t.Fatalf("разбор в обратном порядке: %v", err) + } + assertCategoricals(t, back.Categoricals, res.Categoricals) +} + +func TestParseНепомерноеЗначениеОтброшеноЦеликом(t *testing.T) { + t.Parallel() + + long := strings.Repeat("я", 200) // 400 байт UTF-8 + res, err := hae.Parse(sleepBody(`"`+long+`"`), hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if len(res.Categoricals) != 0 { + t.Errorf("наблюдений %d, ожидался ноль", len(res.Categoricals)) + } + if res.CategoricalDropped != 1 { + t.Errorf("отброшено %d, ожидалась единица", res.CategoricalDropped) + } + // Точка хранится целиком: теряется наблюдение, а не данные. + if len(res.Points) != 1 || !bytes.Contains(res.Points[0].Raw, []byte(long)) { + t.Error("непомерное значение не сохранилось в содержимом точки целиком") + } +} + +// Доставка, у которой слой не выводится, наблюдений не отдаёт: «всё или ничего» +// относится к доставке целиком, иначе реестр пополнился бы при незаписанной +// витрине и повторная свёртка перестала бы быть no-op. +func TestParseОтказПоСлоюНаблюденийНеОтдаёт(t *testing.T) { + t.Parallel() + + // Одна точка на середине часа: плотных метрик нет, наследовать нечего. + body := []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[ + {"date":"2026-07-30 21:48:17 +0300","value":"Во сне"}]}]}}`) + res, err := hae.Parse(body, hae.Meta{Locale: "ru"}) + if err == nil { + t.Skipf("слой выведен, отказа нет — проверять нечего (наблюдений %d)", len(res.Categoricals)) + } + if len(res.Categoricals) != 0 { + t.Errorf("при отказе разбора отдано %d наблюдений", len(res.Categoricals)) + } +} + +func sleepBody(value string) []byte { + return []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[ + {"date":"2026-07-30 21:00:00 +0300","start":"2026-07-30 21:00:00 +0300",` + + `"end":"2026-07-30 22:00:00 +0300","qty":1,"value":` + value + `}]}]}}`) +} + +func sleepBodyMany(values []string) []byte { + var b strings.Builder + b.WriteString(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[`) + for i, v := range values { + if i > 0 { + b.WriteByte(',') + } + // Метки ровно на часах: слой выводится, отказа не будет. + fmt.Fprintf(&b, `{"date":"2026-07-30 %02d:00:00 +0300","qty":1,"value":%q}`, i%24, v) + } + b.WriteString(`]}]}}`) + return []byte(b.String()) +} + +func assertCategoricals(t *testing.T, got, want []hae.Categorical) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("наблюдений %d, ожидалось %d:\n получено %v\n ожидалось %v", len(got), len(want), got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("наблюдение %d: %+v, ожидалось %+v", i, got[i], want[i]) + } + } +} + +func assertRawContains(t *testing.T, points []hae.Point, metric, want string) { + t.Helper() + + for _, p := range points { + if p.Metric == metric && bytes.Contains(p.Raw, []byte(want)) { + return + } + } + t.Errorf("ни одна точка метрики %s не несёт %s дословно", metric, want) +} + +func readTestdata(t *testing.T, name string) []byte { + t.Helper() + + body, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("чтение %s: %v", name, err) + } + // Файл обязан быть настоящим пакетом HAE, а не выдумкой: разбор формата + // проверяется живыми данными. + var env struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil || len(env.Data) == 0 { + t.Fatalf("%s не пакет HAE", name) + } + return body +} + +// Невалидный UTF-8 наблюдением не считается: encoding/json ошибки на нём не +// даёт, он ЗАМЕНЯЕТ негодные байты на U+FFFD — строка на выходе уже не равна +// пришедшей, а реестр требует хранить значение дословно. Точка при этом живёт. +func TestParseНевалидныйUTF8НаблюденияНеДаёт(t *testing.T) { + t.Parallel() + + // Байт 0xFF внутри строкового литерала JSON: сам литерал валиден по + // грамматике, а его содержимое — нет. + body := []byte(`{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` + + "{\"date\":\"2026-07-30 21:00:00 +0300\",\"qty\":1,\"value\":\"\xffВо сне\"}" + + `]}]}}`) + + res, err := hae.Parse(body, hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if len(res.Points) != 1 { + t.Fatalf("точек %d, ожидалась одна: невалидный UTF-8 не имеет права уносить точку", len(res.Points)) + } + if len(res.Categoricals) != 0 { + t.Errorf("наблюдений %d, ожидался ноль: %+v", len(res.Categoricals), res.Categoricals) + } + if res.CategoricalUnknown != 0 { + t.Errorf("счётчик строк без кода %d, ожидался ноль", res.CategoricalUnknown) + } +} + +// Граница числа применяется при накоплении: удерживаются 64 НАИМЕНЬШИХ ключа, а +// не первые попавшиеся. Проверяется на трёх перестановках, а не на паре. +func TestParseУдерживаютсяНаименьшиеКлючи(t *testing.T) { + t.Parallel() + + values := make([]string, 0, 100) + for i := range 100 { + values = append(values, fmt.Sprintf("фаза-%03d", i)) + } + + orders := map[string][]string{ + "по возрастанию": append([]string(nil), values...), + "по убыванию": nil, + "вперемешку": nil, + } + for i := len(values) - 1; i >= 0; i-- { + orders["по убыванию"] = append(orders["по убыванию"], values[i]) + } + // Детерминированная перестановка без источника случайности: тест обязан + // краснеть воспроизводимо. + for i := range values { + orders["вперемешку"] = append(orders["вперемешку"], values[(i*37)%len(values)]) + } + + want := values[:64] + for name, order := range orders { + t.Run(name, func(t *testing.T) { + t.Parallel() + + res, err := hae.Parse(sleepBodyMany(order), hae.Meta{Locale: "ru", FallbackLayer: hae.LayerMinute}) + if err != nil { + t.Fatalf("разбор: %v", err) + } + if len(res.Categoricals) != len(want) { + t.Fatalf("наблюдений %d, ожидалось %d", len(res.Categoricals), len(want)) + } + for i, c := range res.Categoricals { + if c.Value != want[i] { + t.Fatalf("наблюдение %d: %q, ожидалось %q — удержаны не наименьшие ключи", i, c.Value, want[i]) + } + } + }) + } +} diff --git a/internal/hae/hae.go b/internal/hae/hae.go index 2bb8f6a..01953e8 100644 --- a/internal/hae/hae.go +++ b/internal/hae/hae.go @@ -179,6 +179,32 @@ type Result struct { // по координате. SkippedBadEnd int + // Categoricals — различные наблюдённые категориальные значения доставки с + // выведенными кодами, в детерминированном порядке. Повтор одной строки в + // тысяче точек даёт один элемент. + Categoricals []Categorical + // CategoricalUnknown — сколько РАЗЛИЧНЫХ наблюдений не получило кода. + // Считаются различные значения, а не их вхождения: счётчик отвечает на + // вопрос «сколько строк ждёт словаря», а не «сколько точек их несло». + // + // В установившемся режиме он ненулевой — словарь покрывает только фазы сна, + // а контекст пульса и имена тренировок объявлены категориальными заранее. + // Сигналом «появилось новое» служит поэтому новая строка реестра, а не + // ненулевой счётчик. + CategoricalUnknown int + // CategoricalDropped — сколько ВХОЖДЕНИЙ отброшено границами: непомерная + // длина значения или переполнение числа различных значений. + // + // Единица названа вслух и отличается от соседнего счётчика намеренно. + // Считать здесь различные значения нечем: набор ограничен при вставке (иначе + // накопитель растёт вместе с телом, а тело контролирует отправитель), и + // отброшенный ключ нигде не запоминается — запомнить его значило бы вернуть + // ровно тот неограниченный рост, ради устранения которого граница и стоит на + // вставке. Поэтому счётчик отвечает на вопрос «сколько раз сработала + // граница», а не «сколько строк потеряно»; на второй отвечает реестр в + // витрине. + CategoricalDropped int + // Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие // метрики). Пустой, если плотных метрик не было и наследовать было нечего. // Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации @@ -204,6 +230,13 @@ type Meta struct { // из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий — // разбор остаётся чистой функцией. FallbackLayer Layer + + // Locale — нормализованный языковой тег доставки из `Accept-Language` + // (находка 32). Сужает поиск по словарю категориальных значений и НИКУДА не + // сохраняется: заголовков в сыром архиве нет, поэтому доставка, + // восстановленная из осиротевшего тела, приезжает без локали — и обязана + // дать то же состояние. Пустая локаль законна. + Locale string } // Parse разбирает секцию metrics тела доставки в точки. @@ -231,18 +264,28 @@ func Parse(body []byte, meta Meta) (res Result, err error) { res.Uncovered = env.uncovered res.UncoveredDropped = env.dropped + cat := newCategoricals(meta.Locale) + res.Workouts = decodeEntities(env.workouts, workoutsSection, &res) res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res) + // Имя тренировки берётся из уже разобранного заголовка сущности. Мягкое + // чтение превратило значение не того типа в пустую строку, а пустая строка + // наблюдением не считается, — то есть «имени не было» и «имя приехало + // числом» дают один исход, и он верный. + for _, w := range res.Workouts { + cat.add(workoutsSection, fieldName, w.Name) + } metrics := env.metrics res.Metrics = len(metrics) if len(metrics) == 0 { + res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result() return res, nil } groups := make([]group, 0, len(metrics)) for _, m := range metrics { - g := decodeGroup(m, &res) + g := decodeGroup(m, &res, cat) if len(g.summaries) > 0 { groups = append(groups, group{ metric: sleepSummaryMetric, @@ -258,6 +301,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) { } } if len(groups) == 0 { + res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result() return res, nil } @@ -280,6 +324,11 @@ func Parse(body []byte, meta Meta) (res Result, err error) { }, err } + // Наблюдения отдаются только на успешном исходе: «всё или ничего» относится + // к доставке целиком. У отказа по слою (см. ветку выше) сущности не + // отдаются по той же причине. + res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result() + total := 0 for _, g := range groups { total += len(g.points) @@ -400,6 +449,15 @@ type pointHead struct { // TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и // суточную сводку. Общих полей, кроме date и source, у них нет. TotalSleep *json.RawMessage `json:"totalSleep"` + + // Value и Context — категориальные поля точки (фаза сна и контекст пульса, + // находка 37). Сырыми сообщениями, а не строками: объяви их `string`, и + // точка, у которой поле пришло числом, перестала бы разбираться вовсе — + // json.Unmarshal отвечает ошибкой на несовпадение типа, а decodeGroup + // считает такую точку не разобравшейся. Новый путь потери точки ради + // удобства структуры недопустим. + Value *json.RawMessage `json:"value"` + Context *json.RawMessage `json:"context"` } // decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций, @@ -665,7 +723,7 @@ func clipSection(name string) string { // уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод // сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной // корреляции между двумя списками здесь не существует по построению. -func decodeGroup(m metricEnvelope, res *Result) group { +func decodeGroup(m metricEnvelope, res *Result, cat *categoricals) group { g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))} for _, raw := range m.Data { @@ -716,8 +774,14 @@ func decodeGroup(m metricEnvelope, res *Result) group { if m.Name == sleepMetric && head.TotalSleep != nil { p.Metric = sleepSummaryMetric g.summaries = append(g.summaries, p) + // Наблюдение снимается по ИТОГОВОМУ имени метрики, тому же, которым + // адресуется единица хранения. У суточной сводки категориальных + // полей нет, так что здесь это ноль работы, — но правило записано + // один раз и не разойдётся при следующем разделении схем. + cat.addPoint(p.Metric, &head) continue } + cat.addPoint(p.Metric, &head) g.points = append(g.points, p) } diff --git a/internal/healthkit/healthkit.go b/internal/healthkit/healthkit.go new file mode 100644 index 0000000..bb40b9d --- /dev/null +++ b/internal/healthkit/healthkit.go @@ -0,0 +1,183 @@ +// Package healthkit — знание о значениях HealthKit: словарь локализованных +// строк и эквивалентность имён кодов между версиями iOS. +// +// Отдельно от разбора HAE потому, что источников у этого знания будет два. +// HAE отдаёт перечислимые значения строками локали телефона («БДГ», «Сидячий +// образ жизни»), а родной экспорт Apple — кодами (находка 37); словарь нужен +// первому, таблица синонимов — обоим. Пакет не зависит ни от чего внутреннего и +// ничего не пишет: обе операции — чистые функции. +// +// Чего здесь нет намеренно: знания о том, КАКИЕ поля HAE несут категориальные +// значения. Имена `value`, `context`, `name` принадлежат формату HAE и живут в +// internal/hae — иначе импорт родного экспорта потянул бы за собой словарь имён +// полей чужого приложения. +package healthkit + +import "strings" + +// Префикс кодов фазы сна. Вынесен ради читаемости таблицы: без него шесть строк +// словаря отличаются друг от друга последним словом в конце длинной строки. +const sleepPrefix = "HKCategoryValueSleepAnalysis" + +// dictionary — локализованная строка → код HealthKit, по локалям. +// +// Словарь не составлен, а ВЫВЕДЕН: сопоставлением потока HAE с родным экспортом +// Apple за тот же период (docs/research/apple-health.md, находка 43). Числа +// вхождений на живом корпусе — 692 «Основная», 568 «Бодрствование», 206 «БДГ», +// 171 «Во сне», 94 «Глубокий», 38 «В кровати» — сошлись с фазами экспорта +// однозначно. +// +// Локаль одна, `ru`: другого языка телефон не присылал ни разу. Строка +// незнакомой локали получает код вторым разрядом Code, если сама строка +// однозначна, — так что вторая локаль добавляется одной записью и ничего не +// ломает. +// +// Контекста пульса и типов тренировок здесь нет, и это не пробел, а отказ +// угадывать: экспорт Apple хранит контекст пульса метаданным-числом, а не +// `HKCategoryValue*`, и сопоставление «Сидячий образ жизни» с чем бы то ни было +// осталось бы догадкой. Их строки видны реестром с пустым кодом — это и есть +// заявка на будущий вывод. +var dictionary = map[string]map[string]string{ + "ru": { + "Основная": sleepPrefix + "AsleepCore", + "Бодрствование": sleepPrefix + "Awake", + "БДГ": sleepPrefix + "AsleepREM", + "Глубокий": sleepPrefix + "AsleepDeep", + "В кровати": sleepPrefix + "InBed", + "Во сне": sleepPrefix + "AsleepUnspecified", + }, +} + +// synonyms — устаревшее имя кода → нынешнее. +// +// Коды HealthKit устойчивее локализованных строк, но не вечны: те же 338 +// записей сна экспортированы как `…Asleep` в 2021 году и как +// `…AsleepUnspecified` в 2026-м, причём счётчики сошлись до единицы — Apple +// переименовала значение и переписывает историю при выгрузке (находка 43). Без +// этой таблицы история раскололась бы вторично, уже на «стабильной» стороне. +// +// Таблица ПЛОСКАЯ: ни одно её значение не является ключом, поэтому алиас +// разрешается ровно за один шаг. Инвариант держит тест по таблице целиком, а не +// обход цепочек в рантайме: у обхода нет ни одного достижимого сценария, зато +// есть собственный вырожденный случай — «что вернуть при превышении глубины». +var synonyms = map[string]string{ + sleepPrefix + "Asleep": sleepPrefix + "AsleepUnspecified", +} + +// byValue — строка → код, когда локаль неизвестна. +// +// Пустой код означает «строка встречается в разных локалях с разными кодами» и +// от «строки нет вовсе» на выходе Code не отличается: оба исхода означают «не +// угадываем». Различать их незачем — решение одно. +// +// Индекс существует не ради удобства. Заголовки запроса в сыром архиве не +// лежат, поэтому доставка, восстановленная из осиротевшего тела, приезжает без +// `Accept-Language`; правило «нет локали — нет кода» сделало бы состояние +// функцией от того, уцелела ли учётная строка, то есть сломало бы +// `import + replay`. +var byValue = buildByValue() + +func buildByValue() map[string]string { + out := make(map[string]string) + for _, values := range dictionary { + for value, code := range values { + code = Canonical(code) + if prev, seen := out[value]; seen && prev != code { + // Расхождение локалей: выбирать не из чего. + out[value] = "" + continue + } + out[value] = code + } + } + return out +} + +// Code возвращает канонический код HealthKit для локализованной строки. +// +// Три разряда, и второй обязателен, а не удобен (см. byValue): +// +// 1. пара (локаль, строка) есть в словаре — её код; +// 2. локали нет либо пары нет, но строка однозначна по всем локалям — её код; +// 3. иначе — пустой код. +// +// Пустой код честнее догадки: по коду сверяются с экспортом Apple, а неверный +// код неотличим от верного до тех пор, пока по нему не примут решение. +func Code(locale, value string) string { + if value == "" { + return "" + } + if values, ok := dictionary[locale]; ok { + if code, ok := values[value]; ok { + return Canonical(code) + } + } + return byValue[value] +} + +// Canonical приводит устаревшее имя кода к нынешнему. +// +// Зовётся и изнутри Code, поэтому «две формы сходятся в один код» верно по +// построению, а не по дисциплине того, кто правит словарь. +func Canonical(code string) string { + if to, ok := synonyms[code]; ok { + return to + } + return code +} + +// maxLocaleTag — предел длины языкового тега. +// +// Первичный подтег BCP 47 — от двух до восьми букв; предел стоит на всём теге +// до отсечения подтегов, с запасом. Заголовок контролирует отправитель целиком, +// а `MaxHeaderBytes` у Go — мегабайт: без предела мегабайтная строка уехала бы +// в свёртку регистра и в сравнение со словарём. +const maxLocaleTag = 32 + +// Locale нормализует заголовок `Accept-Language` до языкового тега. +// +// Правило lookup RFC 4647: берётся первый тег списка, вес `q` отбрасывается, +// подтеги отсекаются, регистр сворачивается — `RU-ru,ru;q=0.9` даёт `ru`. +// Свёртка регистра обязательна: теги BCP 47 регистронезависимы, и без неё `RU` +// и `ru` были бы разными языками, а локаль сужает поиск по словарю. +// +// Согласования весов нет намеренно: измеренное значение заголовка — `ru` +// (находка 32), одна строка без вариантов. Появятся веса — правило стоит +// пересматривать целиком, а не дописывать. +// +// Не тег — пустая строка: `*`, пустой заголовок, мусор. Пустая локаль законна и +// вывода кода не отменяет (см. Code). +func Locale(header string) string { + tag := header + if i := strings.IndexAny(tag, ",;"); i >= 0 { + tag = tag[:i] + } + tag = strings.TrimSpace(tag) + if len(tag) > maxLocaleTag { + return "" + } + if i := strings.IndexByte(tag, '-'); i >= 0 { + tag = tag[:i] + } + if !isLanguageTag(tag) { + return "" + } + return strings.ToLower(tag) +} + +// isLanguageTag проверяет первичный подтег: от двух до восьми ASCII-букв. +// +// Проверка нужна не эстетике. Без неё `*` из `Accept-Language: *` стал бы +// полноценной локалью, а мусор из чужого заголовка — ключом поиска по словарю. +func isLanguageTag(s string) bool { + if len(s) < 2 || len(s) > 8 { + return false + } + for i := range len(s) { + c := s[i] + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') { + return false + } + } + return true +} diff --git a/internal/healthkit/healthkit_test.go b/internal/healthkit/healthkit_test.go new file mode 100644 index 0000000..a34bd52 --- /dev/null +++ b/internal/healthkit/healthkit_test.go @@ -0,0 +1,138 @@ +package healthkit_test + +import ( + "strings" + "testing" + + "git.vakhrushev.me/av/healthlog/internal/healthkit" +) + +// Обе формы имени, которые Apple дала одному значению, обязаны сойтись в один +// код: иначе история расколется вторично, уже на «стабильной» стороне +// (находка 43). +func TestCanonicalОбеФормыСходятся(t *testing.T) { + t.Parallel() + + const ( + old = "HKCategoryValueSleepAnalysisAsleep" + now = "HKCategoryValueSleepAnalysisAsleepUnspecified" + ) + + if got := healthkit.Canonical(old); got != now { + t.Errorf("устаревшее имя %q дало %q, ожидалось %q", old, got, now) + } + if got := healthkit.Canonical(now); got != now { + t.Errorf("нынешнее имя %q дало %q, ожидалось %q", now, got, now) + } +} + +func TestCanonicalКодБезСинонимаОстаётсяСобой(t *testing.T) { + t.Parallel() + + const code = "HKCategoryValueSleepAnalysisAsleepREM" + if got := healthkit.Canonical(code); got != code { + t.Errorf("код без синонима стал %q", got) + } + if got := healthkit.Canonical(""); got != "" { + t.Errorf("пустой код стал %q", got) + } +} + +// Плоскость таблицы синонимов — то, чем оправдано отсутствие обхода цепочек в +// рантайме. Проверяется по таблице целиком, а не на примере: правило держится +// на всей таблице, и первая же добавленная запись может его нарушить. +// +// Обходим через Canonical, а не через саму карту: наружу она не отдаётся, а +// «значение не является ключом» проверяемо снаружи — канонизация значения +// обязана быть неподвижной точкой. +func TestSynonymsТаблицаПлоская(t *testing.T) { + t.Parallel() + + // Значения таблицы наблюдаемы через Code: словарь уже канонизирован, а + // коды, которые он выдаёт, обязаны быть неподвижными точками. + for _, value := range []string{"Основная", "Бодрствование", "БДГ", "Глубокий", "В кровати", "Во сне"} { + code := healthkit.Code("ru", value) + if code == "" { + t.Fatalf("измеренная строка %q кода не дала", value) + } + if again := healthkit.Canonical(code); again != code { + t.Errorf("код %q не неподвижная точка: канонизация дала %q — таблица синонимов не плоская", code, again) + } + } + // То же для известного алиаса: его канонизация обязана быть неподвижной с + // одного шага. + once := healthkit.Canonical("HKCategoryValueSleepAnalysisAsleep") + if twice := healthkit.Canonical(once); twice != once { + t.Errorf("алиас разрешился не за один шаг: %q → %q", once, twice) + } +} + +func TestCodeФазыСна(t *testing.T) { + t.Parallel() + + cases := []struct { + locale string + value string + want string + }{ + {"ru", "Основная", "HKCategoryValueSleepAnalysisAsleepCore"}, + {"ru", "Бодрствование", "HKCategoryValueSleepAnalysisAwake"}, + {"ru", "БДГ", "HKCategoryValueSleepAnalysisAsleepREM"}, + {"ru", "Глубокий", "HKCategoryValueSleepAnalysisAsleepDeep"}, + {"ru", "В кровати", "HKCategoryValueSleepAnalysisInBed"}, + {"ru", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"}, + // Локали нет — код всё равно выводится вторым разрядом: заголовков в + // сыром архиве не лежит, и усыновлённое тело обязано дать то же + // состояние. + {"", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"}, + // Незнакомая локаль знакомой строки код не отменяет. + {"en", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"}, + // Догадок нет. + {"ru", "Полудрёма", ""}, + {"ru", "", ""}, + {"", "Сидячий образ жизни", ""}, + } + + for _, c := range cases { + t.Run(c.locale+"/"+c.value, func(t *testing.T) { + t.Parallel() + + if got := healthkit.Code(c.locale, c.value); got != c.want { + t.Errorf("Code(%q, %q) = %q, ожидалось %q", c.locale, c.value, got, c.want) + } + }) + } +} + +func TestLocaleНормализация(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + header string + want string + }{ + {"измеренное значение потока", "ru", "ru"}, + {"верхний регистр", "RU", "ru"}, + {"подтег", "ru-RU", "ru"}, + {"список с весами и регистром", "RU-ru,ru;q=0.9,en;q=0.8", "ru"}, + {"пробелы вокруг", " ru ", "ru"}, + {"вес без списка", "ru;q=1", "ru"}, + {"звёздочка тегом не является", "*", ""}, + {"пустой заголовок", "", ""}, + {"мусор", "!!!", ""}, + {"однобуквенный тег", "r", ""}, + {"непомерно длинный тег", strings.Repeat("x", 64), ""}, + {"сто тегов", strings.Repeat("en,", 100) + "ru", "en"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + if got := healthkit.Locale(c.header); got != c.want { + t.Errorf("Locale(%q) = %q, ожидалось %q", c.header, got, c.want) + } + }) + } +} diff --git a/internal/healthkit/tables_internal_test.go b/internal/healthkit/tables_internal_test.go new file mode 100644 index 0000000..83b3bda --- /dev/null +++ b/internal/healthkit/tables_internal_test.go @@ -0,0 +1,69 @@ +package healthkit + +import "testing" + +// Плоскость таблицы синонимов — то, чем оправдано отсутствие обхода цепочек в +// рантайме. Проверять её обязательно ИЗНУТРИ пакета и по самой карте: внешний +// тест умеет обойти только записи, достижимые из словаря, а запись, до которой +// словарь не дотягивается, под утверждение не попадёт вовсе — при этом +// `Canonical` вернёт промежуточный код, и в реестре осядет имя, которого в +// экспорте Apple нет. Проверено воспроизведением: с неплоской таблицей внешний +// тест остаётся зелёным. +func TestSynonymsЗначениеНеЯвляетсяКлючом(t *testing.T) { + t.Parallel() + + for from, to := range synonyms { + if _, ok := synonyms[to]; ok { + t.Errorf("синоним %q → %q, но %q сам является ключом таблицы: цепочка длиннее одного шага, "+ + "а разрешения цепочек в рантайме нет намеренно", from, to, to) + } + if from == to { + t.Errorf("синоним %q указывает на самого себя", from) + } + } +} + +// Словарь обязан быть однозначен по строке независимо от локали, и это не +// вкусовщина, а условие корректности второго разряда `Code`. +// +// Ключ реестра локали не содержит, а `mergeCategories` переписывает `code` +// безусловно. Пока строка даёт один код во всех локалях, три решения +// согласованы. Первая же строка, означающая в двух локалях разное, обнуляет +// `byValue` — и тогда код строки становится функцией того, у какой доставки +// уцелел `Accept-Language`, то есть какая свернулась последней, а не последней +// по журналу. Отпечаток этого не покажет: код в него не входит намеренно. +// Оракула у такого расхождения нет вовсе — поэтому страж стоит здесь. +func TestDictionaryСтрокаОднозначнаПоВсемЛокалям(t *testing.T) { + t.Parallel() + + codes := make(map[string]string) + locales := make(map[string]string) + for locale, values := range dictionary { + for value, code := range values { + code = Canonical(code) + if prev, seen := codes[value]; seen && prev != code { + t.Errorf("строка %q означает %q в локали %q и %q в локали %q: "+ + "код станет функцией порядка свёртки, а не журнала", + value, prev, locales[value], code, locale) + continue + } + codes[value] = code + locales[value] = locale + } + } +} + +// Словарь не должен молча раздваивать код: два разных ключа с одним кодом +// законны (синонимы перевода), а вот пустой код в словаре — нет. Пустота +// означает «не знаем», и записывать её явно значит выдать незнание за знание. +func TestDictionaryПустыхКодовНет(t *testing.T) { + t.Parallel() + + for locale, values := range dictionary { + for value, code := range values { + if code == "" { + t.Errorf("словарь локали %q сопоставляет %q пустому коду", locale, value) + } + } + } +} diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go index a6f631f..17e994b 100644 --- a/internal/replay/archive_test.go +++ b/internal/replay/archive_test.go @@ -16,7 +16,9 @@ import ( "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/hae" + "git.vakhrushev.me/av/healthlog/internal/healthkit" "git.vakhrushev.me/av/healthlog/internal/ident" + "git.vakhrushev.me/av/healthlog/internal/replay" "git.vakhrushev.me/av/healthlog/internal/store" ) @@ -155,6 +157,90 @@ func TestReplayЖивогоАрхива(t *testing.T) { coords, labels) } t.Logf("координат сна %d, различных меток %d", coords, labels) + + measureCategories(t, dst, second) +} + +// measureCategories проверяет реестр категориальных значений на живом корпусе. +// +// Здесь он проверяется единственным способом, каким это вообще возможно: против +// строк, которые телефон действительно присылал. Заголовков в архиве нет, то +// есть локаль у всех доставок пуста — прогон заодно доказывает, что вывод кода +// не зависит от уцелевшей учётной строки. +// +// Утверждаются СВОЙСТВА, а не числа: число строк реестра и число строк без кода +// растут вместе с потоком, а прогон живого архива в гейт не входит — константа +// покраснела бы молча (docs/review.md, 2026-08-02). Измеренное печатается. +func measureCategories(t *testing.T, dst *store.Store, second replay.Report) { + t.Helper() + + values, err := dst.CategoryValues(context.Background()) + if err != nil { + t.Fatalf("реестр категориальных значений: %v", err) + } + if len(values) == 0 { + t.Fatal("реестр пуст — категориальные значения не извлекаются вовсе") + } + if int64(len(values)) != second.Categories { + t.Errorf("повторное проигрывание изменило число строк реестра: %d → %d", + len(values), second.Categories) + } + + var withCode, withoutCode int + for _, v := range values { + if v.Code == "" { + withoutCode++ + continue + } + withCode++ + // Всякий выведенный код обязан быть каноническим: обе формы имени, + // которые Apple дала одному значению, сходятся в одну (находка 43). + // Координаты — метрика и поле; сам код не печатается: он константа + // бинаря, но напечатанный рядом с живым архивом сообщает, что эта фаза + // у человека была. + if healthkit.Canonical(v.Code) != v.Code { + t.Errorf("%s/%s: выведенный код не канонический", v.Metric, v.Field) + } + // Провенанс — доставка журнала, а не пустое место. + if v.FirstDeliveryID == "" || v.FirstSeen.IsZero() { + t.Errorf("%s/%s: провенанс не заполнен", v.Metric, v.Field) + } + } + + // Фазы сна — единственное, что словарь покрывает, и покрывать он их обязан: + // ради этой сверки задача и существует. Строки берутся из ЖИВОГО архива, а + // не из литерала теста. + var sleepValues, sleepCoded int + for _, v := range values { + if v.Metric != "sleep_analysis" || v.Field != "value" { + continue + } + sleepValues++ + if v.Code != "" { + sleepCoded++ + } + } + if sleepValues == 0 { + t.Fatal("фаз сна в реестре нет — проверять нечего") + } + if sleepCoded != sleepValues { + // Печатается ЧИСЛО, а не перечень. Перечень был бы удобнее, и ровно + // поэтому его тут быть не может: прогон идёт против рабочего архива, а + // фаза сна — значение точки. `CLAUDE.md`, «Запреты»: ничего из `./data` + // не попадает ни в логи выше `DEBUG`, ни в вывод агента. Ветка + // срабатывает как раз тогда, когда телефон принёс НОВУЮ фазу, то есть + // именно тогда, когда соблазн напечатать её сильнее всего. + // + // Адрес, по которому строки смотрят, отпечатывать не нужно: они лежат в + // пересобранной базе, `SELECT value FROM category_value WHERE code = ''`. + t.Errorf("фаз сна без кода %d из %d — словарь неполон; строки смотреть в реестре пересобранной базы", + sleepValues-sleepCoded, sleepValues) + } + + // Числа печатаются: они растут с корпусом и утверждению не подлежат. + // Значения при этом НЕ печатаются — это данные о здоровье. + t.Logf("реестр: строк %d, с кодом %d, без кода %d; фаз сна %d, все с кодом", + len(values), withCode, withoutCode, sleepValues) } // measureStyles прогоняет измерение рода агрегации на витрине, собранной из diff --git a/internal/replay/replay.go b/internal/replay/replay.go index 7358c39..731807b 100644 --- a/internal/replay/replay.go +++ b/internal/replay/replay.go @@ -79,8 +79,12 @@ type Report struct { // Workouts и Records — остальные единицы хранения витрины. Считаются рядом // с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а // решение о подмене базы необратимо и требует направления расхождения. - Workouts int64 - Records int64 + Workouts int64 + Records int64 + // Categories — строки реестра категориальных значений: четвёртая единица + // хранения витрины. Без счётчика расхождение по ней безадресно — объекты, + // тренировки и записи при этом не меняются вовсе. + Categories int64 Fingerprint string // Canceled — проигрывание прервано отменой, а не дошло до конца. @@ -184,6 +188,10 @@ func Run(ctx context.Context, o Options) (Report, error) { if err != nil { return stopOr(rep, err) } + rep.Categories, err = o.Target.CountCategoryValues(ctx) + if err != nil { + return stopOr(rep, err) + } rep.Fingerprint, err = o.Target.Fingerprint(ctx) if err != nil { return stopOr(rep, err) @@ -207,7 +215,8 @@ func Run(ctx context.Context, o Options) (Report, error) { "entities_diverging", rep.EntitiesDiverging, "buckets", rep.Buckets, "workouts", rep.Workouts, - "records", rep.Records) + "records", rep.Records, + "category_values", rep.Categories) return rep, nil } diff --git a/internal/store/bucket.go b/internal/store/bucket.go index 35be2c7..9e1c202 100644 --- a/internal/store/bucket.go +++ b/internal/store/bucket.go @@ -246,7 +246,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge stats.RecordsWritten = written stats.EntitiesHeld += held stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...)) - return nil + + return mergeCategories(ctx, tx, in.Categories, from) }) if err != nil { return MergeStats{}, err @@ -602,6 +603,18 @@ func readBucket(ctx context.Context, tx *sql.Tx, key bucketKey) (Bucket, bool, e }, true, nil } +// writeBucket пишет часовой объект. +// +// `first_delivery_id` в `DO UPDATE` НЕ входит намеренно: провенанс объекта — +// «кто создал строку», то есть функция ПОРЯДКА СВЁРТКИ, а не журнала. Это +// допустимо ровно потому, что в отпечаток витрины он не идёт +// (см. fingerprintBuckets): расхождение по нему ненаблюдаемо и решений по нему +// не принимают. +// +// Единице хранения, чей провенанс входит в отпечаток, такого правила МАЛО — там +// нужен явный минимум по журналу, иначе живой приём и пересборка разойдутся при +// одинаковом журнале. Образец — mergeCategories в category.go. Сказано здесь, +// потому что копировать будут отсюда: этот upsert старше и проще. func writeBucket(ctx context.Context, tx *sql.Tx, b Bucket, now time.Time) error { payload, err := encodePayload(b.Points) if err != nil { @@ -806,10 +819,12 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) { // Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а // отпечаток — нет. // -// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи. -// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся -// тренировках, то есть ломался бы молча тем самым изменением, которое добавило -// данные. +// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки, записи и реестр +// категориальных значений. Отпечаток одних объектов давал бы «состояние +// сошлось» при разъехавшихся тренировках, то есть ломался бы молча тем самым +// изменением, которое добавило данные. +// +// От реестра берётся НАБЛЮДЕНИЕ, но не выведенный код: см. fingerprintCategories. // // Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается // под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до» @@ -830,17 +845,65 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) { if err := fingerprintEntities(ctx, tx, h); err != nil { return "", err } + if err := fingerprintCategories(ctx, tx, h); err != nil { + return "", err + } return hex.EncodeToString(h.Sum(nil)), nil } // Признак раздела впереди строки: без него строка одного раздела может совпасть // со строкой другого, и два разных состояния витрины дали бы один отпечаток. const ( - fpBucket = "b" - fpWorkout = "w" - fpRecord = "r" + fpBucket = "b" + fpWorkout = "w" + fpRecord = "r" + fpCategory = "c" ) +// fingerprintCategories добавляет в отпечаток реестр категориальных значений — +// его НАБЛЮДЕНИЕ, но не выведенный код. +// +// Ключ и провенанс — функция журнала: те же тела в том же порядке дают их +// побайтно. Код — функция журнала И версии словаря в бинаре. Включи его сюда, и +// отпечаток перестал бы отвечать на свой единственный вопрос («дал ли повтор +// журнала то же состояние») ровно тогда, когда его задают: всякое пополнение +// словаря давало бы расхождение при побайтно совпавшем журнале, а человек, +// принимающий по отпечатку необратимое решение о подмене базы, читал бы это как +// дефект. Правильность вывода кода проверяют тесты словаря — это другой вопрос, +// и смешение обесценило бы оракул. +// +// Значений наружу отпечаток не раскрывает: он хеш. +func fingerprintCategories(ctx context.Context, tx *sql.Tx, h io.Writer) error { + const q = ` + SELECT metric, field, value, first_seen_utc, first_delivery_id + FROM category_value ORDER BY metric, field, value` + + rows, err := tx.QueryContext(ctx, q) + if err != nil { + return fmt.Errorf("select category values: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var metric, field, value, firstSeen, deliveryID string + if err := rows.Scan(&metric, &field, &value, &firstSeen, &deliveryID); err != nil { + return fmt.Errorf("scan category value: %w", err) + } + // Длина впереди каждого поля переменной длины: значение приходит из тела + // дословно и может содержать что угодно, включая признак раздела и + // разделители. Без длины пара (`a`, `b|c`) дала бы ту же строку, что + // (`a|b`, `c`), — то есть два разных состояния витрины сошлись бы + // отпечатком. Тот же приём в fingerprintBuckets и по той же причине. + fmt.Fprintf(h, "%s|%d:%s|%d:%s|%d:%s|%s|%d:%s\n", + fpCategory, len(metric), metric, len(field), field, + len(value), value, firstSeen, len(deliveryID), deliveryID) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("select category values: %w", err) + } + return nil +} + func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error { const q = ` SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket diff --git a/internal/store/category.go b/internal/store/category.go new file mode 100644 index 0000000..7628be9 --- /dev/null +++ b/internal/store/category.go @@ -0,0 +1,150 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// CategoryValue — строка реестра категориальных значений: перечислимая строка, +// которую приносил поток, и выведенный для неё код HealthKit. +// +// Значение хранится дословно; код — кэш чистой функции от словаря в бинаре, и в +// отпечаток витрины он не входит (см. fingerprintCategories). +type CategoryValue struct { + // Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица + // хранения. + Metric string + // Field — имя поля внутри точки или сущности, дословно как у HAE. + Field string + // Value — строка, как прислал HAE. + Value string + // Code — канонический код HealthKit; пустой означает «словарь не знает». + Code string + + // FirstSeen и FirstDeliveryID — провенанс ПЕРВОЙ встречи по порядку журнала. + // Заполняются хранилищем, вызывающему при записи не нужны. + FirstSeen time.Time + FirstDeliveryID string +} + +// mergeCategories записывает наблюдения доставки в реестр. +// +// Правило записи одно и оно решает всё: код обновляется ВСЕГДА, провенанс — +// только вниз, к более раннему месту в журнале. Иначе повторная свёртка той же +// доставки меняла бы состояние, а проигрывание журнала в порядке `(received_at, +// id)` давало бы не то, что живой приём. +// +// Сравнение провенанса идёт парой `(received_at, id)`, а не одной меткой: +// доставки одной секунды в журнале упорядочены идентификатором, и сравнение по +// одной метке сделало бы исход зависящим от того, какая из них свернулась +// раньше. Пара выражена лексикографически прямо в SQL — переносить сравнение в +// Go значило бы читать строку, решать и писать, то есть добавлять обращение к +// базе на каждое наблюдение внутри транзакции. +func mergeCategories(ctx context.Context, tx *sql.Tx, values []CategoryValue, from DeliveryRef) error { + if len(values) == 0 { + return nil + } + + const q = ` + INSERT INTO category_value + (metric, field, value, code, first_seen_utc, first_delivery_id) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (metric, field, value) DO UPDATE SET + code = excluded.code, + first_seen_utc = CASE + WHEN (excluded.first_seen_utc, excluded.first_delivery_id) + < (category_value.first_seen_utc, category_value.first_delivery_id) + THEN excluded.first_seen_utc ELSE category_value.first_seen_utc END, + first_delivery_id = CASE + WHEN (excluded.first_seen_utc, excluded.first_delivery_id) + < (category_value.first_seen_utc, category_value.first_delivery_id) + THEN excluded.first_delivery_id ELSE category_value.first_delivery_id END` + + stmt, err := tx.PrepareContext(ctx, q) + if err != nil { + return fmt.Errorf("prepare category upsert: %w", err) + } + defer func() { _ = stmt.Close() }() + + at := FormatTime(from.ReceivedAt) + for _, v := range values { + if _, err := stmt.ExecContext(ctx, v.Metric, v.Field, v.Value, v.Code, at, from.ID); err != nil { + // Значение в текст ошибки не попадает: строка категориального + // значения — данные о здоровье наравне со значением точки. + // + // Координаты обрезаются, хотя сегодня они приходят из объявленного + // списка полей и произвольной строкой из тела быть не могут. Предел + // стоит здесь, а не держится на форме того списка: правило вида + // «поле `value` у метрик с таким-то именем» уронило бы имя метрики + // из чужого тела прямо в `ERROR`, и заметить это было бы нечем. + return fmt.Errorf("upsert category value (%s/%s): %w", + clipCoord(v.Metric), clipCoord(v.Field), err) + } + } + return nil +} + +// CategoryValues возвращает реестр целиком в детерминированном порядке. +// +// Реестр мал по построению — на живом потоке различных значений около +// одиннадцати, — поэтому страничного чтения у него нет и заводить его незачем: +// границы разбора не дают доставке положить больше 64 значений, а число полей +// объявлено списком. +func (s *Store) CategoryValues(ctx context.Context) ([]CategoryValue, error) { + const q = ` + SELECT metric, field, value, code, first_seen_utc, first_delivery_id + FROM category_value ORDER BY metric, field, value` + + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("select category values: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []CategoryValue + for rows.Next() { + var v CategoryValue + var firstSeen string + if err := rows.Scan(&v.Metric, &v.Field, &v.Value, &v.Code, &firstSeen, &v.FirstDeliveryID); err != nil { + return nil, fmt.Errorf("scan category value: %w", err) + } + v.FirstSeen, err = ParseTime(firstSeen) + if err != nil { + return nil, err + } + out = append(out, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("select category values: %w", err) + } + return out, nil +} + +// CountCategoryValues возвращает число строк реестра. +// +// Нужно отчёту пересборки: реестр — единица хранения витрины, и без счётчика +// «до и после» расхождение отпечатков по нему безадресно — числа объектов, +// тренировок и записей при этом не меняются. Перечислением отвечать нельзя: +// сами строки — данные о здоровье, а отчёт печатается человеку в терминал. +func (s *Store) CountCategoryValues(ctx context.Context) (int64, error) { + var n int64 + if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM category_value`); err != nil { + return 0, fmt.Errorf("count category values: %w", err) + } + return n, nil +} + +// maxCoordInLog — предел длины координаты наблюдения в тексте ошибки. +// +// Та же граница и по той же причине, что у имени метрики в координатах +// столкновения: значение из тела ничем не ограничено, а предел приёма — 64 МиБ. +const maxCoordInLog = 64 + +func clipCoord(s string) string { + if len(s) <= maxCoordInLog { + return s + } + return s[:maxCoordInLog] + "…" +} diff --git a/internal/store/category_test.go b/internal/store/category_test.go new file mode 100644 index 0000000..904e1c4 --- /dev/null +++ b/internal/store/category_test.go @@ -0,0 +1,285 @@ +package store_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "git.vakhrushev.me/av/healthlog/internal/store" +) + +func category(metric, field, value, code string) store.CategoryValue { + return store.CategoryValue{Metric: metric, Field: field, Value: value, Code: code} +} + +func mergeCategories(t *testing.T, st *store.Store, d store.DeliveryRef, vs ...store.CategoryValue) { + t.Helper() + + if _, err := st.Merge(context.Background(), store.Incoming{Categories: vs}, d); err != nil { + t.Fatalf("слияние наблюдений: %v", err) + } +} + +func categories(t *testing.T, st *store.Store) []store.CategoryValue { + t.Helper() + + got, err := st.CategoryValues(context.Background()) + if err != nil { + t.Fatalf("чтение реестра: %v", err) + } + return got +} + +func TestMergeCategoriesКладётНаблюдение(t *testing.T) { + t.Parallel() + + st := open(t) + d := from(t, "01AAA", "2026-08-03T10:00:00Z") + mergeCategories(t, st, d, + category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"), + category("heart_rate", "context", "Не задано", "")) + + got := categories(t, st) + if len(got) != 2 { + t.Fatalf("строк реестра %d, ожидалось 2: %+v", len(got), got) + } + // Порядок детерминирован ключом. + if got[0].Metric != "heart_rate" || got[1].Metric != "sleep_analysis" { + t.Errorf("порядок реестра не по ключу: %+v", got) + } + // Пустой код — законное состояние: словарь этой строки не знает. + if got[0].Code != "" { + t.Errorf("контекст пульса получил код %q", got[0].Code) + } + if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" { + t.Errorf("фаза сна получила код %q", got[1].Code) + } + if got[1].FirstDeliveryID != d.ID || !got[1].FirstSeen.Equal(d.ReceivedAt) { + t.Errorf("провенанс %s/%s, ожидался %s/%s", + got[1].FirstDeliveryID, got[1].FirstSeen, d.ID, d.ReceivedAt) + } +} + +// Повторная свёртка той же доставки не меняет ни одной колонки: иначе состояние +// зависело бы от числа прогонов, а пересборка перестала бы быть no-op. +func TestMergeCategoriesПовторНичегоНеМеняет(t *testing.T) { + t.Parallel() + + st := open(t) + d := from(t, "01AAA", "2026-08-03T10:00:00Z") + v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified") + + mergeCategories(t, st, d, v) + first := fingerprint(t, st) + before := categories(t, st) + + mergeCategories(t, st, d, v) + if got := fingerprint(t, st); got != first { + t.Error("повторная свёртка сдвинула отпечаток") + } + after := categories(t, st) + if len(after) != len(before) || after[0] != before[0] { + t.Errorf("повтор изменил реестр: было %+v, стало %+v", before, after) + } +} + +// Провенанс — МИНИМУМ по журналу, а не последняя запись: иначе проигрывание +// журнала давало бы не то, что живой приём, и порядок свёртки решал бы исход. +// +// Три доставки во всех шести порядках, а не пара: пара доказывает +// коммутативность и молчит про ассоциативность. +func TestMergeCategoriesПровенансНеЗависитОтПорядка(t *testing.T) { + t.Parallel() + + deliveries := []store.DeliveryRef{ + from(t, "01AAA", "2026-08-01T10:00:00Z"), + from(t, "01BBB", "2026-08-02T10:00:00Z"), + // Та же секунда, что у предыдущей: порядок задаёт идентификатор, и + // сравнение по одной метке сделало бы исход зависящим от того, какая + // свернулась раньше. + from(t, "01AAB", "2026-08-02T10:00:00Z"), + } + v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified") + + orders := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} + var want string + for _, order := range orders { + t.Run(fmt.Sprint(order), func(t *testing.T) { + st := open(t) + for _, i := range order { + mergeCategories(t, st, deliveries[i], v) + } + got := categories(t, st) + if len(got) != 1 { + t.Fatalf("строк реестра %d, ожидалась одна", len(got)) + } + if got[0].FirstDeliveryID != "01AAA" { + t.Errorf("провенанс %s, ожидалась самая ранняя доставка 01AAA", got[0].FirstDeliveryID) + } + // Отпечаток обязан совпасть у всех шести порядков. + fp := fingerprint(t, st) + if want == "" { + want = fp + } else if fp != want { + t.Errorf("порядок %v дал другой отпечаток", order) + } + }) + } +} + +// Наблюдение в отпечаток входит, выведенный код — нет. Код производен от +// словаря в бинаре, а не от журнала: включённый в отпечаток, он заставил бы +// всякое пополнение словаря давать расхождение при совпавшем журнале. +func TestFingerprintРеестрБезКода(t *testing.T) { + t.Parallel() + + d := from(t, "01AAA", "2026-08-03T10:00:00Z") + + withCode := open(t) + mergeCategories(t, withCode, d, category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")) + + otherCode := open(t) + mergeCategories(t, otherCode, d, category("sleep_analysis", "value", "Во сне", "")) + + if fingerprint(t, withCode) != fingerprint(t, otherCode) { + t.Error("расхождение только по коду сдвинуло отпечаток — оракул сходимости стал функцией версии словаря") + } + + otherValue := open(t) + mergeCategories(t, otherValue, d, category("sleep_analysis", "value", "Бодрствование", "HKCategoryValueSleepAnalysisAwake")) + if fingerprint(t, withCode) == fingerprint(t, otherValue) { + t.Error("разошедшееся наблюдение отпечаток не сдвинуло") + } +} + +// Значение приходит из тела дословно и может содержать что угодно, включая +// признак раздела и разделители полей. Длина впереди каждого поля — то, что не +// даёт двум разным состояниям витрины сойтись отпечатком. +func TestFingerprintРазделительВЗначенииГраницуНеПодделывает(t *testing.T) { + t.Parallel() + + d := from(t, "01AAA", "2026-08-03T10:00:00Z") + + a := open(t) + mergeCategories(t, a, d, category("sleep", "value|c|9", "x", "")) + + b := open(t) + mergeCategories(t, b, d, category("sleep", "value", "|c|9|x", "")) + + if fingerprint(t, a) == fingerprint(t, b) { + t.Error("разное разбиение тех же байтов по полям дало один отпечаток") + } +} + +// Пустой реестр отпечаток не ломает: раздел, у которого нет строк, не пишет в +// хеш ничего. +func TestFingerprintПустойРеестр(t *testing.T) { + t.Parallel() + + empty := open(t) + ctx := context.Background() + + fp, err := empty.Fingerprint(ctx) + if err != nil { + t.Fatalf("отпечаток пустой витрины: %v", err) + } + if fp == "" { + t.Error("отпечаток пустой витрины пуст") + } + n, err := empty.CountCategoryValues(ctx) + if err != nil { + t.Fatalf("счёт реестра: %v", err) + } + if n != 0 { + t.Errorf("строк реестра %d, ожидался ноль", n) + } +} + +// Отказ слияния не оставляет строк реестра: доставка — единица свёртки, и +// частичное состояние повторная свёртка не чинит. +func TestMergeCategoriesОтказНеОставляетСтрок(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + + // Точка, содержимое которой не канонизируется, роняет всю транзакцию. + in := store.Incoming{ + Points: []store.IncomingPoint{point(t, "m", "hour", "2026-08-03T10:00:00Z", "2026-08-03T10:00:00Z", `{{{`)}, + Categories: []store.CategoryValue{category("sleep_analysis", "value", "Во сне", "код")}, + } + if _, err := st.Merge(ctx, in, from(t, "01AAA", "2026-08-03T10:00:00Z")); err == nil { + t.Skip("слияние не отказало — проверять нечего") + } + if got := categories(t, st); len(got) != 0 { + t.Errorf("после отказа в реестре %d строк: %+v", len(got), got) + } +} + +// Доставки приходят внахлёст, а провенанс реестра выбирается правилом +// «минимум по журналу» прямо в SQL. Правило, ни разу не исполненное +// конкурентно, проверено ровно наполовину: у точек такой прогон есть с самого +// начала (TestMergeКонкурентноеСлияниеНеТеряетТочки), у реестра его не было. +// +// Порядок горутин недетерминирован намеренно — в этом весь смысл: исход обязан +// быть функцией МНОЖЕСТВА доставок, а не того, кто первым добрался до +// транзакции. +func TestMergeCategoriesКонкурентноНеПортитПровенанс(t *testing.T) { + t.Parallel() + + st := open(t) + ctx := context.Background() + + const writers = 8 + const perWriter = 10 + + shared := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified") + var wg sync.WaitGroup + errs := make(chan error, writers) + + for w := range writers { + wg.Go(func() { + for i := range perWriter { + // Метка приёма растёт вместе с номером доставки: самая ранняя — + // у первой итерации первого писателя, и она обязана победить + // независимо от того, кто дошёл до базы раньше. + n := w*perWriter + i + d := store.DeliveryRef{ + ID: fmt.Sprintf("d%03d", n), + ReceivedAt: ts(t, "2026-08-03T10:00:00Z").Add(time.Duration(n) * time.Second), + } + // Общий ключ у всех писателей плюс свой собственный: проверяется + // и спор за одну строку, и параллельная вставка разных. + own := category("heart_rate", "context", fmt.Sprintf("контекст-%03d", n), "") + if _, err := st.Merge(ctx, store.Incoming{Categories: []store.CategoryValue{shared, own}}, d); err != nil { + errs <- err + return + } + } + }) + } + + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("конкурентное слияние реестра: %v", err) + } + + got := categories(t, st) + if len(got) != writers*perWriter+1 { + t.Fatalf("строк реестра %d, ожидалось %d", len(got), writers*perWriter+1) + } + for _, v := range got { + if v.Metric != "sleep_analysis" { + continue + } + if v.FirstDeliveryID != "d000" { + t.Errorf("провенанс общей строки %q, ожидалась самая ранняя доставка d000", v.FirstDeliveryID) + } + if !v.FirstSeen.Equal(ts(t, "2026-08-03T10:00:00Z")) { + t.Errorf("метка первой встречи %s, ожидалась 2026-08-03T10:00:00Z", v.FirstSeen) + } + } +} diff --git a/internal/store/delivery.go b/internal/store/delivery.go index 828ee77..59b5f9a 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -376,18 +376,23 @@ type DeliveryBody struct { RawPath string AutomationID string Aggregation string + // Headers — заголовки запроса JSON-объектом. Разбору нужен ровно один из + // них, `Accept-Language`: он задаёт язык, на котором приехали + // категориальные строки, и сужает поиск по словарю. Колонки под язык нет + // намеренно — она была бы вторым домом факта, который уже лежит здесь. + Headers string } // DeliveryForParse возвращает сведения о доставке, нужные разбору. func (s *Store) DeliveryForParse(ctx context.Context, id string) (DeliveryBody, error) { const q = ` - SELECT id, received_at, raw_path, automation_id, aggregation + SELECT id, received_at, raw_path, automation_id, aggregation, headers FROM delivery WHERE id = ?` var d DeliveryBody var receivedAt string err := s.db.QueryRowxContext(ctx, q, id). - Scan(&d.ID, &receivedAt, &d.RawPath, &d.AutomationID, &d.Aggregation) + Scan(&d.ID, &receivedAt, &d.RawPath, &d.AutomationID, &d.Aggregation, &d.Headers) if errors.Is(err, sql.ErrNoRows) { return DeliveryBody{}, ErrNotFound } diff --git a/internal/store/entity.go b/internal/store/entity.go index eaa06c8..95f7dd7 100644 --- a/internal/store/entity.go +++ b/internal/store/entity.go @@ -46,6 +46,10 @@ type Incoming struct { Points []IncomingPoint Workouts []IncomingEntity Records []IncomingEntity + // Categories — наблюдённые категориальные значения доставки. Пишутся той же + // транзакцией: состояние «точки легли, реестр нет» повторная свёртка не + // чинит — хеш содержимого сойдётся, и объекты переписываться не станут. + Categories []CategoryValue } // DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней diff --git a/internal/store/migrations/00010_category_value.sql b/internal/store/migrations/00010_category_value.sql new file mode 100644 index 0000000..daceb26 --- /dev/null +++ b/internal/store/migrations/00010_category_value.sql @@ -0,0 +1,67 @@ +-- +goose Up +-- Реестр категориальных значений: какие перечислимые строки поток приносил и +-- какой у них стабильный код HealthKit. +-- +-- HAE отдаёт фазу сна как «БДГ», контекст пульса как «Сидячий образ жизни», тип +-- тренировки как «В помещении Ходьба» — строками локали телефона, а родной +-- экспорт Apple говорит кодами (`HKCategoryValueSleepAnalysisAsleepREM`). +-- Источники несопоставимы, и на этой сверке стоит устаревание нижнего слоя. +-- Словарь фаз сна выведен сопоставлением потока с экспортом за тот же период +-- (docs/research/apple-health.md, находка 43). +-- +-- ПОЧЕМУ ОТДЕЛЬНАЯ ТАБЛИЦА, А НЕ ПОЛЕ РЯДОМ СО СТРОКОЙ В ТОЧКЕ. Точка хранится +-- исходными байтами; дописать в неё ключ можно только пересериализацией, а она +-- теряет литерал — ровно то, от чего эти байты и защищают. Параллельный массив +-- кодов в `bucket` завёл бы производную величину в путь слияния и хеширования: +-- правило полноты, тай-брейк и `content_hash` пришлось бы учить носить код, не +-- давая ему влиять на исход. Это правка на поверхности critical-инвариантов +-- ради нуля новых сведений — код есть функция от того, что уже лежит. И +-- пополнение словаря переписывало бы каждый объект с фазами сна; здесь меняется +-- десяток строк. +-- +-- ПОЧЕМУ ЛОКАЛИ НЕТ В КЛЮЧЕ. Она приезжает заголовком `Accept-Language`, а +-- заголовков в сыром архиве не лежит: они были заголовками запроса, а не телом. +-- Доставка, восстановленная из осиротевшего тела, приходит без локали — и ключ +-- с локалью положил бы вторую строку на то же значение, то есть состояние стало +-- бы функцией от того, уцелела ли учётная строка. Ключ по трём полям — функция +-- одних тел. На каком языке приехала строка, восстанавливается по доставке +-- провенанса: `first_delivery_id` → `delivery.headers`. +-- +-- ПОЧЕМУ ПРОВЕНАНС ТОЛЬКО ПЕРВОЙ ВСТРЕЧИ. Минимум по журналу идемпотентен при +-- повторной свёртке той же доставки; счётчик встреч не идемпотентен и сделал бы +-- состояние зависящим от числа прогонов. «Когда эта строка появилась впервые» +-- отвечает на вопрос о смене языка телефона — «сколько раз» не отвечает ни на +-- один заданный. +-- +-- `code` — КЭШ. Он производная не от журнала, а от словаря в бинаре, и потому +-- в отпечаток витрины не входит (см. Fingerprint): включённый туда, он заставил +-- бы всякое пополнение словаря давать расхождение при побайтно совпавшем +-- журнале, а человек, принимающий по отпечатку необратимое решение о подмене +-- базы, читал бы это как дефект. Строка, переставшая приезжать, держит код +-- прежнего словаря до пересборки — осознанная цена, названная вслух. +-- +-- Пустой код — законное состояние: он означает «словарь этой строки не знает». +-- Пустая строка, а не NULL: различать «кода нет» и «кода не считали» здесь +-- нечем — вывод кода идёт на каждой встрече, и третьего состояния у него не +-- бывает. +-- +-- `WITHOUT ROWID`: обращение всегда по полному первичному ключу, а строк +-- единицы — на живом потоке различных значений по всем трём полям около +-- одиннадцати. +-- +-- Data-миграции нет и быть не может: коды выводятся из ТЕЛ, а тела лежат в +-- архиве, а не в базе. Реестр рабочей витрины наполняется по мере свёртки новых +-- доставок и целиком — пересборкой. Отсюда первое расхождение отпечатков после +-- выкатки: оно законно, и отчёт `reindex` называет его ожидаемым классом. +CREATE TABLE category_value ( + metric TEXT NOT NULL, + field TEXT NOT NULL, + value TEXT NOT NULL, + code TEXT NOT NULL, + first_seen_utc TEXT NOT NULL, + first_delivery_id TEXT NOT NULL, + PRIMARY KEY (metric, field, value) +) WITHOUT ROWID; + +-- +goose Down +DROP TABLE category_value; diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/.openspec.yaml b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/.openspec.yaml new file mode 100644 index 0000000..e08b5f8 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-03 diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md new file mode 100644 index 0000000..39c1ba6 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md @@ -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; ретеншена архива пока нет. diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/proposal.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/proposal.md new file mode 100644 index 0000000..ca4d7d2 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/proposal.md @@ -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 + + +### 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` обязателен: правило разбора меняется. diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/code-triage.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/code-triage.md new file mode 100644 index 0000000..aafc420 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/code-triage.md @@ -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` с измерениями формата. Отсутствующих документов ни один проход не назвал; деградации не было. diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/design-triage.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/design-triage.md new file mode 100644 index 0000000..9c9c7e7 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/review/design-triage.md @@ -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 через требование «значения не в логи». diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/parsing/spec.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/parsing/spec.md new file mode 100644 index 0000000..c237f7d --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/parsing/spec.md @@ -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** содержимое точки сохранено дословно и целиком diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/reindex/spec.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/reindex/spec.md new file mode 100644 index 0000000..9ffd28a --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/reindex/spec.md @@ -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** он не содержит ни значений точек, ни имён метрик, ни имён устройств, + ни наблюдённых категориальных строк diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/storage/spec.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/storage/spec.md new file mode 100644 index 0000000..af3c28d --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/specs/storage/spec.md @@ -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** счётчики значений без кода в записи присутствуют diff --git a/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/tasks.md b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/tasks.md new file mode 100644 index 0000000..c9cc67b --- /dev/null +++ b/openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/tasks.md @@ -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`. diff --git a/openspec/specs/parsing/spec.md b/openspec/specs/parsing/spec.md index 80cb179..a118d3b 100644 --- a/openspec/specs/parsing/spec.md +++ b/openspec/specs/parsing/spec.md @@ -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** содержимое точки сохранено дословно и целиком diff --git a/openspec/specs/reindex/spec.md b/openspec/specs/reindex/spec.md index 0a2ec1e..045b10e 100644 --- a/openspec/specs/reindex/spec.md +++ b/openspec/specs/reindex/spec.md @@ -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** отчёт пересборки называет число удержанных версий больше нуля - diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index f72092f..fa30704 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -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** счётчики значений без кода в записи присутствуют