Modelando Depleção Não-Linear com Polinômio Cúbico e Busca por Bisseção em Swift
Introdução
Você está construindo um rastreador de hábitos. Cada hábito tem um "nível" que enche quando o usuário faz check-in e se esgota com o tempo. A abordagem óbvia — depleção linear — cria um problema: um nível em 80% parece igual a um nível em 20%. Não há urgência, nenhuma pressão visual para agir antes que seja tarde demais.
O que você quer é uma curva que se esgota devagar no início e acelera no final. O usuário vê seu nível se manter estável por um tempo, depois observa ele drenar mais rápido conforme o prazo se aproxima. Essa pressão psicológica é exatamente o que torna apps de hábitos envolventes.
Um polinômio cúbico nos dá essa forma com uma linha de matemática. E quando precisamos da inversa — "se eu quero este nível em 60%, quanto tempo se passou?" — a busca por bisseção encontra a resposta em 32 iterações com precisão de bilionésimos. Tudo cabe em 68 linhas de Swift.
Por Que Não Linear?
A depleção linear mapeia o tempo decorrido diretamente para o nível: nível = 1 - tempo. O gráfico é uma linha reta de 100% a 0%.
Nível
1.0 ┤╲
│ ╲ Linear: taxa constante
0.5 ┤ ╲ Sem urgência em nenhum ponto
│ ╲
0.0 ┤ ╲
└──────────
0 0.5 1.0
Tempo →O problema: cada momento parece igualmente importante. Os usuários não sentem um prazo porque o feedback visual é uniforme. Não existe o momento "está caindo rápido, preciso agir agora".
Compare com uma curva acentuada no final:
Nível
1.0 ┤──╲
│ ╲ Cúbica: início lento, final rápido
0.5 ┤ ╲ Urgência se constrói visualmente
│ ╲╲
0.0 ┤ ╲╲__
└──────────────
0 0.5 1.0
Tempo →A curva se mantém estável na fase inicial, depois acelera. Os usuários se sentem seguros em 80% mas ansiosos em 30% — exatamente o empurrão comportamental que um rastreador de hábitos precisa.
O Polinômio Cúbico
Nossa função de depleção é um polinômio cúbico avaliado sobre o tempo normalizado t ∈ [0, 1]:
f(t) = at³ + bt² + ct + dOnde:
t = 0→ nível é 1.0 (acabou de encher)t = 1→ nível é 0.0 (totalmente esgotado)- A curva é monotonicamente decrescente (o nível nunca sobe sozinho)
- A inclinação é mais acentuada perto de
t = 1(urgência no final)
Os coeficientes que produzem essa 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,
)Não são arbitrários — satisfazem as condições de contorno f(0) = 1 e f(1) = 0 enquanto produzem uma curva que "segura" na fase inicial e cai abruptamente no final. O termo d = 1.0 garante que o nível começa em 100%. Os coeficientes negativos garantem que a curva decresce monotonicamente.
TIP
Você pode verificar as condições de contorno: f(0) = d = 1.0 e f(1) = a + b + c + d = -0.132 - 0.093 - 0.775 + 1.0 ≈ 0.0. O pequeno erro de arredondamento é desprezível na precisão de exibição.
Método de Horner: Avaliação Eficiente
A forma ingênua de avaliar at³ + bt² + ct + d requer 6 multiplicações (calculando t², t³, depois multiplicando pelos coeficientes). O método de Horner reescreve o polinômio na forma aninhada:
f(t) = ((a·t + b)·t + c)·t + dIsso usa apenas 3 multiplicações e 3 adições — metade das operações:
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
}O clamp max(0, min(1, ...)) garante que a entrada fique no intervalo. Valores fora de [0, 1] produziriam resultados extrapolados — um nível negativo ou acima de 100% — o que não faria sentido na UI.
TIP
O método de Horner não é apenas mais rápido — é também mais numericamente estável. Cada resultado intermediário fica mais próximo da magnitude final, reduzindo erros de arredondamento de ponto flutuante que se acumulam quando você calcula potências grandes separadamente.
O Problema Inverso: Busca por Bisseção
A função direta responde: "dado o tempo decorrido, qual é o nível?" Mas também precisamos do reverso: "dado um nível alvo, quanto tempo se passou?"
Isso surge quando usuários ajustam manualmente seu nível. Se alguém arrasta um slider para 60%, precisamos calcular qual lastRefillDate produziria esse nível — efetivamente rodando o relógio para trás.
Inverter analiticamente um polinômio cúbico é possível (fórmula de Cardano) mas complicado e numericamente frágil. A busca por bisseção é mais simples, robusta e rápida o suficiente:
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
}Como Funciona
O algoritmo explora o fato de que nossa curva é monotonicamente decrescente: conforme o tempo aumenta, o nível diminui. Isso significa que para qualquer nível alvo, existe exatamente um valor de tempo que o produz.
- Comece com o intervalo completo
[0, 1] - Verifique o ponto médio: avalie
f(mid) - Se
f(mid) > alvo, a resposta está à direita (mais tempo necessário) → restrinja para[mid, upper] - Se
f(mid) ≤ alvo, a resposta está à esquerda → restrinja para[lower, mid] - Repita 32 vezes
Cada iteração divide o intervalo de busca pela metade. Após 32 iterações, o intervalo é 1 / 2³² ≈ 2.3 × 10⁻¹⁰ — precisão de sub-nanossegundo em tempo normalizado. Para um período de depleção de 7 dias, isso é precisão dentro de 0.00001 segundos.
Casos Extremos
Os dois retornos antecipados tratam valores de contorno:
if clampedLevel >= 0.999_999 { return 0.0 } // "100% cheio" → nenhum tempo passou
if clampedLevel <= 0.000_001 { return 1.0 } // "0% cheio" → totalmente decorridoSem eles, a bisseção convergiria mas desperdiçaria iterações em valores onde a resposta é óbvia. Os valores epsilon (0.999_999 e 0.000_001) evitam problemas de igualdade de ponto flutuante nos limites exatos.
WARNING
A bisseção requer que a função seja monotônica sobre o intervalo de busca. Se sua curva tem mínimos ou máximos locais (como uma onda senoidal), a bisseção pode convergir para a raiz errada. Para nosso cúbico monotonicamente decrescente, isso não é um problema.
Conectando ao Tempo Real
O polinômio opera em tempo normalizado t ∈ [0, 1], mas o app lida com datas e dias. A conversão é direta:
public static let secondsInDay: Double = 86400.0
// Direto: Data → Nível
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: Nível → Data
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)
}O caminho direto normaliza os segundos reais decorridos em [0, 1], avalia o polinômio e obtém uma porcentagem de preenchimento. O caminho inverso pega uma porcentagem, encontra o tempo normalizado via bisseção, converte de volta para segundos e define a data de recarga esses segundos no passado.
Esse padrão de "ajustar o passado para corresponder ao presente" é elegante — em vez de armazenar um campo separado de "nível atual" que precisa de sincronização, derivamos o nível inteiramente de lastRefillDate e depletionDays. O polinômio é a única fonte de verdade.
Uso em Widgets
A mesma curva funciona em extensões de widget, onde você precisa pré-calcular valores de exibição a partir de dados 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 é uma função pura sem estado ou dependências, pode ser compartilhada entre o target principal do app e a extensão de widget sem configuração adicional — basta incluir o arquivo em ambos os targets.
A Implementação Completa
Aqui está o enum DepletionCurve completo — 68 linhas, sem dependências além 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
/// Direto: tempo normalizado → nível de preenchimento
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: nível de preenchimento → tempo normalizado (busca por bisseção)
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
}
}Um enum sem cases é o padrão Swift para namespace — não pode ser instanciado, comunicando claramente que é uma coleção de funções puras, não um objeto com estado.
Adaptando a Curva
Diferentes aplicações podem precisar de formas de curva diferentes. Você pode ajustar os coeficientes mantendo as condições de contorno f(0) = 1 e f(1) = 0:
Como f(0) = d, sempre precisamos de d = 1.0. E como f(1) = a + b + c + d = 0, precisamos de a + b + c = -1. Isso nos dá dois parâmetros livres.
| Forma da Curva | a | b | c | Efeito |
|---|---|---|---|---|
| Atual | -0.132 | -0.093 | -0.775 | Início lento, final acentuado |
| Mais linear | 0.0 | 0.0 | -1.0 | Linha reta |
| Final muito acentuado | -0.5 | 0.0 | -0.5 | Segura mais, cai mais rápido |
| Início acentuado | 0.5 | 0.0 | -1.5 | Cai rápido, depois desacelera |
TIP
Para visualizar diferentes coeficientes, plote ((a*t + b)*t + c)*t + 1 para t ∈ [0, 1] em qualquer ferramenta gráfica. Desde que a + b + c = -1, a curva começará em 1 e terminará em 0.
Conclusão
Esta implementação de 68 linhas demonstra três técnicas que se combinam em algo maior que suas partes:
- Polinômio cúbico — Uma função simples que cria feedback não-linear psicologicamente eficaz. A forma da curva (início lento, final acentuado) incentiva o comportamento do usuário mais efetivamente que a decadência linear.
- Método de Horner — Avalia o polinômio em 3 multiplicações em vez de 6, com melhor estabilidade numérica. Uma micro-otimização que importa quando chamada a cada frame para renderização de barras de progresso.
- Busca por bisseção — Inverte o polinômio numericamente em 32 iterações com precisão de sub-nanossegundo. Mais simples e robusto que a alternativa analítica (fórmula de Cardano) para este caso de uso.
A principal percepção arquitetural é normalizar tudo para [0, 1]. O polinômio não sabe sobre dias, datas ou pixels — ele mapeia tempo normalizado para nível normalizado. O código chamador lida com a conversão entre unidades do mundo real e o domínio normalizado. Isso torna a curva reutilizável no app principal, widgets, cálculos de sequência e edições manuais de nível sem nenhuma modificação.
