идентичность эпизода — интервал: блокер снят измерением

- находка 47: ключ (date,start,end) даёт 174 координаты сна против 170 по
  метке и ноль столкновений внутри доставки против 33; value в ключе лишний
- UUID в выгрузку Apple не попадает, другой идентичности у эпизода нет
- поэпизодный sleep_analysis вернулся в scope razbor-metrik-v-obekty
This commit is contained in:
av
2026-08-01 17:09:45 +03:00
parent 809153e03f
commit 3c9d226a34
11 changed files with 159 additions and 122 deletions
+4 -1
View File
@@ -36,7 +36,10 @@ Module path — `git.vakhrushev.me/av/healthlog`.
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
битый JSON — 400, непонятое содержимое — 200. битый JSON — 400, непонятое содержимое — 200.
- **Ничего не теряем молча.** Идентичность — координаты - **Ничего не теряем молча.** Идентичность — координаты
(`метрика + слой + метка`); `source` в ключ не входит, он нестабилен. Хеш (`метрика + слой + метка`), а у точки-интервала — `метрика + слой + начало +
конец`: под одной меткой лежит до трёх эпизодов сна, и эпизодность выводится
из формы точки, а не из списка метрик. `source` в ключ не входит, он
нестабилен. Хеш
канонизированного содержимого остался детектором изменений. При канонизированного содержимого остался детектором изменений. При
столкновении выигрывает **более полная** точка, а не последняя. Изменение столкновении выигрывает **более полная** точка, а не последняя. Изменение
запечатанного часа — `WARN`, но данные всё равно пишутся. запечатанного часа — `WARN`, но данные всё равно пишутся.
+20 -1
View File
@@ -24,7 +24,8 @@ healthlog принимает выгрузки Apple Health из приложен
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
(см. «Приём»). (см. «Приём»).
- **Ничего не теряем молча.** Идентичность — устойчивые координаты - **Ничего не теряем молча.** Идентичность — устойчивые координаты
(`метрика + слой + метка времени`); `source` в ключ не входит, он (`метрика + слой + метка времени`), а у точки-интервала — `метрика + слой +
начало + конец`; `source` в ключ не входит, он
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений, нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
чтобы не писать зря. При столкновении выигрывает более полная точка, а не чтобы не писать зря. При столкновении выигрывает более полная точка, а не
последняя пришедшая: бедная доставка не должна стирать поля у богатой. последняя пришедшая: бедная доставка не должна стирать поля у богатой.
@@ -464,6 +465,24 @@ hour метки выровнены на час heart_rate 00:00:00
значения: qty / Min / Avg / Max / source / … ← перезаписываются значения: 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)`.
Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали
задвоение на живых данных: задвоение на живых данных:
+1
View File
@@ -4,3 +4,4 @@
<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … --> <!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Был приоритет: … -->
- 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Был приоритет: высокий. - 2026-08-01 `bekap-dannyh` — Резервное копирование ./data. Причина: бекап обеспечивает готовый механизм на сервере пет-проектов — своего заводить не нужно, задача снимается деплоем. Был приоритет: высокий.
- 2026-08-01 `identichnost-epizodnyh-metrik` — Идентичность эпизодных метрик. Причина: решён измерением и prior art: ключ эпизода — метрика+слой+start+end (находка 47), вариант А; вернулся в scope razbor-metrik-v-obekty. Был приоритет: блокеры.
-1
View File
@@ -12,7 +12,6 @@
одному — прерывать поток ради каждого дороже, чем накопить. одному — прерывать поток ради каждого дороже, чем накопить.
## блокеры ## блокеры
- [Идентичность эпизодных метрик](identichnost-epizodnyh-metrik.md) — Координатный ключ схлопывает эпизоды сна: 31 координата в 21 доставке из 89, тай-брейк неисполним
## высокий ## высокий
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это - [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
@@ -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`) блокером не затронута — у неё метка
на полуночи уникальна.
+5 -9
View File
@@ -9,7 +9,8 @@ Read API, MCP — стоит на этой задаче.
Правила выведены на живом потоке и проверены, изобретать заново не нужно Правила выведены на живом потоке и проверены, изобретать заново не нужно
(`docs/local-research.md`, находки 33, 35, 36, 38, 39, 41): (`docs/local-research.md`, находки 33, 35, 36, 38, 39, 41):
- ключ точки — координаты `метрика + слой + метка`; `source` в ключ не входит; - ключ точки — координаты `метрика + слой + метка`, у эпизода — `метрика + слой
+ начало + конец` (находка 47); `source` в ключ не входит;
- слой выводится из выравнивания меток: плотная метрика (≥10 точек) - слой выводится из выравнивания меток: плотная метрика (≥10 точек)
классифицируется сама, редкая наследует преобладающий слой доставки; классифицируется сама, редкая наследует преобладающий слой доставки;
- при столкновении выигрывает более полная точка, а не последняя пришедшая - при столкновении выигрывает более полная точка, а не последняя пришедшая
@@ -24,14 +25,9 @@ Read API, MCP — стоит на этой задаче.
- слияние точек в объект read-modify-write, хеш объекта как детектор изменений; - слияние точек в объект read-modify-write, хеш объекта как детектор изменений;
- `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`). - `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`).
**Границы после ревью дизайна.** Поэпизодный `sleep_analysis` в этой задаче не Готово, когда по существующим доставкам собирается хранилище, суммы по часовому
сохраняется — модель идентичности вынесена блокером слою сходятся с проверкой из `tmp/research/`, а эпизодов сна выходит 174, а не
[identichnost-epizodnyh-metrik](identichnost-epizodnyh-metrik.md). Остальное 170 (находка 47).
делается целиком.
Готово, когда по существующим 89 доставкам собирается хранилище (кроме
эпизодов сна), а суммы по часовому слою сходятся с проверкой из
`tmp/research/`.
Связано: `docs/architecture.md` → «Хранилище», план шаг 3. Связано: `docs/architecture.md` → «Хранилище», план шаг 3.
+53
View File
@@ -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, только стандартная Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
@@ -30,10 +30,6 @@
- Словарь категориальных значений: строка пока хранится дословно и без кода. - Словарь категориальных значений: строка пока хранится дословно и без кода.
- Род агрегации и каталог разрезов. - Род агрегации и каталог разрезов.
- Своя агрегация при записи: слои не сводятся друг к другу никогда. - Своя агрегация при записи: слои не сводятся друг к другу никогда.
- **Хранение эпизодных схем** (поэпизодный `sleep_analysis`): модель их
идентичности вынесена блокером `identichnost-epizodnyh-metrik`. Точки
разбираются и считаются, но не сохраняются; тела в архиве, подберёт
пересборка.
## Decisions ## Decisions
@@ -125,6 +121,41 @@ double, и хеш-детектор начнёт видеть изменения
Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ. Сжатие наблюдалось около 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` ### Слияние — по полноте, при равенстве — по `received_at`
Координатный ключ означает перезапись значения. Кто побеждает — решает Координатный ключ означает перезапись значения. Кто побеждает — решает
@@ -23,6 +23,10 @@
- **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в - **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в
ключ не входит: он нестабилен и меняется задним числом. При столкновении ключ не входит: он нестабилен и меняется задним числом. При столкновении
выигрывает **более полная** точка, а не последняя пришедшая. выигрывает **более полная** точка, а не последняя пришедшая.
- **Точка-интервал адресуется интервалом**: `метрика + слой + начало + конец`.
Под одной меткой лежит до трёх эпизодов сна, и 33 столкновения из измеренных
происходят внутри одной доставки, где тай-брейк по времени приёма неприменим.
Эпизодность выводится из формы точки, а не из списка имён метрик.
- **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической - **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической
формы — детектор изменений, а не ключ. формы — детектор изменений, а не ключ.
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку — - `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
@@ -43,13 +47,7 @@
Сходимость на них проверяется скриптом поверх архива, а не командой сервиса; Сходимость на них проверяется скриптом поверх архива, а не командой сервиса;
- **словарь категориальных значений** (переведённые строки → коды HealthKit) — - **словарь категориальных значений** (переведённые строки → коды HealthKit) —
отдельная задача; пока строка хранится дословно и без кода рядом; отдельная задача; пока строка хранится дословно и без кода рядом;
- **род агрегации и каталог разрезов** — отдельная задача, она следующая; - **род агрегации и каталог разрезов** — отдельная задача, она следующая.
- **хранение поэпизодного `sleep_analysis`** — вынуто ревью дизайна блокером
`identichnost-epizodnyh-metrik`: координатный ключ его схлопывает (измерено:
31 координата в 21 доставке из 89 задвоена внутри одной доставки, где
тай-брейк по времени приёма неприменим в принципе). Точки разбираются и
считаются, но не сохраняются; тела в архиве, подберёт пересборка.
Суточной сводки это не касается.
## Capabilities ## Capabilities
@@ -2,11 +2,11 @@
### Requirement: Идентичность точки по координатам ### Requirement: Идентичность точки по координатам
Система SHALL адресовать точку координатами `метрика + слой + метка времени`. Система SHALL адресовать точку-измерение координатами
Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем `метрика + слой + метка времени`. Поле `source` в ключ входить MUST NOT: оно
же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как нестабильно — то же измерение с тем же значением приезжает то как
`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним `Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`, потому что
числом. Health переосмысливает атрибуцию задним числом.
Идентичность по хешу содержимого проверялась и отвергнута: она задваивала Идентичность по хешу содержимого проверялась и отвергнута: она задваивала
минутный слой целиком — 120 точек в часе вместо 60. минутный слой целиком — 120 точек в часе вместо 60.
@@ -21,27 +21,38 @@
- **WHEN** точка с теми же координатами приезжает с другой строкой `source` - **WHEN** точка с теми же координатами приезжает с другой строкой `source`
- **THEN** она остаётся одной точкой, а не превращается в две - **THEN** она остаётся одной точкой, а не превращается в две
### Requirement: Эпизодные схемы не сохраняются до решения об идентичности ### Requirement: Идентичность эпизода по интервалу
Система MUST NOT сохранять точки схем, у которых метка времени не уникальна, — Система SHALL адресовать точку-интервал координатами
пока модель их идентичности не определена. Такие точки разбираются и `метрика + слой + начало + конец`. Метки времени для неё недостаточно: под
учитываются счётчиком, но в объекты не пишутся. одной меткой лежит до трёх разных эпизодов сна.
Известная такая схема одна: поэпизодный `sleep_analysis`. Измерено: 31 Эпизодность SHALL выводиться из формы точки — точка несёт `start` и `end`,
координата в 21 доставке из 89 задвоена **внутри одной доставки**, где отличные от `date`, — а не назначаться списком имён метрик. Список был бы
`received_at` общий и тай-брейк по нему неприменим в принципе. Сохранять их по вторым способом описывать то, что уже сказано формой точки, и разошёлся бы с
правилу, о котором известно, что оно теряет, хуже, чем не сохранять: тела ней на первой же новой метрике HAE.
остаются в архиве, и после решения их подберёт пересборка.
Развилка вынесена блокером `identichnost-epizodnyh-metrik`. Суточной сводки Измерено на всех 94 доставках (1880 эпизодных точек, находка 47): ключ по метке
(`sleep_analysis_summary`) ограничение не касается — у неё метка на полуночи даёт 170 координат и 33 столкновения **внутри одной доставки**, ключ по
уникальна. интервалу — 174 координаты и ноль столкновений. Внутридоставочные столкновения
и делают ключ по метке неисправимым: `received_at` там общий, тай-брейк по нему
неприменим в принципе, и исход решал бы порядок элементов в JSON-массиве — а он
нестабилен.
#### Scenario: Поэпизодная запись сна не попадает в объект Час объекта для точки-интервала SHALL определяться по началу эпизода: эпизод
пересекает границы часов, и любой другой выбор сделал бы принадлежность
объекту зависящей от длительности.
- **WHEN** разобрана точка метрики `sleep_analysis` поэпизодной схемы #### Scenario: Эпизоды с одной меткой и разными интервалами не схлопываются
- **THEN** она не сохраняется в часовой объект
- **AND** факт учитывается счётчиком в итоге разбора доставки - **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и
разными парами `start`/`end`
- **THEN** каждая сохраняется отдельной точкой
#### Scenario: Повтор эпизода в следующей доставке не задваивает
- **WHEN** точка с тем же `start` и `end` приезжает следующей доставкой
- **THEN** она остаётся одной точкой
### Requirement: Разрешение столкновений по полноте ### Requirement: Разрешение столкновений по полноте
@@ -2,6 +2,7 @@
- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени - [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default` - [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default`
- [ ] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить)
- [ ] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста - [ ] 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.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` требует её при изменении миграций) - [ ] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
@@ -32,7 +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 Поэпизодный `sleep_analysis` **не сохраняется** (блокер `identichnost-epizodnyh-metrik`): точки считаются, в объекты не пишутся - [ ] 4.4 Ключ точки-интервала — `метрика + слой + start + end`; эпизодность выводится из формы точки (`start`/`end`, отличные от `date`), а не из списка имён. Час объекта — по началу эпизода
- [ ] 4.4a Тест на фикстуре 1.3a: три эпизода с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает
- [ ] 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`
@@ -71,3 +73,4 @@
- [ ] 7.10 Время нормализовано без потери зоны - [ ] 7.10 Время нормализовано без потери зоны
- [ ] 7.11 Слой выводится из данных, а не из заголовка; неопределимый слой имеет явную судьбу - [ ] 7.11 Слой выводится из данных, а не из заголовка; неопределимый слой имеет явную судьбу
- [ ] 7.12 Парсер детерминирован и чист: ни `time.Now`, ни генерации id; два вызова на одном входе равны - [ ] 7.12 Парсер детерминирован и чист: ни `time.Now`, ни генерации id; два вызова на одном входе равны
- [ ] 7.13 (добавлено после снятия блокера) Точка-интервал не схлопывается по метке: прогон архива даёт 174 координаты сна, а не 170