Skip to content

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 + d

Onde:

  • 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:

swift
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 , , depois multiplicando pelos coeficientes). O método de Horner reescreve o polinômio na forma aninhada:

f(t) = ((a·t + b)·t + c)·t + d

Isso usa apenas 3 multiplicações e 3 adições — metade das operações:

swift
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:

swift
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.

  1. Comece com o intervalo completo [0, 1]
  2. Verifique o ponto médio: avalie f(mid)
  3. Se f(mid) > alvo, a resposta está à direita (mais tempo necessário) → restrinja para [mid, upper]
  4. Se f(mid) ≤ alvo, a resposta está à esquerda → restrinja para [lower, mid]
  5. 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:

swift
if clampedLevel >= 0.999_999 { return 0.0 }  // "100% cheio" → nenhum tempo passou
if clampedLevel <= 0.000_001 { return 1.0 }  // "0% cheio" → totalmente decorrido

Sem 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:

swift
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:

swift
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:

swift
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 CurvaabcEfeito
Atual-0.132-0.093-0.775Início lento, final acentuado
Mais linear0.00.0-1.0Linha reta
Final muito acentuado-0.50.0-0.5Segura mais, cai mais rápido
Início acentuado0.50.0-1.5Cai 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:

  1. 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.
  2. 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.
  3. 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.