diff --git a/.claude/skills/review-pipeline/SKILL.md b/.claude/skills/review-pipeline/SKILL.md index 2b67fd3..537ebc3 100644 --- a/.claude/skills/review-pipeline/SKILL.md +++ b/.claude/skills/review-pipeline/SKILL.md @@ -34,8 +34,9 @@ description: Конвейер ревью изменений healthlog — дет Инварианты, нарушение которых — по умолчанию `critical` (подробно — `CLAUDE.md`, `docs/architecture.md`): -- **Точка хранится дословно.** Всё, что теряет поле внутри точки, необратимо: - сырой архив живёт 14 дней, дальше истина — сами точки. +- **Точка хранится дословно.** Хранилище — свёртка по журналу + (`import(экспорт) + replay(доставки)`), поэтому разобранное пересобираемо, а + вот не принятое — нет: доставка мимо архива теряется навсегда. - **Идентичность по координатам** (`метрика + слой + метка`). `source` в ключ не входит. Неверное правило слияния портит историю молча — заметить это можно только сверкой с родным экспортом Apple, то есть месяцами позже. diff --git a/.claude/skills/task-pipeline/SKILL.md b/.claude/skills/task-pipeline/SKILL.md index 220368d..75db08e 100644 --- a/.claude/skills/task-pipeline/SKILL.md +++ b/.claude/skills/task-pipeline/SKILL.md @@ -24,13 +24,15 @@ healthlog — хранилище данных о здоровье, у котор (`task up` / `task restart`), данные в `./data`. Перезапуск на пару секунд безопасен — дыру закроют средний и глубокий проходы синхронизации; а вот сломанный приём, оставленный работать, теряет данные необратимо. -- **Потерянная точка не восстанавливается.** Сырой архив живёт 14 дней. Любая - правка разбора, слияния или вывода слоя — это `deep`-профиль ревью, без - исключений. +- **Потерянная доставка не восстанавливается.** Тело, не попавшее в архив, в + журнал не попадает вовсе: телефон его не перешлёт. Разобранное же всегда + пересобираемо свёрткой, поэтому цена ошибки разбора и цена ошибки приёма + различаются на порядок. Любая правка разбора, слияния или вывода слоя — это + `deep`-профиль ревью, без исключений. - **Данные чувствительны.** Ничего из `./data` не попадает ни в git, ни в логи выше `DEBUG`, ни в вывод агента. Гейт проверяет первое механически (`no-health-data`), остальное — на тебе. -- **Разведка уже проведена.** `docs/local-research.md` — 41 находка на живом +- **Разведка уже проведена.** `docs/local-research.md` — 46 находок на живом потоке, половина расходится с документацией HAE. Проверь там, прежде чем строить догадку о формате: скорее всего вопрос уже закрыт измерением. diff --git a/CLAUDE.md b/CLAUDE.md index 15b9630..f4cdc78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,15 @@ Module path — `git.vakhrushev.me/av/healthlog`. ## Инварианты - **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде, - в каком их прислал HAE. На этом инварианте держится всё остальное: сырой - архив живёт лишь 14 дней, дальше истина — сами объекты. Начнём что-то - отбрасывать внутри точки — срок хранения архива станет сроком жизни данных. + в каком их прислал HAE. Начнём что-то отбрасывать внутри точки — потеряем + безвозвратно. +- **Хранилище — свёртка по журналу.** Экспорт Apple это снапшот всей истории, + доставки HAE после его даты — события поверх. Состояние всегда пересобираемо: + `import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив + живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка + обязана быть детерминированной. Что не восстанавливается — `stateOfMind` + (его в экспорте нет) и верхние слои за периоды с удалёнными доставками; + каталог обязан говорить об этом честно, а не досчитывать молча. - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое содержимое — 200. - **Ничего не теряем молча.** Идентичность — координаты @@ -82,7 +88,8 @@ Module path — `git.vakhrushev.me/av/healthlog`. запускаются. **Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём, -оставленный работать, теряет данные необратимо — сырой архив живёт 14 дней. +оставленный работать, теряет данные необратимо: доставка, не попавшая в +архив, в журнал не попадает вовсе — телефон её не перешлёт. Ничего из `./data` не попадает ни в git, ни в логи выше `DEBUG`, ни в вывод агента. diff --git a/README.md b/README.md index ff2301a..ad44ccd 100644 --- a/README.md +++ b/README.md @@ -34,22 +34,22 @@ healthlog делает это один раз. Телефон шлёт данн ## Как устроено ``` -iPhone ──HTTPS POST──► healthlog ──► сырой архив (.json.gz, 14 дней) +экспорт Apple ────────┐ снапшот всей истории, раз в 2–3 месяца + ▼ +iPhone ──HTTPS POST──► healthlog ──► журнал доставок (.json.gz) │ │ - │ └── страховка разбора, не склад + │ └── события поверх снапшота ▼ SQLite ──┬──► HTTP read API ──► мои приложения - (точки по │ - слоям) └──► MCP ───────────► агенты - ▲ -экспорт Apple ──────────────┘ нижний слой, раз в 2–3 месяца + (свёртка по │ + журналу) └──► MCP ───────────► агенты ``` Приём сначала кладёт тело запроса на диск как есть и только потом разбирает. -Значит, ошибка в нашем разборе не может привести к потере данных: хранилище -пересобирается из архива командой `healthlog reindex`. Архив при этом -недолговечен — дальше истина в самих точках, и потому точки хранятся -дословно. +Значит, ошибка в разборе не теряет данные: состояние всегда пересобирается +свёрткой `import(экспорт) + replay(доставки)`. Отсюда и главный инвариант — +точки хранятся дословно: журнал, из которого что-то выброшено, перестаёт быть +журналом. Подробности — [docs/architecture.md](docs/architecture.md). @@ -59,7 +59,7 @@ iPhone ──HTTPS POST──► healthlog ──► сырой архив (.jso сырой архив. Разбора, хранилища и read API ещё нет — план в [docs/plan.md](docs/plan.md). -Разведка формата закончена: 41 находка на живом потоке, половина расходится с +Разведка формата закончена: 46 находок на живом потоке, половина расходится с документацией Health Auto Export — [docs/local-research.md](docs/local-research.md). ## Команды diff --git a/docs/architecture.md b/docs/architecture.md index dcd17b8..0242bfe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,11 +15,12 @@ healthlog принимает выгрузки Apple Health из приложен в каком их прислал HAE — без переименований, пересчётов и отбрасывания незнакомых полей. Поэтому хранилище само по себе является полной копией данных, а не производной выжимкой. -- **Сырой архив — страховка разбора, а не вечный склад.** Тело запроса ложится - на диск до разбора и живёт ограниченный срок (по умолчанию две недели): - этого хватает, чтобы пережить ошибку в нашем разборе и пересобрать - хранилище (`healthlog reindex`). Дальше источником истины остаются часовые - объекты — прямое следствие пункта выше, см. «Хранилище». +- **Хранилище — свёртка по журналу, а не единственная копия.** Экспорт Apple + это снапшот всей истории, доставки HAE после его даты — события поверх + снапшота. Состояние всегда пересобираемо: `import(экспорт) + replay(доставки)`. + Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не + произвольные две недели. Исключение названо вслух: `stateOfMind` в экспорт не + попадает, см. «Хранилище». - **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор (см. «Приём»). - **Ничего не теряем молча.** Идентичность — устойчивые координаты @@ -236,16 +237,63 @@ HRV); у накопительных — только `date`. Поэтому то ## Хранилище -### Сырой архив — короткая страховка +### Сырой архив и восстановление состояния `raw/ГГГГ/ММ/ДД/.json.gz` — тело запроса как пришло, не редактируется. -Живёт **ограниченный срок** (`storage.raw_retention`, по умолчанию 14 дней), -после чего удаляется. -Смысл срока: архив нужен, чтобы пережить ошибку в **нашем** разборе и -пересобрать хранилище (`healthlog reindex`). Двух недель на это заведомо -хватает. Вечно хранить его незачем — часовые объекты держат те же точки -дословно, так что архив дублировал бы данные, а не страховал их. +Два источника вместе образуют **полный журнал событий**, а хранилище — +свёртку по нему: + +``` +состояние = import(последний проверенный экспорт) ← снапшот всей истории + + replay(доставки после его даты) ← хвост событий +``` + +Экспорт Apple — не просто «источник истины для нижнего слоя», а снапшот: он +содержит всю историю целиком (3.6 млн записей с 2019 года). Доставки HAE после +его даты — события поверх снапшота. Значит любое повреждение хранилища, +включая ошибку в нашем разборе любой давности, лечится пересборкой, а не +восстановлением из бекапа. + +Отсюда три следствия, каждое из которых меняет реализацию. + +**Срок жизни архива определяется циклом экспорта, а не календарём.** Прежние +14 дней были произвольным числом. Правильное правило: доставки хранятся **до +следующего проверенного экспорта**, иначе в журнале появится дыра между концом +ретеншена и датой снапшота. Цена измерена: поток даёт ~23 МБ архива в сутки, +то есть ~2 ГБ за квартал между экспортами. Это дёшево за возможность +пересобрать что угодно. + +**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же +состояние, что и приём в реальном времени. Слияние «выигрывает более полная +точка» коммутативно и порядка не требует; но когда две одинаково полные точки +несут разные значения, исход решает порядок — поэтому воспроизведение идёт +строго по `received_at`, а не по порядку файлов в каталоге. + +**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт +снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не +существует, она просто вырожденный случай с пустым снапшотом. + +#### Что не восстанавливается, и это сказано вслух + +Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать. + +**`stateOfMind` в экспорте отсутствует вовсе.** Проверено на свежем экспорте: +ни одного типа со словом `StateOfMind` (есть только `MindfulSession` — это +минуты осознанности, другое). Состояние разума живёт **только** в доставках +HAE. Значит для него доставки не хвост журнала, а единственный источник: либо +они не удаляются никогда, либо его история держится на самих сохранённых +записях и восстановлению не подлежит. + +**Верхние слои за периоды с удалёнными доставками.** После проигрывания +снапшота у старого периода будет только слой `sample`; `minute` и `hour` за +него не воскреснут. Посчитать их вниз из `sample` технически можно — и нельзя +по инварианту: это была бы **наша** агрегация под видом присланной. + +Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть +честным.** Каталог разрезов показывает, какие слои есть за какой период; после +пересборки старый период честно объявляет один слой вместо трёх, а не +притворяется, что ничего не изменилось. ### Устаревание нижнего слоя diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 218e776..8b6425c 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -7,7 +7,7 @@ ## высокий - [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это - [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика -- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Разбор будет ошибаться, а окно на исправление — 14 дней жизни архива +- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате diff --git a/docs/backlog/reindex-iz-arhiva.md b/docs/backlog/reindex-iz-arhiva.md index 89fc2b3..4c124a2 100644 --- a/docs/backlog/reindex-iz-arhiva.md +++ b/docs/backlog/reindex-iz-arhiva.md @@ -3,14 +3,26 @@ **Приоритет:** высокий Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск. -Риск в другом: сырой архив живёт 14 дней, и окно на исправление ошибки равно -этому сроку. Без `reindex` ошибка разбора становится потерей данных. +Риск в другом: без пересборки ошибка разбора становится потерей данных — +исправленный код не применится к тому, что уже разобрано неверно. Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род агрегации на полном ряду доставок надёжнее, чем на одной. -Готово, когда `healthlog reindex` пересобирает хранилище с нуля из `data/raw` -и результат совпадает с накопленным приёмом. +Проектировать это надо сразу как **свёртку по журналу**, а не как разовую +утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его +даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда +`reindex` и `import` окажутся одной операцией с разным входом, а не двумя +похожими. + +Отсюда требование, которое легко упустить: **свёртка обязана быть +детерминированной.** Проигрывание должно давать то же состояние, что приём в +реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две +одинаково полные точки с разными значениями разрешает порядок — значит +воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге. + +Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным +приёмом, и повторный прогон ничего не меняет. Связано: план шаг 3, `docs/architecture.md` → «Сырой архив». diff --git a/docs/backlog/retenshen-syrogo-arhiva.md b/docs/backlog/retenshen-syrogo-arhiva.md index 74eb87c..5d1b72c 100644 --- a/docs/backlog/retenshen-syrogo-arhiva.md +++ b/docs/backlog/retenshen-syrogo-arhiva.md @@ -5,10 +5,24 @@ Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является. -Включать **после** того, как разбор устоится и `reindex` докажет, что -хранилище действительно пересобирается: иначе страховка исчезнет раньше, чем +**Само правило изменилось.** Экспорт Apple — снапшот всей истории, доставки +после его даты — события поверх снапшота, и состояние всегда пересобираемо +свёрткой. Значит доставки должны жить **до следующего проверенного экспорта**, +а не фиксированные две недели: иначе между концом ретеншена и датой снапшота +образуется дыра в журнале, и пересобрать этот отрезок будет нечем. + +Цена измерена: ~23 МБ архива в сутки, то есть ~2 ГБ за квартал между +экспортами. Дёшево за возможность пересобрать что угодно. + +Отдельное исключение: `stateOfMind` в экспорт не попадает вовсе (проверено на +свежем архиве). Для него доставки — не хвост журнала, а единственный источник, +и под общее правило удаления он не подпадает. + +Включать **после** того, как разбор устоится и пересборка докажет, что +хранилище действительно восстанавливается: иначе страховка исчезнет раньше, чем перестанет быть нужна. -Готово, когда старые тела удаляются по расписанию, а `/stats` показывает -глубину архива в днях. +Готово, когда удаляются только доставки старше последнего проверенного +экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает +глубину архива и дату снапшота, до которой он подрезан. diff --git a/docs/local-research.md b/docs/local-research.md index 619b9e9..6ed1353 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -1427,6 +1427,38 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51 То есть правило «экспорт говорит кодами» верно для типов записей и категориальных значений, но не для всего документа. +## 46. `stateOfMind` в экспорт не попадает — единственная дыра в журнале + +Экспорт плюс доставки после его даты образуют полный журнал событий: состояние +пересобирается свёрткой `import(снапшот) + replay(хвост)`. Проверка показала +ровно одно исключение. + +В свежем экспорте **нет ни одного типа со словом `StateOfMind`**: + +``` +grep -oiE 'type="[^"]*(mind|mood|emotion)[^"]*"' → только +type="HKCategoryTypeIdentifierMindfulSession" ← минуты осознанности, другое +type="HKWorkoutEventTypeMotionPaused|Resumed" ← совпадение по подстроке +``` + +При этом HAE состояние разума шлёт исправно, и шлёт правильно — стабильными +кодами HealthKit (`momentary_emotion`, `slightly_pleasant`, `drained`), в +отличие от переведённых фаз сна (находка 37). + +**Следствие:** для `stateOfMind` доставки HAE — не хвост журнала, а +единственный источник. Под общее правило ретеншена он не подпадает: удалив +доставки, мы потеряем возможность восстановить его историю навсегда. + +### Цена журнала + +Поток даёт **~23 МБ сырого архива в сутки** (89 доставок за 16.4 часа дали +15.6 МБ). При экспорте раз в 2–3 месяца это ~2 ГБ между снапшотами — дёшево за +возможность пересобрать хранилище с любой точки. + +Прежние 14 дней ретеншена были произвольным числом; правильный срок — до +следующего проверенного экспорта, иначе между концом ретеншена и датой +снапшота в журнале образуется дыра. + ## Инструмент Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная diff --git a/docs/plan.md b/docs/plan.md index 9e7d7a8..12c5782 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -14,7 +14,7 @@ Разведка закончена: правило вывода слоя, модель идентичности и формы точки проверены на живом потоке, выводы — в -[local-research.md](local-research.md), 41 находка. +[local-research.md](local-research.md), 46 находок. ## Шаги @@ -73,9 +73,9 @@ - ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом (находка 37). -- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление - старых тел. Пока архив не подчищается; включить после того, как разбор - устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна. +- **Ретеншен сырого архива** — удаление доставок старше последнего + проверенного экспорта (не фиксированный срок: журнал не должен рваться). + Пока архив не подчищается; включить после того, как разбор устоится. - **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).