ключ точки — интервал одной формы, класс «эпизодных метрик» убран

Перепись по 22 метрикам с интервалами опровергла признак из первой редакции:
start всегда равен date, интервалы несёт не только сон, обе формы точки не
смешиваются внутри метрики одной доставки, а разные интервалы под одной меткой
всегда несут разное содержимое. Значит ключ единый — метрика + слой + начало +
конец, у измерения вырожденный, без ветвления по классу.
This commit is contained in:
av
2026-08-01 17:15:49 +03:00
parent 3c9d226a34
commit 3e93dd95b4
7 changed files with 119 additions and 73 deletions
+3 -3
View File
@@ -36,9 +36,9 @@ Module path — `git.vakhrushev.me/av/healthlog`.
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
битый JSON — 400, непонятое содержимое — 200. битый JSON — 400, непонятое содержимое — 200.
- **Ничего не теряем молча.** Идентичность — координаты - **Ничего не теряем молча.** Идентичность — координаты
(`метрика + слой + метка`), а у точки-интервала — `метрика + слой + начало + (`метрика + слой + начало + конец`), у точки-измерения конец равен началу:
конец`: под одной меткой лежит до трёх эпизодов сна, и эпизодность выводится под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек —
из формы точки, а не из списка метрик. `source` в ключ не входит, он отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он
нестабилен. Хеш нестабилен. Хеш
канонизированного содержимого остался детектором изменений. При канонизированного содержимого остался детектором изменений. При
столкновении выигрывает **более полная** точка, а не последняя. Изменение столкновении выигрывает **более полная** точка, а не последняя. Изменение
+15 -14
View File
@@ -24,8 +24,8 @@ healthlog принимает выгрузки Apple Health из приложен
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
(см. «Приём»). (см. «Приём»).
- **Ничего не теряем молча.** Идентичность — устойчивые координаты - **Ничего не теряем молча.** Идентичность — устойчивые координаты
(`метрика + слой + метка времени`), а у точки-интервала — `метрика + слой + (`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
начало + конец`; `source` в ключ не входит, он `source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений, нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой. последняя пришедшая: бедная доставка не должна стирать поля у богатой.
@@ -461,22 +461,23 @@ hour метки выровнены на час heart_rate 00:00:00
**Идентичность точки — координаты, а не содержимое.** **Идентичность точки — координаты, а не содержимое.**
``` ```
ключ: метрика + слой + метка времени ключ: метрика + слой + начало + конец конец = начало, если end нет
значения: qty / Min / Avg / Max / source / … ← перезаписываются значения: qty / Min / Avg / Max / source / … ← перезаписываются
``` ```
**У точки-интервала ключ — интервал.** Метка на эпизоде не уникальна: под одним **Ключ — интервал, а не метка.** Метка на записи сна не уникальна: под одним
`date` лежит до трёх записей сна, и это не дефект, а способ Apple выразить `date` лежит до трёх записей, и это не дефект, а способ Apple выразить
вложенность «в кровати» и фазы внутри неё. Ключ вложенность «в кровати» и фазы внутри неё. Измерено на всём корпусе (находка
`метрика + слой + начало + конец` измерен на всём корпусе (находка 47): 174 47): 174 координаты против 170 по метке, ноль столкновений против 33 **внутри
координаты против 170 по метке, ноль столкновений против 33 **внутри одной одной доставки**, где тай-брейк по времени приёма неприменим в принципе.
доставки**, где тай-брейк по времени приёма неприменим в принципе. `value` в `value` в ключе ничего не добавляет.
ключе ничего не добавляет.
Эпизодность выводится из формы точки — есть `start` и `end`, отличные от `date`, Форма ключа **одна для всех точек**. Интервалы несут 22 метрики, а не только
— а не из списка имён метрик: список был бы вторым способом описывать то, что сон; `start`, когда он есть, всегда равен `date`; обе формы точки не
уже сказано данными. Час объекта берётся по началу эпизода, иначе смешиваются внутри метрики одной доставки. Поэтому отдельного класса «эпизодных
принадлежность объекту зависела бы от длительности. метрик» нет — нечего выводить и нечего поддерживать в каталоге и Read API. Час
объекта берётся по началу, иначе принадлежность объекту зависела бы от
длительности.
`HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает — `HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает —
там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`, там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`,
+29
View File
@@ -1495,6 +1495,35 @@ type sourceName sourceVersion creationDate startDate endDate value
идентичности обязана выражаться через `start`/`end`: иначе `import(экспорт)` не идентичности обязана выражаться через `start`/`end`: иначе `import(экспорт)` не
сойдётся с `replay(HAE)` и журнал перестанет быть журналом. сойдётся с `replay(HAE)` и журнал перестанет быть журналом.
### Уточнение: ключ один, и это интервал
Перепись по всем 22 метрикам, несущим `start`/`end`, поправила формулировку:
- **`start` всегда равен `date`** — ноль исключений на всём корпусе. Признак
«`start` и `end`, отличные от `date`» неработоспособен: отличается только
`end`.
- **Интервалы несёт не один сон, а 22 метрики** (`heart_rate`,
`physical_effort`, `walking_speed`, `apple_stand_hour`, …).
- **Обе формы точки никогда не смешиваются** внутри одной метрики в одной
доставке: интервальность — свойство режима выгрузки, а не отдельной точки.
Значит ключ с интервалом не разорвёт надвое точку, которая приехала то с
`end`, то без.
- **Разные интервалы под одной меткой** встречаются только у `sleep_analysis`
(3 метки) и `resting_heart_rate` (2 метки), и во **всех** случаях содержимое
точек различается. То есть это разные данные, а не поправленный задним числом
интервал: ключ с интервалом ничего не задваивает.
Отсюда ключ не двух форм, а одной:
```
координата = метрика + слой + начало + конец
у точки-измерения конец = начало
```
Понятия «эпизодная схема» не требуется вовсе — выводить нечего, ветвления в
коде нет, и правило разрешения столкновений по полноте продолжает работать
ровно там, где работало.
### Как это решают другие ### Как это решают другие
- **Health CSV Importer** дедуплицирует по `Start Date + End Date + Data Type + - **Health CSV Importer** дедуплицирует по `Start Date + End Date + Data Type +
@@ -121,23 +121,34 @@ double, и хеш-детектор начнёт видеть изменения
Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ. Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ.
### Эпизод адресуется интервалом, а не меткой ### Координата — интервал, у измерения вырожденный
Координата `метрика + слой + метка` верна для точки-измерения и неверна для Ключ `метрика + слой + метка` верен для точки-измерения и неверен для
точки-интервала: под одной меткой лежит до трёх разных эпизодов сна. Поэтому точки-интервала: под одной меткой лежит до трёх записей сна. Ключ единый:
у точки, несущей `start` и `end`, координата — `метрика + слой + start + end`.
Замер по всем 94 доставкам (1880 эпизодных точек, находка 47): метка одна даёт ```
170 координат и 33 столкновения **внутри одной доставки**, интервал — 174 координата = метрика + слой + начало + конец конец = начало, если end нет
координаты и ноль столкновений; `value` в ключе не добавляет ни одной ```
координаты. Из 174 координат ни одна не несёт двух разных содержимых, то есть
на эпизодном сне правило разрешения столкновений не срабатывает ни разу.
**Эпизодность выводится из данных, а не курируется списком** — так же, как слой Замер по 94 доставкам (находка 47): метка одна даёт 170 координат сна и 33
выводится из выравнивания меток, а не из заголовка. Признак: точка несёт `start` столкновения **внутри одной доставки**, интервал — 174 и ноль; `value` в ключе
и `end`, отличные от `date`. Список имён метрик здесь был бы вторым способом не добавляет ни одной координаты.
описывать то, что уже сказано формой точки, и разошёлся бы с ней на первой же
новой метрике HAE. Первая редакция вводила отдельный класс «эпизодных схем» с признаком «`start` и
`end`, отличные от `date`». Перепись по всем 22 метрикам с интервалами его
опровергла:
- **`start` всегда равен `date`** — признак не сработал бы ни разу;
- **интервалы несёт не только сон**, а 22 метрики, так что «эпизодная схема» —
не класс, а норма;
- **обе формы точки не смешиваются** внутри метрики одной доставки, поэтому
единый ключ не разорвёт надвое точку, приехавшую то с `end`, то без;
- **разные интервалы под одной меткой всегда несут разное содержимое**
(проверено по всем метрикам), так что ключ с интервалом не задваивает
поправленное задним числом.
Одна форма ключа вместо двух убирает из кода ветвление и понятие, которое
пришлось бы поддерживать в каталоге, Read API и импорте экспорта Apple.
Почему не «принять потерю и писать `WARN`»: в дублях внутри одной доставки Почему не «принять потерю и писать `WARN`»: в дублях внутри одной доставки
`received_at` общий, и тай-брейк по времени приёма неприменим в принципе — `received_at` общий, и тай-брейк по времени приёма неприменим в принципе —
@@ -20,13 +20,12 @@
`Default` соответствует трём разным режимам. `Default` соответствует трём разным режимам.
- Точки складываются в **часовые объекты** (`bucket`, ключ `метрика + слой + - Точки складываются в **часовые объекты** (`bucket`, ключ `метрика + слой +
час`), содержимое — gzip-BLOB. Запись — read-modify-write со слиянием. час`), содержимое — gzip-BLOB. Запись — read-modify-write со слиянием.
- **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в - **Идентичность точки — координаты** (`метрика + слой + начало + конец`; у
ключ не входит: он нестабилен и меняется задним числом. При столкновении точки-измерения конец равен началу). Ключ одной формы для всех точек: под
одной меткой лежит до трёх записей сна, и 33 столкновения происходят внутри
одной доставки, где тай-брейк по времени приёма неприменим. `source` в ключ
не входит: он нестабилен и меняется задним числом. При столкновении
выигрывает **более полная** точка, а не последняя пришедшая. выигрывает **более полная** точка, а не последняя пришедшая.
- **Точка-интервал адресуется интервалом**: `метрика + слой + начало + конец`.
Под одной меткой лежит до трёх эпизодов сна, и 33 столкновения из измеренных
происходят внутри одной доставки, где тай-брейк по времени приёма неприменим.
Эпизодность выводится из формы точки, а не из списка имён метрик.
- **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической - **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической
формы — детектор изменений, а не ключ. формы — детектор изменений, а не ключ.
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку — - `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
@@ -2,11 +2,33 @@
### Requirement: Идентичность точки по координатам ### Requirement: Идентичность точки по координатам
Система SHALL адресовать точку-измерение координатами Система SHALL адресовать точку координатами
`метрика + слой + метка времени`. Поле `source` в ключ входить MUST NOT: оно `метрика + слой + начало + конец`. У точки-измерения конец равен началу; у
нестабильно — то же измерение с тем же значением приезжает то как точки-интервала — концу интервала. Ключ MUST быть одной формы для всех точек:
`Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`, потому что интервальная и точечная формы не встречаются вперемешку внутри одной метрики
Health переосмысливает атрибуцию задним числом. одной доставки (проверено на всём корпусе), поэтому ветвление по «классу
метрики» не нужно и вводить его MUST NOT.
Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем
же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как
`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним
числом.
Начало точки берётся из `start`, а при его отсутствии — из `date`; конец — из
`end`, а при его отсутствии — из начала. Измерено: `start`, когда он есть,
**всегда** совпадает с `date` (ноль исключений на 22 метриках), поэтому правило
не вводит второго источника метки — оно лишь закрывает случай, когда HAE
перестанет их дублировать.
Час объекта определяется по началу точки: интервал пересекает границы часов, и
любой другой выбор сделал бы принадлежность объекту зависящей от длительности.
Ключ по одной метке проверялся и отвергнут: он схлопывает записи сна. Измерено
на всех 94 доставках — 170 координат против 174 и **33 столкновения внутри
одной доставки**, где `received_at` общий, тай-брейк по нему неприменим в
принципе, и исход решал бы порядок элементов в JSON-массиве, а он нестабилен.
При этом разные интервалы под одной меткой всегда несут разное содержимое
(проверено по всем метрикам), то есть ключ с интервалом ничего не задваивает.
Идентичность по хешу содержимого проверялась и отвергнута: она задваивала Идентичность по хешу содержимого проверялась и отвергнута: она задваивала
минутный слой целиком — 120 точек в часе вместо 60. минутный слой целиком — 120 точек в часе вместо 60.
@@ -21,44 +43,28 @@ Health переосмысливает атрибуцию задним число
- **WHEN** точка с теми же координатами приезжает с другой строкой `source` - **WHEN** точка с теми же координатами приезжает с другой строкой `source`
- **THEN** она остаётся одной точкой, а не превращается в две - **THEN** она остаётся одной точкой, а не превращается в две
### Requirement: Идентичность эпизода по интервалу #### Scenario: Записи с одной меткой и разными интервалами не схлопываются
Система SHALL адресовать точку-интервал координатами
`метрика + слой + начало + конец`. Метки времени для неё недостаточно: под
одной меткой лежит до трёх разных эпизодов сна.
Эпизодность SHALL выводиться из формы точки — точка несёт `start` и `end`,
отличные от `date`, — а не назначаться списком имён метрик. Список был бы
вторым способом описывать то, что уже сказано формой точки, и разошёлся бы с
ней на первой же новой метрике HAE.
Измерено на всех 94 доставках (1880 эпизодных точек, находка 47): ключ по метке
даёт 170 координат и 33 столкновения **внутри одной доставки**, ключ по
интервалу — 174 координаты и ноль столкновений. Внутридоставочные столкновения
и делают ключ по метке неисправимым: `received_at` там общий, тай-брейк по нему
неприменим в принципе, и исход решал бы порядок элементов в JSON-массиве — а он
нестабилен.
Час объекта для точки-интервала SHALL определяться по началу эпизода: эпизод
пересекает границы часов, и любой другой выбор сделал бы принадлежность
объекту зависящей от длительности.
#### Scenario: Эпизоды с одной меткой и разными интервалами не схлопываются
- **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и - **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и
разными парами `start`/`end` разными парами `start`/`end`
- **THEN** каждая сохраняется отдельной точкой - **THEN** каждая сохраняется отдельной точкой
#### Scenario: Повтор эпизода в следующей доставке не задваивает #### Scenario: Повтор записи в следующей доставке не задваивает
- **WHEN** точка с тем же `start` и `end` приезжает следующей доставкой - **WHEN** точка с тем же началом и концом приезжает следующей доставкой
- **THEN** она остаётся одной точкой - **THEN** она остаётся одной точкой
#### Scenario: Точка-измерение адресуется вырожденным интервалом
- **WHEN** точка не несёт `end`
- **THEN** её конец равен началу, и ключ имеет ту же форму, что у интервала
### Requirement: Разрешение столкновений по полноте ### Requirement: Разрешение столкновений по полноте
Когда по одним координатам приходят разные содержимые, система SHALL оставлять Когда по одним координатам приходят разные содержимые, система SHALL оставлять
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю **более полную** точку — ту, у которой больше значащих полей, — а не последнюю
пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой. пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт:
0.66% координат различаются именно набором полей при одинаковом значении.
Если полнота равна, а значения различаются, исход MUST быть детерминированным Если полнота равна, а значения различаются, исход MUST быть детерминированным
и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по
@@ -71,9 +77,9 @@ Health переосмысливает атрибуцию задним число
#### Scenario: Бедная точка не стирает поля богатой #### Scenario: Бедная точка не стирает поля богатой
- **WHEN** сохранена точка с `qty`, `start` и `end` - **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
- **AND** по тем же координатам приезжает точка только с `qty` - **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
- **THEN** сохранённая точка остаётся с `start` и `end` - **THEN** сохранённая точка остаётся с `context`
#### Scenario: Одинаково полные точки с разными значениями #### Scenario: Одинаково полные точки с разными значениями
@@ -33,8 +33,8 @@
- [ ] 4.1 Модель объекта в `internal/store`; содержимое — исходные байты точек, gzip-BLOB, точки упорядочены по времени - [ ] 4.1 Модель объекта в `internal/store`; содержимое — исходные байты точек, gzip-BLOB, точки упорядочены по времени
- [ ] 4.2 Слияние: координатный ключ, победа более полной точки, при равной полноте — детерминированный исход по порядку канонических форм - [ ] 4.2 Слияние: координатный ключ, победа более полной точки, при равной полноте — детерминированный исход по порядку канонических форм
- [ ] 4.3 Столкновение с различием канонической формы — `WARN` без значений и счётчик перезаписей - [ ] 4.3 Столкновение с различием канонической формы — `WARN` без значений и счётчик перезаписей
- [ ] 4.4 Ключ точки-интервала`метрика + слой + start + end`; эпизодность выводится из формы точки (`start`/`end`, отличные от `date`), а не из списка имён. Час объекта — по началу эпизода - [ ] 4.4 Ключ точки — `метрика + слой + начало + конец` одной формы для всех точек: начало из `start`, иначе из `date`; конец из `end`, иначе равен началу. Час объекта — по началу. Ветвления по «классу метрики» быть не должно
- [ ] 4.4a Тест на фикстуре 1.3a: три эпизода с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает - [ ] 4.4a Тест на фикстуре 1.3a: три записи с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает; точка без `end` кладётся вырожденным интервалом
- [ ] 4.5 Хеш объекта как детектор изменений: совпал — записи нет - [ ] 4.5 Хеш объекта как детектор изменений: совпал — записи нет
- [ ] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю тройку** чтение-слияние-запись; путь «хеш совпал» — под `TxOptions{ReadOnly: true}` - [ ] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю тройку** чтение-слияние-запись; путь «хеш совпал» — под `TxOptions{ReadOnly: true}`
- [ ] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store` - [ ] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store`