добавлен словарь категориальных значений 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
+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)
}
}
}
}