добавлен словарь категориальных значений 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
+66 -2
View File
@@ -179,6 +179,32 @@ type Result struct {
// по координате.
SkippedBadEnd int
// Categoricals — различные наблюдённые категориальные значения доставки с
// выведенными кодами, в детерминированном порядке. Повтор одной строки в
// тысяче точек даёт один элемент.
Categoricals []Categorical
// CategoricalUnknown — сколько РАЗЛИЧНЫХ наблюдений не получило кода.
// Считаются различные значения, а не их вхождения: счётчик отвечает на
// вопрос «сколько строк ждёт словаря», а не «сколько точек их несло».
//
// В установившемся режиме он ненулевой — словарь покрывает только фазы сна,
// а контекст пульса и имена тренировок объявлены категориальными заранее.
// Сигналом «появилось новое» служит поэтому новая строка реестра, а не
// ненулевой счётчик.
CategoricalUnknown int
// CategoricalDropped — сколько ВХОЖДЕНИЙ отброшено границами: непомерная
// длина значения или переполнение числа различных значений.
//
// Единица названа вслух и отличается от соседнего счётчика намеренно.
// Считать здесь различные значения нечем: набор ограничен при вставке (иначе
// накопитель растёт вместе с телом, а тело контролирует отправитель), и
// отброшенный ключ нигде не запоминается — запомнить его значило бы вернуть
// ровно тот неограниченный рост, ради устранения которого граница и стоит на
// вставке. Поэтому счётчик отвечает на вопрос «сколько раз сработала
// граница», а не «сколько строк потеряно»; на второй отвечает реестр в
// витрине.
CategoricalDropped int
// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
// метрики). Пустой, если плотных метрик не было и наследовать было нечего.
// Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации
@@ -204,6 +230,13 @@ type Meta struct {
// из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий —
// разбор остаётся чистой функцией.
FallbackLayer Layer
// Locale — нормализованный языковой тег доставки из `Accept-Language`
// (находка 32). Сужает поиск по словарю категориальных значений и НИКУДА не
// сохраняется: заголовков в сыром архиве нет, поэтому доставка,
// восстановленная из осиротевшего тела, приезжает без локали — и обязана
// дать то же состояние. Пустая локаль законна.
Locale string
}
// Parse разбирает секцию metrics тела доставки в точки.
@@ -231,18 +264,28 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
res.Uncovered = env.uncovered
res.UncoveredDropped = env.dropped
cat := newCategoricals(meta.Locale)
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
// Имя тренировки берётся из уже разобранного заголовка сущности. Мягкое
// чтение превратило значение не того типа в пустую строку, а пустая строка
// наблюдением не считается, — то есть «имени не было» и «имя приехало
// числом» дают один исход, и он верный.
for _, w := range res.Workouts {
cat.add(workoutsSection, fieldName, w.Name)
}
metrics := env.metrics
res.Metrics = len(metrics)
if len(metrics) == 0 {
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
return res, nil
}
groups := make([]group, 0, len(metrics))
for _, m := range metrics {
g := decodeGroup(m, &res)
g := decodeGroup(m, &res, cat)
if len(g.summaries) > 0 {
groups = append(groups, group{
metric: sleepSummaryMetric,
@@ -258,6 +301,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
}
}
if len(groups) == 0 {
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
return res, nil
}
@@ -280,6 +324,11 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
}, err
}
// Наблюдения отдаются только на успешном исходе: «всё или ничего» относится
// к доставке целиком. У отказа по слою (см. ветку выше) сущности не
// отдаются по той же причине.
res.Categoricals, res.CategoricalUnknown, res.CategoricalDropped = cat.result()
total := 0
for _, g := range groups {
total += len(g.points)
@@ -400,6 +449,15 @@ type pointHead struct {
// TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и
// суточную сводку. Общих полей, кроме date и source, у них нет.
TotalSleep *json.RawMessage `json:"totalSleep"`
// Value и Context — категориальные поля точки (фаза сна и контекст пульса,
// находка 37). Сырыми сообщениями, а не строками: объяви их `string`, и
// точка, у которой поле пришло числом, перестала бы разбираться вовсе —
// json.Unmarshal отвечает ошибкой на несовпадение типа, а decodeGroup
// считает такую точку не разобравшейся. Новый путь потери точки ради
// удобства структуры недопустим.
Value *json.RawMessage `json:"value"`
Context *json.RawMessage `json:"context"`
}
// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций,
@@ -665,7 +723,7 @@ func clipSection(name string) string {
// уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод
// сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной
// корреляции между двумя списками здесь не существует по построению.
func decodeGroup(m metricEnvelope, res *Result) group {
func decodeGroup(m metricEnvelope, res *Result, cat *categoricals) group {
g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}
for _, raw := range m.Data {
@@ -716,8 +774,14 @@ func decodeGroup(m metricEnvelope, res *Result) group {
if m.Name == sleepMetric && head.TotalSleep != nil {
p.Metric = sleepSummaryMetric
g.summaries = append(g.summaries, p)
// Наблюдение снимается по ИТОГОВОМУ имени метрики, тому же, которым
// адресуется единица хранения. У суточной сводки категориальных
// полей нет, так что здесь это ноль работы, — но правило записано
// один раз и не разойдётся при следующем разделении схем.
cat.addPoint(p.Metric, &head)
continue
}
cat.addPoint(p.Metric, &head)
g.points = append(g.points, p)
}