The Today screen in MeteoHealth shows an energy battery — a number from 0 to 100, a scale, a trend arrow. For several months that number jittered by a random ±10 right in front of the user — Double.random(in: -10...10), with a comment in the code that said "for realism." Nobody sees the code behind a number: the user sees the number and does the only reasonable thing you can do with a number — they trust it. Over several weeks of digging through MeteoHealth, I found a whole series of places like that, where the number shown had nothing to do with what it claimed to measure. What follows is not an app overview (the project card is here: MeteoHealth) but a breakdown of specific false numbers, commit by commit. The audit isn't finished — this is what has turned up so far.
Energy from a random number generator#
The most literal lie was also the simplest in code. TodayViewModel.recalculateEnergy() computed the energy battery like this:
private func recalculateEnergy() {
let recentEntries = AppEnvironment.bootstrap.dataStore.fetchRecentMoodEntries(limit: 5)
if !recentEntries.isEmpty {
// Average wellness influences energy
let avgWellness = recentEntries.reduce(0.0) { $0 + Double($1.wellnessLevel) } / Double(recentEntries.count)
let baseEnergy = (avgWellness / 4.0) * 100
// Add random variation for realism
let variation = Double.random(in: -10...10)
currentEnergyPercentage = Int(max(0, min(100, baseEnergy + variation)))
}
}"For realism" is a comment from the source, not irony added after the fact: the random number was there so the battery wouldn't look static between refreshes. Meanwhile, EnergyCalculator had been sitting in the codebase for a year — 14 factors, weights, imputation, confidence — and was never called from the app once; only manual entry worked. The real calculator existed and stayed silent while the screen showed noise.
The fix wasn't to improve the formula, but to wire up the source that already existed:
@discardableResult
func refreshEnergy() async -> EnergyLevel {
let energy = await getBlendedEnergy()
await persistToHistoryIfNeeded(energy)
return energy
}Three parallel implementations of the energy calculation inside TodayViewModel were deleted. Writing to history is limited to once per hour: the history feeds the personal-baseline calculation, and a data point on every launch would have skewed the personal range toward active days.
Energy turned out not to be the only score computed dishonestly. Recovery and training readiness had their own, much quieter strain of the same disease.
Baseline 50: how "no data" impersonates the middle#
Three composite scores — recovery, training readiness, and the energy estimate for analytics — followed the same template: start from a base value of 50 and add weighted deltas from each available factor.
func calculateRecoveryScore(hrv: Double?, restingHR: Double?) -> Double? {
var score: Double = 50 // Base value
var factors: Int = 0
if let currentHRV = hrv, hrvBaseline7Days > 0 {
score += (hrvScore - 50) * 0.6 // HRV factor weight
factors += 1
}
// Resting HR — same pattern, weight 0.4
guard factors > 0 else { return nil }
return max(0, min(100, score))
}With one factor out of two, the result mathematically couldn't leave the 40–60 range: the delta was weighted by that factor's share (0.6 or 0.4), not by 1.0. The number settled near the center and read as a measured middle, not as "we know almost nothing."
The fix is to normalize by the sum of the weights of the factors that are present, not by a full 1.0, with a coverage threshold of 0.5:
guard coveredWeight >= Self.minimumWeightCoverage else { return nil }
return max(0, min(100, weightedSum / coveredWeight))Below the threshold, the score isn't shown at all — nil here is not a bug but the result: an empty space is more honest than the middle of the scale. The same technique was applied to the energy estimate in analytics.
"Just now," a week old#
This one wasn't found by reading code, but on a live device: the owner didn't wear a watch for an entire day and saw "50 out of 100" with a caption implying it had just been computed.
The cause — StressScoreService took samples.first?.sdnn without checking the sample's age: first returned the last known HRV reading, even one several days old, while the caption next to it showed the time of the recomputation, not the time of the measurement.
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
}The states .staleData and .learningBaseline were introduced. The baseline maturity threshold was raised from 3 samples to 7: on three data points, the standard deviation of ln(SDNN) — the denominator of the z-score — isn't estimated, it's estimated by noise; Garmin, Oura, and Whoop build that kind of baseline on 7–28 days.
Separately, the floor on the personal SD was raised from 0.1 to 0.15: the old floor, on a flat HRV series, pinned stress at 100 out of 100, even though the day-to-day spread of SDNN in healthy people is 20–30%, which on a logarithmic scale is ≈0.18–0.26.
Time isn't the only hidden assumption in composite scores. The energy breakdown screen turned out to have a similar but separate ailment: it confused "unknown" with "neutral."
A breakdown built on weights that don't exist#
The energy breakdown screen ("what influenced it") had been written back in June and lived only in snapshot tests — dead code. When it was finally wired up, a second problem surfaced: the breakdown was built on the weights of the formula's first version, while the number had already been computed by the second version for a month. The screen would have explained a result using weights it was not produced by.
The solution was not to patch the weights in two places, but to remove the duplication: a single registry, EnergyFactorKind, from which both the formula and the breakdown take their weights and neutral values.
/// For "level" factors (sleep, HRV) the neutral is the middle of the scale; for
/// "impact" factors (weather, post-workout recovery) it's "nothing is
/// interfering," i.e. closer to 1.0.
var neutralValue: Double {
switch self {
case .workoutRecovery, .weatherImpact: return 1.0
case .restingHeartRate, .currentHeartRate, .cyclePhase: return 0.75
default: return 0.5
}
}Previously, a missing factor silently received 0.5 — "the middle." But for weather, 0.5 reads as "a moderately bad influence," when it actually means "we don't know what the weather is." Factors gained an isImputed field, and impact factors got their own neutral: "normal" and "unknown" stopped being the same digit. Imputed factors are excluded from the list of "drivers" — calling the unknown a cause would have been a separate fabrication on top of the one already cleaned up.
The energy breakdown at least showed real factors, even if with the wrong weights. The correlation engine, over the same period, managed to find a relationship where one physically could not exist.
A series correlated with itself#
The correlation engine looks for relationships between well-being signals. On demo data, the only "strong relationship" it found was "7-day average ↔ Mood" — the engine showed the user a series correlated with itself and called it the key insight.
The cause lay in the very approach to filtering tautologies: a name-by-name blocklist knew the pair wellnessAvg7d ↔ wellness but not wellnessAvg7d ↔ mood, even though it's literally the same series: 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) }
}A name-by-name list catches only the tautologies you remembered while writing it; a signal family catches every derivative of one source at once. Along the way, 7 tautological pairs and 6 duplicates of the form "X ↔ mood" where an "X ↔ wellness" pair already existed were removed from keyPairs — they double-counted the same relationship, crowding out spots in the top list and inflating the Benjamini-Hochberg correction for the real relationships in it.
The statistics under the engine's hood are covered separately in the article on honest on-device statistics; what matters here is different: even a correct statistical apparatus breaks if you feed it the same variable twice under different names.
Sometimes a wrong number isn't the result of a computation at all — it's simply pulled out of thin air and pasted straight into the interface.
Numbers with no source get deleted, not softened#
The cycle settings screen showed contraception "Reliability" in percentages: 87 / 93 / 96 / 99 / 78. The numbers were hardcoded with no source, made no distinction between typical and perfect use, and lumped hormonal and copper IUDs into one value. Meanwhile, the caption claimed the method was "factored into fertility predictions" — yet grep for .reliability returned exactly four places, and all four were screen output. Not a single prediction calculation read that field.
The solution was not to soften it with a disclaimer but to remove it entirely: the row, the color indication (green at 99% read as advice — "a good choice"), and the localization key across six locales. A disclaimer next to a sourceless percentage doesn't fix the problem — it turns a feature without a caveat into a feature with a caveat that contradicts it.
Nearby sat numbers of the same nature, only simpler. The nutrition summary was computed as todaySummary.calories × days_in_period: one day of logged food was extrapolated across the whole range, and daysTracked always equaled the number of days in the period, even if no food had been logged at all.
Similar arithmetic hid in medication adherence for a course started today: the raw date difference gave 0 days → 0% in the denominator, while the statistics further down the code counted the same days with a +1 — two denominators of one metric diverged by a day. That defect was found not by tests but while reviewing screenshots for the website.
The Rules That Stayed After the Audit#
After all the fixes, I was left with a handful of rules I now apply without thinking. nil is more honest than the middle of the scale: not enough data means there is no score, not a score pinned near 50. A factor's neutral value and "we don't know" are different things, and they must look different on screen. A name-by-name blocklist only catches what you remembered while writing it — signal families also catch what you didn't. And every composite score must know the age of its input data and its weight coverage share, otherwise "just now" and "a week ago" are indistinguishable to it.
And perhaps the most uncomfortable part: some of this was found not by tests, but by reviewing screenshots and by real use with no watch on the wrist. Tests verify that the code does what the spec says it should — they don't verify whether the spec itself is lying.
This is a snapshot of what has already been found and fixed, not a complete audit. I keep looking for places where "show at least something" has quietly replaced "show what is actually there."



