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。数字停在中心附近,被解读为"测得的中间水平",而不是"我们几乎一无所知"。
修复方式:按在场因子的权重之和归一化,而不是按完整的 1.0,并设 0.5 的覆盖率阈值:
guard coveredWeight >= Self.minimumWeightCoverage else { return nil }
return max(0, min(100, weightedSum / coveredWeight))低于阈值时,分数根本不显示——这里的 nil 不是 bug,而是结果:空白比刻度中间值更诚实。同样的手法也应用到了分析里的能量估计上。
一周前的「刚刚」#
这个问题不是读代码发现的,而是在真机上发现的:所有者一整天没戴手表,却看到了"100 分中的 50 分",旁边的说明文字仿佛表示这是刚刚算出来的。
原因是 StressScoreService 取 samples.first?.sdnn 时不检查样本的时效:first 返回的是最后一次已知的 HRV 测量值,哪怕已经是好几天前的,而旁边的说明显示的是重新计算的时间,不是测量的时间。
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 个:只有三个点时,z 分数的分母——ln(SDNN) 的标准差——无法估计,估计出来的只是噪声;Garmin、Oura 和 Whoop 都在 7–28 天上构建这类基准。
另外把个人 SD 的下限从 0.1 提高到 0.15:旧下限在 HRV 平稳时会把压力钉在 100 分中的 100 分,而健康人 SDNN 的日间波动是 20–30%,在对数刻度上约为 ≈0.18–0.26。
时间并不是复合分数里唯一的隐藏假设。能量归因页面被查出了一种相似却独立的病:它把"未知"和"中性"混为一谈。
基于不存在的权重的归因#
能量归因页面("是什么影响了它")早在六月就写好了,却只活在快照测试里——死代码。接上之后暴露出第二个问题:归因是按公式第一版的权重构建的,而数字本身已经按第二版算了一个月。这个页面会用一套并未产生该结果的权重去解释结果。
解决方案不是在两处修补权重,而是消除重复:建立唯一的登记表 EnergyFactorKind,公式和归因都从它那里获取权重和中性值。
/// 对于「水平型」因子(睡眠、HRV),中性值是刻度的中间;对于
/// 「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"这一对了。它们把同一条关联计了两次,既挤占榜单位置,又加重了对列表中真实关联的 Benjamini-Hochberg 校正。
引擎底层的统计学本身另有专文,见关于诚实的端上统计的文章;这里重要的是另一点:哪怕统计装置本身是正确的,只要把同一个变量换个名字喂进去两次,它就会失效。
有时,错误的数字根本不是计算的结果——它只是凭空捏出来、直接塞进界面里的。
没有来源的数字要删除,而不是弱化#
周期设置页面用百分比展示避孕方式的"可靠性":87 / 93 / 96 / 99 / 78。这些数字是硬编码的、没有来源,不区分典型使用和完美使用,还把激素宫内节育器和铜制宫内节育器塞进同一个值。而旁边的说明声称该方式"已纳入生育力预测"——然而用 grep 搜索 .reliability,恰好只有四处,四处全是屏幕输出。没有任何一个预测计算读取过这个字段。
解决方案不是加免责声明来弱化,而是整体删除:那一行、颜色指示(99% 上的绿色读起来像一条建议——"好选择")、以及六个语言区域的本地化键。放在无来源百分比旁边的免责声明修不好这个问题——它只是把一个没有附加条件的功能,变成一个带着与之自相矛盾的附加条件的功能。
旁边还有同样性质、但更简单的数字。营养摘要按 todaySummary.calories × 周期天数 计算:吃过东西的一天被外推到整个区间,而 daysTracked 永远等于周期的天数,哪怕根本没有录入任何食物。
类似的算术还藏在今天开始的疗程的服药依从性里:日期差"原样"相减得到 0 天 → 分母里是 0%,而代码下方的统计对同样的天数已经按 +1 计算——同一个指标的两个分母相差一天。这个缺陷不是测试发现的,而是在为网站整理截图时发现的。
审计之后留下的规则#
所有修复做完之后,我手里留下了几条如今不假思索就会套用的规则。nil 比刻度中间值更诚实:数据不足意味着没有评估,而不是一个被压在 50 附近的评估。因子的中性值和"我们不知道"是两回事,它们在屏幕上必须呈现得不一样。逐名列举的 blocklist 只能拦住写它时想起来的东西——信号家族连没想起来的也能拦住。而每一个复合分数都必须知道其输入数据的时效和权重覆盖率,否则"刚刚"和"一周前"对它来说无法区分。
而或许最让人不自在的一点:其中一部分不是靠测试发现的,而是靠翻看截图和手腕上没戴手表的真实使用发现的。测试验证的是代码按规格行事——却不验证规格本身是否在说谎。
这只是已发现并修复内容的一个切面,而不是完整的审计。我会继续寻找还有哪些地方,"至少显示点什么"悄悄取代了"显示真实存在的东西"。



