La pantalla «Hoy» de MeteoHealth muestra una batería de energía: un número de 0 a 100, una escala, una flecha de tendencia. Durante varios meses ese número tembló ante los ojos del usuario en ±10 aleatorios — Double.random(in: -10...10), con un comentario en el código que decía «para dar realismo». El código que hay detrás de un número nadie lo ve: el usuario ve el número y hace lo único razonable que se puede hacer con un número — creérselo. Durante varias semanas de revisión de MeteoHealth apareció toda una serie de lugares así, donde el número mostrado no tenía nada que ver con lo que supuestamente medía. Lo que sigue no es una reseña de la app (la ficha del proyecto está aquí: MeteoHealth), sino un desglose de números falsos concretos, commit a commit. La auditoría no está cerrada: esto es lo que se ha encontrado hasta hoy.
Energía salida de un generador de números aleatorios#
La mentira más literal era también la más simple en código. TodayViewModel.recalculateEnergy() calculaba la batería de energía así:
private func recalculateEnergy() {
let recentEntries = AppEnvironment.bootstrap.dataStore.fetchRecentMoodEntries(limit: 5)
if !recentEntries.isEmpty {
// El wellness medio influye en la energía
let avgWellness = recentEntries.reduce(0.0) { $0 + Double($1.wellnessLevel) } / Double(recentEntries.count)
let baseEnergy = (avgWellness / 4.0) * 100
// Añadimos variación aleatoria para dar realismo
let variation = Double.random(in: -10...10)
currentEnergyPercentage = Int(max(0, min(100, baseEnergy + variation)))
}
}«Para dar realismo» es un comentario del código fuente, no ironía a posteriori: el número aleatorio se añadía para que la batería no pareciera estática entre actualizaciones. Mientras tanto, EnergyCalculator llevaba un año viviendo en la base de código — 14 factores, pesos, imputación, confidence — y no se llamaba desde la app ni una sola vez; solo funcionaba la entrada manual. El calculador de verdad existía y callaba mientras la pantalla mostraba ruido.
El arreglo no fue mejorar la fórmula, sino conectar la fuente que ya existía:
@discardableResult
func refreshEnergy() async -> EnergyLevel {
let energy = await getBlendedEnergy()
await persistToHistoryIfNeeded(energy)
return energy
}Se eliminaron tres implementaciones paralelas del cálculo de energía dentro de TodayViewModel. La escritura en el historial se limitó a una vez por hora: el historial alimenta el cálculo de la norma personal, y un punto por cada apertura habría sesgado el rango personal a favor de los días activos.
La energía resultó no ser la única puntuación que se calculaba de forma deshonesta. Recovery y training readiness tenían su propia versión, mucho más silenciosa, de la misma enfermedad.
Base 50: cómo «no hay datos» se hace pasar por el punto medio#
Tres puntuaciones compuestas — recovery, training readiness y la estimación de energía para analítica — se calculaban con la misma plantilla: partir de un valor base de 50 y sumar deltas ponderadas de cada factor disponible.
func calculateRecoveryScore(hrv: Double?, restingHR: Double?) -> Double? {
var score: Double = 50 // Valor base
var factors: Int = 0
if let currentHRV = hrv, hrvBaseline7Days > 0 {
score += (hrvScore - 50) * 0.6 // peso del factor HRV
factors += 1
}
// Resting HR — mismo patrón, peso 0.4
guard factors > 0 else { return nil }
return max(0, min(100, score))
}Con un factor de dos, el resultado matemáticamente no podía salir del rango 40–60: la delta se ponderaba por la cuota de ese factor (0.6 o 0.4), no por 1.0. El número se asentaba cerca del centro y se leía como un punto medio medido, no como «no sabemos casi nada».
El arreglo: normalizar por la suma de los pesos de los factores presentes, no por la unidad completa, con un umbral de cobertura de 0.5:
guard coveredWeight >= Self.minimumWeightCoverage else { return nil }
return max(0, min(100, weightedSum / coveredWeight))Por debajo del umbral, la puntuación no se muestra en absoluto — aquí nil no es un bug, sino el resultado: un espacio vacío es más honesto que el centro de la escala. La misma técnica se aplicó a la estimación de energía en la analítica.
Un «hace un momento» con una semana de antigüedad#
Esta no apareció leyendo código, sino en un dispositivo real: el propietario no llevó el reloj durante todo un día y vio «50 de 100» con una leyenda que sugería que se acababa de calcular.
La causa: StressScoreService tomaba samples.first?.sdnn sin comprobar la edad de la muestra: first devolvía la última medición conocida de VFC, aunque tuviera varios días, mientras la leyenda de al lado mostraba la hora del recálculo, no la de la medición.
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
}Aparecieron los estados .staleData y .learningBaseline. El umbral de madurez del baseline subió de 3 samples a 7: con tres puntos, la desviación estándar de ln(SDNN) — el denominador del z-score — no se estima, la estima el ruido; Garmin, Oura y Whoop construyen esa norma sobre 7–28 días.
Por separado se elevó el suelo de la SD personal de 0.1 a 0.15: el suelo anterior, con una VFC plana, clavaba el estrés en 100 de 100, cuando la dispersión diaria de SDNN en personas sanas es del 20–30%, en escala logarítmica ≈0.18–0.26.
El tiempo no es la única suposición oculta en las puntuaciones compuestas. La pantalla de desglose de energía resultó tener una enfermedad parecida, pero distinta: confundía «desconocido» con «neutro».
Un desglose sobre pesos que no existen#
La pantalla de desglose de energía («qué ha influido») estaba escrita desde junio y vivía solo en tests de snapshot — código muerto. Al conectarla, afloró un segundo problema: el desglose se construía con los pesos de la primera versión de la fórmula, mientras que el número llevaba ya un mes calculándose con la segunda. La pantalla habría explicado el resultado con pesos con los que no se había obtenido.
La solución no fue retocar los pesos en dos sitios, sino eliminar la duplicación: un registro único, EnergyFactorKind, del que tanto la fórmula como el desglose toman pesos y valores neutros.
/// Para los factores «de nivel» (sueño, VFC) el neutro es el centro de la escala;
/// para los factores «impact» (clima, recuperación tras el entrenamiento) es
/// «nada estorba», es decir, más cerca de 1.0.
var neutralValue: Double {
switch self {
case .workoutRecovery, .weatherImpact: return 1.0
case .restingHeartRate, .currentHeartRate, .cyclePhase: return 0.75
default: return 0.5
}
}Antes, un factor ausente recibía en silencio 0.5 — «el punto medio». Pero para el clima, 0.5 se lee como «influencia moderadamente mala», cuando en realidad significa «no sabemos qué clima hace». El factor ganó un campo isImputed, y los factores impact, su propio neutro: «normal» y «desconocido» dejaron de ser la misma cifra. Los factores imputados quedaron excluidos de la lista de «drivers»: llamar causa a lo desconocido habría sido otra invención encima de la que ya se había limpiado.
El desglose de energía al menos mostraba factores reales, aunque con los pesos confundidos. El motor de correlaciones, en ese mismo período, se las arregló para encontrar una relación donde físicamente no podía existir.
La correlación de una serie consigo misma#
El motor de correlaciones busca relaciones entre señales de bienestar. Con datos de demo, la única «relación fuerte» que encontró fue «Media de 7 días ↔ Estado de ánimo»: el motor mostró al usuario la relación de una serie consigo misma y la llamó el insight principal.
La causa estaba en el propio enfoque de filtrado de tautologías: la blocklist nominal conocía la pareja wellnessAvg7d ↔ wellness, pero no wellnessAvg7d ↔ mood, aunque es literalmente la misma serie: 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) }
}Una lista nominal atrapa solo las tautologías que recordaste al escribirla; una familia de señales atrapa de golpe todas las derivadas de una misma fuente. De paso, de keyPairs salieron 7 parejas tautológicas y 6 duplicados del tipo «X ↔ mood» donde ya existía la pareja «X ↔ wellness»: duplicaban el conteo de una misma relación, quitando sitio en el top y encareciendo la corrección de Benjamini-Hochberg para las relaciones reales de la lista.
Sobre la estadística bajo el capó del motor hablo aparte, en el artículo sobre estadística honesta on-device; aquí lo importante es otra cosa: incluso un aparato estadístico correcto se rompe si le das como entrada la misma variable dos veces con nombres distintos.
A veces un número erróneo no es en absoluto el resultado de un cálculo — simplemente está sacado de la nada e insertado directamente en la interfaz.
Los números sin fuente se eliminan, no se suavizan#
La pantalla de ajustes del ciclo mostraba la «Fiabilidad» de la anticoncepción en porcentajes: 87 / 93 / 96 / 99 / 78. Los números estaban hardcodeados sin fuente, no distinguían entre uso típico y uso perfecto y metían el DIU hormonal y el de cobre en un mismo valor. La leyenda, además, afirmaba que el método «se tiene en cuenta en las predicciones de fertilidad» — aunque un grep de .reliability dio exactamente cuatro lugares, y los cuatro eran salida a pantalla. Ningún cálculo de predicción leía ese campo.
La solución no fue suavizarlo con un disclaimer, sino eliminarlo por completo: la fila, la indicación por color (el verde en el 99% se leía como un consejo — «buena elección») y la clave de localización en seis locales. Un disclaimer junto a un porcentaje sin fuente no arregla el problema: convierte una función sin reservas en una función con una reserva que la contradice.
Al lado, números de la misma naturaleza, más simples. El resumen de nutrición se calculaba como todaySummary.calories × días_del_período: un solo día con comida registrada se extrapolaba a todo el rango, y daysTracked siempre era igual al número de días del período, aunque no se hubiera registrado comida en absoluto.
Una aritmética parecida se escondía en la adherencia a la medicación para un tratamiento empezado hoy: la diferencia de fechas «tal cual» daba 0 días → 0% en el denominador, mientras que la estadística más abajo en el código contaba esos mismos días ya con +1 — dos denominadores de una misma métrica divergían en un día. Ese defecto no lo encontraron los tests, sino la revisión de capturas de pantalla para el sitio web.
Las reglas que quedaron tras la auditoría#
Después de todas las correcciones me quedaron unas cuantas reglas que ahora aplico sin pensarlo. nil es más honesto que el centro de la escala: si no hay datos suficientes, no hay puntuación, en lugar de una puntuación pegada al 50. El valor neutro de un factor y «no lo sabemos» son cosas distintas, y en pantalla están obligadas a verse distintas. Una blocklist nominal solo atrapa lo que recordaste al escribirla — las familias de señales atrapan también lo que no recordaste. Y toda puntuación compuesta está obligada a conocer la edad de sus datos de entrada y la cuota de cobertura de los pesos; si no, «hace un momento» y «hace una semana» le resultan indistinguibles.
Y quizá lo más incómodo: parte de esto no lo encontraron los tests, sino la revisión de capturas y el uso real sin reloj en la muñeca. Los tests comprueban que el código hace lo que dicta la especificación — pero no comprueban si la propia especificación miente.
Esto es un corte de lo ya encontrado y corregido, no una auditoría completa. Sigo buscando dónde más «mostrar al menos algo» ha sustituido a «mostrar lo que hay en realidad».



