Skip to main content

Health Dashboard, часть 5: проблема EnergyBank

Часть 5 моего build log по Health Dashboard: как дневной показатель энергии, который работал на бумаге, сломался на втором пользователе, почему редизайн держится на 14-дневном contraction mapping и что означает graduated trust, когда часы сняты с запястья.

· 16 мин чтения

В части 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 накопить семь дней распределения и затем откалибровать заново.

Пять дней спустя я проверил лежащие под этим данные:

датаутренний запас v2bank в конце дня v2запас v1verdict v1
05-0837 (bootstrap)2783rest
05-09773692rest
05-10804894rest
05-11793185rest
05-12805983rest

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, но с двумя принципиальными поправками:

  1. Никаких ежедневных сбросов. Bank переносится между днями. Плохая ночь утягивает утреннюю стартовую точку вниз. Хорошая ночь дозаряжает bank к потолку, но не прибивает его к нему.

  2. 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:

СценарийПоведение
Новый пользователь после warmupForward iteration от seed=50; seed 14-дневной давности умирает до рендера сегодняшнего дня
Существующий пользователь при запуске v2То же самое, migration path не нужен
Bump formula_versionRe-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:

СостояниеTriggerRenderAI usage
fresh≤1 imputed day за последние 7Bank нормальный, без flagИспользуется в рекомендациях
estimated2–4 consecutive imputed days ИЛИ 2–4 imputed в 14-day windowПунктирная линия + badge «оценка · нет sensor data»Используется с hedge: «на основе 7d pattern, а не прямого измерения»
stale≥5 consecutive imputed days ИЛИ >7 в 14-day windowBank НЕ рендерится; «Нет свежих данных · проверь синхронизацию часов»AI переключается с activity advice на «sync your watch»
Раздел EnergyBank с полосой емкости, текущим значением банка и 30-дневным спарклайном
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 в бессмысленные значения.


  1. Вариабельность сердечного ритма (ВСР, HRV). Колебания интервалов между последовательными сокращениями сердца в миллисекундах. Высокая вариабельность указывает на парасимпатическое восстановление, а низкая — на доминирование симпатического тонуса. ↩︎

  2. Пульс в покое (ЧСС покой, RHR). Частота сердечных сокращений, измеренная в состоянии покоя (в идеале — в последние часы ночного сна). Увеличивающийся тренд на протяжении нескольких дней — классический сигнал болезни или переутомления. ↩︎

  3. Математическое свойство итерационных функций, при котором каждое повторное применение приближает значение к уникальной неподвижной точке независимо от начального значения. Классический пример: если x_new = (x + 100/x)/2, итерации от любого положительного значения сойдутся к √100 = 10. В нашем случае это означает, что с какого бы значения банка вы ни начинали расчет, результат через несколько дней будет одинаковым. Начальное значение экспоненциально забывается. ↩︎

  4. Вычисляемый столбец Postgres, значение которого рассчитывается на основе выражения, а не записывается приложением напрямую. Всегда отражает актуальное состояние выражения; может быть физически сохранен на диске (STORED) или рассчитываться при чтении (VIRTUAL, начиная с Postgres 17). ↩︎

  5. Метка стабильности функции в Postgres. Означает, что при одинаковых входных параметрах функция всегда возвращает один и тот же результат и не имеет побочных эффектов. Требуется для выражений в генерируемых колонках и функциональных индексах, так как Postgres агрессивно кэширует их результаты. Функции вроде now() или current_setting() не являются IMMUTABLE, поскольку они считывают внешнее динамическое состояние. ↩︎

  6. Двоичный тип хранения JSON в Postgres. В отличие от простого JSON, парсится в дерево при записи (что замедляет запись, но ускоряет чтение), поддерживает индексацию по ключам и элементам массивов, а также проверяет валидность JSON при вставке. ↩︎

  7. Обобщенный инвертированный индекс (Generalized Inverted Index). Тип индекса Postgres, оптимизированный для поиска вхождений элементов («это значение содержится в одном из многих внутри данной строки»). Классический пример — проверка вхождения в массив ('foo' = ANY(my_text_array)) и полнотекстовый поиск. ↩︎