Экран «Сегодня» в MeteoHealth показывает батарею энергии — число от 0 до 100, шкалу, стрелку тренда. Несколько месяцев это число на глазах пользователя дрожало на случайные ±10 — Double.random(in: -10...10), с комментарием в коде «для реалистичности». Кода за числом никто не видит: пользователь видит число и делает с ним единственное разумное — верит ему. За несколько недель разбора MeteoHealth нашлась целая серия таких мест, где показанное число не имело отношения к тому, что оно якобы измеряло. Ниже — не обзор приложения (карточка проекта — MeteoHealth), а разбор конкретных ложных чисел, коммит за коммитом. Аудит не закрыт — это то, что нашлось на сегодня.
Энергия из генератора случайных чисел#
Самое буквальное враньё было и самым простым по коду. TodayViewModel.recalculateEnergy() считал батарею энергии так:
private func recalculateEnergy() {
let recentEntries = AppEnvironment.bootstrap.dataStore.fetchRecentMoodEntries(limit: 5)
if !recentEntries.isEmpty {
// Средний wellness влияет на энергию
let avgWellness = recentEntries.reduce(0.0) { $0 + Double($1.wellnessLevel) } / Double(recentEntries.count)
let baseEnergy = (avgWellness / 4.0) * 100
// Добавляем случайную вариацию для реалистичности
let variation = Double.random(in: -10...10)
currentEnergyPercentage = Int(max(0, min(100, baseEnergy + variation)))
}
}«Для реалистичности» — комментарий из исходника, не ирония постфактум: случайное число добавлялось, чтобы батарея не выглядела статичной между обновлениями. При этом в кодовой базе год как жил EnergyCalculator — 14 факторов, веса, импутация, confidence — и не вызывался из приложения ни разу; работал только ручной ввод. Настоящий калькулятор существовал и молчал, пока экран показывал шум.
Фикс — не улучшить формулу, а подключить источник, который уже был:
@discardableResult
func refreshEnergy() async -> EnergyLevel {
let energy = await getBlendedEnergy()
await persistToHistoryIfNeeded(energy)
return energy
}Три параллельные реализации расчёта энергии внутри TodayViewModel удалены. Запись в историю ограничена раз в час: история кормит расчёт личной нормы, и точка на каждый вход исказила бы личный коридор в пользу активных дней.
Энергия оказалась не единственным скором, который считался нечестно. У recovery и training readiness была своя, куда более тихая версия той же болезни.
База 50: как «нет данных» притворяется серединой#
Три составных скора — recovery, training readiness, оценка энергии для аналитики — считались по одному шаблону: старт с базового значения 50 и прибавка взвешенных дельт от каждого доступного фактора.
func calculateRecoveryScore(hrv: Double?, restingHR: Double?) -> Double? {
var score: Double = 50 // Базовое значение
var factors: Int = 0
if let currentHRV = hrv, hrvBaseline7Days > 0 {
score += (hrvScore - 50) * 0.6 // вес фактора HRV
factors += 1
}
// Resting HR — аналогично, вес 0.4
guard factors > 0 else { return nil }
return max(0, min(100, score))
}При одном факторе из двух результат математически не мог выйти за пределы 40–60: дельта взвешивалась на его долю (0.6 или 0.4), а не на 1.0. Число садилось у центра и читалось как измеренная середина, а не как «мы почти ничего не знаем».
Фикс — нормировка на сумму весов присутствующих факторов, а не на полную единицу, с порогом покрытия 0.5:
guard coveredWeight >= Self.minimumWeightCoverage else { return nil }
return max(0, min(100, weightedSum / coveredWeight))Ниже порога скор не показывается вовсе — nil здесь не баг, а результат: пустое место честнее середины шкалы. Тот же приём применён к оценке энергии в аналитике.
«Только что» недельной давности#
Нашлась не при чтении кода, а на живом устройстве: владелец не носил часы целый день и увидел «50 из 100» с подписью, будто это только что посчитано.
Причина — StressScoreService брал samples.first?.sdnn без проверки возраста сэмпла: first возвращал последний известный замер ВСР, даже многодневной давности, а подпись рядом показывала время пересчёта, а не время замера.
guard let latest = samples.first else {
scoreState = .learningBaseline(collected: 0, required: Self.minBaselineSamples)
return
}
guard now.timeIntervalSince(latest.timestamp) <= Self.maxSampleAge else {
scoreState = .staleData(lastSampleDate: latest.timestamp)
return
}Появились состояния .staleData и .learningBaseline. Порог зрелости baseline поднят с 3 сэмплов до 7: на трёх точках стандартное отклонение ln(SDNN) — знаменатель z-оценки — не оценивается, оно оценивается шумом; Garmin, Oura и Whoop строят такую норму на 7–28 днях.
Отдельно поднят пол личного SD с 0.1 до 0.15: прежний пол на ровной ВСР прижимал стресс к 100 из 100, хотя суточный разброс SDNN у здоровых людей — 20–30%, в логарифмической шкале ≈0.18–0.26.
Время — не единственное скрытое допущение в композитных скорах. У экрана разбора энергии нашлась похожая, но отдельная болезнь: он путал «неизвестно» с «нейтрально».
Разбор по весам, которых нет#
Экран разбора энергии («что повлияло») был написан ещё в июне и жил только в снапшот-тестах — мёртвым кодом. Когда его подключили, вскрылась вторая проблема: разбор строился по весам первой версии формулы, тогда как число уже месяц считалось по второй. Экран объяснял бы результат весами, по которым он не был получен.
Решение — не подправить веса в двух местах, а убрать дублирование: единый справочник EnergyFactorKind, из которого веса и нейтрали берут и формула, и разбор.
/// Для «уровневых» факторов (сон, ВСР) нейтраль — середина шкалы, для
/// «impact»-факторов (погода, восстановление после тренировки) — «ничего
/// не мешает», то есть ближе к 1.0.
var neutralValue: Double {
switch self {
case .workoutRecovery, .weatherImpact: return 1.0
case .restingHeartRate, .currentHeartRate, .cyclePhase: return 0.75
default: return 0.5
}
}Раньше отсутствующий фактор молча получал 0.5 — «середину». Но для погоды 0.5 читается как «умеренно плохое влияние», хотя означает «мы не знаем, какая погода». У фактора появилось поле isImputed, и у impact-факторов — своя нейтраль: «в норме» и «неизвестно» перестали быть одной цифрой. Импутированные факторы исключены из списка «драйверов» — назвать неизвестное причиной было бы отдельной выдумкой поверх уже почищенной.
Разбор энергии хотя бы показывал реальные факторы, пусть и с перепутанными весами. Корреляционный движок в тот же период умудрился найти связь там, где её физически не могло быть.
Связь ряда с самим собой#
Корреляционный движок ищет связи между сигналами самочувствия. На демо-данных единственной «сильной связью», которую он нашёл, оказалась «Среднее за 7 дней ↔ Настроение» — движок показал пользователю связь ряда с самим собой и назвал её главным инсайтом.
Причина — в самом подходе к фильтрации тавтологий: поимённый blocklist знал пару wellnessAvg7d ↔ wellness, но не знал wellnessAvg7d ↔ mood, хотя это буквально один ряд: moodPoints[day] = score.
nonisolated static let signalFamilies: [Set<CorrelationFactor>] = [
[.wellness, .mood, .energy, .manualEnergy, .manualEnergyTrend,
.wellnessTrend, .energyTrend, .wellnessAvg7d],
[.hrv, .hrvTrend, .recoveryScore, .trainingReadiness],
// ...
]
nonisolated static func isBlocklisted(_ a: CorrelationFactor, _ b: CorrelationFactor) -> Bool {
if a == b { return true }
if signalFamilies.contains(where: { $0.contains(a) && $0.contains(b) }) { return true }
return blocklistPairs.contains { ($0.0 == a && $0.1 == b) || ($0.0 == b && $0.1 == a) }
}Поимённый список ловит только те тавтологии, что вспомнил в момент написания; семейство сигналов ловит все производные одного источника сразу. Заодно из keyPairs ушли 7 тавтологичных пар и 6 дублей вида «X ↔ mood» там, где уже была пара «X ↔ wellness» — они задваивали счёт одной связи, отбирая место в топе и утяжеляя поправку Бенджамини-Хохберга для настоящих связей в списке.
О самой статистике под капотом движка — отдельно, в статье про честную статистику on-device; здесь важно другое: даже корректный статистический аппарат ломается, если на вход подать одну переменную дважды под разными именами.
Иногда неверное число вообще не результат расчёта — оно просто взято с потолка и вставлено в интерфейс напрямую.
Числа без источника удаляют, а не смягчают#
Экран настроек цикла показывал «Надёжность» контрацепции в процентах: 87 / 93 / 96 / 99 / 78. Числа были захардкожены без источника, не различали типичное и идеальное применение и сваливали гормональную и медную ВМС в одно значение. Подпись при этом утверждала, что метод «учитывается в прогнозах фертильности» — хотя grep по .reliability дал ровно четыре места, и все четыре были выводом на экран. Ни один расчёт прогноза это поле не читал.
Решение — не смягчить дисклеймером, а убрать целиком: строку, цветовую индикацию (зелёный на 99% читался как совет «хороший выбор») и локализационный ключ в шести локалях. Дисклеймер рядом с процентом без источника не чинит проблему — он превращает функцию без оговорки в функцию с оговоркой, которая ей противоречит.
Рядом — числа той же природы, попроще. Сводка питания считалась как todaySummary.calories × дни_периода: один съеденный день экстраполировался на весь диапазон, а daysTracked всегда равнялся числу дней периода, даже если еду не вносили вовсе.
Похожая арифметика — в приверженности приёму лекарств для курса, начатого сегодня: разница дат «как есть» давала 0 суток → 0% в знаменателе, а статистика ниже по коду считала те же дни уже с +1 — два знаменателя одной метрики расходились на день. Этот дефект нашёлся не тестами, а просмотром скриншотов для сайта.
Правила, которые остались после аудита#
После всех исправлений у меня осталось несколько правил, которые теперь применяются не задумываясь. nil честнее середины шкалы: недостаточно данных — значит, оценки нет, а не оценка, зажатая у 50. Нейтральное значение фактора и «мы не знаем» — разные вещи, и на экране они обязаны выглядеть по-разному. Поимённый blocklist ловит только то, что вспомнил при написании, — семейства сигналов ловят и то, что не вспомнил. А каждый композитный скор обязан знать возраст своих входных данных и долю покрытия весов, иначе «только что» и «неделю назад» для него неотличимы.
И, пожалуй, самое неудобное: часть этого нашлась не тестами, а просмотром скриншотов и живым использованием без часов на руке. Тесты проверяют, что код делает то, что должен по спецификации, — но не проверяют, врёт ли сама спецификация.
Это срез того, что уже нашлось и исправлено, а не полный аудит. Продолжаю искать, где ещё «показать хоть что-то» подменяет собой «показать то, что есть на самом деле».



