Skip to content

Floating-Point Robustness: Epsilons, Exact Predicates and Filters

Why the orientation test gives wrong signs in double, why an epsilon does not fix it, and how a filtered predicate with an exact fallback does.

A wrong sign is a wrong answer

In the previous lesson, three points on the line y = 3x, written as 0.1, 0.3 and so on, came out as a left turn in double. The cross product was about 2e-17 instead of 0. In most numerical code that would be harmless: an area off by 2e-17 is still the right area.

A geometric predicate is different. Its result is a sign, and an algorithm branches on it. Report "left" when the truth is "on the line", and a convex hull keeps a point it should drop. Report "left" for one ordering of three points and "right" for another ordering of the same three points, and the algorithm's assumptions stop holding: hulls get dents, triangulations get overlapping triangles, and loops that rely on progress never finish. Kettner, Mehlhorn, Pion, Schirra and Yap collected such failures in a paper whose figures show convex hull programs dropping obvious extreme points and producing non-convex output on small inputs.

This lesson measures the problem first, then looks at the three standard responses: the epsilon (which does not work), integer coordinates (which work when you can use them) and filtered exact predicates (which work for arbitrary double input and cost almost nothing on typical data). From this lesson on, the course uses integer coordinates with stated bounds.

What a double can and cannot hold

A double on every platform you are likely to use is an IEEE 754 binary64 number: a sign, an 11-bit exponent and a 53-bit significand (52 bits stored, plus an implicit leading 1). The C++ standard does not require this format. std::numeric_limits<double>::is_iec559 tells you whether you have it, and the exact predicate later in this lesson checks it with a static_assert.

Three facts about binary64 carry most of this lesson.

  • Each basic operation is correctly rounded. +, -, *, /, sqrt and fma return the exact mathematical result rounded to the nearest representable double (ties go to the even one). The relative error of one operation is at most ε = 2^-53, about 1.1e-16, provided the result does not overflow or fall into the subnormal range.
  • Integers are exact up to 2^53. Every integer with absolute value at most 2^53 (about 9.0e15) is a double. A sum, difference or product of such integers is exact if its true result also fits in 53 bits.
  • Most decimal fractions are not representable. 0.1 is stored as the nearest binary fraction, which is slightly more than one tenth.
main.cppc++23
#include <cmath>
#include <print>

int main() {
    // Integers are exact up to 2^53; beyond that the gaps between doubles exceed 1.
    double twoTo53 = 9007199254740992.0;
    std::println("2^53 + 1 == 2^53:         {}", twoTo53 + 1 == twoTo53);
    std::println("next double after 2^53:   {}", std::nextafter(twoTo53, INFINITY));

    // Most decimal fractions have no exact binary form.
    std::println("0.1 + 0.2 == 0.3:         {}", 0.1 + 0.2 == 0.3);
    std::println("0.1 is really             {:.25f}", 0.1);

    // A product is exact if the true result fits in 53 bits.
    long long a = 67'108'863, b = 67'108'859;          // both below 2^26
    long long viaDouble = static_cast<long long>(double(a) * double(b));
    std::println("26-bit product error:     {}", viaDouble - a * b);
    long long c = 1'073'741'823, d = 1'073'741'789;    // both below 2^30
    viaDouble = static_cast<long long>(double(c) * double(d));
    std::println("30-bit product error:     {}", viaDouble - c * d);

    // Subtracting two close doubles is exact (Sterbenz), but it exposes earlier errors.
    double x = 0.3, y = 0.1 * 3;
    std::println("0.3 - 0.1*3 =             {}", x - y);
}
Output

Press Run to compile and run this example.

2^53 + 1 == 2^53:         true
next double after 2^53:   9007199254740994
0.1 + 0.2 == 0.3:         false
0.1 is really             0.1000000000000000055511151
26-bit product error:     0
30-bit product error:     -35
0.3 - 0.1*3 =             -5.551115123125783e-17

The product of two 26-bit integers needs at most 52 bits, so it is exact. The product of two 30-bit integers needs about 60, so the last seven or so bits are rounded away, and the result is off by 35. The orientation formula multiplies coordinate differences, so this is the line between "double is exact for my data" and "it is not": with integer coordinates below about 2^25 in magnitude, differences stay below 2^26, and the whole determinant is computed exactly in double.

The last line shows the other face of rounding. Subtracting two nearly equal doubles is exact (this is Sterbenz's lemma: if y/2 <= x <= 2y, then x - y is representable). The subtraction adds no error, but it removes the large equal parts and leaves the earlier errors as the whole answer. The orientation test near a line is exactly this situation: two nearly equal products, subtracted.

Measuring how the orientation test fails

Kettner and coauthors give an experiment worth repeating. Take the line through q = (12, 12) and r = (24, 24), which is y = x. Near (0.5, 0.5), consecutive doubles are 2^-53 apart, so the points p = (0.5 + i·2^-53, 0.5 + j·2^-53) for i, j from 0 to 255 form a tiny 256 by 256 grid of exactly representable points. For each one the true answer is known without any arithmetic: p is left of the line from q to r when j > i, on it when j == i, right of it when j < i.

The program evaluates the textbook formula in double with p as the first point, as Kettner et al. do, and counts how often it is wrong. It also evaluates the two rotations (q, r, p) and (r, p, q), which in exact arithmetic always give the same sign.

main.cppc++23
#include <cmath>
#include <print>

struct PointD { double x, y; };

// The textbook formula in double. Two statements on purpose: see the pitfall section.
int orientNaive(PointD a, PointD b, PointD c) {
    double left = (b.x - a.x) * (c.y - a.y);
    double right = (b.y - a.y) * (c.x - a.x);
    double det = left - right;
    return (det > 0) - (det < 0);
}

int main() {
    const double u = std::ldexp(1.0, -53);  // the gap between doubles just above 0.5
    const PointD q{12, 12}, r{24, 24};      // the line y = x
    int total = 0, right = 0, toZero = 0, flipped = 0, orderMatters = 0;
    for (int i = 0; i < 256; ++i) {
        for (int j = 0; j < 256; ++j) {
            PointD p{0.5 + i * u, 0.5 + j * u};  // every p is an exact double
            int truth = (j > i) - (j < i);       // above y = x means a left turn
            int got = orientNaive(p, q, r);
            ++total;
            if (got == truth) ++right;
            else if (got == 0) ++toZero;
            else ++flipped;
            // Exact orientation is the same whichever point comes first.
            int rotated1 = orientNaive(q, r, p), rotated2 = orientNaive(r, p, q);
            if (rotated1 != got || rotated2 != got) ++orderMatters;
        }
    }
    std::println("points tested:                 {}", total);
    std::println("correct sign:                  {}", right);
    std::println("reported collinear, but isn't: {}", toZero);
    std::println("reported on the wrong side:    {}", flipped);
    std::println("answer depends on point order: {}", orderMatters);
}
Output

Press Run to compile and run this example.

points tested:                 65536
correct sign:                  53564
reported collinear, but isn't: 11300
reported on the wrong side:    672
answer depends on point order: 13968

Two kinds of error appear. The common one is rounding to zero: a point that is strictly off the line gets the answer "collinear". The dangerous one is the 672 sign inversions: a point clearly on one side is reported on the other. And for 13,968 points, more than a fifth of the grid, the answer depends on which of the three points you name first. Any algorithm that asks about the same triple twice, in different orders, can receive contradictory answers.

Here is the same grid as a picture. Light blue is the true left side, white the true right side, and the blue diagonal is the line itself. Dark cells were reported collinear although they are not; red cells were reported on the wrong side. Each cell is one representable point, and the picture is drawn with y pointing up (the Svg helper flips it for the screen).

main.cppc++23
#include <cmath>
#include <format>
#include <print>
#include <string>
#include <vector>

struct Point { long long x, y; };
struct PointD { double x, y; };

class Svg {
public:
    Svg(double width, double height) : w_(width), h_(height) {}
    void line(Point a, Point b, const char* color = "#1f2937", double width = 1.5) {
        add("<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" stroke-width=\"{}\"/>",
            a.x, fy(a.y), b.x, fy(b.y), color, width);
    }
    void dot(Point p, const char* color = "#1f2937", double r = 3) {
        add("<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\"/>", p.x, fy(p.y), r, color);
    }
    void polygon(const std::vector<Point>& pts, const char* stroke = "#1f2937",
                 const char* fill = "none") {
        std::string list;
        for (Point p : pts) list += std::format("{},{} ", p.x, fy(p.y));
        add("<polygon points=\"{}\" stroke=\"{}\" fill=\"{}\" stroke-width=\"1.5\"/>", list, stroke, fill);
    }
    void text(Point p, const std::string& s, double size = 8) {
        add("<text x=\"{}\" y=\"{}\" font-size=\"{}\">{}</text>", p.x, fy(p.y), size, s);
    }
    void print() const {
        std::println("<svg viewBox=\"0 0 {} {}\">", w_, h_);
        std::print("{}", body_);
        std::println("</svg>");
    }

private:
    double fy(double y) const { return h_ - y; }  // SVG's y axis points down
    template <class... Args>
    void add(std::format_string<Args...> fmt, Args&&... args) {
        body_ += std::format(fmt, std::forward<Args>(args)...);
        body_ += '\n';
    }
    double w_, h_;
    std::string body_;
};

int orientNaive(PointD a, PointD b, PointD c) {
    double left = (b.x - a.x) * (c.y - a.y);
    double right = (b.y - a.y) * (c.x - a.x);
    double det = left - right;
    return (det > 0) - (det < 0);
}

int main() {
    constexpr int kN = 256;
    const double u = std::ldexp(1.0, -53);
    const PointD q{12, 12}, r{24, 24};
    Svg svg(kN, kN);
    // The true left side of y = x in light blue; the right side stays white.
    svg.polygon({{0, 0}, {0, kN}, {kN, kN}}, "none", "rgba(37,99,235,0.15)");
    auto kind = [&](int i, int j) {  // 0 correct, 1 false "collinear", 2 wrong side
        int truth = (j > i) - (j < i);
        int got = orientNaive({0.5 + i * u, 0.5 + j * u}, q, r);
        return got == truth ? 0 : got == 0 ? 1 : 2;
    };
    for (int j = 0; j < kN; ++j) {
        for (int i = 0; i < kN;) {  // one rectangle per horizontal run of equal cells
            int k = kind(i, j), start = i;
            while (i < kN && kind(i, j) == k) ++i;
            if (k == 0) continue;
            const char* color = k == 1 ? "#1f2937" : "#dc2626";
            svg.polygon({{start, j}, {i, j}, {i, j + 1}, {start, j + 1}}, "none", color);
        }
    }
    svg.line({0, 0}, {kN, kN}, "#2563eb", 1);
    svg.print();
}
Output

Press Run to compile and run this example.

The errors are not a thin fuzzy band along the line. They form blocks, with arms that reach well into both half-planes, and the wrong-side cells sit right next to the diagonal. The set of points that the formula calls "on the line" is not a line at all. Kettner et al. explain the block structure in detail; the short version is that the rounding of 0.5 + i·2^-53 - 12 happens in steps, because doubles near 11.5 are 16 times further apart than doubles near 0.5.

A wrong sign breaks a convex hull

The next program builds convex hulls with Andrew's monotone chain, which lesson 4 explains in detail. All you need to know now is that it sorts the points and keeps a point only if it makes a left turn with the two before it, so the orientation test is its only geometric operation.

Each test set has the two points (12, 12) and (24, 24) plus 20 points within a few units in the last place of (0.5, 0.5). The hull is built once with the double formula and once with an exact predicate, and each result is checked with the exact predicate: a correct counter-clockwise hull turns strictly left at every corner and has no input point strictly to the right of any edge.

main.cppc++23
#include <algorithm>
#include <cmath>
#include <print>
#include <vector>

struct PointD { double x, y; };

struct Lcg {
    unsigned long long s;
    unsigned next() { s = s * 6364136223846793005ULL + 1442695040888963407ULL; return unsigned(s >> 33); }
    int range(int lo, int hi) { return lo + int(next() % unsigned(hi - lo + 1)); }  // inclusive
};

int orientNaive(PointD a, PointD b, PointD c) {
    double left = (b.x - a.x) * (c.y - a.y);
    double right = (b.y - a.y) * (c.x - a.x);
    double det = left - right;
    return (det > 0) - (det < 0);
}

// Exact for these inputs only: every coordinate is a multiple of 2^-53 below 32,
// so scaling by 2^53 gives integers, and __int128 holds the products exactly.
int orientExact(PointD a, PointD b, PointD c) {
    auto n = [](double v) { return static_cast<__int128>(std::ldexp(v, 53)); };
    __int128 det = (n(b.x) - n(a.x)) * (n(c.y) - n(a.y))
                 - (n(b.y) - n(a.y)) * (n(c.x) - n(a.x));
    return (det > 0) - (det < 0);
}

bool lexLess(PointD a, PointD b) { return a.x < b.x || (a.x == b.x && a.y < b.y); }

// Andrew's monotone chain; `orient` is its only geometric operation.
template <class Orient>
std::vector<PointD> hull(std::vector<PointD> p, Orient orient) {
    std::ranges::sort(p, lexLess);
    std::vector<PointD> h;
    for (int pass = 0; pass < 2; ++pass) {  // lower chain, then upper chain
        const size_t base = h.size();
        for (PointD q : p) {
            while (h.size() >= base + 2 && orient(h[h.size() - 2], h.back(), q) <= 0)
                h.pop_back();
            h.push_back(q);
        }
        h.pop_back();  // the last point starts the other chain
        std::ranges::reverse(p);
    }
    return h;
}

bool valid(const std::vector<PointD>& h, const std::vector<PointD>& pts) {
    const size_t n = h.size();
    for (size_t i = 0; i < n; ++i) {
        PointD a = h[i], b = h[(i + 1) % n];
        if (orientExact(a, b, h[(i + 2) % n]) <= 0) return false;  // not a left turn
        for (PointD p : pts)
            if (orientExact(a, b, p) < 0) return false;  // a point outside the hull
    }
    return true;
}

int main() {
    const double u = std::ldexp(1.0, -53);
    const int trials = 1000;
    int badNaive = 0, badExact = 0;
    for (int seed = 0; seed < trials; ++seed) {
        Lcg rng{static_cast<unsigned long long>(seed)};
        std::vector<PointD> pts{{12, 12}, {24, 24}};
        for (int i = 0; i < 20; ++i)
            pts.push_back({0.5 + rng.range(0, 255) * u, 0.5 + rng.range(0, 255) * u});
        badNaive += !valid(hull(pts, orientNaive), pts);
        badExact += !valid(hull(pts, orientExact), pts);
    }
    std::println("hulls checked:                     {}", trials);
    std::println("wrong with the double formula:     {}", badNaive);
    std::println("wrong with an exact predicate:     {}", badExact);
}
Output

Press Run to compile and run this example.

hulls checked:                     1000
wrong with the double formula:     237
wrong with an exact predicate:     0

Almost a quarter of the hulls are wrong, and nothing crashes: the program returns a plausible list of points. The algorithm is the same in both runs. Only the predicate changed.

The input was chosen to sit on a line. Real data rarely looks like that everywhere, but often somewhere: points digitized along a straight wall, a mesh with many points on a plane. Those are exactly the inputs where the naive predicate fails.

Why an epsilon does not fix it

The usual first fix is a tolerance: treat the cross product as zero if |cross| <= eps. It hides the 2e-17 from the previous lesson. It does not make the predicate correct, for two reasons that the next program shows.

An absolute epsilon depends on units. The same triangle answers "collinear" in meters and "left" in millimeters, because the cross product scales with the square of the coordinates. A relative epsilon, which compares the cross product with the size of the two products, fixes the scale problem. Neither fixes the second problem: "collinear within a tolerance" is not transitive. Exact collinearity is: if a, b, c lie on one line and b, c, d lie on one line (with b != c), then a, b, d lie on that line too. Algorithms rely on this without saying so, for example when they drop the middle point of every collinear triple.

main.cppc++23
#include <cmath>
#include <print>

struct PointD { double x, y; };

constexpr double kEps = 1e-9;

// "Collinear if the cross product is tiny": the usual quick fix.
int orientAbs(PointD a, PointD b, PointD c) {
    double left = (b.x - a.x) * (c.y - a.y);
    double right = (b.y - a.y) * (c.x - a.x);
    double v = left - right;
    return v > kEps ? 1 : v < -kEps ? -1 : 0;
}

// Relative version: compare with the size of the two products instead.
int orientRel(PointD a, PointD b, PointD c) {
    double left = (b.x - a.x) * (c.y - a.y);
    double right = (b.y - a.y) * (c.x - a.x);
    double v = left - right;
    double scale = std::fabs(left) + std::fabs(right);
    return v > kEps * scale ? 1 : v < -kEps * scale ? -1 : 0;
}

int main() {
    // 1. The same triangle in meters, millimeters and micrometers.
    for (double unit : {1.0, 1e3, 1e6}) {
        PointD a{0, 0}, b{unit, 0}, c{unit / 2, 1e-10 * unit};
        std::println("scale {:>7}: absolute eps says {:>2}, relative eps says {:>2}",
                     unit, orientAbs(a, b, c), orientRel(a, b, c));
    }
    // 2. A gentle curve p[i] = (i, i + h*i*i): neighbours look collinear, ends do not.
    auto chain = [](const char* name, auto orient, double h) {
        PointD p[6];
        for (int i = 0; i < 6; ++i) p[i] = {double(i), i + h * i * i};
        std::print("{}: neighbours", name);
        for (int i = 0; i + 2 < 6; ++i)
            std::print(" {}", orient(p[i], p[i + 1], p[i + 2]));
        std::println(", but p0 p1 p5 gives {}", orient(p[0], p[1], p[5]));
    };
    chain("absolute", orientAbs, 4e-10);
    chain("relative", orientRel, 1.5e-9);
}
Output

Press Run to compile and run this example.

scale       1: absolute eps says  0, relative eps says  1
scale    1000: absolute eps says  1, relative eps says  1
scale   1e+06: absolute eps says  1, relative eps says  1
absolute: neighbours 0 0 0 0, but p0 p1 p5 gives 1
relative: neighbours 0 0 0 0, but p0 p1 p5 gives 1

Every consecutive triple on the curve is "collinear", yet the first and last points are not on the line through the first two. A hull algorithm that removes collinear middle points removes all four interior points here, one at a time, each step justified by the tolerance, and loses a real corner. A tolerance large enough to absorb the sign inversions from the grid experiment also turns a band of non-collinear points into "collinear", which is a different wrong answer.

There are legitimate tolerances. "Snap the cursor to a vertex within 5 pixels", "merge survey points closer than 1 mm", "consider two meshes touching if they are within 0.01 mm": these are decisions about the data, made once, outside the predicates, by someone who knows what the units mean. After the data has been cleaned, the predicates should answer exactly about the data they are given.

Predicates and constructions

Every geometric computation does one of two things.

A predicate answers a question with one of a few discrete values: the sign of an orientation, whether a point is inside a circle, which of two distances is smaller. Because the answer is discrete, it can be computed exactly, as long as the inputs are taken as exact numbers.

A construction produces a new geometric object: the intersection point of two segments, the circumcenter of a triangle, a rotated point. The exact result is often not representable (the intersection of two lines through integer points is rational; a rotation usually involves an irrational cosine), so a construction must round.

The algorithms in this course make their decisions with predicates and use constructions only for output. If every decision is exact, the combinatorial result (which points are on the hull, which segments intersect, which triangles exist) is exactly right for the given input, even if a computed coordinate is off in its last bit.

CGAL, the Computational Geometry Algorithms Library, is organized around this split. A CGAL algorithm is written against a kernel, a bundle of number type, point types, predicates and constructions. One of its predefined kernels, CGAL::Exact_predicates_inexact_constructions_kernel, stores double coordinates, and its documentation states that it provides exact predicates while constructions are not exact in general. Its predicates are filtered: CGAL first evaluates them with interval arithmetic and computes exactly only when the interval does not determine the sign. CGAL::Exact_predicates_exact_constructions_kernel makes constructions exact as well, at a higher cost. The rest of this lesson builds the same two ideas, an exact representation and a filter, by hand.

Exact by construction: integer coordinates

The simplest exact predicate is the one from lesson 1: integer coordinates and integer arithmetic. With |x|, |y| <= 10^9, the orientation determinant is at most 8·10^18 in absolute value, which fits in long long. Every program from lesson 3 on uses this:

struct Point { long long x, y; };

constexpr long long kMaxCoord = 1'000'000'000;  // |x|, |y| <= kMaxCoord

long long cross(Point a, Point b, Point c) {  // twice the signed area of abc, exact
    return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}

int orientation(Point a, Point b, Point c) {  // -1 right turn, 0 collinear, +1 left
    long long v = cross(a, b, c);
    return (v > 0) - (v < 0);
}

Real-valued data gets there by scaling to a grid: multiply by a fixed factor (1000 for meters to millimeters) and round once with std::llround. That rounding moves each point by at most half a grid step and can change the geometry: three nearly collinear points can become exactly collinear. If the application cannot accept that, it needs exact arithmetic on the original doubles, the subject of the next two sections.

Higher-degree predicates need more bits. The in-circle test of lesson 7 multiplies four coordinate differences, so long long overflows quickly. GCC and Clang provide __int128 as an extension (it is not standard C++ and MSVC does not have it), which covers the in-circle determinant for a smaller coordinate bound that lesson 7 works out. Beyond that you need a multiprecision type such as GMP's, or the techniques below.

Signed overflow is undefined behavior in C++, not merely a wrong answer, so the bound is part of the predicate's contract: put it in a named constant and a static_assert, and validate input against it where data enters the program.