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 が1年前から存在していた——14 の因子、重み、補完、confidence——そしてアプリから一度も呼ばれていなかった。動いていたのは手動入力だけ。本物の計算機は存在しながら沈黙し、画面はノイズを表示していた。
修正は数式の改良ではなく、すでに存在していたソースを接続することだった:
@discardableResult
func refreshEnergy() async -> EnergyLevel {
let energy = await getBlendedEnergy()
await persistToHistoryIfNeeded(energy)
return energy
}TodayViewModel 内に並存していた3つのエネルギー計算実装は削除された。履歴への書き込みは1時間に1回に制限した。履歴は個人ノルムの計算に使われるため、起動のたびに点を打てば、個人レンジがアクティブな日に偏って歪んでしまうからだ。
不誠実に計算されていたスコアは、エネルギーだけではなかった。recovery と training readiness には、同じ病のはるかに静かなバージョンがあった。
ベース値 50:「データなし」が中間値になりすます仕組み#
3つの複合スコア——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))
}2因子のうち1つしかない場合、結果は数学的に 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 はバグではなく結果だ。空欄はスケールの中間値より誠実である。同じ手法を分析側のエネルギー推定にも適用した。
1週間前の「たった今」#
これはコードを読んで見つけたのではなく、実機で見つかった。オーナーが丸一日ウォッチを着けずに過ごしたところ、たった今計算されたかのようなキャプション付きの「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 へ引き上げた。3点では z スコアの分母である ln(SDNN) の標準偏差は推定できず、ノイズが推定されるだけだ。Garmin、Oura、Whoop はこの種のノルムを 7–28 日で構築している。
別途、個人 SD の下限を 0.1 から 0.15 に引き上げた。旧来の下限では、HRV が平坦なときにストレスが 100 点中 100 に張り付いていた。しかし健康な人の SDNN の日内変動は 20–30% で、対数スケールでは ≈0.18–0.26 なのだ。
複合スコアに隠れていた暗黙の前提は、時間だけではない。エネルギーの内訳画面には、似ているが別の病が見つかった。「不明」と「中立」を混同していたのだ。
存在しない重みによる内訳表示#
エネルギーの内訳画面(「何が影響したか」)は6月に書かれ、スナップショットテストの中だけで生きていた——デッドコードだった。接続してみると、第二の問題が露呈した。内訳は数式の第1バージョンの重みで構築されていたが、数値自体はすでに1か月間、第2バージョンで計算されていたのだ。画面は、その結果を生んでいない重みで結果を説明することになっていた。
解決策は2箇所の重みを直すことではなく、重複を排除すること。単一のレジストリ 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組と、すでに「X ↔ wellness」ペアがある箇所での「X ↔ mood」型の重複6組を削除した。これらは同一の関係を二重にカウントし、トップの枠を奪い、リスト内の本物の関係に対する Benjamini-Hochberg 補正を重くしていた。
エンジンの内部統計そのものについては別稿、オンデバイスの誠実な統計についての記事で扱っている。ここで重要なのは別のことだ。統計装置がいかに正しくても、同じ変数を別名で二度入力すれば壊れる。
そして時には、誤った数値はそもそも計算の結果ですらない——どこからともなく取ってきて、インターフェースに直接貼り付けただけのものだ。
出典のない数値は削除する、和らげるのではなく#
周期設定画面には避妊法の「信頼性」がパーセントで表示されていた:87 / 93 / 96 / 99 / 78。数値は出典なしでハードコードされ、典型的使用と理想的使用を区別せず、ホルモン IUS と銅 IUD を一つの値に押し込めていた。しかもキャプションは、その方法が「妊孕性予測に考慮される」と主張していた——ところが .reliability を grep すると該当箇所はちょうど4つで、4つとも画面出力だった。予測計算はどれ一つ、このフィールドを読んでいなかった。
解決策は免責文で和らげることではなく、丸ごと削除すること。行、色による表示(99% の緑は「良い選択」という助言に読める)、6ロケール分のローカライズキーを削除した。出典なきパーセントの横に置かれた免責文は問題を直さない——それは但し書きのない機能を、自らと矛盾する但し書き付きの機能に変えるだけだ。
その隣には、同じ性質の、もっと単純な数値があった。栄養サマリーは todaySummary.calories × 期間の日数 として計算されていた。食事を記録した1日が全範囲に外挿され、daysTracked は食事をまったく記録していなくても常に期間の日数と等しかった。
似たような算術が、今日開始した服薬コースの遵守率にも隠れていた。日付の差を「そのまま」取ると 0 日 → 分母が 0% になる一方、コードの下方にある統計は同じ日数を +1 付きで数えていた——一つの指標の二つの分母が1日ずれていたのだ。この欠陥はテストではなく、サイト用スクリーンショットの確認中に見つかった。
監査のあとに残ったルール#
すべての修正を終えたあと、手元にはいくつかのルールが残り、いまでは考えるまでもなく適用している。nil はスケールの中間値より誠実だ——データが足りないなら評価は「ない」のであって、50 付近に押し込められた評価ではない。因子の中立値と「わからない」は別物であり、画面上でも違って見えなければならない。名前ベースの blocklist は書いた時点で思い出せたものしか捕まえない——シグナルファミリーは思い出せなかったものまで捕まえる。そして、すべての複合スコアは入力データの鮮度と重みのカバレッジ率を知る義務がある。さもなければ「たった今」と「1週間前」の区別がつかない。
そしておそらく最も居心地の悪い事実:この一部はテストではなく、スクリーンショットの確認と、腕にウォッチのない実使用で見つかった。テストはコードが仕様どおりに動くことを検証するが、仕様そのものが嘘をついていないかは検証しない。
これは、すでに見つかり修正されたものの断面であって、完全な監査ではない。「とにかく何かを表示する」が「実際にあるものを表示する」にすり替わっている場所を、これからも探し続ける。



