diff --git a/CLAUDE.md b/CLAUDE.md index 843a58b..ef5011f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,10 @@ Module path — `git.vakhrushev.me/av/healthlog`. - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200. - **Ничего не теряем молча.** Идентичность — координаты - (`метрика + слой + метка`); `source` в ключ не входит, он нестабилен. Хеш + (`метрика + слой + метка`), а у точки-интервала — `метрика + слой + начало + + конец`: под одной меткой лежит до трёх эпизодов сна, и эпизодность выводится + из формы точки, а не из списка метрик. `source` в ключ не входит, он + нестабилен. Хеш канонизированного содержимого остался детектором изменений. При столкновении выигрывает **более полная** точка, а не последняя. Изменение запечатанного часа — `WARN`, но данные всё равно пишутся. diff --git a/docs/architecture.md b/docs/architecture.md index 0242bfe..ea50b90 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,8 @@ healthlog принимает выгрузки Apple Health из приложен - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор (см. «Приём»). - **Ничего не теряем молча.** Идентичность — устойчивые координаты - (`метрика + слой + метка времени`); `source` в ключ не входит, он + (`метрика + слой + метка времени`), а у точки-интервала — `метрика + слой + + начало + конец`; `source` в ключ не входит, он нестабилен. Хеш канонизированного содержимого остаётся детектором изменений, чтобы не писать зря. При столкновении выигрывает более полная точка, а не последняя пришедшая: бедная доставка не должна стирать поля у богатой. @@ -464,6 +465,24 @@ hour метки выровнены на час heart_rate 00:00:00 значения: qty / Min / Avg / Max / source / … ← перезаписываются ``` +**У точки-интервала ключ — интервал.** Метка на эпизоде не уникальна: под одним +`date` лежит до трёх записей сна, и это не дефект, а способ Apple выразить +вложенность «в кровати» и фазы внутри неё. Ключ +`метрика + слой + начало + конец` измерен на всём корпусе (находка 47): 174 +координаты против 170 по метке, ноль столкновений против 33 **внутри одной +доставки**, где тай-брейк по времени приёма неприменим в принципе. `value` в +ключе ничего не добавляет. + +Эпизодность выводится из формы точки — есть `start` и `end`, отличные от `date`, +— а не из списка имён метрик: список был бы вторым способом описывать то, что +уже сказано данными. Час объекта берётся по началу эпизода, иначе +принадлежность объекту зависела бы от длительности. + +`HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает — +там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`, +`startDate`, `endDate`, `value`. Значит модель обязана выражаться через +`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`. + Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали задвоение на живых данных: diff --git a/docs/backlog/CLOSED.md b/docs/backlog/CLOSED.md index c6575de..9b62a66 100644 --- a/docs/backlog/CLOSED.md +++ b/docs/backlog/CLOSED.md @@ -4,3 +4,4 @@ - 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Был приоритет: высокий. +- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Был приоритет: блокеры. diff --git a/docs/backlog/README.md b/docs/backlog/README.md index b00dc31..03787e0 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -12,7 +12,6 @@ одному — прерывать поток ради каждого дороже, чем накопить. ## блокеры -- [Идентичность эпизодных метрик](identichnost-epizodnyh-metrik.md) — Координатный ключ схлопывает эпизоды сна: 31 координата в 21 доставке из 89, тай-брейк неисполним ## высокий - [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это diff --git a/docs/backlog/identichnost-epizodnyh-metrik.md b/docs/backlog/identichnost-epizodnyh-metrik.md deleted file mode 100644 index 62a0913..0000000 --- a/docs/backlog/identichnost-epizodnyh-metrik.md +++ /dev/null @@ -1,77 +0,0 @@ -# Идентичность эпизодных метрик - -**Приоритет:** блокеры - -Вынуто из задачи `razbor-metrik-v-obekty` ревью дизайна (профиль `design`, -находка №1 триажа, severity critical). Пока не решено — эпизодные схемы не -хранятся, см. «Что стоит без решения». - -## Что решить - -Состав координатного ключа и правило слияния для метрик, у которых **метка -времени не уникальна**. Принятая модель — `метрика + слой + метка` — для них -неверна. - -## Оракул: измерено на живых данных - -Из 173 координат сна три несут разное содержимое, одна — три разных эпизода: - -``` -sleep_analysis 2026-07-31 22:04:00 - start=22:04 end=22:16 qty=0.2 value=Во сне - start=22:04 end=02:21 qty=4.28 value=В кровати - start=22:04 end=07:51 qty=9.78 value=В кровати -``` - -Хуже: **31 координата в 21 доставке из 89** (почти четверть) задвоена **внутри -одной доставки**. Там `received_at` у обеих точек один и тот же, поэтому -предписанный тай-брейк «побеждает больший `received_at`» неприменим в -принципе — исход решил бы порядок элементов в JSON-массиве, а он нестабилен -(находка 2). Это ломало бы и детерминированность свёртки: пересборка из архива -давала бы другое состояние, чем живой приём. - -Правило полноты не спасает: точка сна несёт одновременно `start`/`end` и -`startDate`/`endDate`, так что два набора разных полей равной мощности -равнополны. - -## Варианты и цена - -**(А) Включить `start`/`end` в координату для эпизодных схем.** -Закрывает и междоставочные, и внутридоставочные дубли — все 31 относятся к -`sleep_analysis`. Цена: ключ разной формы для разных классов метрик, и надо -определить, что делает метрику «эпизодной» (наличие `start`/`end`? список?). - -**(Б) Эпизодные метрики — множество с идентичностью по хешу канонической формы -(append-only).** Ничего не теряется по построению. Цена: две модели -идентичности в `store` и рост объекта — эпизоды не схлопываются никогда, даже -когда повтор действительно повтор. - -**(В) Принять потерю: нынешнее правило плюс обязательный `WARN`.** -Дёшево. Цена: необратимая потеря эпизодов примерно в четверти доставок сна, -обнаружимая только сверкой с экспортом Apple, то есть месяцами позже. Прямо -противоречит инварианту «ничего не теряем молча». - -Независимо от выбора: тай-брейк `received_at` надо заменить или дополнить -детерминированным (например, лексикографически по канонической форме) — без -провенанса в схеме нынешний неисполним даже между доставками. - -## Рекомендация - -**(А).** Она закрывает оба класса дублей, не плодит вторую модель хранения и не -требует принимать потерю. «Эпизодность» выводится из данных, а не курируется: -точка, несущая `start` и `end`, отличные от `date`, — эпизод. Это в духе того, -как в проекте уже выводится слой — из формы данных, а не из объявления. - -Против (Б): рост объекта ради случая, которого можно избежать. Против (В): это -ровно тот класс молчаливой потери, ради защиты от которого заведён инвариант. - -## Что стоит без решения - -`sleep_analysis` (поэпизодная схема) в задаче `razbor-metrik-v-obekty` **не -сохраняется**: точки разбираются и считаются, но в объекты не пишутся. Сохранять -их по правилу, о котором известно, что оно теряет, — хуже, чем не сохранять: -тела лежат в архиве, и после решения блокера их подберёт `reindex`. - -Суточная сводка (`sleep_analysis_summary`) блокером не затронута — у неё метка -на полуночи уникальна. - diff --git a/docs/backlog/razbor-metrik-v-obekty.md b/docs/backlog/razbor-metrik-v-obekty.md index c6c8828..c2819e0 100644 --- a/docs/backlog/razbor-metrik-v-obekty.md +++ b/docs/backlog/razbor-metrik-v-obekty.md @@ -9,7 +9,8 @@ Read API, MCP — стоит на этой задаче. Правила выведены на живом потоке и проверены, изобретать заново не нужно (`docs/local-research.md`, находки 33, 35, 36, 38, 39, 41): -- ключ точки — координаты `метрика + слой + метка`; `source` в ключ не входит; +- ключ точки — координаты `метрика + слой + метка`, у эпизода — `метрика + слой + + начало + конец` (находка 47); `source` в ключ не входит; - слой выводится из выравнивания меток: плотная метрика (≥10 точек) классифицируется сама, редкая наследует преобладающий слой доставки; - при столкновении выигрывает более полная точка, а не последняя пришедшая @@ -24,14 +25,9 @@ Read API, MCP — стоит на этой задаче. - слияние точек в объект read-modify-write, хеш объекта как детектор изменений; - `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`). -**Границы после ревью дизайна.** Поэпизодный `sleep_analysis` в этой задаче не -сохраняется — модель идентичности вынесена блокером -[identichnost-epizodnyh-metrik](identichnost-epizodnyh-metrik.md). Остальное -делается целиком. - -Готово, когда по существующим 89 доставкам собирается хранилище (кроме -эпизодов сна), а суммы по часовому слою сходятся с проверкой из -`tmp/research/`. +Готово, когда по существующим доставкам собирается хранилище, суммы по часовому +слою сходятся с проверкой из `tmp/research/`, а эпизодов сна выходит 174, а не +170 (находка 47). Связано: `docs/architecture.md` → «Хранилище», план шаг 3. diff --git a/docs/local-research.md b/docs/local-research.md index 6ed1353..b515cd7 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -1459,6 +1459,59 @@ type="HKWorkoutEventTypeMotionPaused|Resumed" ← совпадение по следующего проверенного экспорта, иначе между концом ретеншена и датой снапшота в журнале образуется дыра. +## 47. Идентичность эпизода сна — `start` и `end`, и другой у Apple нет + +Координата `метрика + слой + метка` для поэпизодного `sleep_analysis` неверна: +под одной меткой лежит до трёх разных эпизодов. Замер по всем 94 доставкам +(1880 эпизодных точек, суточные сводки исключены): + +| Ключ | Координат | Схлопнуто точек | Дублей **внутри одной** доставки | +|---|---|---|---| +| `date` | 170 | 1710 | 33 | +| `date + start + end` | 174 | 1706 | **0** | +| `date + start + end + value` | 174 | 1706 | 0 | + +`start`/`end` закрывают всё; `value` в ключе не добавляет ни одной координаты. +Сильнее: из 174 координат **ни одна не несёт двух разных содержимых** — ни с +`source`, ни без него. Правило разрешения столкновений на эпизодном сне за весь +корпус не срабатывает ни разу. + +Внутридоставочные дубли важнее междоставочных: там `received_at` общий, и любой +тай-брейк по времени приёма неприменим в принципе — исход решал бы порядок +элементов в JSON-массиве, а он нестабилен (находка 2). + +**Это не дефект данных, а задуманное представление.** Записи с одним `startDate` +и разными `endDate` — вложенность «в кровати» и фазы сна внутри неё; HealthKit +намеренно допускает перекрывающиеся сэмплы, чтобы выразить «в кровати» и +«спит» одновременно. + +**Другой идентичности Apple не даёт.** Атрибуты записи сна в экспорте: + +``` +type sourceName sourceVersion creationDate startDate endDate value +``` + +`HKObject.uuid` существует в API, но **в выгрузку не попадает**. Значит модель +идентичности обязана выражаться через `start`/`end`: иначе `import(экспорт)` не +сойдётся с `replay(HAE)` и журнал перестанет быть журналом. + +### Как это решают другие + +- **Health CSV Importer** дедуплицирует по `Start Date + End Date + Data Type + + Type Identifier`. Совпадает с нашим замером, включая отсутствие источника в + ключе. +- **Ингесторы поверх InfluxDB** (`irvinlim/apple-health-ingester`, + `joeecarter/health-import-server`) ключуют по `measurement + tags + timestamp` + — то есть имеют ровно эту коллизию и разрешают её молчаливым last-write-wins + движка. Один обходит её тем, что кладёт сон плоской точкой + (`inBed`/`inBedStart`/`inBedEnd`), то есть поддерживает только суточную схему. +- **Пайплайн HAE → FastAPI → Postgres** (ladvien) даёт каждой записи + суррогатный UUID-PK без уникального ограничения: обещанная идемпотентность не + реализована, повторная доставка задваивает строки. + +То есть обе распространённые схемы хранения теряют данные ровно там, где мы это +измерили, а единственная принятая схема дедупликации — это `start`+`end`. + ## Инструмент Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная diff --git a/openspec/changes/razbor-metrik-v-obekty/design.md b/openspec/changes/razbor-metrik-v-obekty/design.md index 4fe9d2c..51afb55 100644 --- a/openspec/changes/razbor-metrik-v-obekty/design.md +++ b/openspec/changes/razbor-metrik-v-obekty/design.md @@ -30,10 +30,6 @@ - Словарь категориальных значений: строка пока хранится дословно и без кода. - Род агрегации и каталог разрезов. - Своя агрегация при записи: слои не сводятся друг к другу никогда. -- **Хранение эпизодных схем** (поэпизодный `sleep_analysis`): модель их - идентичности вынесена блокером `identichnost-epizodnyh-metrik`. Точки - разбираются и считаются, но не сохраняются; тела в архиве, подберёт - пересборка. ## Decisions @@ -125,6 +121,41 @@ double, и хеш-детектор начнёт видеть изменения Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ. +### Эпизод адресуется интервалом, а не меткой + +Координата `метрика + слой + метка` верна для точки-измерения и неверна для +точки-интервала: под одной меткой лежит до трёх разных эпизодов сна. Поэтому +у точки, несущей `start` и `end`, координата — `метрика + слой + start + end`. + +Замер по всем 94 доставкам (1880 эпизодных точек, находка 47): метка одна даёт +170 координат и 33 столкновения **внутри одной доставки**, интервал — 174 +координаты и ноль столкновений; `value` в ключе не добавляет ни одной +координаты. Из 174 координат ни одна не несёт двух разных содержимых, то есть +на эпизодном сне правило разрешения столкновений не срабатывает ни разу. + +**Эпизодность выводится из данных, а не курируется списком** — так же, как слой +выводится из выравнивания меток, а не из заголовка. Признак: точка несёт `start` +и `end`, отличные от `date`. Список имён метрик здесь был бы вторым способом +описывать то, что уже сказано формой точки, и разошёлся бы с ней на первой же +новой метрике HAE. + +Почему не «принять потерю и писать `WARN`»: в дублях внутри одной доставки +`received_at` общий, и тай-брейк по времени приёма неприменим в принципе — +исход решал бы порядок элементов в JSON-массиве, а он нестабилен (находка 2). +Свёртка перестала бы быть детерминированной: пересборка из архива давала бы не +то состояние, что живой приём. + +Почему не append-only по хешу содержимого: это вторая модель идентичности в +`store` ради случая, которого можно избежать, и эпизоды не схлопывались бы +никогда — даже когда повтор действительно повтор, а их здесь 1706 из 1880. + +Проверено против чужих решений (находка 47): единственная принятая схема +дедупликации Apple Health — `Start + End + тип`, без источника в ключе; +популярные ингесторы поверх InfluxDB ключуют по метке и теряют эпизоды +молчаливым last-write-wins движка. `HKObject.uuid` дал бы идентичность даром, +но **в выгрузку Apple он не попадает** — значит модель обязана выражаться через +`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`. + ### Слияние — по полноте, при равенстве — по `received_at` Координатный ключ означает перезапись значения. Кто побеждает — решает diff --git a/openspec/changes/razbor-metrik-v-obekty/proposal.md b/openspec/changes/razbor-metrik-v-obekty/proposal.md index 6f79ad5..2059c51 100644 --- a/openspec/changes/razbor-metrik-v-obekty/proposal.md +++ b/openspec/changes/razbor-metrik-v-obekty/proposal.md @@ -23,6 +23,10 @@ - **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в ключ не входит: он нестабилен и меняется задним числом. При столкновении выигрывает **более полная** точка, а не последняя пришедшая. +- **Точка-интервал адресуется интервалом**: `метрика + слой + начало + конец`. + Под одной меткой лежит до трёх эпизодов сна, и 33 столкновения из измеренных + происходят внутри одной доставки, где тай-брейк по времени приёма неприменим. + Эпизодность выводится из формы точки, а не из списка имён метрик. - **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической формы — детектор изменений, а не ключ. - `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку — @@ -43,13 +47,7 @@ Сходимость на них проверяется скриптом поверх архива, а не командой сервиса; - **словарь категориальных значений** (переведённые строки → коды HealthKit) — отдельная задача; пока строка хранится дословно и без кода рядом; -- **род агрегации и каталог разрезов** — отдельная задача, она следующая; -- **хранение поэпизодного `sleep_analysis`** — вынуто ревью дизайна блокером - `identichnost-epizodnyh-metrik`: координатный ключ его схлопывает (измерено: - 31 координата в 21 доставке из 89 задвоена внутри одной доставки, где - тай-брейк по времени приёма неприменим в принципе). Точки разбираются и - считаются, но не сохраняются; тела в архиве, подберёт пересборка. - Суточной сводки это не касается. +- **род агрегации и каталог разрезов** — отдельная задача, она следующая. ## Capabilities 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 ea41240..69a37b8 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,11 @@ ### Requirement: Идентичность точки по координатам -Система SHALL адресовать точку координатами `метрика + слой + метка времени`. -Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем -же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как -`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним -числом. +Система SHALL адресовать точку-измерение координатами +`метрика + слой + метка времени`. Поле `source` в ключ входить MUST NOT: оно +нестабильно — то же измерение с тем же значением приезжает то как +`Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`, потому что +Health переосмысливает атрибуцию задним числом. Идентичность по хешу содержимого проверялась и отвергнута: она задваивала минутный слой целиком — 120 точек в часе вместо 60. @@ -21,27 +21,38 @@ - **WHEN** точка с теми же координатами приезжает с другой строкой `source` - **THEN** она остаётся одной точкой, а не превращается в две -### Requirement: Эпизодные схемы не сохраняются до решения об идентичности +### Requirement: Идентичность эпизода по интервалу -Система MUST NOT сохранять точки схем, у которых метка времени не уникальна, — -пока модель их идентичности не определена. Такие точки разбираются и -учитываются счётчиком, но в объекты не пишутся. +Система SHALL адресовать точку-интервал координатами +`метрика + слой + начало + конец`. Метки времени для неё недостаточно: под +одной меткой лежит до трёх разных эпизодов сна. -Известная такая схема одна: поэпизодный `sleep_analysis`. Измерено: 31 -координата в 21 доставке из 89 задвоена **внутри одной доставки**, где -`received_at` общий и тай-брейк по нему неприменим в принципе. Сохранять их по -правилу, о котором известно, что оно теряет, хуже, чем не сохранять: тела -остаются в архиве, и после решения их подберёт пересборка. +Эпизодность SHALL выводиться из формы точки — точка несёт `start` и `end`, +отличные от `date`, — а не назначаться списком имён метрик. Список был бы +вторым способом описывать то, что уже сказано формой точки, и разошёлся бы с +ней на первой же новой метрике HAE. -Развилка вынесена блокером `identichnost-epizodnyh-metrik`. Суточной сводки -(`sleep_analysis_summary`) ограничение не касается — у неё метка на полуночи -уникальна. +Измерено на всех 94 доставках (1880 эпизодных точек, находка 47): ключ по метке +даёт 170 координат и 33 столкновения **внутри одной доставки**, ключ по +интервалу — 174 координаты и ноль столкновений. Внутридоставочные столкновения +и делают ключ по метке неисправимым: `received_at` там общий, тай-брейк по нему +неприменим в принципе, и исход решал бы порядок элементов в JSON-массиве — а он +нестабилен. -#### Scenario: Поэпизодная запись сна не попадает в объект +Час объекта для точки-интервала SHALL определяться по началу эпизода: эпизод +пересекает границы часов, и любой другой выбор сделал бы принадлежность +объекту зависящей от длительности. -- **WHEN** разобрана точка метрики `sleep_analysis` поэпизодной схемы -- **THEN** она не сохраняется в часовой объект -- **AND** факт учитывается счётчиком в итоге разбора доставки +#### Scenario: Эпизоды с одной меткой и разными интервалами не схлопываются + +- **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и + разными парами `start`/`end` +- **THEN** каждая сохраняется отдельной точкой + +#### Scenario: Повтор эпизода в следующей доставке не задваивает + +- **WHEN** точка с тем же `start` и `end` приезжает следующей доставкой +- **THEN** она остаётся одной точкой ### Requirement: Разрешение столкновений по полноте diff --git a/openspec/changes/razbor-metrik-v-obekty/tasks.md b/openspec/changes/razbor-metrik-v-obekty/tasks.md index b07d36d..ef62b09 100644 --- a/openspec/changes/razbor-metrik-v-obekty/tasks.md +++ b/openspec/changes/razbor-metrik-v-obekty/tasks.md @@ -2,6 +2,7 @@ - [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени - [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default` +- [ ] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить) - [ ] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста - [ ] 1.4 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `units`, `payload` BLOB, `content_hash`, `points`, `first_ts`, `last_ts`, `first_delivery_id`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)` - [ ] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций) @@ -32,7 +33,8 @@ - [ ] 4.1 Модель объекта в `internal/store`; содержимое — исходные байты точек, gzip-BLOB, точки упорядочены по времени - [ ] 4.2 Слияние: координатный ключ, победа более полной точки, при равной полноте — детерминированный исход по порядку канонических форм - [ ] 4.3 Столкновение с различием канонической формы — `WARN` без значений и счётчик перезаписей -- [ ] 4.4 Поэпизодный `sleep_analysis` **не сохраняется** (блокер `identichnost-epizodnyh-metrik`): точки считаются, в объекты не пишутся +- [ ] 4.4 Ключ точки-интервала — `метрика + слой + start + end`; эпизодность выводится из формы точки (`start`/`end`, отличные от `date`), а не из списка имён. Час объекта — по началу эпизода +- [ ] 4.4a Тест на фикстуре 1.3a: три эпизода с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает - [ ] 4.5 Хеш объекта как детектор изменений: совпал — записи нет - [ ] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю тройку** чтение-слияние-запись; путь «хеш совпал» — под `TxOptions{ReadOnly: true}` - [ ] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store` @@ -71,3 +73,4 @@ - [ ] 7.10 Время нормализовано без потери зоны - [ ] 7.11 Слой выводится из данных, а не из заголовка; неопределимый слой имеет явную судьбу - [ ] 7.12 Парсер детерминирован и чист: ни `time.Now`, ни генерации id; два вызова на одном входе равны +- [ ] 7.13 (добавлено после снятия блокера) Точка-интервал не схлопывается по метке: прогон архива даёт 174 координаты сна, а не 170