diff --git a/CLAUDE.md b/CLAUDE.md index ef5011f..eb1a034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,9 +36,9 @@ Module path — `git.vakhrushev.me/av/healthlog`. - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200. - **Ничего не теряем молча.** Идентичность — координаты - (`метрика + слой + метка`), а у точки-интервала — `метрика + слой + начало + - конец`: под одной меткой лежит до трёх эпизодов сна, и эпизодность выводится - из формы точки, а не из списка метрик. `source` в ключ не входит, он + (`метрика + слой + начало + конец`), у точки-измерения конец равен началу: + под одной меткой лежит до трёх записей сна. Ключ одной формы для всех точек — + отдельного класса «эпизодных метрик» нет. `source` в ключ не входит, он нестабилен. Хеш канонизированного содержимого остался детектором изменений. При столкновении выигрывает **более полная** точка, а не последняя. Изменение diff --git a/docs/architecture.md b/docs/architecture.md index ea50b90..5888a98 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 / … ← перезаписываются ``` -**У точки-интервала ключ — интервал.** Метка на эпизоде не уникальна: под одним -`date` лежит до трёх записей сна, и это не дефект, а способ Apple выразить -вложенность «в кровати» и фазы внутри неё. Ключ -`метрика + слой + начало + конец` измерен на всём корпусе (находка 47): 174 -координаты против 170 по метке, ноль столкновений против 33 **внутри одной -доставки**, где тай-брейк по времени приёма неприменим в принципе. `value` в -ключе ничего не добавляет. +**Ключ — интервал, а не метка.** Метка на записи сна не уникальна: под одним +`date` лежит до трёх записей, и это не дефект, а способ Apple выразить +вложенность «в кровати» и фазы внутри неё. Измерено на всём корпусе (находка +47): 174 координаты против 170 по метке, ноль столкновений против 33 **внутри +одной доставки**, где тай-брейк по времени приёма неприменим в принципе. +`value` в ключе ничего не добавляет. -Эпизодность выводится из формы точки — есть `start` и `end`, отличные от `date`, -— а не из списка имён метрик: список был бы вторым способом описывать то, что -уже сказано данными. Час объекта берётся по началу эпизода, иначе -принадлежность объекту зависела бы от длительности. +Форма ключа **одна для всех точек**. Интервалы несут 22 метрики, а не только +сон; `start`, когда он есть, всегда равен `date`; обе формы точки не +смешиваются внутри метрики одной доставки. Поэтому отдельного класса «эпизодных +метрик» нет — нечего выводить и нечего поддерживать в каталоге и Read API. Час +объекта берётся по началу, иначе принадлежность объекту зависела бы от +длительности. `HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает — там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`, diff --git a/docs/local-research.md b/docs/local-research.md index b515cd7..e918e04 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -1495,6 +1495,35 @@ type sourceName sourceVersion creationDate startDate endDate value идентичности обязана выражаться через `start`/`end`: иначе `import(экспорт)` не сойдётся с `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 + diff --git a/openspec/changes/razbor-metrik-v-obekty/design.md b/openspec/changes/razbor-metrik-v-obekty/design.md index 51afb55..f242664 100644 --- a/openspec/changes/razbor-metrik-v-obekty/design.md +++ b/openspec/changes/razbor-metrik-v-obekty/design.md @@ -121,23 +121,34 @@ double, и хеш-детектор начнёт видеть изменения Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ. -### Эпизод адресуется интервалом, а не меткой +### Координата — интервал, у измерения вырожденный -Координата `метрика + слой + метка` верна для точки-измерения и неверна для -точки-интервала: под одной меткой лежит до трёх разных эпизодов сна. Поэтому -у точки, несущей `start` и `end`, координата — `метрика + слой + start + end`. +Ключ `метрика + слой + метка` верен для точки-измерения и неверен для +точки-интервала: под одной меткой лежит до трёх записей сна. Ключ единый: -Замер по всем 94 доставкам (1880 эпизодных точек, находка 47): метка одна даёт -170 координат и 33 столкновения **внутри одной доставки**, интервал — 174 -координаты и ноль столкновений; `value` в ключе не добавляет ни одной -координаты. Из 174 координат ни одна не несёт двух разных содержимых, то есть -на эпизодном сне правило разрешения столкновений не срабатывает ни разу. +``` +координата = метрика + слой + начало + конец конец = начало, если end нет +``` -**Эпизодность выводится из данных, а не курируется списком** — так же, как слой -выводится из выравнивания меток, а не из заголовка. Признак: точка несёт `start` -и `end`, отличные от `date`. Список имён метрик здесь был бы вторым способом -описывать то, что уже сказано формой точки, и разошёлся бы с ней на первой же -новой метрике HAE. +Замер по 94 доставкам (находка 47): метка одна даёт 170 координат сна и 33 +столкновения **внутри одной доставки**, интервал — 174 и ноль; `value` в ключе +не добавляет ни одной координаты. + +Первая редакция вводила отдельный класс «эпизодных схем» с признаком «`start` и +`end`, отличные от `date`». Перепись по всем 22 метрикам с интервалами его +опровергла: + +- **`start` всегда равен `date`** — признак не сработал бы ни разу; +- **интервалы несёт не только сон**, а 22 метрики, так что «эпизодная схема» — + не класс, а норма; +- **обе формы точки не смешиваются** внутри метрики одной доставки, поэтому + единый ключ не разорвёт надвое точку, приехавшую то с `end`, то без; +- **разные интервалы под одной меткой всегда несут разное содержимое** + (проверено по всем метрикам), так что ключ с интервалом не задваивает + поправленное задним числом. + +Одна форма ключа вместо двух убирает из кода ветвление и понятие, которое +пришлось бы поддерживать в каталоге, Read API и импорте экспорта Apple. Почему не «принять потерю и писать `WARN`»: в дублях внутри одной доставки `received_at` общий, и тай-брейк по времени приёма неприменим в принципе — diff --git a/openspec/changes/razbor-metrik-v-obekty/proposal.md b/openspec/changes/razbor-metrik-v-obekty/proposal.md index 2059c51..1a7644b 100644 --- a/openspec/changes/razbor-metrik-v-obekty/proposal.md +++ b/openspec/changes/razbor-metrik-v-obekty/proposal.md @@ -20,13 +20,12 @@ `Default` соответствует трём разным режимам. - Точки складываются в **часовые объекты** (`bucket`, ключ `метрика + слой + час`), содержимое — gzip-BLOB. Запись — read-modify-write со слиянием. -- **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в - ключ не входит: он нестабилен и меняется задним числом. При столкновении +- **Идентичность точки — координаты** (`метрика + слой + начало + конец`; у + точки-измерения конец равен началу). Ключ одной формы для всех точек: под + одной меткой лежит до трёх записей сна, и 33 столкновения происходят внутри + одной доставки, где тай-брейк по времени приёма неприменим. `source` в ключ + не входит: он нестабилен и меняется задним числом. При столкновении выигрывает **более полная** точка, а не последняя пришедшая. -- **Точка-интервал адресуется интервалом**: `метрика + слой + начало + конец`. - Под одной меткой лежит до трёх эпизодов сна, и 33 столкновения из измеренных - происходят внутри одной доставки, где тай-брейк по времени приёма неприменим. - Эпизодность выводится из формы точки, а не из списка имён метрик. - **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической формы — детектор изменений, а не ключ. - `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку — diff --git a/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md b/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md index 69a37b8..c5780e1 100644 --- a/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md +++ b/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md @@ -2,11 +2,33 @@ ### Requirement: Идентичность точки по координатам -Система SHALL адресовать точку-измерение координатами -`метрика + слой + метка времени`. Поле `source` в ключ входить MUST NOT: оно -нестабильно — то же измерение с тем же значением приезжает то как -`Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`, потому что -Health переосмысливает атрибуцию задним числом. +Система SHALL адресовать точку координатами +`метрика + слой + начало + конец`. У точки-измерения конец равен началу; у +точки-интервала — концу интервала. Ключ MUST быть одной формы для всех точек: +интервальная и точечная формы не встречаются вперемешку внутри одной метрики +одной доставки (проверено на всём корпусе), поэтому ветвление по «классу +метрики» не нужно и вводить его 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. @@ -21,44 +43,28 @@ Health переосмысливает атрибуцию задним число - **WHEN** точка с теми же координатами приезжает с другой строкой `source` - **THEN** она остаётся одной точкой, а не превращается в две -### Requirement: Идентичность эпизода по интервалу - -Система SHALL адресовать точку-интервал координатами -`метрика + слой + начало + конец`. Метки времени для неё недостаточно: под -одной меткой лежит до трёх разных эпизодов сна. - -Эпизодность SHALL выводиться из формы точки — точка несёт `start` и `end`, -отличные от `date`, — а не назначаться списком имён метрик. Список был бы -вторым способом описывать то, что уже сказано формой точки, и разошёлся бы с -ней на первой же новой метрике HAE. - -Измерено на всех 94 доставках (1880 эпизодных точек, находка 47): ключ по метке -даёт 170 координат и 33 столкновения **внутри одной доставки**, ключ по -интервалу — 174 координаты и ноль столкновений. Внутридоставочные столкновения -и делают ключ по метке неисправимым: `received_at` там общий, тай-брейк по нему -неприменим в принципе, и исход решал бы порядок элементов в JSON-массиве — а он -нестабилен. - -Час объекта для точки-интервала SHALL определяться по началу эпизода: эпизод -пересекает границы часов, и любой другой выбор сделал бы принадлежность -объекту зависящей от длительности. - -#### Scenario: Эпизоды с одной меткой и разными интервалами не схлопываются +#### Scenario: Записи с одной меткой и разными интервалами не схлопываются - **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и разными парами `start`/`end` - **THEN** каждая сохраняется отдельной точкой -#### Scenario: Повтор эпизода в следующей доставке не задваивает +#### Scenario: Повтор записи в следующей доставке не задваивает -- **WHEN** точка с тем же `start` и `end` приезжает следующей доставкой +- **WHEN** точка с тем же началом и концом приезжает следующей доставкой - **THEN** она остаётся одной точкой +#### Scenario: Точка-измерение адресуется вырожденным интервалом + +- **WHEN** точка не несёт `end` +- **THEN** её конец равен началу, и ключ имеет ту же форму, что у интервала + ### Requirement: Разрешение столкновений по полноте Когда по одним координатам приходят разные содержимые, система SHALL оставлять **более полную** точку — ту, у которой больше значащих полей, — а не последнюю -пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой. +пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт: +0.66% координат различаются именно набором полей при одинаковом значении. Если полнота равна, а значения различаются, исход MUST быть детерминированным и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по @@ -71,9 +77,9 @@ Health переосмысливает атрибуцию задним число #### Scenario: Бедная точка не стирает поля богатой -- **WHEN** сохранена точка с `qty`, `start` и `end` -- **AND** по тем же координатам приезжает точка только с `qty` -- **THEN** сохранённая точка остаётся с `start` и `end` +- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context` +- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max` +- **THEN** сохранённая точка остаётся с `context` #### Scenario: Одинаково полные точки с разными значениями diff --git a/openspec/changes/razbor-metrik-v-obekty/tasks.md b/openspec/changes/razbor-metrik-v-obekty/tasks.md index ef62b09..ba240e4 100644 --- a/openspec/changes/razbor-metrik-v-obekty/tasks.md +++ b/openspec/changes/razbor-metrik-v-obekty/tasks.md @@ -33,8 +33,8 @@ - [ ] 4.1 Модель объекта в `internal/store`; содержимое — исходные байты точек, gzip-BLOB, точки упорядочены по времени - [ ] 4.2 Слияние: координатный ключ, победа более полной точки, при равной полноте — детерминированный исход по порядку канонических форм - [ ] 4.3 Столкновение с различием канонической формы — `WARN` без значений и счётчик перезаписей -- [ ] 4.4 Ключ точки-интервала — `метрика + слой + start + end`; эпизодность выводится из формы точки (`start`/`end`, отличные от `date`), а не из списка имён. Час объекта — по началу эпизода -- [ ] 4.4a Тест на фикстуре 1.3a: три эпизода с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает +- [ ] 4.4 Ключ точки — `метрика + слой + начало + конец` одной формы для всех точек: начало из `start`, иначе из `date`; конец из `end`, иначе равен началу. Час объекта — по началу. Ветвления по «классу метрики» быть не должно +- [ ] 4.4a Тест на фикстуре 1.3a: три записи с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает; точка без `end` кладётся вырожденным интервалом - [ ] 4.5 Хеш объекта как детектор изменений: совпал — записи нет - [ ] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю тройку** чтение-слияние-запись; путь «хеш совпал» — под `TxOptions{ReadOnly: true}` - [ ] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store`