Skip to content

Modeling Non-Linear Depletion with a Cubic Polynomial and Bisection Search in Swift

Introduction

You're building a habit tracker. Each habit has a "level" that fills when the user checks in and depletes over time. The obvious approach — linear depletion — creates a problem: a level at 80% feels the same as a level at 20%. There's no urgency, no visual pressure to act before it's too late.

What you want is a curve that depletes slowly at first and accelerates toward the end. The user sees their level hold steady for a while, then watches it drain faster as the deadline approaches. This psychological pressure is exactly what makes habit apps sticky.

A cubic polynomial gives us this shape with one line of math. And when we need the inverse — "if I want this level at 60%, how much time has elapsed?" — bisection search finds the answer in 32 iterations with billionth-precision. The whole thing fits in 68 lines of Swift.

Why Not Linear?

Linear depletion maps elapsed time directly to fill level: level = 1 - elapsed. The graph is a straight line from 100% to 0%.

Level
1.0 ┤╲
    │  ╲               Linear: constant rate
0.5 ┤    ╲             No urgency anywhere
    │      ╲
0.0 ┤        ╲
    └──────────
    0    0.5    1.0
       Time →

The problem: every moment feels equally important. Users don't sense a deadline because the visual feedback is uniform. There's no "it's dropping fast, I need to act now" moment.

Compare that with a steep-end curve:

Level
1.0 ┤──╲
    │    ╲              Cubic: slow start, fast end
0.5 ┤      ╲            Urgency builds visually
    │        ╲╲
0.0 ┤          ╲╲__
    └──────────────
    0    0.5    1.0
        Time →

The curve holds steady in the early phase, then accelerates. Users feel safe at 80% but anxious at 30% — exactly the behavioral nudge a habit tracker needs.

The Cubic Polynomial

Our depletion function is a cubic polynomial evaluated over normalized time t ∈ [0, 1]:

f(t) = at³ + bt² + ct + d

Where:

  • t = 0 → level is 1.0 (just filled)
  • t = 1 → level is 0.0 (fully depleted)
  • The curve is monotonically decreasing (level never goes up on its own)
  • The slope is steeper near t = 1 (urgency at the end)

The coefficients that produce this shape:

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,
)

These aren't arbitrary — they satisfy the boundary conditions f(0) = 1 and f(1) = 0 while producing a curve that "holds" in the early phase and drops steeply at the end. The d = 1.0 term ensures the level starts at 100%. The negative coefficients ensure the curve decreases monotonically.

TIP

You can verify the boundary conditions: f(0) = d = 1.0 and f(1) = a + b + c + d = -0.132 - 0.093 - 0.775 + 1.0 ≈ 0.0. The small rounding error is negligible at display precision.

Horner's Method: Efficient Evaluation

The naive way to evaluate at³ + bt² + ct + d requires 6 multiplications (computing , , then multiplying by coefficients). Horner's method rewrites the polynomial in nested form:

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

This uses only 3 multiplications and 3 additions — half the operations:

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
}

The max(0, min(1, ...)) clamp guarantees the input stays in range. Values outside [0, 1] would produce extrapolated results — a negative level or one above 100% — which would make no sense in the UI.

TIP

Horner's method isn't just faster — it's also more numerically stable. Each intermediate result stays closer to the final magnitude, reducing floating-point rounding errors that accumulate when you compute large powers separately.

The forward function answers: "given elapsed time, what's the level?" But we also need the reverse: "given a target level, how much time has elapsed?"

This arises when users manually adjust their level. If someone drags a slider to 60%, we need to calculate what lastRefillDate would produce that level — effectively running the clock backward.

Analytically inverting a cubic polynomial is possible (Cardano's formula) but messy and numerically fragile. Bisection search is simpler, robust, and fast enough:

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
}

How It Works

The algorithm exploits the fact that our curve is monotonically decreasing: as time increases, the level decreases. This means for any target level, there's exactly one time value that produces it.

  1. Start with the full range [0, 1]
  2. Check the midpoint: evaluate f(mid)
  3. If f(mid) > target, the answer is to the right (more time needed) → narrow to [mid, upper]
  4. If f(mid) ≤ target, the answer is to the left → narrow to [lower, mid]
  5. Repeat 32 times

Each iteration halves the search interval. After 32 iterations, the interval is 1 / 2³² ≈ 2.3 × 10⁻¹⁰ — sub-nanosecond precision in normalized time. For a 7-day depletion period, that's accuracy to within 0.00001 seconds.

Edge Cases

The two early returns handle boundary values:

swift
if clampedLevel >= 0.999_999 { return 0.0 }  // "100% full" → no time elapsed
if clampedLevel <= 0.000_001 { return 1.0 }  // "0% full" → fully elapsed

Without these, bisection would converge but waste iterations on values where the answer is obvious. The epsilon values (0.999_999 and 0.000_001) avoid floating-point equality issues at the exact boundaries.

WARNING

Bisection requires the function to be monotonic over the search interval. If your curve has local minima or maxima (like a sine wave), bisection can converge to the wrong root. For our monotonically decreasing cubic, this isn't a concern.

Connecting to Real Time

The polynomial operates on normalized time t ∈ [0, 1], but the app deals in dates and days. The conversion is straightforward:

swift
public static let secondsInDay: Double = 86400.0

// Forward: Date → Level
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)
}

// Inverse: Level → Date
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)
}

The forward path normalizes real elapsed seconds into [0, 1], evaluates the polynomial, and gets a fill percentage. The inverse path takes a fill percentage, finds the normalized elapsed time via bisection, converts back to seconds, and sets the refill date that many seconds in the past.

This "adjust the past to match the present" pattern is elegant — instead of storing a separate "current level" field that needs syncing, we derive the level entirely from lastRefillDate and depletionDays. The polynomial is the single source of truth.

Usage in Widgets

The same curve works in widget extensions, where you need to precompute display values from snapshot data:

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())))

Since DepletionCurve is a pure function with no state or dependencies, it can be shared across the main app target and widget extension without any additional setup — just include the file in both targets.

The Complete Implementation

Here's the entire DepletionCurve enum — 68 lines, no dependencies beyond 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

    /// Forward: normalized time → fill level
    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
    }

    /// Inverse: fill level → normalized time (bisection search)
    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
    }
}

An enum with no cases is the standard Swift pattern for a namespace — it can't be instantiated, clearly communicating that this is a collection of pure functions, not an object with state.

Adapting the Curve

Different applications might need different curve shapes. You can adjust the coefficients while maintaining the boundary conditions f(0) = 1 and f(1) = 0:

Since f(0) = d, we always need d = 1.0. And since f(1) = a + b + c + d = 0, we need a + b + c = -1. This gives us two free parameters.

Curve ShapeabcEffect
Current-0.132-0.093-0.775Slow start, steep end
More linear0.00.0-1.0Straight line
Very steep end-0.50.0-0.5Holds longer, drops faster
Steep start0.50.0-1.5Drops fast, then slows

TIP

To visualize different coefficients, plot ((a*t + b)*t + c)*t + 1 for t ∈ [0, 1] in any graphing tool. As long as a + b + c = -1, the curve will start at 1 and end at 0.

Conclusion

This 68-line implementation demonstrates three techniques that combine into something greater than their parts:

  1. Cubic polynomial — A simple function that creates psychologically effective non-linear feedback. The curve shape (slow start, steep end) nudges user behavior more effectively than linear decay.
  2. Horner's method — Evaluates the polynomial in 3 multiplications instead of 6, with better numerical stability. A micro-optimization that matters when called every frame for progress bar rendering.
  3. Bisection search — Inverts the polynomial numerically in 32 iterations with sub-nanosecond precision. Simpler and more robust than the analytical alternative (Cardano's formula) for this use case.

The key architectural insight is normalizing everything to [0, 1]. The polynomial doesn't know about days, dates, or pixels — it maps normalized time to normalized level. The calling code handles the conversion between real-world units and the normalized domain. This makes the curve reusable across the main app, widgets, streak calculations, and manual level edits without any modification.