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

- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
This commit is contained in:
av
2026-08-04 07:32:19 +03:00
parent eb3fca77ee
commit 1b649ba3d5
40 changed files with 4317 additions and 42 deletions
+11 -3
View File
@@ -179,9 +179,14 @@ type report struct {
// единица, которой нет в счётчиках, делает расхождение безадресным. // единица, которой нет в счётчиках, делает расхождение безадресным.
sourceWorkouts int64 sourceWorkouts int64
sourceRecords int64 sourceRecords int64
sourceBefore int64 // sourceCategories — то же «было» для реестра категориальных значений.
sourceAfter int64 // Перечень единиц хранения закрытый, и он пополняется ТЕМ ЖЕ изменением,
sourceMissing bool // которое заводит единицу: не внесённая сюда, она молчит ровно там, где
// расхождение впервые становится заметным.
sourceCategories int64
sourceBefore int64
sourceAfter int64
sourceMissing bool
} }
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл // 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 { if rep.sourceRecords, err = src.CountRecords(ctx); err != nil {
return canceledOr(rep, err, stopped) return canceledOr(rep, err, stopped)
} }
if rep.sourceCategories, err = src.CountCategoryValues(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
} }
removeDB(t.partial) removeDB(t.partial)
+23
View File
@@ -66,6 +66,7 @@ func writeReport(w io.Writer, r report) {
p(" объектов: %d", r.replay.Buckets) p(" объектов: %d", r.replay.Buckets)
p(" тренировок: %d", r.replay.Workouts) p(" тренировок: %d", r.replay.Workouts)
p(" записей: %d", r.replay.Records) p(" записей: %d", r.replay.Records)
p(" строк реестра категориальных значений: %d", r.replay.Categories)
p("") p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath) p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.") p("не восстанавливаются: в архиве их нет.")
@@ -76,6 +77,8 @@ func writeReport(w io.Writer, r report) {
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets) p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts) p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records) p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
p(" строк реестра категориальных значений: было %d, стало %d",
r.sourceCategories, r.replay.Categories)
p("") p("")
p(" отпечаток рабочей: %s", r.sourcePrint) p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint) p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
@@ -89,6 +92,26 @@ func writeReport(w io.Writer, r report) {
p(" ожидаемые причины: исправленный разбор; покрытая разбором новая") p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
p(" секция (её единиц хранения в рабочей базе нет по построению);") p(" секция (её единиц хранения в рабочей базе нет по построению);")
p(" признак sealed не переносится (правила его выставления ещё нет)") p(" признак sealed не переносится (правила его выставления ещё нет)")
if r.sourceCategories < r.replay.Categories {
// Класс назван отдельно от факта расхождения: реестр появился
// вместе с бинарём, и у витрины, свёрнутой прежним, его нет по
// построению. Не назвав это, отчёт приучает человека
// игнорировать расхождение — то есть обесценивает оракул ровно
// там, где по нему принимается необратимое решение.
//
// Условие — НЕПОЛНОТА, а не пустота. Между выкаткой и прогоном
// проходят дни: воркер успевает набрать частые значения (фазы
// сна, контекст пульса) и не успевает редкие — имя тренировки,
// которая с тех пор не повторялась. Проверка «в рабочей базе
// реестра нет вовсе» такое состояние не ловила бы, и человек
// получил бы безадресное «разошлись» при совпавших числах
// объектов, тренировок и записей.
p(" РЕЕСТР НЕПОЛОН: строк категориальных значений в рабочей базе %d,",
r.sourceCategories)
p(" в пересобранной %d — реестр наполняется по мере свёртки, а целиком",
r.replay.Categories)
p(" его даёт только пересборка. Расхождение объясняется этим и лечится ею же")
}
if partialJournal { if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может") p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором") p(" объясняться этим, а не разбором")
+106
View File
@@ -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)
}
}
@@ -0,0 +1,64 @@
# Код HealthKit кладётся реестром рядом, а не полем внутри точки
- Дата: 2026-08-03
- Источник: openspec/changes/archive/2026-08-03-slovar-kategorialnyh-znachenij/design.md
## Решение
Стабильный код HealthKit для локализованной строки хранится **отдельной строкой
таблицы `category_value`** с ключом `(метрика, поле, значение)`, а не полем
`value_code` внутри точки, как рисовал `architecture.md`. Словарь и таблица
синонимов живут в бинаре (`internal/healthkit`), а не в базе. Наблюдение входит
в отпечаток витрины, выведенный код — **нет**.
## Почему
Рассматривались три формы, и отвергнутые названы вместе с ценой.
**Поле внутри точки** — отвергнуто. Цитата источника: «Точка хранится
**исходными байтами**; дописать в неё ключ можно только пересериализацией, а она
теряет литерал (`1.0``1`, целые больше 2^53 сдвигаются, невалидный UTF-8 →
U+FFFD) — ровно то, от чего `Point.Raw` защищает. Побайтовая врезка в чужой
JSON — фокус, а не решение. Параллельный массив кодов в `bucket` завёл бы
производную величину в путь слияния и хеширования: правило полноты, тай-брейк и
`content_hash` пришлось бы учить носить код, не давая ему влиять на исход.
Правка на поверхности `critical`-инвариантов ради нуля новых сведений — код есть
**функция** от того, что уже лежит».
**Код нигде не хранится, выводится на чтении** — отвергнуто по одной причине:
«тогда код недостижим ничем, кроме бинаря. Владелец сегодня читает витрину
`sqlite` на хосте (`Read API` ещё нет), а вся задача затевается против того, что
„клиент угадывает словарь“. Реестр без кода сообщает только „такая строка
была“ — это половина ответа».
**Словарь в базе, а не в бинаре** — отвергнуто: «словарь стал бы входом,
которого нет в журнале, и `import + replay` перестал бы задавать состояние
однозначно. `stateOfMind` уже единственная дыра в журнале; вторую заводить
незачем».
**Код вне отпечатка** — обратная сторона того же решения: «Ключ и провенанс —
функция журнала; `code` — функция журнала **и версии словаря в бинаре**. Включи
его в отпечаток, и он перестал бы отвечать на свой единственный вопрос („дал ли
повтор журнала то же состояние“) ровно тогда, когда его задают: всякое
пополнение словаря — а оно объявлено рабочим циклом — давало бы расхождение при
побайтно совпавшем журнале, и человек, принимающий необратимое решение о
подмене базы, читал бы это как дефект».
Prior art: FHIR `ConceptMap` (отображение «чужая система значений → своя») и
`CodeSystem` с `replaced-by` для устаревших имён — те же два отношения,
разведённые по разным сущностям. Форма взята, реализация FHIR отвергнута ценой.
## Последствия
- `+` Инвариант «точки хранятся дословно» не тронут вовсе: точка не меняется ни
байтом, обратное преобразование возможно всегда.
- `+` Пути слияния, тай-брейка и `content_hash` не знают о кодах — правки на
поверхности `critical`-инвариантов не потребовалось.
- `+` Пополнение словаря меняет десяток строк реестра, а не каждый объект с
фазами сна; отпечаток при этом не двигается, потому что код в него не входит.
- `` Потребитель обязан делать соединение по `(метрика, поле, значение)` вместо
чтения одного поля. Форма ответа Read API это скроет, когда он появится.
- `` Код в базе отстаёт от словаря в бинаре для строк, переставших приезжать.
Лечится пересборкой; на сходимость не влияет.
- `` Ключ реестра зафиксирован миграцией `00010`: смена формы ключа стоит
второй миграции и пересборки.
+5 -2
View File
@@ -33,8 +33,11 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
Записей пока нет: каталог заведён переездом на канон 2026-08-03. Сырьё для - [ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom](ADR-2026-08-03-kod-ryadom-so-strokoj-reestrom.md)
промоута накоплено — девять архивных изменений в — код HealthKit кладётся реестром рядом со строкой, а не полем внутри точки;
словарь живёт в бинаре, выведенный код в отпечаток витрины не входит.
Сырьё для промоута накоплено — архивные изменения в
`openspec/changes/archive/`, из них решения с дорогим откатом и намеренные `openspec/changes/archive/`, из них решения с дорогим откатом и намеренные
отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey` отказы есть как минимум в `2026-08-01-polnota-tochki-mnozhestvom-klyuchey`
(идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы (идентичность точки и тай-брейк), `2026-08-02-reindex-iz-arhiva` (подмену базы
+31 -7
View File
@@ -1103,21 +1103,45 @@ HAE отдаёт перечислимые значения строками из
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя — Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
но сверить покрытие по этим полям было бы нечем. но сверить покрытие по этим полям было бы нечем.
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**: Поэтому строка **хранится дословно, а рядом кладётся выведенный код**
отдельной строкой реестра `category_value`, а не полем внутри точки:
``` ```
value "БДГ" ← как прислал HAE category_value sleep_analysis / value / "БДГ" → HKCategoryValueSleepAnalysisAsleepREM
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю точка {"date": …, "value": "БДГ", …} ← не тронута
``` ```
Словарь ключуется парой `(локаль, строка)`; локаль берётся из Рядом, а не внутри, по трём причинам: точка хранится исходными байтами и
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой дописать в неё ключ можно только пересериализацией; параллельный массив кодов в
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как `bucket` завёл бы производную величину в путь слияния и хеширования; пополнение
список того, что пора добавить в словарь. словаря переписывало бы каждый объект с фазами сна. Обоснование целиком — в
[журнале решений](adr/README.md).
Ключ реестра — `(метрика, поле, значение)`. Словарь при этом ключуется парой
`(локаль, строка)`, локаль берётся из `Accept-Language` (находка 32) и **в ключ
реестра не входит**: заголовков в сыром архиве нет, и ключ с локалью сделал бы
состояние функцией от того, уцелела ли учётная строка. Локаль сужает поиск; её
отсутствие вывода не отменяет, если строка однозначна по всем локалям.
Словарь и таблица синонимов кодов живут в бинаре (`internal/healthkit`), а не в
базе: словарь, наполняемый руками, стал бы входом, которого нет в журнале, и
`import + replay` перестал бы задавать состояние однозначно. Синонимы нужны
потому, что коды тоже не вечны: Apple переименовала `…Asleep` в
`…AsleepUnspecified` и переписывает историю при выгрузке (находка 43).
Для незнакомой строки код пустой — пустота честнее догадки, и перечень таких
строк в реестре есть заявка на пополнение словаря. Счётчик неизвестных строк
уходит в лог свёртки числом; сами строки — данные о здоровье и в лог не
попадают.
Дословность инварианта не нарушена: код **приписывается**, а не подменяет Дословность инварианта не нарушена: код **приписывается**, а не подменяет
строку. Обратное преобразование всегда возможно. строку. Обратное преобразование всегда возможно.
Реестр — единица хранения витрины и входит в отпечаток **наблюдением**, но не
выведенным кодом: код производен от словаря в бинаре, а не от журнала, и в
отпечатке он превратил бы всякое пополнение словаря в расхождение при совпавшем
журнале.
### Тренировки и прочие секции ### Тренировки и прочие секции
<!-- канон: поведение → openspec/specs/parsing --> <!-- канон: поведение → openspec/specs/parsing -->
+18
View File
@@ -16,6 +16,24 @@
единица, которой нет в счётчиках, делает расхождение безадресным: человек единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы. необратимое решение о подмене базы.
- **Провенанс, входящий в отпечаток, обязан быть явной функцией журнала.**
«Кто первым записал строку» — функция порядка свёртки, а он порядку журнала не
равен: живой приём и пересборка разойдутся при одинаковом журнале. Там, где
провенанс в отпечаток не идёт, слабое правило допустимо и должно быть названо
слабым на месте — иначе его скопируют туда, где оно неверно (`bucket` против
`category_value`).
- **Колонка, производная от бинаря, а не от журнала, в отпечаток не входит.**
Кэш чистой функции (код по словарю, справочное имя) в отпечатке превращает
всякую правку бинаря в расхождение при побайтно совпавшем журнале — и человек,
принимающий по отпечатку необратимое решение о подмене базы, читает это как
дефект. Правильность самой производной проверяют её тесты: это другой вопрос,
и смешение обесценивает оракул сходимости.
- **Граница на число элементов, набираемых из чужого тела, применяется при
накоплении, а не при выдаче.** Накопитель без границы растёт вместе с телом,
а тело контролирует отправитель; отказ по памяти в фоновой горутине не
перехватывается, и перезапуск берёт ту же доставку. Усечение при этом обязано
остаться функцией множества (например, N наименьших ключей), иначе порядок
элементов на проводе решает состав витрины.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией - Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является: состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
+10
View File
@@ -23,6 +23,16 @@
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
без числа не отличается от предположения, а цена ошибки здесь — необратимое без числа не отличается от предположения, а цена ошибки здесь — необратимое
решение о судьбе тел. решение о судьбе тел.
- **Значение, попадающее в ключ витрины или в словарь, приёмочный тест берёт из
`testdata`, а не из литерала в тесте.** Литерал, набранный руками, не
воспроизводит невидимые символы источника — Apple шлёт неразрывные пробелы
внутри своих строк (находка 24), — и совпадение теста с реализацией доказывает
только согласие автора с самим собой.
- **Утверждение о таблице-константе обходит саму таблицу, а не её видимые
следствия.** Проверка «таблица синонимов плоская», написанная через
экспортированные функции, обходит лишь записи, достижимые из словаря: с
неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения
живут во внутреннем тесте пакета и перебирают саму структуру.
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой - **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
+52
View File
@@ -46,6 +46,17 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
│ created_at TEXT │ └──────────────────────────┘ │ created_at TEXT │ └──────────────────────────┘
│ updated_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` **внешним ключом не объявлена** Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
@@ -156,6 +167,47 @@ ROWID` строка целиком, вместе со сжатым `payload`, ж
журнала, а не свёрнутая последней. Подробности и обоснование — в журнала, а не свёрнутая последней. Подробности и обоснование — в
`architecture.md`, раздел «Тренировки и прочие секции». `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`. - **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`.
+8
View File
@@ -1369,6 +1369,14 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
То есть первую и главную часть словаря не надо составлять вручную — она То есть первую и главную часть словаря не надо составлять вручную — она
выводится сопоставлением потока с экспортом за тот же период. выводится сопоставлением потока с экспортом за тот же период.
**Замер покрытия, 2026-08-03.** Прогон всего живого архива (145 доставок) через
разбор с этим словарём даёт **12 различных категориальных строк** по трём полям:
6 фаз сна — все с кодом, 6 без кода (`heart_rate.context` и имена тренировок,
для которых словарь не выводился). То есть шесть выведенных строк покрывают
поток целиком, а не частично: неопознанных фаз сна на корпусе ноль. Заголовков
в архиве нет, поэтому прогон идёт с пустой локалью — и коды всё равно выводятся,
что подтверждает: сопоставление по строке однозначно, пока словарь одноязычен.
## 44. `Correlation` — структурный элемент, и он появился только что ## 44. `Correlation` — структурный элемент, и он появился только что
Давление приезжает не записью, а обёрткой из двух записей: Давление приезжает не записью, а обёрткой из двух записей:
+49
View File
@@ -120,6 +120,13 @@
- **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или - **`reimpl`** запускается по триггеру «новое правило слияния, идентичности или
разбора». Единственный раз, когда триаж назвал его отсутствие дырой разбора». Единственный раз, когда триаж назвал его отсутствие дырой
покрытия, — это была задача с новым правилом слияния сущностей. покрытия, — это была задача с новым правилом слияния сущностей.
Второй замер (2026-08-03, словарь категориальных значений): триггер сработал
на новом правиле разбора и ключе реестра, проход **окупился** — он независимо
подтвердил замером две находки, до того имевшие только одно измерение (пик
памяти накопителя: 1002 МиБ против 780 на базе; единицы счётчика отброшенных),
и отдельно назвал семь мест, где существующее решение оказалось **лучше** его
собственного. Второе ценно не меньше первого: оно показывает, где проход
соглашается, а не только где спорит.
- **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет - **`quick`** — правка документов, конфигурации, сообщений; ничего, что меняет
хранимое. хранимое.
@@ -360,6 +367,48 @@
- **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 78, `design` 3. - **Итог по конвейеру:** `quick` 4, `standard` 6, `deep` 78, `design` 3.
Было 11 на коде и 4 на дизайне. Было 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 — ответ владельца не превращал задачу в берущуюся [проскочил] ## 2026-08-03 — ответ владельца не превращал задачу в берущуюся [проскочил]
- **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос» - **Где:** конвейер, а не код — учёт задач, шаг «ответ на вопрос»
+13 -2
View File
@@ -32,8 +32,10 @@ disabled`, `read auth disabled`), но стартовать не отказыв
значения, метки времени, имена источников и устройств, имена секций, `id` значения, метки времени, имена источников и устройств, имена секций, `id`
тренировок и записей, содержимое маршрута. тренировок и записей, содержимое маршрута.
- **Заголовки доставки** — включая `automation-id`, `automation-aggregation`, - **Заголовки доставки** — включая `automation-id`, `automation-aggregation`,
`User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery` и `User-Agent`, `Accept-Language`, `Upload-Complete`; они пишутся в `delivery`,
участвуют в выводе слоя. Заголовки полуправдивы: `automation-aggregation` участвуют в выводе слоя, а `Accept-Language` — ещё и в выводе кода
категориального значения (тег ограничен по длине и по форме, не тег даёт
пустую локаль). Заголовки полуправдивы: `automation-aggregation`
реальной гранулярности не описывает (разведка, находка 33). реальной гранулярности не описывает (разведка, находка 33).
- **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной - **Размер тела** — предела на одну сущность нет; наблюдалось 63 МиБ на одной
координате и 768 МиБ пика кучи на теле 40 МиБ. координате и 768 МиБ пика кучи на теле 40 МиБ.
@@ -57,6 +59,15 @@ disabled`, `read auth disabled`), но стартовать не отказыв
отчёт, имеет названный предел длины. отчёт, имеет названный предел длины.
- **Ключ сущности**`род секции + id` из HealthKit для `record`, `id` для - **Ключ сущности**`род секции + id` из HealthKit для `record`, `id` для
`workout`. `id` приходит из тела. `workout`. `id` приходит из тела.
- **Ключ наблюдённого категориального значения**`метрика + поле + значение`.
Значение приходит из тела дословно и уезжает в первичный ключ: предел на него
назван числом (128 байт), число различных значений одной доставки ограничено
(64), и **граница применяется при накоплении, а не при выдаче** — иначе
накопитель растёт вместе с телом, а тело контролирует отправитель (измерено:
миллион различных значений в теле 60 МиБ поднимал пик процесса с 780 до
1002 МиБ). Значение, которое разбор JSON подменил (невалидный UTF-8, одинокий
суррогат), наблюдением не считается вовсе: в ключ обязано попасть то, что
пришло, а не то, что получилось.
- **Файл базы и каталог архива** — из конфига, не из запроса. - **Файл базы и каталог архива** — из конфига, не из запроса.
## Что разграничивает доступ ## Что разграничивает доступ
+277
View File
@@ -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)
}
}
+86 -3
View File
@@ -9,6 +9,7 @@ package fold
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -18,6 +19,7 @@ import (
"git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/archive"
"git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/healthkit"
"git.vakhrushev.me/av/healthlog/internal/store" "git.vakhrushev.me/av/healthlog/internal/store"
) )
@@ -90,6 +92,16 @@ type Stats struct {
Uncovered []string Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка. // UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int UncoveredDropped int
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
// CategoricalUnknown — сколько из них словарь не знает;
// CategoricalDropped — сколько отброшено границами разбора.
//
// Только числа: сами строки — данные о здоровье, контекст пульса и фаза сна
// описывают человека не меньше, чем число.
Categoricals int
CategoricalUnknown int
CategoricalDropped int
} }
// ErrPanicked — свёртка паниковала. Доставка получает `failed`: тело в архиве, и // 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{ parsed, err := hae.Parse(body, hae.Meta{
Aggregation: d.Aggregation, Aggregation: d.Aggregation,
FallbackLayer: hae.Layer(fallback), FallbackLayer: hae.Layer(fallback),
Locale: localeOf(d.Headers),
}) })
if err != nil { if err != nil {
// Список непокрытых секций переживает отказ: доставка, у которой не // Список непокрытых секций переживает отказ: доставка, у которой не
@@ -169,6 +182,9 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
stats.Layer = string(parsed.Layer) stats.Layer = string(parsed.Layer)
stats.LayerMismatch = parsed.LayerMismatch 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{ merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
ID: d.ID, ID: d.ID,
@@ -256,6 +272,12 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// построчный разбор логов. Содержимого секций здесь нет. // построчный разбор логов. Содержимого секций здесь нет.
"uncovered", st.Uncovered, "uncovered", st.Uncovered,
"uncovered_dropped", st.UncoveredDropped, "uncovered_dropped", st.UncoveredDropped,
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
"categoricals", st.Categoricals,
"categoricals_unknown", st.CategoricalUnknown,
"categoricals_dropped", st.CategoricalDropped,
} }
if len(st.Collisions) > 0 { if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions)) attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
@@ -298,6 +320,19 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь, // Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
// а границу выбило больше тридцати двух. // а границу выбило больше тридцати двух.
s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...) 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: case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и // Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при // информативнее, на живом потоке не случавшееся ни разу. Признаки при
@@ -479,12 +514,60 @@ func toIncoming(parsed hae.Result) store.Incoming {
}) })
} }
return store.Incoming{ return store.Incoming{
Points: points, Points: points,
Workouts: toEntities(parsed.Workouts), Workouts: toEntities(parsed.Workouts),
Records: toEntities(parsed.Records), 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 { func toEntities(in []hae.Entity) []store.IncomingEntity {
if len(in) == 0 { if len(in) == 0 {
return nil return nil
+257
View File
@@ -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
}
+404
View File
@@ -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])
}
}
})
}
}
+66 -2
View File
@@ -179,6 +179,32 @@ type Result struct {
// по координате. // по координате.
SkippedBadEnd int SkippedBadEnd int
// Categoricals — различные наблюдённые категориальные значения доставки с
// выведенными кодами, в детерминированном порядке. Повтор одной строки в
// тысяче точек даёт один элемент.
Categoricals []Categorical
// CategoricalUnknown — сколько РАЗЛИЧНЫХ наблюдений не получило кода.
// Считаются различные значения, а не их вхождения: счётчик отвечает на
// вопрос «сколько строк ждёт словаря», а не «сколько точек их несло».
//
// В установившемся режиме он ненулевой — словарь покрывает только фазы сна,
// а контекст пульса и имена тренировок объявлены категориальными заранее.
// Сигналом «появилось новое» служит поэтому новая строка реестра, а не
// ненулевой счётчик.
CategoricalUnknown int
// CategoricalDropped — сколько ВХОЖДЕНИЙ отброшено границами: непомерная
// длина значения или переполнение числа различных значений.
//
// Единица названа вслух и отличается от соседнего счётчика намеренно.
// Считать здесь различные значения нечем: набор ограничен при вставке (иначе
// накопитель растёт вместе с телом, а тело контролирует отправитель), и
// отброшенный ключ нигде не запоминается — запомнить его значило бы вернуть
// ровно тот неограниченный рост, ради устранения которого граница и стоит на
// вставке. Поэтому счётчик отвечает на вопрос «сколько раз сработала
// граница», а не «сколько строк потеряно»; на второй отвечает реестр в
// витрине.
CategoricalDropped int
// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие // Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
// метрики). Пустой, если плотных метрик не было и наследовать было нечего. // метрики). Пустой, если плотных метрик не было и наследовать было нечего.
// Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации // Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации
@@ -204,6 +230,13 @@ type Meta struct {
// из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий — // из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий —
// разбор остаётся чистой функцией. // разбор остаётся чистой функцией.
FallbackLayer Layer FallbackLayer Layer
// Locale — нормализованный языковой тег доставки из `Accept-Language`
// (находка 32). Сужает поиск по словарю категориальных значений и НИКУДА не
// сохраняется: заголовков в сыром архиве нет, поэтому доставка,
// восстановленная из осиротевшего тела, приезжает без локали — и обязана
// дать то же состояние. Пустая локаль законна.
Locale string
} }
// Parse разбирает секцию metrics тела доставки в точки. // Parse разбирает секцию metrics тела доставки в точки.
@@ -231,18 +264,28 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
res.Uncovered = env.uncovered res.Uncovered = env.uncovered
res.UncoveredDropped = env.dropped res.UncoveredDropped = env.dropped
cat := newCategoricals(meta.Locale)
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res) res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res) res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
// Имя тренировки берётся из уже разобранного заголовка сущности. Мягкое
// чтение превратило значение не того типа в пустую строку, а пустая строка
// наблюдением не считается, — то есть «имени не было» и «имя приехало
// числом» дают один исход, и он верный.
for _, w := range res.Workouts {
cat.add(workoutsSection, fieldName, w.Name)
}
metrics := env.metrics metrics := env.metrics
res.Metrics = len(metrics) res.Metrics = len(metrics)
if len(metrics) == 0 { if len(metrics) == 0 {
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
return res, nil return res, nil
} }
groups := make([]group, 0, len(metrics)) groups := make([]group, 0, len(metrics))
for _, m := range metrics { for _, m := range metrics {
g := decodeGroup(m, &res) g := decodeGroup(m, &res, cat)
if len(g.summaries) > 0 { if len(g.summaries) > 0 {
groups = append(groups, group{ groups = append(groups, group{
metric: sleepSummaryMetric, metric: sleepSummaryMetric,
@@ -258,6 +301,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
} }
} }
if len(groups) == 0 { if len(groups) == 0 {
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
return res, nil return res, nil
} }
@@ -280,6 +324,11 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
}, err }, err
} }
// Наблюдения отдаются только на успешном исходе: «всё или ничего» относится
// к доставке целиком. У отказа по слою (см. ветку выше) сущности не
// отдаются по той же причине.
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
total := 0 total := 0
for _, g := range groups { for _, g := range groups {
total += len(g.points) total += len(g.points)
@@ -400,6 +449,15 @@ type pointHead struct {
// TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и // TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и
// суточную сводку. Общих полей, кроме date и source, у них нет. // суточную сводку. Общих полей, кроме date и source, у них нет.
TotalSleep *json.RawMessage `json:"totalSleep"` TotalSleep *json.RawMessage `json:"totalSleep"`
// Value и Context — категориальные поля точки (фаза сна и контекст пульса,
// находка 37). Сырыми сообщениями, а не строками: объяви их `string`, и
// точка, у которой поле пришло числом, перестала бы разбираться вовсе —
// json.Unmarshal отвечает ошибкой на несовпадение типа, а decodeGroup
// считает такую точку не разобравшейся. Новый путь потери точки ради
// удобства структуры недопустим.
Value *json.RawMessage `json:"value"`
Context *json.RawMessage `json:"context"`
} }
// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций, // 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))} g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}
for _, raw := range 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 { if m.Name == sleepMetric && head.TotalSleep != nil {
p.Metric = sleepSummaryMetric p.Metric = sleepSummaryMetric
g.summaries = append(g.summaries, p) g.summaries = append(g.summaries, p)
// Наблюдение снимается по ИТОГОВОМУ имени метрики, тому же, которым
// адресуется единица хранения. У суточной сводки категориальных
// полей нет, так что здесь это ноль работы, — но правило записано
// один раз и не разойдётся при следующем разделении схем.
cat.addPoint(p.Metric, &head)
continue continue
} }
cat.addPoint(p.Metric, &head)
g.points = append(g.points, p) g.points = append(g.points, p)
} }
+183
View File
@@ -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
}
+138
View File
@@ -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)
}
})
}
}
@@ -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)
}
}
}
}
+86
View File
@@ -16,7 +16,9 @@ import (
"git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/hae" "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/ident"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store" "git.vakhrushev.me/av/healthlog/internal/store"
) )
@@ -155,6 +157,90 @@ func TestReplayЖивогоАрхива(t *testing.T) {
coords, labels) coords, labels)
} }
t.Logf("координат сна %d, различных меток %d", 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 прогоняет измерение рода агрегации на витрине, собранной из // measureStyles прогоняет измерение рода агрегации на витрине, собранной из
+12 -3
View File
@@ -79,8 +79,12 @@ type Report struct {
// Workouts и Records — остальные единицы хранения витрины. Считаются рядом // Workouts и Records — остальные единицы хранения витрины. Считаются рядом
// с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а // с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а
// решение о подмене базы необратимо и требует направления расхождения. // решение о подмене базы необратимо и требует направления расхождения.
Workouts int64 Workouts int64
Records int64 Records int64
// Categories — строки реестра категориальных значений: четвёртая единица
// хранения витрины. Без счётчика расхождение по ней безадресно — объекты,
// тренировки и записи при этом не меняются вовсе.
Categories int64
Fingerprint string Fingerprint string
// Canceled — проигрывание прервано отменой, а не дошло до конца. // Canceled — проигрывание прервано отменой, а не дошло до конца.
@@ -184,6 +188,10 @@ func Run(ctx context.Context, o Options) (Report, error) {
if err != nil { if err != nil {
return stopOr(rep, err) 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) rep.Fingerprint, err = o.Target.Fingerprint(ctx)
if err != nil { if err != nil {
return stopOr(rep, err) return stopOr(rep, err)
@@ -207,7 +215,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
"entities_diverging", rep.EntitiesDiverging, "entities_diverging", rep.EntitiesDiverging,
"buckets", rep.Buckets, "buckets", rep.Buckets,
"workouts", rep.Workouts, "workouts", rep.Workouts,
"records", rep.Records) "records", rep.Records,
"category_values", rep.Categories)
return rep, nil return rep, nil
} }
+71 -8
View File
@@ -246,7 +246,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
stats.RecordsWritten = written stats.RecordsWritten = written
stats.EntitiesHeld += held stats.EntitiesHeld += held
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...)) stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
return nil
return mergeCategories(ctx, tx, in.Categories, from)
}) })
if err != nil { if err != nil {
return MergeStats{}, err return MergeStats{}, err
@@ -602,6 +603,18 @@ func readBucket(ctx context.Context, tx *sql.Tx, key bucketKey) (Bucket, bool, e
}, true, nil }, 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 { func writeBucket(ctx context.Context, tx *sql.Tx, b Bucket, now time.Time) error {
payload, err := encodePayload(b.Points) payload, err := encodePayload(b.Points)
if err != nil { 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 { if err := fingerprintEntities(ctx, tx, h); err != nil {
return "", err return "", err
} }
if err := fingerprintCategories(ctx, tx, h); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
// Признак раздела впереди строки: без него строка одного раздела может совпасть // Признак раздела впереди строки: без него строка одного раздела может совпасть
// со строкой другого, и два разных состояния витрины дали бы один отпечаток. // со строкой другого, и два разных состояния витрины дали бы один отпечаток.
const ( const (
fpBucket = "b" fpBucket = "b"
fpWorkout = "w" fpWorkout = "w"
fpRecord = "r" 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 { func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error {
const q = ` const q = `
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
+150
View File
@@ -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] + "…"
}
+285
View File
@@ -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)
}
}
}
+7 -2
View File
@@ -376,18 +376,23 @@ type DeliveryBody struct {
RawPath string RawPath string
AutomationID string AutomationID string
Aggregation string Aggregation string
// Headers — заголовки запроса JSON-объектом. Разбору нужен ровно один из
// них, `Accept-Language`: он задаёт язык, на котором приехали
// категориальные строки, и сужает поиск по словарю. Колонки под язык нет
// намеренно — она была бы вторым домом факта, который уже лежит здесь.
Headers string
} }
// DeliveryForParse возвращает сведения о доставке, нужные разбору. // DeliveryForParse возвращает сведения о доставке, нужные разбору.
func (s *Store) DeliveryForParse(ctx context.Context, id string) (DeliveryBody, error) { func (s *Store) DeliveryForParse(ctx context.Context, id string) (DeliveryBody, error) {
const q = ` 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 = ?` FROM delivery WHERE id = ?`
var d DeliveryBody var d DeliveryBody
var receivedAt string var receivedAt string
err := s.db.QueryRowxContext(ctx, q, id). 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) { if errors.Is(err, sql.ErrNoRows) {
return DeliveryBody{}, ErrNotFound return DeliveryBody{}, ErrNotFound
} }
+4
View File
@@ -46,6 +46,10 @@ type Incoming struct {
Points []IncomingPoint Points []IncomingPoint
Workouts []IncomingEntity Workouts []IncomingEntity
Records []IncomingEntity Records []IncomingEntity
// Categories — наблюдённые категориальные значения доставки. Пишутся той же
// транзакцией: состояние «точки легли, реестр нет» повторная свёртка не
// чинит — хеш содержимого сойдётся, и объекты переписываться не станут.
Categories []CategoryValue
} }
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней // DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
@@ -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;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-03
@@ -0,0 +1,272 @@
## Context
Точки хранятся дословно: `bucket.payload` — исходные байты точек, сжатые gzip;
`content_hash` — детектор изменений по канонической форме этих же байтов.
Пересборка обязана давать то же состояние (`import + replay`), и единственный
её оракул — отпечаток витрины.
Локаль приезжает заголовком `Accept-Language` **доставки** (находка 32,
измеренное значение — `ru`). Часовой объект при этом собирается из точек многих
доставок, а из провенанса у него только `first_delivery_id`. Значит локаль
точки после слияния восстановить нечем — вывод кода обязан происходить в
разборе, пока доставка ещё цела.
Словарь фаз сна выведен измерением (находка 43) и переписыванию не подлежит.
Коды HealthKit при этом сами не вечны: те же записи сна приезжают как
`…Asleep` в экспорте 2021 года и как `…AsleepUnspecified` в экспорте 2026-го.
## Goals / Non-Goals
**Goals:**
- Рядом с локализованной строкой появляется стабильный код HealthKit, выведенный,
а не угаданный.
- Незнакомая строка видна, а не молчит.
- Переименование кода самой Apple не раскалывает историю.
- Состояние остаётся детерминированной свёрткой по журналу.
**Non-Goals:**
- Словарь для `heart_rate.context` и типов тренировок. Он не выведен: экспорт
Apple хранит контекст пульса метаданным-числом, а не `HKCategoryValue*`, и
сопоставление с «Сидячий образ жизни» было бы догадкой. Поля объявляются
категориальными **сейчас**, чтобы их строки попали в реестр с пустым кодом —
это и есть заявка на будущий вывод.
- Показ реестра в `/stats` и в Read API: ни того, ни другого ещё нет
(`stats-endpoint`, `read-api-points`).
- Импорт родного экспорта Apple. Синонимы кодов заводятся ради него, но
разбирать экспорт эта задача не начинает. Отсюда прямое следствие для
приёмки: сверка «поток против экспорта на живой базе» этой задачей
недостижима — оракула нет, и это названо в отчёте, а не обойдено.
- Отдача кода в ответе чтения. Форма ответа — забота Read API; хранилище отдаёт
строку и реестр, по которому код сопоставляется.
- Обслуживание реестра: удаления строк нет, реестр накопителен. Строка,
единственные доставки которой выпали из журнала ретеншеном, переживёт их в
рабочей витрине и не появится в пересобранной. Это тот же класс, что и
«верхние слои за периоды с удалёнными доставками» из инварианта, и он
называется вслух, а не досчитывается.
## Decisions
### 1. Код кладётся в реестр наблюдённых значений, а не внутрь точки
Архитектура рисовала `value` и `value_code` соседними полями одной записи. Форма
принята другая — **таблица `category_value`, ключ `(metric, field, value)`**, а
точка не меняется вовсе.
Рассмотрены три формы, компромисс каждой назван:
**(1) Код — поле внутри точки.** Отвергнута. Точка хранится **исходными
байтами**; дописать в неё ключ можно только пересериализацией, а она теряет
литерал (`1.0``1`, целые больше 2^53 сдвигаются, невалидный UTF-8 →
U+FFFD) — ровно то, от чего `Point.Raw` защищает. Побайтовая врезка в чужой
JSON — фокус, а не решение. Параллельный массив кодов в `bucket` завёл бы
производную величину в путь слияния и хеширования: правило полноты, тай-брейк и
`content_hash` пришлось бы учить носить код, не давая ему влиять на исход.
Правка на поверхности `critical`-инвариантов ради нуля новых сведений — код есть
**функция** от того, что уже лежит. Плюс пополнение словаря переписывало бы
каждый объект с фазами сна.
**(2) Код нигде не хранится, выводится на чтении.** Отвергнута, но по одной
причине и с названной ценой: тогда код недостижим ничем, кроме бинаря. Владелец
сегодня читает витрину `sqlite` на хосте (`Read API` ещё нет), а вся задача
затевается против того, что «клиент угадывает словарь». Реестр без кода
сообщает только «такая строка была» — это половина ответа. Цена отказа
названа в решении 6: код объявлен **кэшем** и из отпечатка исключён, поэтому
недостатки материализации (отпечаток как функция версии бинаря, пересборка
после каждого пополнения) не наступают.
**(3) Реестр с материализованным кодом.** Принята. Что теряется: потребитель
делает соединение по `(metric, field, value)` вместо чтения одного поля, а
код в базе может отставать от словаря в бинаре ровно для тех строк, которые
перестали приезжать. Отставание лечится пересборкой и не влияет на сходимость.
Prior art: FHIR `ConceptMap` (отображение «чужая система значений → своя») и
`CodeSystem` с `replaced-by` для устаревших имён — ровно два наших отношения,
разведённые по разным сущностям. Форму берём, реализацию FHIR — нет: она стоит
дороже всего проекта.
### 2. Словарь и синонимы — код бинаря, реестр — база
`internal/healthkit` держит две таблицы Go: `(локаль, строка) → код` и
`алиас → канонический код`. В базе — только наблюдённое.
Почему не строки в миграции: словарь есть **знание об Apple**, выведенное
измерением, и меняться ему положено вместе с бинарём и через ревью. Данные,
засеянные миграцией, живут в двух местах сразу (в файле миграции и в базе), и
после первой правки словаря они расходятся молча — база помнит засев, бинарь
знает новое.
Почему не таблица в базе, наполняемая руками: словарь стал бы входом, которого
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно.
`stateOfMind` уже единственная дыра в журнале; вторую заводить незачем.
Это решение нормируется требованием, а не остаётся в дизайне: иначе следующий
автор заведёт рядом ту самую таблицу словаря, наполняемую руками, — и обоснование
против неё останется в архиве изменения, куда он не пойдёт.
### 3. Локаль сужает поиск и в ключ реестра не входит
Локаль нормализуется по правилу lookup RFC 4647: первый тег списка, подтеги
отсекаются, регистр свёрнут (`RU-ru,ru;q=0.9``ru`). Свёртка регистра
обязательна — теги BCP 47 регистронезависимы, и без неё `RU` и `ru` были бы
разными языками.
Разрешение кода идёт тремя разрядами:
```
1. (локаль, строка) есть в словаре → её код
2. локали нет или пары нет, но строка known
во всех локалях даёт ОДИН код → этот код
3. иначе → пустой код
```
Второй разряд — не поблажка, а требование пересобираемости. Заголовки запроса в
сыром архиве **не лежат**: они были заголовками запроса, а не телом. Доставка,
восстановленная из осиротевшего тела (`replay.adopt`), приезжает без
`Accept-Language`, и правило «нет локали — нет кода» сделало бы состояние
функцией от того, уцелела ли строка учёта, — то есть сломало бы `import +
replay` на ровном месте.
**В ключ реестра локаль не идёт, и это та же причина, доведённая до конца.**
Ключ, содержащий локаль, оставляет ту же зависимость этажом ниже: усыновлённая
доставка положила бы вторую строку с пустой локалью, и раздел реестра в
отпечатке разошёлся бы вне всякого названного класса. Ключ по
`(метрика, поле, значение)` — функция одних тел.
Что теряется: «на каком языке приехала эта строка» больше не колонка. Ответ
остаётся достижим по ссылке — `first_delivery_id``delivery.headers`, ровно
тем же приёмом, каким провенанс устроен у часового объекта. Побочная выгода:
у тройки-ключа неоднозначности не бывает по построению, поэтому читателю
нечего разрешать и нечего угадывать.
Своя нормализация, а не `golang.org/x/text/language`: измеренное значение
заголовка — `ru`, а x/text тянет в статический бинарь таблицы CLDR ради разбора
одной строки. Разборщика `Accept-Language` в стандартной библиотеке нет.
Отвергнуто по цене, не по качеству — если появится согласование весов `q`,
решение стоит пересмотреть.
### 4. Синонимы — плоская карта в один шаг
`Canonical(код)` — одно чтение карты `алиас → канонический код`. Зовётся и
внутри `Code(...)`, поэтому «две формы сходятся в один код» верно по
построению, а не по дисциплине автора словаря. Направление одно — старое имя к
новому: `HKCategoryValueSleepAnalysisAsleep`
`HKCategoryValueSleepAnalysisAsleepUnspecified` (находка 43).
Цепочек и ограничения глубины нет намеренно. Инвариант таблицы — «ни одно
значение не является ключом» — проверяется тестом по таблице целиком, и при нём
цепочка длиннее одного шага невозможна по построению. Рантайм-обход был бы
подстраховкой поверх подстраховки: у него нет ни одного вызывающего сценария,
зато он рождает новый вырожденный случай — «что вернуть при превышении глубины».
### 5. Какие поля категориальны — знание HAE, какой у них код — знание HealthKit
Список пар `(метрика/секция, поле)` живёт в `internal/hae`: имена `value`,
`context`, `name` принадлежат формату HAE. В ключ реестра идёт **то же** имя
метрики, которым адресуется часовой объект: разделение схем под одним именем
(`sleep_analysis``sleep_analysis_summary`) тогда проходит по обеим единицам
хранения одновременно и лечится одной пересборкой.
Словарь живёт в `internal/healthkit`: он понадобится импорту родного экспорта,
который про HAE не знает ничего. Пакет `healthkit` не зависит ни от чего
внутреннего.
Поля читаются **тем же** разбором заголовка точки, что и метки, — второго
прохода по данным нет. Значения берутся `json.RawMessage` и принимаются только
как JSON-строка: объяви поле `string`, и точка, у которой `value` пришло числом,
перестала бы разбираться вовсе — новый путь потери данных ради удобства
структуры.
Имя тренировки при этом берётся из уже разобранного заголовка сущности, а не из
её сырых байт. `softString` превращает значение не того типа в `""`, отличить
«имени не было» от «имя приехало числом» на нём нечем — и не нужно: пустая
строка категориальным значением не считается, так что оба случая дают один
исход, и он верный. Второй разбор сущности стоил бы полного прохода по
мегабайтному маршруту ради поля в её заголовке.
Пустая строка исключена и сама по себе: она несла бы вечную единицу в счётчике
строк без кода, а сказать о данных ей нечего.
### 6. Реестр — единица хранения; в отпечаток идёт наблюдение, а не код
Реестр входит в отпечаток витрины отдельным разделом (`c`). Иначе
недетерминированный upsert прошёл бы мимо единственного оракула сходимости.
**В раздел идут ключ и провенанс, но не `code`.** Ключ и провенанс — функция
журнала; `code` — функция журнала **и версии словаря в бинаре**. Включи его в
отпечаток, и он перестал бы отвечать на свой единственный вопрос («дал ли
повтор журнала то же состояние») ровно тогда, когда его задают: всякое
пополнение словаря — а оно объявлено рабочим циклом — давало бы расхождение
при побайтно совпавшем журнале, и человек, принимающий необратимое решение о
подмене базы, читал бы это как дефект. Правильность вывода кода проверяется
тестами словаря, а не оракулом сходимости журнала: это разные вопросы, и
смешивать их — способ обесценить оракул.
Прецедент цены назван: как с `workout`/`record` (миграция 00007), первая сверка
после выкатки покажет расхождение, потому что рабочая витрина получит реестр
только с пересворачиванием. Отчёт `reindex` обязан назвать это ожидаемым
классом и напечатать счётчик строк реестра «до и после» — иначе расхождение
безадресно: числа объектов, тренировок и записей не изменятся.
Провенанс — **только первая встреча** (`first_seen_utc`, `first_delivery_id`,
минимум по журналу `(received_at, id)`). Минимум идемпотентен при повторной
свёртке той же доставки; счётчик встреч не идемпотентен и потому не заводится
вовсе.
Границы, потому что тело контролирует отправитель целиком:
```
maxCategoricalValues 64 различных значения на доставку измерено ~11
maxCategoricalValueLen 128 байт на значение измерено 36 («Сидячий образ жизни»)
```
Числа названы здесь, а не «по аналогии с 32/64 у непокрытых секций»: там имена
секций короткие и латинские, здесь — русские фразы в UTF-8, и предел в 32 байта
отбросил бы две из трёх измеренных строк контекста пульса.
Слишком длинное значение **отбрасывается со счётчиком, а не обрезается**:
обрезанная строка неотличима от настоящей и попала бы в ключ реестра как
самостоятельное значение. Точка при этом хранится целиком — теряется запись в
реестре, а не данные.
При переполнении границы числа уцелевший набор — **функция множества, а не
порядка элементов на проводе**: наблюдения сортируются по
`(метрика, поле, значение)` и берутся первые `maxCategoricalValues`. Иначе то
же содержимое, переприсланное в другом порядке ключей (порядок у HAE
нестабилен, находка 2), давало бы другой реестр — и расхождение вышло бы как
«пересборка не сошлась», без адреса.
### 7. Запись реестра идёт в транзакции слияния
Реестр обновляется тем же `store.Merge`, что и объекты: доставка либо свёрнута
целиком, либо не свёрнута. Отдельный вызов дал бы состояние «точки легли,
реестр нет», которое повторная свёртка не чинит — хеш содержимого сойдётся, и
объекты переписываться не станут. Строк на доставку единицы, удержание
блокировки не растёт.
## Risks / Trade-offs
- **Строки локали — данные о здоровье** («Сидячий образ жизни» — контекст
пульса) → в лог уходят только счётчики; значения и коды не попадают в атрибуты
свёртки ни на каком уровне. Проверяется тем же приёмом, что и значения точек:
разбором записи, а не поиском подстроки в сыром буфере.
- **Отправитель раздувает реестр** тысячей различных «фаз сна» → границы на
число и длину, отброшенное считается, уцелевшее детерминировано.
- **Первая сверка отпечатков после выкатки разойдётся** → названо в миграции, в
спеке, в отчёте `reindex` отдельным классом и счётчиком.
- **Код в базе отстаёт от словаря в бинаре** для строк, переставших приезжать →
осознанная цена материализации (решение 1, форма 2); лечится пересборкой,
сходимость не задевает, потому что `code` вне отпечатка.
- **Словарь покрывает только фазы сна**`heart_rate.context` и имена
тренировок попадут в реестр с пустым кодом. Счётчик неизвестных строк поэтому
ненулевой в установившемся режиме, и как сигнал «появилось новое» он не
годится; сигналом служит **новая строка в реестре**, а не ненулевой счётчик.
- **Словарь набирался литералами Go, а сверяться будет с байтами тела**
приёмочный тест берёт строку **из пакета `testdata`**, а не из константы
теста. Прецедент: Apple шлёт неразрывные пробелы внутри строк (находка 24);
в наблюдённых категориальных полях их сегодня нет (проверено `grep` по
`testdata`), но литерал, набранный руками, эту проверку не заменяет.
- **Реестр не сравнивается с усечённым журналом** → при подрезке архива строки,
чьи доставки выпали, останутся в рабочей витрине и не появятся в
пересобранной. Названо в Non-Goals; ретеншена архива пока нет.
@@ -0,0 +1,66 @@
## Why
HAE отдаёт перечислимые значения строками локали телефона («БДГ», «Сидячий
образ жизни», «В помещении Ходьба»), а родной экспорт Apple — кодами HealthKit
(`HKCategoryValueSleepAnalysisAsleepREM`). Источники несопоставимы (находка 37),
и на этой сверке стоит устаревание нижнего слоя. Плюс два молчащих отказа:
клиент вынужден угадывать словарь, а смена языка телефона расколет историю так,
что по координатному ключу это неотличимо от изменения данных.
Словарь фаз сна уже выведен сопоставлением потока с экспортом за тот же период
(находка 43) — составлять его руками не нужно, нужно завести механизм.
## What Changes
- Разбор извлекает **категориальные значения** объявленных полей HAE
(`sleep_analysis.value`, `heart_rate.context`, `workouts.name`) и выводит для
каждого стабильный код HealthKit по словарю `(локаль, строка) → код`. Локаль
берётся из `Accept-Language` доставки (находка 32).
- Строка **не трогается**: она остаётся в точке дословно, код кладётся рядом —
отдельной строкой реестра, а не полем внутри точки (обоснование — design.md).
Ключ реестра — `(метрика, поле, значение)`; локаль сужает поиск по словарю, но
в ключ не входит: её нет в сыром архиве, и ключ с ней сделал бы состояние
функцией от того, уцелела ли строка учёта.
- Незнакомая строка даёт **пустой** код, а не догадку, и попадает в счётчик
доставки и в реестр строкой с пустым кодом — это и есть список того, что пора
добавить в словарь.
- Переименование кода самой Apple (`…Asleep``…AsleepUnspecified`, находка 43)
не раскалывает историю: эквивалентность имён держит таблица синонимов, обе
формы сходятся в один канонический код.
- Витрина получает таблицу `category_value` (миграция `00010`): наблюдённые
категориальные значения с выведенным кодом и провенансом первой встречи. Её
наблюдение входит в отпечаток витрины — как всякая единица хранения; **код в
отпечаток не входит**: он функция не журнала, а версии словаря в бинаре.
- Отчёт `reindex` учится считать строки реестра и называть «появившуюся единицу
хранения» ожидаемым классом расхождения — иначе первая сверка после выкатки
безадресна, а по ней принимается необратимое решение о подмене базы.
## Capabilities
### New Capabilities
<!-- новых capability нет: поведение ложится в разбор и хранение -->
### Modified Capabilities
- `parsing`: разбор выделяет категориальные значения объявленных полей, выводит
код по словарю и синонимам, считает неразобранные словарём строки; отсутствие
или неизвестность локали не даёт догадки и не отменяет вывода кода, если
строка однозначна.
- `storage`: реестр наблюдённых категориальных значений — единица хранения
витрины: детерминированный по журналу upsert, участие наблюдения в отпечатке
при исключении из него выведенного кода, происхождение словаря.
- `reindex`: счётчик строк реестра в отчёте и ожидаемый класс расхождения
«появилась единица хранения».
## Impact
- `internal/healthkit` (новый пакет): словарь `(локаль, строка) → код` и таблица
синонимов кодов. Знание о HealthKit, общее с будущим импортом родного
экспорта Apple.
- `internal/hae`: `Meta.Locale`, извлечение категориальных значений из точек и
сущностей, счётчики; `Point.Raw` и `Entity.Raw` не меняются.
- `internal/store`: миграция `00010`, таблица `category_value`, upsert внутри
транзакции слияния, отпечаток.
- `internal/fold`: локаль из заголовков доставки, счётчик в логе свёртки.
- `docs/database.md` — описание таблицы (без него гейт красный),
`docs/architecture.md` — раздел «Категориальные значения».
- Оракул `task verify:archive` обязателен: правило разбора меняется.
@@ -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` с измерениями формата. Отсутствующих документов ни один проход не назвал; деградации не было.
@@ -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 через требование «значения не в логи».
@@ -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** содержимое точки сохранено дословно и целиком
@@ -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** он не содержит ни значений точек, ни имён метрик, ни имён устройств,
ни наблюдённых категориальных строк
@@ -0,0 +1,161 @@
## ADDED Requirements
### Requirement: Реестр наблюдённых категориальных значений
Хранилище SHALL держать реестр категориальных значений, которые приносил поток:
одна строка на тройку `(метрика или секция, поле, значение)`, с выведенным кодом
HealthKit рядом. Значение хранится **дословно**, тем же текстом, каким пришло.
Локаль в ключ MUST NOT входить. Она живёт в заголовке доставки, которого нет в
сыром архиве, — ключ с локалью сделал бы состояние функцией от того, уцелела ли
строка учёта: усыновлённая доставка положила бы вторую строку с пустой локалью.
Язык, на котором приехала строка, восстанавливается по доставке провенанса.
Реестр SHALL нести выведенный код и провенанс **первой** встречи — метку приёма
и идентификатор доставки, в которой строка появилась впервые по порядку журнала.
Первая встреча отвечает на вопрос «когда сменился язык телефона»; счётчик встреч
не заводится вовсе — он не идемпотентен при повторной свёртке той же доставки, а
значит сделал бы состояние зависящим от числа прогонов.
Пустой код — законное состояние строки: он означает «словарь этой строки не
знает», и перечень таких строк есть заявка на пополнение словаря.
Словарь, по которому выводится код, SHALL жить в бинаре, а не в таблице базы и
не в строках миграции. Таблица, наполняемая руками, стала бы входом, которого
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно;
данные, засеянные миграцией, живут в двух местах сразу и расходятся с бинарём
молча после первой же правки словаря.
Строки реестра SHALL писаться **той же транзакцией**, что и точки доставки.
Доставка — единица свёртки; частичное состояние «точки легли, реестр нет»
повторная свёртка не чинит: хеш содержимого сойдётся, и объекты переписываться
не станут.
Обслуживания у реестра нет: строки не удаляются. Строка, единственные доставки
которой выпали из журнала подрезкой архива, переживёт их в рабочей витрине и не
появится в пересобранной. Это тот же класс, что «верхние слои за периоды с
удалёнными доставками», и он MUST называться, а не досчитываться.
#### Scenario: Фаза сна попадает в реестр с кодом
- **WHEN** свёрнута доставка с фазой сна «Во сне» и локалью `ru`
- **THEN** в реестре есть строка `sleep_analysis / value / «Во сне»` с кодом
`HKCategoryValueSleepAnalysisAsleepUnspecified`
- **AND** содержимое точки в часовом объекте не изменилось
#### Scenario: Та же строка без заголовка локали даёт ту же строку реестра
- **WHEN** та же строка приехала доставкой без `Accept-Language`
- **THEN** второй строки в реестре не появляется
#### Scenario: Строка без кода в реестре видна
- **WHEN** свёрнута доставка с `heart_rate.context`, которого словарь не знает
- **THEN** в реестре есть строка с этим значением и пустым кодом
#### Scenario: Отказ слияния не оставляет строк реестра
- **WHEN** слияние доставки отказало
- **THEN** реестр не содержит значений этой доставки
### Requirement: Реестр детерминирован по журналу
Повторная свёртка той же доставки MUST оставлять реестр без изменений, а
провенанс первой встречи MUST быть **минимумом** по порядку журнала
(`received_at`, затем идентификатор доставки), а не значением последней записи.
Проигрывание журнала в любом порядке доставок, дающем тот же префикс, SHALL
приводить реестр к тому же состоянию. Реестр — свёртка по журналу, как и всё
остальное в витрине.
Выведенный код SHALL обновляться при каждой встрече строки: словарь живёт в
бинаре, и пересборка обязана давать код по текущему словарю, а не по тому,
который действовал при первой свёртке. Строка, переставшая приезжать, держит код
прежнего словаря до пересборки — это осознанная цена материализации, и на
сходимость она не влияет, потому что код в отпечаток не входит.
#### Scenario: Повторная свёртка ничего не меняет
- **WHEN** одна и та же доставка свёрнута дважды
- **THEN** строки реестра и их провенанс совпадают с состоянием после первой
свёртки
#### Scenario: Провенанс — самая ранняя доставка
- **WHEN** одна строка приехала сперва поздней доставкой, затем ранней
- **THEN** провенансом остаётся ранняя по журналу
#### Scenario: Порядок свёртки на реестр не влияет
- **WHEN** три доставки свёрнуты во всех шести порядках
- **THEN** реестр и его раздел отпечатка совпадают у всех шести прогонов
#### Scenario: Пересборка даёт тот же реестр
- **WHEN** журнал проигран в свежую витрину
- **THEN** реестр пересобранной витрины совпадает с реестром исходной
### Requirement: Отпечаток покрывает наблюдение реестра, но не выведенный код
Отпечаток витрины SHALL покрывать реестр категориальных значений отдельным
разделом: ключ и провенанс первой встречи. Иначе недетерминированная запись
реестра прошла бы мимо единственного оракула сходимости.
Выведенный код в отпечаток входить MUST NOT. Ключ и провенанс — функция журнала;
код — функция журнала **и версии словаря в бинаре**. Включённый в отпечаток, он
заставил бы всякое пополнение словаря давать расхождение при побайтно совпавшем
журнале, а пополнение объявлено рабочим циклом. Человек, принимающий по
отпечатку необратимое решение о подмене базы, читал бы это как дефект.
Правильность вывода кода проверяется тестами словаря — это другой вопрос, и
смешение обесценило бы оракул.
Раздел SHALL быть отличим от прочих признаком впереди строки, а поля переменной
длины SHALL идти с длиной впереди: два разных состояния витрины не имеют права
дать один отпечаток.
Цена названа вслух: у витрины, свёрнутой до этого изменения, реестр пуст, и
первая же сверка отпечатков после выкатки покажет расхождение. Это законное
расхождение, а не дефект; отчёт пересборки обязан назвать его ожидаемым классом
и напечатать счётчик строк реестра.
#### Scenario: Разошедшееся наблюдение меняет отпечаток
- **WHEN** у двух витрин совпадают объекты и сущности, но в реестре одной есть
строка, которой нет в другой
- **THEN** отпечатки различны
#### Scenario: Расхождение только по коду отпечаток не двигает
- **WHEN** две витрины несут те же строки реестра с тем же провенансом, но
разными кодами
- **THEN** отпечатки совпадают
#### Scenario: Значение с разделителем границу поля не подделывает
- **WHEN** значение содержит признак раздела, цифры и нулевой байт
- **THEN** отпечаток отличается от отпечатка витрины с другим разбиением тех же
байтов по полям
#### Scenario: Пустой реестр отпечаток не ломает
- **WHEN** в витрине нет ни одной строки реестра
- **THEN** отпечаток считается и совпадает с отпечатком такой же витрины без
реестра
### Requirement: Значения категориальных строк не попадают в логи
Сами наблюдённые строки и выведенные коды MUST NOT попадать в записи лога выше
`DEBUG`; в журнал SHALL уходить только счётчики — сколько значений наблюдалось и
сколько осталось без кода. Основание: строки категориальных значений — данные о
здоровье, контекст пульса и фаза сна описывают человека не меньше, чем число.
Проверка отсутствия строки в логе SHALL разбирать запись и сравнивать значения
полей, а не искать подстроку в сыром буфере: служебная метка времени содержит
произвольные цифры, и поиск по буферу делает тест флаки по построению.
#### Scenario: Свёртка доставки со сном не пишет строк в лог
- **WHEN** свёрнута доставка с фазами сна и контекстом пульса
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
одного кода
- **AND** счётчики значений без кода в записи присутствуют
@@ -0,0 +1,145 @@
# Задачи
## 1. Словарь HealthKit — `internal/healthkit`
- [x] 1.1 Новый пакет без внутренних зависимостей: словарь `(локаль, строка) → код`
с выведенными фазами сна (находка 43) и **плоская** таблица синонимов кодов
(`…Asleep``…AsleepUnspecified`).
- [x] 1.2 `Canonical(код)` — одно чтение карты, без обхода цепочек и ограничения
глубины: плоскость таблицы держит тест, а не рантайм.
- [x] 1.3 `Code(локаль, строка)` — три разряда: точная пара, однозначность по
всем локалям, пустой код.
- [x] 1.4 Нормализация локали по lookup RFC 4647: первый тег, подтеги отсечены,
регистр свёрнут.
- [x] 1.5 Тесты: обе формы синонима сходятся; таблица плоская (ни одно значение
не является ключом); неизвестная строка — пустой код; табличный тест
нормализации (`ru`, `RU`, `ru-RU`, `RU-ru,ru;q=0.9`, ` ru `, `*`, пусто,
мусор, сто тегов).
## 2. Разбор — `internal/hae`
- [x] 2.1 `Meta.Locale`; объявленный список категориальных полей
(`sleep_analysis.value`, `heart_rate.context`, `workouts.name`).
- [x] 2.2 Извлечение значений тем же разбором заголовка точки; поля —
`json.RawMessage`, принимается только непустая JSON-строка.
- [x] 2.3 Имя тренировки — из уже разобранного заголовка сущности. `softString`
превращает значение не того типа в `""`, а пустая строка наблюдением не
считается: «имени не было» и «имя приехало числом» дают один исход, и он
верный. Второго разбора мегабайтного маршрута не заводим.
- [x] 2.4 `Result.Categoricals` — детерминированное множество наблюдений
`(метрика, поле, строка, код)`, ключ тот же, что у реестра; счётчики
`CategoricalUnknown` и `CategoricalDropped`.
- [x] 2.5 Границы: `maxCategoricalValues = 64`, `maxCategoricalValueLen = 128`;
длинное отбрасывается, не обрезается; уцелевший набор — функция множества
(сортировка по ключу, затем усечение).
- [x] 2.6 Тесты на реальных пакетах `testdata` (`sparse_sleep.json`,
`handmade_edge.json`, `workout_route.json`, `raw.json`): строка берётся
**из пакета**, а не из литерала теста; побайтное равенство `Point.Raw`
фрагменту тела; обе границы; порядок точек на результат не влияет.
## 3. Хранилище — `internal/store`
- [x] 3.1 Миграция `00010_category_value.sql` — таблица `category_value`,
ключ `(metric, field, value)`, `WITHOUT ROWID`, с обоснованием в
комментарии.
- [x] 3.2 Upsert внутри транзакции `Merge`: код обновляется всегда, провенанс —
минимум по `(received_at, id)`.
- [x] 3.3 `DeliveryForParse` отдаёт заголовки доставки.
- [x] 3.4 Раздел реестра в `Fingerprint` (признак `c`, поля с длиной впереди);
**`code` в раздел не входит**.
- [x] 3.5 Чтение реестра и его счёт — для отчёта пересборки и тестов.
- [x] 3.6 Тесты: идемпотентность повторной свёртки, минимум провенанса, шесть
порядков трёх доставок дают один реестр, влияние наблюдения на отпечаток,
независимость отпечатка от кода, пустой реестр, значение с разделителем и
нулевым байтом.
## 4. Свёртка — `internal/fold`
- [x] 4.1 Локаль из `Accept-Language` заголовков доставки.
- [x] 4.2 Счётчики в `Stats` и в единственном логирующем чекпоинте; строк и
кодов в логе нет.
- [x] 4.3 Тест на отсутствие строк и кодов в логе — разбор записи, а не поиск в
сыром буфере.
## 5. Пересборка — `cmd/healthlog`
- [x] 5.1 Счётчик строк реестра «до и после» в отчёте `reindex`.
- [x] 5.2 Ожидаемый класс расхождения «появилась единица хранения».
- [x] 5.3 Тест отчёта: пустой реестр рабочей витрины против непустого
пересобранной.
## 6. Документы
- [x] 6.1 `docs/database.md` — таблица `category_value` (без правки гейт красный).
- [x] 6.2 `docs/architecture.md` — раздел «Категориальные значения» приводится в
соответствие с принятой формой хранения, тем же коммитом, что и миграция.
## 7. Оракулы
- [x] 7.1 `task gate` зелёный.
- [x] 7.2 `task verify:archive` на живом архиве основного репозитория
(`ARCHIVE=…/data/raw`) — правило разбора изменено. Новые утверждения
проверяют **свойства**, а не числа, производные от размера корпуса
(число строк реестра и счётчик строк без кода растут с потоком); числа
печатаются `t.Logf`.
- [x] 7.3 `task verify:busy` — свёртка трогает транзакцию слияния.
## Приёмочные критерии
### Из постановки задачи
- [x] фаза сна из потока («БДГ») и из экспорта Apple
(`HKCategoryValueSleepAnalysisAsleepREM`) за один период сопоставляются
напрямую — оракул: запрос на живой базе за период с известным перекрытием,
ноль несопоставимых строк.
**Оракул недостижим в рамках этой задачи:** импорт родного экспорта Apple
объявлен Non-Goal, кодов экспорта в живой базе нет и не появится. Снимается
настолько, насколько возможно: все шесть измеренных строк фаз сна
(находка 43), прочитанные из живого архива, дают непустой канонический код,
и множество выведенных кодов совпадает с множеством кодов сна из находки 43.
Полная сверка принадлежит задаче `apple-export-import`; расхождение названо
в отчёте, а не обойдено.
- [x] строка сохранена **дословно**, код лежит рядом отдельным полем — оракул:
тест разбора на реальном пакете HAE из `internal/hae/testdata`
- [x] незнакомая строка даёт пустой код, разбор не падает, а событие попадает в
счётчик — оракул: тест на выдуманной фазе сна плюс проверка счётчика
- [x] переименование кода самой Apple (`…Asleep``…AsleepUnspecified`,
находка 43) не раскалывает историю — оракул: тест на паре синонимов, обе
формы сходятся в один код
- [x] повторный прогон живого архива даёт то же состояние — оракул:
`task verify:archive`
### Из ревью предложения (рубрика профиля `design`)
- [x] **Дословность и живучесть точки.** `Point.Raw`/`Entity.Raw` побайтно равны
фрагменту тела; значение не-строка, отсутствие поля, пустая строка,
невалидный UTF-8 — точка разбирается как обычно, наблюдения не выводится.
*Оракул:* тесты на четырёх пакетах `testdata` + табличный тест форм.
- [x] **Реестр — функция префикса журнала, а не порядка свёртки.** Три доставки
во всех шести порядках дают один реестр и один отпечаток; повторная
свёртка не меняет ни одной колонки. *Оракул:* тест перестановок + тест
повтора.
- [x] **Уцелевший при переполнении набор — функция множества.** *Оракул:* два
тела с одним множеством строк и разным порядком точек дают один результат.
- [x] **Отпечаток инъективен по наблюдению и слеп к коду.** Различие в значении,
ключе, числе строк — меняет отпечаток; различие только в коде — не меняет;
значение с разделителем и цифрами границу поля не подделывает. *Оракул:*
тест с враждебной строкой + тест «пустой реестр».
- [x] **Расхождение «появилась единица хранения» отличимо машиной.** *Оракул:*
тест отчёта `reindex`: класс назван, счётчик напечатан, прогон не красный.
- [x] **Ключ восстановим из того, что переживает доставку.** Локали в ключе нет;
доставка без `Accept-Language` даёт ту же строку реестра. *Оракул:* тест
без заголовка + `task verify:archive` (архив заголовков не несёт).
- [x] **Нормализация локали не расщепляет язык вариантами тега.** *Оракул:*
табличный тест на девяти входах, включая `RU` и `*`.
- [x] **Словарь опознаёт байты живого потока, а не литерал теста.** *Оракул:*
приёмочный тест берёт строку из пакета `testdata`.
- [x] **Словарь и синонимы — чистая функция без изменяемого состояния.**
*Оракул:* тесты по таблицам целиком + `go test -race` (шаг гейта).
- [x] **Границы названы числом, отброшенное считается, рост реестра назван.**
*Оракул:* тесты обеих границ + число строк реестра живого архива в отчёте
задачи.
- [x] **Утверждения на живом корпусе — свойства, не числа от размера корпуса.**
*Оракул:* `task verify:archive` дважды подряд + чтение утверждений.
- [x] **Ни одна строка и ни один код не выходят выше `DEBUG`.** *Оракул:* тест
по разобранной записи лога; `task verify:busy`.
+251
View File
@@ -704,3 +704,254 @@ SHALL называть **тип** встреченного токена и см
- **AND** текст называет тип токена словарём JSON и смещение перед токеном - **AND** текст называет тип токена словарём JSON и смещение перед токеном
- **AND** смещение не меняется, если то же значение сделать длиннее - **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** содержимое точки сохранено дословно и целиком
+27 -10
View File
@@ -323,10 +323,13 @@
или нет. или нет.
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**: Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину часовые объекты, тренировки, записи и строки реестра категориальных значений.
целиком, поэтому единица, которой нет в счётчиках, делает расхождение Отпечаток отвечает «да/нет» за витрину целиком, поэтому единица, которой нет в
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не счётчиках, делает расхождение безадресным: человек увидит «не совпало» при
отличит появление двадцати семи тренировок от пропажи двух. неизменившемся числе объектов и не отличит появление двадцати семи тренировок от
пропажи двух. Перечень пополняется **тем же изменением**, которое заводит
единицу хранения: список закрытый, и единица, не внесённая в него, молчит ровно
там, где расхождение впервые становится заметным.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
@@ -362,13 +365,17 @@
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона, единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор, **покрытая непереносимый признак запечатанного часа, исправленный разбор, **покрытая
разбором новая секция**) SHALL называться отдельно от самого факта расхождения. разбором новая секция**, **появившаяся единица хранения**) SHALL называться
отдельно от самого факта расхождения.
Класс «покрыта новая секция» назван потому, что первый прогон после такого Класс «покрыта новая секция» назван потому, что первый прогон после такого
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
приучает человека игнорировать расхождение отпечатков — то есть обесценивает приучает человека игнорировать расхождение отпечатков — то есть обесценивает
оракул ровно тогда, когда по нему принимается необратимое решение. оракул ровно тогда, когда по нему принимается необратимое решение. Класс
«появилась единица хранения» — та же причина в общем виде: реестр категориальных
значений пуст у витрины, свёрнутой прежним бинарём, и первая сверка после
выкатки расходится по нему одному, при неизменившихся объектах и сущностях.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после **Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
@@ -383,7 +390,9 @@
выполнивший напечатанную процедуру, заменил бы витрину пустой. выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в MUST NOT: отпечаток берёт содержимое хешем. Строки реестра при этом счётом, а не
перечислением: наблюдённое значение — данные о здоровье наравне со значением
точки. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно. столкновения (метрика, слой, час) разрешены явно.
@@ -402,7 +411,15 @@ SHALL идти в поток ошибок, а не смешиваться с о
- **WHEN** пересборка завершилась - **WHEN** пересборка завершилась
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов, - **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
тренировок и записей тренировок, записей и строк реестра категориальных значений
#### Scenario: Пустой реестр рабочей витрины назван ожидаемым классом
- **WHEN** рабочая витрина свёрнута прежним бинарём и строк реестра не имеет, а
пересобранная их получила
- **THEN** отчёт называет «появилась единица хранения» ожидаемым классом
расхождения
- **AND** печатает число строк реестра «до и после»
#### Scenario: Расхождение отпечатков не является отказом #### Scenario: Расхождение отпечатков не является отказом
@@ -444,7 +461,8 @@ SHALL идти в поток ошибок, а не смешиваться с о
#### Scenario: Отчёт не раскрывает данных о здоровье #### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан - **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств - **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств,
ни наблюдённых категориальных строк
### Requirement: Отказ на одной доставке не останавливает пересборку ### Requirement: Отказ на одной доставке не останавливает пересборку
@@ -497,4 +515,3 @@ SHALL идти в поток ошибок, а не смешиваться с о
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой - **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
теряет содержание сохранённой теряет содержание сохранённой
- **THEN** отчёт пересборки называет число удержанных версий больше нуля - **THEN** отчёт пересборки называет число удержанных версий больше нуля
+159
View File
@@ -1335,3 +1335,162 @@ MUST NOT, а закрывать базу по выходу одной из дв
- **THEN** база не закрывается, а запись лога называет этап, не указывая - **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** счётчики значений без кода в записи присутствуют