Health Dashboard, часть 5: проблема EnergyBank
Часть 5 моего build log по Health Dashboard: как дневной показатель энергии, который работал на бумаге, сломался на втором пользователе, почему редизайн держится на 14-дневном contraction mapping и что означает graduated trust, когда часы сняты с запястья.
В части 4 аудит обнулил readiness-формулу и выявил параллельный факт: EnergyBank v1 имел противоположный знак корреляции с HRV1 следующего дня (r=−0.149) и RHR2 следующего дня (r=+0.212) за 387 парных дней.
Противоположный знак корреляции — это сильное утверждение. Это означает, что сама концепция жива, но target был задан неверно: то, что измерял v1, вероятно, было productive strain, а не recovery deficit. Эти два показателя коррелируют во многие дни и расходятся именно там, где это важнее всего: в тихий, но тревожный день, во время болезни или когда часы сняты с запястья.
Эта часть — о редизайне, который вырос из этой находки. Техническое ядро небольшое: state machine, contraction mapping и набор trust states. Интереснее другое: от каких конкретных ошибок защищает каждый из этих элементов.
Момент, который запустил редизайн
Первым конкретным артефактом стал скриншот моего собственного дашборда. Утром 2026-05-12 главный блок показывал полосу на 59%, а прямо под ней — текст «Осталось всего 25% мощности после сегодняшней нагрузки, держите интенсивность лёгкой». Эти два числа не совпадали, потому что они приходили из двух разных формул. Полоса уже считалась по v2, а текст всё ещё генерировался по v1.
Это было переходное состояние half-cutover, которое я сознательно выкатил. Числа v2 и sparkline v2 уже отображались в главном блоке, но verdict-пороги v1 (rest / active_recovery / moderate / push_hard) и объясняющий их текст оставались активными: пороги v1 имели реальную production-калибровку, а пороги v2 ещё нет. План был дать v2 накопить семь дней распределения и затем откалибровать заново.
Пять дней спустя я проверил лежащие под этим данные:
| дата | утренний запас v2 | bank в конце дня v2 | запас v1 | verdict v1 |
|---|---|---|---|---|
| 05-08 | 37 (bootstrap) | 27 | 83 | rest |
| 05-09 | 77 | 36 | 92 | rest |
| 05-10 | 80 | 48 | 94 | rest |
| 05-11 | 79 | 31 | 85 | rest |
| 05-12 | 80 | 59 | 83 | rest |
Verdict v1 был rest каждый день, потому что формула current = readiness − allostatic_drain в v1 выдавала ≤25 практически ежедневно для человека с умеренно активными привычками. Рекомендация формулы оставалась одинаковой независимо от входных данных. При этом утренний запас v2 двигался от 37 до 80, а EOD bank v2 — от 27 до 59. В колонке v2 был реальный сигнал. Колонка v1 повторяла «rest» как сломанные часы, которые дважды в сутки всё-таки правы.
План half-cutover неявно предполагал, что v1 — валидный baseline, относительно которого можно откалибровать v2. Данные показали, что v1 был вырожденным. План «подождать семь дней» превратился в план backfill-нуть v2 по истории и дать истории откалибровать пороги. Примерно 630 EOD-точек за период 2024-07 → 2026-05 — достаточно, чтобы честно выставить диапазоны.
Этот backfill стал cmd/energy_backfill. Он же стал моментом, когда началась cross-tenant история: как только v2 должен был работать на ретроспективных данных, «работает на моём ноутбуке» перестало быть аргументом.
Что делал v1
internal/health/energy.go — файл v1, который всё ещё лежит в репозитории, — считает daily snapshot:
drain = strain · (1 + 0.5 · stress / 100)
restore = sleep_quality_units
bank = clamp(restore − drain, 0, 100)
Стресс здесь был множителем нагрузки. День с низкими active calories остаётся маленьким, на какой бы stress factor его ни умножали. Тихий, сидячий, но автономно нагружающий день — например, когда я, как обычно, выгуливал собаку, но у неё случился приступ аллергии, и я весь день переживал за неё, — почти не создавал drain в формуле, потому что множитель умножал почти нулевую базу. Bank не двигался. График говорил «fresh», тело возражало.
Вторая проблема v1 была структурной: bank сбрасывался каждый день. Вчерашний остаток не переносился на сегодня. Три ночи плохого сна подряд не тянули утреннюю стартовую точку всё ниже; каждый день был независимым snapshot. Для метрики, которая должна отражать sustained recovery, это неверная форма.
Аудит сделал методологическую проблему конкретной. r=−0.149 относительно HRV следующего утра — это не «слабый сигнал в ожидаемом направлении». Это сигнал, указывающий в неправильную сторону. Формула ловила что-то настоящее, но то, что она ловила — активные продуктивные дни сильнее расходуют bank, — не предсказывало autonomic recovery, зависящее от другого набора факторов.
Редизайн в одной формуле
EnergyBank v2 — это state machine:
bank[t] = bank[t−1] + restore(t−1 → t) − drain(t−1 → t)
Та же идея, что и Garmin Body Battery, но с двумя принципиальными поправками:
Никаких ежедневных сбросов. Bank переносится между днями. Плохая ночь утягивает утреннюю стартовую точку вниз. Хорошая ночь дозаряжает bank к потолку, но не прибивает его к нему.
Drain аддитивен, а не мультипликативен.
drain(Δt) = α · active_energy_kcal + β · sustained_hr_load. Одних калорий хватает для большинства дней. Слагаемоеβ · sustained_hr_loadловит стрессовые сидячие дни, которые калории пропускают. В релиз оно уходит сβ = 0и становится ненулевым только per-user после прохождения validation rubric — это территория части 6.
Три свойства asymptotic restore оказались настолько важными, что вокруг них и строился дизайн.
Дефицит сна за несколько ночей накапливается. Restore считается как capacity_today = bank_yesterday + (100 − bank_yesterday) · sleep_quality. Чем ближе bank к максимуму, тем меньше прирост. Одна плохая ночь — 5.4h, 9 апреля в validation dataset — удерживает bank на уровне не выше 73/100 следующим утром даже после нормального сна 7.1h на следующую ночь.
Capacity никогда не прибивается к потолку. За 31 симулированный день аддитивный вариант прилипал к 100 в 28 случаях. Асимптотический вариант не достиг потолка ни разу. Нижний хвост bank тоже получает настоящий разброс: p10 = 17 против 35 в аддитивной форме. Значит, график наконец содержит информацию.
structure_factor только штрафует, но никогда не награждает. Асимптота уже даёт верхнюю границу. Бонусные веса лишь гнались бы за потолком, который уже есть. Поэтому factor остаётся в диапазоне [0.5, 1.0].
Cold start и contraction mapping
У stateful-формулы есть вопрос cold start: с чего начинать bank, если вчерашнего состояния нет?
Скучный ответ — сохранять seed. Такие скучные ответы в подобных системах быстро становятся долгосрочным долгом. Каждый bump formula_version, каждая миграция, каждое «мы поменяли расчёт sleep_quality» превращаются в разговор о том, какой seed и как переносить.
Асимптотическое восстановление оказывается contraction mapping3. Любой seed экспоненциально забывается через множитель (100 − bank) · sleep_quality. На реальных данных три seed-а {10, 50, 90} сходятся с точностью до 0.005 к 7-му дню и становятся практически идентичными к 9-му.
Это означает, что состояние seed вообще не нужно сохранять. Каждый recompute проходит вперёд по последним 14 дням от жёстко заданного bank = 50:
function compute_today_bank():
bank = 50.0
for day in metric_points.last_14_days:
sq = sleep_quality(day.sleep_metrics)
bank = (bank + (100 - bank) * sq) - drain(day.activity_metrics)
return bank
К моменту, когда итерация доходит до «сегодня», seed математически исчезает. 14 дней — примерно вдвое больше окна сходимости.
Показать computeBankFromDays (Go, ~20 строк)
// Pure iteration: 21 days of daily inputs in, today's bank out.
// Extracted so unit tests exercise imputation and trust-state
// logic without a database.
func computeBankFromDays(days []dailyInputs, cfg EnergyConfig) BankResult {
if len(days) != energyWindowDays {
return BankResult{State: "stale", FormulaVersion: cfg.FormulaVersion}
}
bank := 50.0 // contraction-mapping seed; forgotten by day 9
for i := 0; i < energyWindowDays; i++ {
sq, sqImputed := sleepQualityOrTrailingAvg(days, i)
drain, drainImputed := drainOrTrailingAvg(days, i, cfg)
// ... imputation flag bookkeeping omitted ...
bank = (bank + (100.0-bank)*sq) - drain
bank = clamp(bank, -50.0, 100.0)
}
return BankResult{
Bank: int(math.Round(bank)),
FormulaVersion: cfg.FormulaVersion,
// ... flags, state derived from imputation count ...
}
}
Реальная версия — с nil-checks по каждому дню, two-pass imputation и выводом trust state — живёт в internal/storage/energy_compute.go. Здесь её читаемая форма.
Один механизм закрывает все edge cases, которые иначе потребовали бы отдельных code paths:
| Сценарий | Поведение |
| Новый пользователь после warmup | Forward iteration от seed=50; seed 14-дневной давности умирает до рендера сегодняшнего дня |
| Существующий пользователь при запуске v2 | То же самое, migration path не нужен |
Bump formula_version | Re-iterate 14 дней по новой формуле; dashboard плавно переходит в течение недели |
| Data gap меньше 14 дней | Self-heals: пропущенные дни дают sq=0, drain=0, bank затухает к 0, первая свежая ночь асимптотически дозаряжает |
| Data gap больше 14 дней | Запускает повторный вход в staleness / warmup, bank не рендерится до появления свежих данных |
Это дизайн, который выглядит очевидным постфактум, но появляется только после трёх неудачных форм. Ранние версии держали persisted seed с «EMA warmup state machine», которой требовалась собственная migration logic под каждый bump формулы. Свойство сходимости всё это убрало.
Была и соблазнительная оптимизация, которую в итоге не приняли. Во время design review прозвучала идея кэшировать вчерашний EOD bank в базе и начинать каждую итерацию дня от этого значения, а не проходить 14 дней от seed = 50. На бумаге это экономит около 13 дней вычислений на recompute. На практике прошлогодняя floating-point арифметика и прошлонедельная версия формулы тянулись бы вперёд во времени, а любая тихая ошибка накапливалась бы бесконечно. Convergence-from-seed гарантирует, что каждый recompute idempotent over source data: drift не может накапливаться, потому что ему не во что накапливаться. Дорогая на вид итерация оказывается дешёвым долгосрочным решением. Реальная performance-оптимизация другая: recompute window ограничен последними 24 часами на ingest event, а не всей историей.
Cross-tenant тест, который сломал v2
Первая версия v2 работала на моих данных и пережила backfill. Она рухнула в течение 24 часов после запуска на данных второго tenant-а.
Контекст важен. Проект self-hostable, и его запустил настоящий другой человек: аккаунт моей жены на том же сервере, с её Apple Watch, её RingConn и её паттерном ношения устройств. К этому моменту у системы уже было одно доказательство, что другие пользователи будут приходить неожиданным способом. В части 2 был PR #17 от makvitaly: внешний contributor добавил endpoint для workouts. Второй tenant оказался другим типом доказательства: не «кто-то хочет отправлять данные так же, как я», а «повседневная реальность другого человека вообще не похожа на мою».
Это был тот же код, но другой человек и другой паттерн ношения часов. Данные сна отсутствовали в 21 из 90 дней — около 23% gaps. Часы на зарядке, пропущенная синхронизация, обычная жизнь: телефон остался дома, зарядка забыта в поездке, несколько ночей без кольца. Первая версия формулы ставила sq=0 — нет восстановления — в дни пропусков, но оставляла active drain. В результате bank ушёл в runaway negative signed state. Внутреннее значение дошло до −54.
Все пять «худших дней» этого tenant-а на дашборде были data gaps, а не реальное истощение. Система говорила человеку, что он exhausted, потому что он не носил часы. Я на пару часов взял её телефон: она попросила посмотреть, почему дашборд всё время говорит ей отдыхать. Смотреть на строку, которую ты написал за один день, и объяснять её реальному человеку — качественно не то же самое, что смотреть на неё в своём аккаунте.
В этот момент дизайн получил две детали, которых изначально не было. Обе появились из наблюдения за неправильным поведением production-shaped run на реальном втором пользователе, а не из теории.
Trailing-average imputation с жёстким запретом feedback. Когда за день нет метрик сна, sq[d] берётся как средний sleep quality за последние 7 дней валидных, не-imputed данных. То же самое для activity. Imputed days получают флаги imputed_sleep, imputed_activity, но ещё важнее — они исключаются из trailing windows следующих дней. Если дать imputed values попадать в среднее следующего дня, gap тихо утянет формулу к тому, как выглядел сам gap, а не к реальному baseline пользователя.
Если в lookback window меньше 3 валидных не-imputed дней, fallback — all-time median пользователя, а trust state эскалируется.
Три уровня доверия. Бинарное «imputed или нет» скрывает то, что пользователь должен знать. Bank деградирует через три состояния с явными triggers и явным UI:
| Состояние | Trigger | Render | AI usage |
fresh | ≤1 imputed day за последние 7 | Bank нормальный, без flag | Используется в рекомендациях |
estimated | 2–4 consecutive imputed days ИЛИ 2–4 imputed в 14-day window | Пунктирная линия + badge «оценка · нет sensor data» | Используется с hedge: «на основе 7d pattern, а не прямого измерения» |
stale | ≥5 consecutive imputed days ИЛИ >7 в 14-day window | Bank НЕ рендерится; «Нет свежих данных · проверь синхронизацию часов» | AI переключается с activity advice на «sync your watch» |
![]() | |||
| Hero EnergyBank v2. Sparkline показывает, как bank переносится между днями; badge под числом, если он виден, показывает trust state: fresh / estimated / stale. |
Два параллельных trigger-а, consecutive и proportion, ловят два разных failure modes: чистый отрезок плохой синхронизации и разбросанные gap-и, которые накапливаются. Cross-user data показали 23% scattered gaps; одно правило consecutive их пропустило бы.
Выход из stale — это не мгновенный fresh. Когда данные возвращаются, bank входит в 3-дневное состояние recovering, где заново bootstrap-ится от seed=50 по последним 14 дням, игнорируя замороженные pre-stale snapshots. Пять и больше дней без данных могут означать болезнь, поездку, burnout или просто мёртвые часы. Применить состояние до gap — это тихое допущение, которое превращает маленький баг в неправильную историю о теле пользователя.
Также появился signed floor снизу на −50. Первоначальный черновик clamp-ил только сверху. Cross-tenant run показал, что симметричный floor нужен: ниже −50 сигнал насыщается, а дальнейшая интеграция — просто шум.
Хранилище, которое не врёт о версиях формул
energy_snapshots в схеме выглядит компактно:
CREATE TABLE energy_snapshots (
ts_bucket TIMESTAMPTZ NOT NULL,
date TEXT NOT NULL,
bank INTEGER NOT NULL,
drain_delta INTEGER NOT NULL,
restore_delta INTEGER NOT NULL,
formula_version INTEGER NOT NULL,
components JSONB,
flags TEXT[] NOT NULL DEFAULT '{}',
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (ts_bucket)
);
Три детали заслуживают места в таблице. Ни одна из них не гениальная; каждая предотвращает конкретный тип тихой поломки.
date вычисляется в Go, а не генерируется Postgres. Выражение для GENERATED4 колонки обязано быть IMMUTABLE5, а это вынуждает зашивать timezone прямо в DDL. Для multi-tenant системы это неверно. TZ пользователя берётся из REPORT_TZ; дата считается при записи:
loc, _ := time.LoadLocation(tenantTZ)
dateStr := tsBucket.In(loc).Format("2006-01-02")
Snapshot, записанный в Белграде, несёт date = '2026-05-08', даже если пользователь позже переедет в Нью-Йорк. Будущие snapshots используют новую TZ после смены настройки. Прошлые данные остаются как были — и это правильно, потому что тогда в пользовательской timezone действительно был этот день.
components и flags намеренно разделены. components — audit trail входов формулы (JSONB6): HR averages, использованный RHR baseline, kcal, sleep stage breakdown. Read-mostly. Ответ на вопрос «что видела формула?» flags TEXT[] — state metadata: imputation status, trust level, calibration markers. Если смешать их, изменение отрисовки flag может изменить audit trail. GIN index7 на flags делает membership queries (WHERE 'imputed_sleep' = ANY(flags)) дешёвыми.
formula_version хранится per-row и никогда не перезаписывается across versions. Backfilled rows из v2.0 остаются v2.0 даже после релиза v2.2. График читает последнюю версию per date. Week-over-week comparisons остаются валидными внутри одной formula version, а UI явно показывает calibration-change boundaries. Это правило превращает «rerunning backfill changed last week’s numbers» из повторяющегося бага в осознанное решение.
К чему формула адаптируется в v2.5
Тот же cross-tenant run, который вскрыл collapse на data gaps, поднял вопрос калибровки. Статический α для всех — правильный базовый инструмент, но неправильный долгосрочный ответ. Athlete с низким RHR и высоким active kcal действительно имеет другую energy economy, чем sedentary user. Некоторым пользователям понадобится scaling α.
Наивный путь — подстраивать α по тому, насколько хорошо bank tracks itself. Это классическая overfitting trap: tuning формулы против её собственных predictions. Нужен external signal, который формула не видит.
В v2.5 таким сигналом становится next-morning HRV. Считается Pearson r между сегодняшним bank_eod и завтрашним overnight HRV на 30-day rolling window:
if r < 0.2: formula does not explain this user, alert, do NOT auto-tune
elif r < 0.3: narrow tune: alpha_factor ∈ [0.8, 1.2]
elif r < 0.5: full tune: alpha_factor ∈ [0.5, 1.5]
else: strong: alpha_factor ∈ [0.4, 1.7] (rare)
Здесь важны два guard rails. Узкий tune band критичен: на 90-day prototype run unconstrained optimum упирался в границу поиска (factor=0.5) ради маргинального улучшения r. Это учебниковый edge-solution признак misspecification, а не personalisation. Clamp до [0.8, 1.2] в зоне marginal r не даёт маленькому статистическому сигналу вызвать большой сдвиг формулы.
И если r < 0 — низкий bank коррелирует с высоким HRV, то есть в противоположную сторону от того, что утверждает формула, — auto-tune полностью подавляется, а case уходит в calibration review. Гнаться за wrong-direction correlation — отличный способ сделать систему, обученную на собственных predictions, ещё дальше от физиологии, чем в начале.
Изначальный дизайн v2.5 также планировал использовать next-day RHR как второй сигнал. Тот же 90-day prototype run показал, что RHR физиологически меняет знак: vagal rebound после высокой нагрузки может означать ниже RHR на следующее утро, а не выше. RHR убрали из auto-tune signal и оставили для diagnostics.
Поэтапный запуск и границы версии
План rollout разбит на этапы с жёсткими критериями перехода:
- v2.0: skeleton. DB schema, event-driven recompute, restore only from sleep, hourly chart в dashboard hero.
β = 0. Константы зашиты в код, calibration deferred. Старая v1 работает параллельно до подтверждения v2.0. - v2.1: calibration. 7 дней сбора данных, per-user настройка
αпо histogram распределения bank, recalibrate verdict thresholds на основе новых percentiles. - v2.2: autonomous-load drain term становится ненулевым per-user после прохождения validation из части 6.
- v2.3: daytime restore + day-tagging UI (
alcohol,caffeine_high,illness_confirmed,travel,menstrual). Tags не меняют drain напрямую, но корректируют verdict copy. - v2.4: integrations. Реальные actions, например рекомендации типа тренировки в morning briefing или блокировка calendar slots.
- v2.5: auto-personalisation
αчерез HRV analysis. - v3.0: user profiles (
athlete/sedentary/recovery-impaired), которые совместно настраиваютαиstructure_factor.
Самое сложное решение в v2.5 — выбрать transparency over knobs. На дашборде показывается системное сообщение: «калибровано автоматически · α_factor=0.85 · последнее обновление: Пн, 5 мая», а в /admin/energy-calibration остаётся audit log. Пользователь видит, что система адаптировалась, но не получает набор ручек, которыми легко сломать формулу.
Что осталось out of scope: ручная коррекция bank, ручные α sliders, попытка предсказывать subjective energy без отдельной разметки и любые LLM-driven изменения формулы. AI может объяснять bank и trust state. AI не меняет саму state machine.
Настоящий урок
EnergyBank v1 был хорошим решением для первой итерации и неприемлемым для production. Код v2 небольшой: forward iteration, asymptotic update rule и простая state machine для trust level. Каждая строка появилась потому, что предыдущий подход рассказывал историю, которую данные не подтверждали.
Предыдущая статья была о доверии к target. Эта — о доверии к молчанию формулы: нужно уметь вовремя скрыть bank, когда сенсоры молчат, а не уверенно показывать цифры, высосанные из пальца.
Следующая статья продолжит открытый вопрос: что такое sustained_hr_load, почему простой интеграл HR − RHR оказался неправильным выбором и как validation защищает формулу от auto-tune в бессмысленные значения.
Вариабельность сердечного ритма (ВСР, HRV). Колебания интервалов между последовательными сокращениями сердца в миллисекундах. Высокая вариабельность указывает на парасимпатическое восстановление, а низкая — на доминирование симпатического тонуса. ↩︎
Пульс в покое (ЧСС покой, RHR). Частота сердечных сокращений, измеренная в состоянии покоя (в идеале — в последние часы ночного сна). Увеличивающийся тренд на протяжении нескольких дней — классический сигнал болезни или переутомления. ↩︎
Математическое свойство итерационных функций, при котором каждое повторное применение приближает значение к уникальной неподвижной точке независимо от начального значения. Классический пример: если
x_new = (x + 100/x)/2, итерации от любого положительного значения сойдутся к √100 = 10. В нашем случае это означает, что с какого бы значения банка вы ни начинали расчет, результат через несколько дней будет одинаковым. Начальное значение экспоненциально забывается. ↩︎Вычисляемый столбец Postgres, значение которого рассчитывается на основе выражения, а не записывается приложением напрямую. Всегда отражает актуальное состояние выражения; может быть физически сохранен на диске (
STORED) или рассчитываться при чтении (VIRTUAL, начиная с Postgres 17). ↩︎Метка стабильности функции в Postgres. Означает, что при одинаковых входных параметрах функция всегда возвращает один и тот же результат и не имеет побочных эффектов. Требуется для выражений в генерируемых колонках и функциональных индексах, так как Postgres агрессивно кэширует их результаты. Функции вроде
now()илиcurrent_setting()не являютсяIMMUTABLE, поскольку они считывают внешнее динамическое состояние. ↩︎Двоичный тип хранения JSON в Postgres. В отличие от простого
JSON, парсится в дерево при записи (что замедляет запись, но ускоряет чтение), поддерживает индексацию по ключам и элементам массивов, а также проверяет валидность JSON при вставке. ↩︎Обобщенный инвертированный индекс (Generalized Inverted Index). Тип индекса Postgres, оптимизированный для поиска вхождений элементов («это значение содержится в одном из многих внутри данной строки»). Классический пример — проверка вхождения в массив (
'foo' = ANY(my_text_array)) и полнотекстовый поиск. ↩︎
