Modelando Depleción No Lineal con Polinomio Cúbico y Búsqueda por Bisección en Swift
Introducción
Estás construyendo un rastreador de hábitos. Cada hábito tiene un "nivel" que se llena cuando el usuario hace check-in y se agota con el tiempo. El enfoque obvio — depleción lineal — crea un problema: un nivel al 80% se siente igual que uno al 20%. No hay urgencia, ninguna presión visual para actuar antes de que sea demasiado tarde.
Lo que quieres es una curva que se agote lentamente al principio y acelere al final. El usuario ve su nivel mantenerse estable por un tiempo, luego observa cómo drena más rápido conforme se acerca la fecha límite. Esa presión psicológica es exactamente lo que hace que las apps de hábitos enganchen.
Un polinomio cúbico nos da esta forma con una línea de matemáticas. Y cuando necesitamos la inversa — "si quiero este nivel al 60%, ¿cuánto tiempo ha pasado?" — la búsqueda por bisección encuentra la respuesta en 32 iteraciones con precisión de mil millonésimas. Todo cabe en 68 líneas de Swift.
¿Por Qué No Lineal?
La depleción lineal mapea el tiempo transcurrido directamente al nivel: nivel = 1 - tiempo. La gráfica es una línea recta de 100% a 0%.
Nivel
1.0 ┤╲
│ ╲ Lineal: tasa constante
0.5 ┤ ╲ Sin urgencia en ningún punto
│ ╲
0.0 ┤ ╲
└──────────
0 0.5 1.0
Tiempo →El problema: cada momento se siente igualmente importante. Los usuarios no perciben una fecha límite porque el feedback visual es uniforme. No existe el momento de "está cayendo rápido, necesito actuar ahora".
Compara con una curva pronunciada al final:
Nivel
1.0 ┤──╲
│ ╲ Cúbica: inicio lento, final rápido
0.5 ┤ ╲ La urgencia se construye visualmente
│ ╲╲
0.0 ┤ ╲╲__
└──────────────
0 0.5 1.0
Tiempo →La curva se mantiene estable en la fase temprana, luego acelera. Los usuarios se sienten seguros al 80% pero ansiosos al 30% — exactamente el empujón conductual que un rastreador de hábitos necesita.
El Polinomio Cúbico
Nuestra función de depleción es un polinomio cúbico evaluado sobre tiempo normalizado t ∈ [0, 1]:
f(t) = at³ + bt² + ct + dDonde:
t = 0→ nivel es 1.0 (recién llenado)t = 1→ nivel es 0.0 (totalmente agotado)- La curva es monótonamente decreciente (el nivel nunca sube solo)
- La pendiente es más pronunciada cerca de
t = 1(urgencia al final)
Los coeficientes que producen esta forma:
private static let coefficients = (
a: -0.131_963_982_801_121,
b: -0.093_028_201_856_207_9,
c: -0.775_007_815_342_671,
d: 1.0,
)No son arbitrarios — satisfacen las condiciones de contorno f(0) = 1 y f(1) = 0 mientras producen una curva que "aguanta" en la fase inicial y cae abruptamente al final. El término d = 1.0 asegura que el nivel empiece al 100%. Los coeficientes negativos aseguran que la curva decrezca monótonamente.
TIP
Puedes verificar las condiciones de contorno: f(0) = d = 1.0 y f(1) = a + b + c + d = -0.132 - 0.093 - 0.775 + 1.0 ≈ 0.0. El pequeño error de redondeo es despreciable a la precisión de pantalla.
Método de Horner: Evaluación Eficiente
La forma ingenua de evaluar at³ + bt² + ct + d requiere 6 multiplicaciones (calculando t², t³, luego multiplicando por coeficientes). El método de Horner reescribe el polinomio en forma anidada:
f(t) = ((a·t + b)·t + c)·t + dEsto usa solo 3 multiplicaciones y 3 sumas — la mitad de operaciones:
public static func level(forNormalizedElapsed normalizedElapsed: Double) -> Double {
let clamped = max(0.0, min(1.0, normalizedElapsed))
let (a, b, c, d) = coefficients
return ((a * clamped + b) * clamped + c) * clamped + d
}El clamp max(0, min(1, ...)) garantiza que la entrada quede en rango. Valores fuera de [0, 1] producirían resultados extrapolados — un nivel negativo o arriba de 100% — lo cual no tendría sentido en la UI.
TIP
El método de Horner no es solo más rápido — también es más numéricamente estable. Cada resultado intermedio se mantiene más cercano a la magnitud final, reduciendo errores de redondeo de punto flotante que se acumulan cuando calculas potencias grandes por separado.
El Problema Inverso: Búsqueda por Bisección
La función directa responde: "dado el tiempo transcurrido, ¿cuál es el nivel?" Pero también necesitamos lo inverso: "dado un nivel objetivo, ¿cuánto tiempo ha pasado?"
Esto surge cuando los usuarios ajustan manualmente su nivel. Si alguien arrastra un slider al 60%, necesitamos calcular qué lastRefillDate produciría ese nivel — efectivamente corriendo el reloj hacia atrás.
Invertir analíticamente un polinomio cúbico es posible (fórmula de Cardano) pero complicado y numéricamente frágil. La búsqueda por bisección es más simple, robusta y suficientemente rápida:
public static func normalizedElapsed(forLevel targetLevel: Double) -> Double {
let clampedLevel = max(0.0, min(1.0, targetLevel))
if clampedLevel >= 0.999_999 { return 0.0 }
if clampedLevel <= 0.000_001 { return 1.0 }
var lower = 0.0
var upper = 1.0
var mid = 0.0
for _ in 0 ..< maxIterations {
mid = (lower + upper) * 0.5
let value = level(forNormalizedElapsed: mid)
if value > clampedLevel {
lower = mid
} else {
upper = mid
}
}
return (lower + upper) * 0.5
}Cómo Funciona
El algoritmo explota el hecho de que nuestra curva es monótonamente decreciente: conforme el tiempo aumenta, el nivel disminuye. Esto significa que para cualquier nivel objetivo, existe exactamente un valor de tiempo que lo produce.
- Empieza con el rango completo
[0, 1] - Verifica el punto medio: evalúa
f(mid) - Si
f(mid) > objetivo, la respuesta está a la derecha (más tiempo necesario) → restringe a[mid, upper] - Si
f(mid) ≤ objetivo, la respuesta está a la izquierda → restringe a[lower, mid] - Repite 32 veces
Cada iteración divide el intervalo de búsqueda a la mitad. Después de 32 iteraciones, el intervalo es 1 / 2³² ≈ 2.3 × 10⁻¹⁰ — precisión de sub-nanosegundo en tiempo normalizado. Para un período de depleción de 7 días, eso es precisión dentro de 0.00001 segundos.
Casos Extremos
Los dos retornos tempranos manejan valores de contorno:
if clampedLevel >= 0.999_999 { return 0.0 } // "100% lleno" → no ha pasado tiempo
if clampedLevel <= 0.000_001 { return 1.0 } // "0% lleno" → totalmente transcurridoSin ellos, la bisección convergiría pero desperdiciaría iteraciones en valores donde la respuesta es obvia. Los valores epsilon (0.999_999 y 0.000_001) evitan problemas de igualdad de punto flotante en los límites exactos.
WARNING
La bisección requiere que la función sea monotónica sobre el intervalo de búsqueda. Si tu curva tiene mínimos o máximos locales (como una onda senoidal), la bisección puede converger a la raíz incorrecta. Para nuestro cúbico monótonamente decreciente, esto no es una preocupación.
Conectando al Tiempo Real
El polinomio opera en tiempo normalizado t ∈ [0, 1], pero la app maneja fechas y días. La conversión es directa:
public static let secondsInDay: Double = 86400.0
// Directo: Fecha → Nivel
func currentLevel(at date: Date = Date()) -> Double {
let elapsed = max(0.0, date.timeIntervalSince(lastRefillDate))
let totalDuration = Double(depletionDays) * DepletionCurve.secondsInDay
let normalizedElapsed = min(1.0, elapsed / totalDuration)
return DepletionCurve.level(forNormalizedElapsed: normalizedElapsed)
}
// Inverso: Nivel → Fecha
func setCurrentFill(_ fill: Double) {
let clamped = max(0.0, min(1.0, fill))
let normalizedElapsed = DepletionCurve.normalizedElapsed(forLevel: clamped)
let seconds = normalizedElapsed * Double(depletionDays) * DepletionCurve.secondsInDay
lastRefillDate = Date().addingTimeInterval(-seconds)
}El camino directo normaliza los segundos reales transcurridos en [0, 1], evalúa el polinomio y obtiene un porcentaje de llenado. El camino inverso toma un porcentaje, encuentra el tiempo normalizado vía bisección, convierte de vuelta a segundos y establece la fecha de recarga esos segundos en el pasado.
Este patrón de "ajustar el pasado para corresponder al presente" es elegante — en vez de almacenar un campo separado de "nivel actual" que necesita sincronización, derivamos el nivel enteramente de lastRefillDate y depletionDays. El polinomio es la única fuente de verdad.
Uso en Widgets
La misma curva funciona en extensiones de widget, donde necesitas precalcular valores de visualización desde datos de snapshot:
let elapsed = now.timeIntervalSince(snapshot.lastRefillDate)
let totalDuration = Double(snapshot.depletionDays) * DepletionCurve.secondsInDay
let normalizedElapsed = min(1.0, elapsed / totalDuration)
let currentLevel = DepletionCurve.level(forNormalizedElapsed: normalizedElapsed)
let percentage = max(0, min(100, Int((currentLevel * 100).rounded())))Como DepletionCurve es una función pura sin estado ni dependencias, puede compartirse entre el target principal del app y la extensión de widget sin configuración adicional — solo incluye el archivo en ambos targets.
La Implementación Completa
Aquí está el enum DepletionCurve completo — 68 líneas, sin dependencias más allá de Foundation:
import Foundation
public enum DepletionCurve {
public static let secondsInDay: Double = 86400.0
private static let coefficients = (
a: -0.131_963_982_801_121,
b: -0.093_028_201_856_207_9,
c: -0.775_007_815_342_671,
d: 1.0,
)
private static let maxIterations = 32
/// Directo: tiempo normalizado → nivel de llenado
public static func level(
forNormalizedElapsed normalizedElapsed: Double
) -> Double {
let clamped = max(0.0, min(1.0, normalizedElapsed))
let (a, b, c, d) = coefficients
return ((a * clamped + b) * clamped + c) * clamped + d
}
/// Inverso: nivel de llenado → tiempo normalizado (búsqueda por bisección)
public static func normalizedElapsed(
forLevel targetLevel: Double
) -> Double {
let clampedLevel = max(0.0, min(1.0, targetLevel))
if clampedLevel >= 0.999_999 { return 0.0 }
if clampedLevel <= 0.000_001 { return 1.0 }
var lower = 0.0
var upper = 1.0
var mid = 0.0
for _ in 0 ..< maxIterations {
mid = (lower + upper) * 0.5
let value = level(forNormalizedElapsed: mid)
if value > clampedLevel {
lower = mid
} else {
upper = mid
}
}
return (lower + upper) * 0.5
}
}Un enum sin cases es el patrón estándar de Swift para un namespace — no puede ser instanciado, comunicando claramente que es una colección de funciones puras, no un objeto con estado.
Adaptando la Curva
Diferentes aplicaciones pueden necesitar formas de curva distintas. Puedes ajustar los coeficientes manteniendo las condiciones de contorno f(0) = 1 y f(1) = 0:
Como f(0) = d, siempre necesitamos d = 1.0. Y como f(1) = a + b + c + d = 0, necesitamos a + b + c = -1. Esto nos da dos parámetros libres.
| Forma de Curva | a | b | c | Efecto |
|---|---|---|---|---|
| Actual | -0.132 | -0.093 | -0.775 | Inicio lento, final pronunciado |
| Más lineal | 0.0 | 0.0 | -1.0 | Línea recta |
| Final muy pronunciado | -0.5 | 0.0 | -0.5 | Aguanta más, cae más rápido |
| Inicio pronunciado | 0.5 | 0.0 | -1.5 | Cae rápido, luego desacelera |
TIP
Para visualizar diferentes coeficientes, grafica ((a*t + b)*t + c)*t + 1 para t ∈ [0, 1] en cualquier herramienta gráfica. Mientras a + b + c = -1, la curva empezará en 1 y terminará en 0.
Conclusión
Esta implementación de 68 líneas demuestra tres técnicas que se combinan en algo mayor que la suma de sus partes:
- Polinomio cúbico — Una función simple que crea feedback no lineal psicológicamente efectivo. La forma de la curva (inicio lento, final pronunciado) incentiva el comportamiento del usuario más efectivamente que la decadencia lineal.
- Método de Horner — Evalúa el polinomio en 3 multiplicaciones en vez de 6, con mejor estabilidad numérica. Una micro-optimización que importa cuando se llama cada frame para renderizar barras de progreso.
- Búsqueda por bisección — Invierte el polinomio numéricamente en 32 iteraciones con precisión de sub-nanosegundo. Más simple y robusto que la alternativa analítica (fórmula de Cardano) para este caso de uso.
La percepción arquitectónica clave es normalizar todo a [0, 1]. El polinomio no sabe de días, fechas ni píxeles — mapea tiempo normalizado a nivel normalizado. El código que lo llama maneja la conversión entre unidades del mundo real y el dominio normalizado. Esto hace la curva reutilizable en el app principal, widgets, cálculos de rachas y ediciones manuales de nivel sin ninguna modificación.
