добавлен словарь категориальных значений 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
+86
View File
@@ -16,7 +16,9 @@ import (
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/healthkit"
"git.vakhrushev.me/av/healthlog/internal/ident"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
@@ -155,6 +157,90 @@ func TestReplayЖивогоАрхива(t *testing.T) {
coords, labels)
}
t.Logf("координат сна %d, различных меток %d", coords, labels)
measureCategories(t, dst, second)
}
// measureCategories проверяет реестр категориальных значений на живом корпусе.
//
// Здесь он проверяется единственным способом, каким это вообще возможно: против
// строк, которые телефон действительно присылал. Заголовков в архиве нет, то
// есть локаль у всех доставок пуста — прогон заодно доказывает, что вывод кода
// не зависит от уцелевшей учётной строки.
//
// Утверждаются СВОЙСТВА, а не числа: число строк реестра и число строк без кода
// растут вместе с потоком, а прогон живого архива в гейт не входит — константа
// покраснела бы молча (docs/review.md, 2026-08-02). Измеренное печатается.
func measureCategories(t *testing.T, dst *store.Store, second replay.Report) {
t.Helper()
values, err := dst.CategoryValues(context.Background())
if err != nil {
t.Fatalf("реестр категориальных значений: %v", err)
}
if len(values) == 0 {
t.Fatal("реестр пуст — категориальные значения не извлекаются вовсе")
}
if int64(len(values)) != second.Categories {
t.Errorf("повторное проигрывание изменило число строк реестра: %d → %d",
len(values), second.Categories)
}
var withCode, withoutCode int
for _, v := range values {
if v.Code == "" {
withoutCode++
continue
}
withCode++
// Всякий выведенный код обязан быть каноническим: обе формы имени,
// которые Apple дала одному значению, сходятся в одну (находка 43).
// Координаты — метрика и поле; сам код не печатается: он константа
// бинаря, но напечатанный рядом с живым архивом сообщает, что эта фаза
// у человека была.
if healthkit.Canonical(v.Code) != v.Code {
t.Errorf("%s/%s: выведенный код не канонический", v.Metric, v.Field)
}
// Провенанс — доставка журнала, а не пустое место.
if v.FirstDeliveryID == "" || v.FirstSeen.IsZero() {
t.Errorf("%s/%s: провенанс не заполнен", v.Metric, v.Field)
}
}
// Фазы сна — единственное, что словарь покрывает, и покрывать он их обязан:
// ради этой сверки задача и существует. Строки берутся из ЖИВОГО архива, а
// не из литерала теста.
var sleepValues, sleepCoded int
for _, v := range values {
if v.Metric != "sleep_analysis" || v.Field != "value" {
continue
}
sleepValues++
if v.Code != "" {
sleepCoded++
}
}
if sleepValues == 0 {
t.Fatal("фаз сна в реестре нет — проверять нечего")
}
if sleepCoded != sleepValues {
// Печатается ЧИСЛО, а не перечень. Перечень был бы удобнее, и ровно
// поэтому его тут быть не может: прогон идёт против рабочего архива, а
// фаза сна — значение точки. `CLAUDE.md`, «Запреты»: ничего из `./data`
// не попадает ни в логи выше `DEBUG`, ни в вывод агента. Ветка
// срабатывает как раз тогда, когда телефон принёс НОВУЮ фазу, то есть
// именно тогда, когда соблазн напечатать её сильнее всего.
//
// Адрес, по которому строки смотрят, отпечатывать не нужно: они лежат в
// пересобранной базе, `SELECT value FROM category_value WHERE code = ''`.
t.Errorf("фаз сна без кода %d из %d — словарь неполон; строки смотреть в реестре пересобранной базы",
sleepValues-sleepCoded, sleepValues)
}
// Числа печатаются: они растут с корпусом и утверждению не подлежат.
// Значения при этом НЕ печатаются — это данные о здоровье.
t.Logf("реестр: строк %d, с кодом %d, без кода %d; фаз сна %d, все с кодом",
len(values), withCode, withoutCode, sleepValues)
}
// measureStyles прогоняет измерение рода агрегации на витрине, собранной из
+12 -3
View File
@@ -79,8 +79,12 @@ type Report struct {
// Workouts и Records — остальные единицы хранения витрины. Считаются рядом
// с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а
// решение о подмене базы необратимо и требует направления расхождения.
Workouts int64
Records int64
Workouts int64
Records int64
// Categories — строки реестра категориальных значений: четвёртая единица
// хранения витрины. Без счётчика расхождение по ней безадресно — объекты,
// тренировки и записи при этом не меняются вовсе.
Categories int64
Fingerprint string
// Canceled — проигрывание прервано отменой, а не дошло до конца.
@@ -184,6 +188,10 @@ func Run(ctx context.Context, o Options) (Report, error) {
if err != nil {
return stopOr(rep, err)
}
rep.Categories, err = o.Target.CountCategoryValues(ctx)
if err != nil {
return stopOr(rep, err)
}
rep.Fingerprint, err = o.Target.Fingerprint(ctx)
if err != nil {
return stopOr(rep, err)
@@ -207,7 +215,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
"entities_diverging", rep.EntitiesDiverging,
"buckets", rep.Buckets,
"workouts", rep.Workouts,
"records", rep.Records)
"records", rep.Records,
"category_values", rep.Categories)
return rep, nil
}