From 2798246b7632baa9337f181170d5ea7516cbe7bd Mon Sep 17 00:00:00 2001 From: nhz2 Date: Tue, 27 Oct 2026 18:54:38 -0400 Subject: [PATCH 01/11] Accurate distances with a rounding error analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the point-triangle and line-line kernels from Eberly's expanded quadratic to a split into normal and in-plane parts, with the normal from `cross3_accurate` (Kahan's FMA 2×2 determinant). Expanding the squared distance lost about half the digits in Float32 for large simplexes close together, and the Gram determinant `a*c - b*b` cancels for nearly parallel segments and slivers. Point-triangle computes each barycentric weight from its own edge. Testing `sbar + tbar ≤ 1` judged a Float32 point 0.2 past the short edge of a needle triangle as inside. Inside, the returned weights are capped so they stay a convex combination after rounding. error-analysis/ has a symbolic tracer that runs the kernels on traced numbers and gives first order distance error bounds for all inputs in terms of the input sizes, checked against BigFloat, and a write-up of the point-point, point-line, point-triangle and line-line bounds. Co-Authored-By: Claude Opus 5.5 --- error-analysis/ErrorBounds.jl | 567 ++++++++++++++++++++++++++++++++++ error-analysis/analyze.jl | 189 ++++++++++++ error-analysis/distances.md | 391 +++++++++++++++++++++++ src/distances.jl | 398 +++++++++--------------- src/util.jl | 27 +- test/test-mindistance.jl | 88 +++++- 6 files changed, 1410 insertions(+), 250 deletions(-) create mode 100644 error-analysis/ErrorBounds.jl create mode 100644 error-analysis/analyze.jl create mode 100644 error-analysis/distances.md diff --git a/error-analysis/ErrorBounds.jl b/error-analysis/ErrorBounds.jl new file mode 100644 index 0000000..a61c45b --- /dev/null +++ b/error-analysis/ErrorBounds.jl @@ -0,0 +1,567 @@ +""" +First order rounding error bounds for the distance kernels, valid for all +inputs, found by running the kernels on [`EB`](@ref) numbers. + +An `EB` holds upper bounds as polynomials in size symbols: `mag` on the +absolute value of the exact result, and `err` on the rounding error, in +units of `u`. Each operation applies a rule that holds for all inputs, so +the bounds are functions of the sizes, not of a particular input. Products +of two errors, `O(u²)`, are dropped. + +A sum or difference of two exact values, such as `y[1] - x[1]`, becomes a +new size symbol, for example `|Pᵢ|`, rounded once. So the bounds are in the +sizes of the differences, not of the coordinates. + +In `:backward` mode, the rounded differences of input vertices are instead +treated as exact differences of slightly moved vertices. The kernel is then +analysed on the moved vertices, and [`vertex_moves`](@ref) gives how far +each vertex moved, which changes the exact distance by at most the largest +move on each simplex. This is how the kernels use the differences: the same +rounded `E0`, `E1` everywhere, so the result is the accurate distance of a +nearby triangle, even when it is a sliver. + +The kernels are branch free, so tracing evaluates both arms of every +select. A select joins the bounds of its arms, and adds a jump term when +the computed condition can disagree with the exact one: + +- A running minimum, `cand[1] < best[1]`, has no jump, since `min` is + 1-Lipschitz. `r`, `s` and `t` are compared with the exact values of the + candidate that was chosen. +- An `inside` test has a jump of `Σ err(v) * L(v)` over its distinct + variables `v`, where `L(v)` comes from [`JUMPS`](@ref): a hand derived + bound on how far the boundary is, in distance, per unit of `v`. +""" +module ErrorBounds + +using SimplexCellLists: SimplexCellLists, SIMD +using StaticArrays: SVector + +export EB, SPoly, Sym, input_simplex, normbound, vecnormbound, maxpoly, boundvalue, + reset!, vertex_moves, substitute_abscross, NAMES, CROSS_DEFS, ABSCROSS_DEFS, JUMPS, ASSUMPTIONS, NOTES + +const Q = Rational{BigInt} + +# A size symbol: `|base_i|` for component `i` in 1:3, or `‖base‖` when `i == 0`. +struct Sym + base::Symbol + i::Int +end +Base.isless(a::Sym, b::Sym) = isless((a.base, a.i), (b.base, b.i)) + +# Sorted `Sym => exponent` pairs, with nonzero exponents. +const Mono = Vector{Pair{Sym, Int}} + +function monomul(a::Mono, b::Mono) + d = Dict{Sym, Int}() + for (s, e) in Iterators.flatten((a, b)) + d[s] = get(d, s, 0) + e + end + sort!([s => e for (s, e) in d if e != 0]; by=first) +end + +""" + SPoly + +A polynomial with nonnegative coefficients in size symbols, used as an +upper bound. Exponents may be negative for norms, as in `‖R‖⁻²`. +""" +struct SPoly + t::Dict{Mono, Q} +end +SPoly() = SPoly(Dict{Mono, Q}()) +SPoly(c::Real) = iszero(c) ? SPoly() : SPoly(Dict(Mono() => Q(c))) +SPoly(s::Sym, e::Int=1) = SPoly(Dict([s => e] => one(Q))) + +Base.iszero(p::SPoly) = isempty(p.t) +function Base.:+(a::SPoly, b::SPoly) + t = copy(a.t) + for (m, c) in b.t + t[m] = get(t, m, zero(Q)) + c + end + SPoly(t) +end +function Base.:*(a::SPoly, b::SPoly) + t = Dict{Mono, Q}() + for (ma, ca) in a.t, (mb, cb) in b.t + m = monomul(ma, mb) + t[m] = get(t, m, zero(Q)) + ca*cb + end + SPoly(t) +end +Base.:*(c::Real, p::SPoly) = iszero(c) ? SPoly() : SPoly(Dict(m => Q(c)*v for (m, v) in p.t)) + +""" + maxpoly(a::SPoly, b::SPoly) + +An upper bound on `max(a, b)`: the largest coefficient of each monomial. +""" +function maxpoly(a::SPoly, b::SPoly) + t = copy(a.t) + for (m, c) in b.t + t[m] = max(get(t, m, zero(Q)), c) + end + SPoly(t) +end + +# The square root of a monomial with even exponents and coefficient one. +function _sqrtmag(p::SPoly) + length(p.t) == 1 || return nothing + (m, c), = p.t + isone(c) && all(iseven ∘ last, m) || return nothing + SPoly(Dict(Mono([s => e ÷ 2 for (s, e) in m]) => one(Q))) +end + +# The component indices that appear in `p`. +indices(p::SPoly) = Set(s.i for m in keys(p.t) for (s, _) in m if s.i > 0) + +const SUPERSCRIPTS = Dict(zip("-0123456789", "⁻⁰¹²³⁴⁵⁶⁷⁸⁹")) +function Base.show(io::IO, p::SPoly) + iszero(p) && return print(io, "0") + terms = sort!(collect(p.t); by=x -> string(x[1])) + for (k, (m, c)) in enumerate(terms) + k > 1 && print(io, " + ") + (isone(c) && !isempty(m)) || print(io, isinteger(c) ? numerator(c) : c) + for (s, e) in m + print(io, s.i == 0 ? "‖$(s.base)‖" : "|$(s.base)$(('₀':'₉')[s.i + 1])|") + e == 1 || print(io, map(ch -> SUPERSCRIPTS[ch], string(e))) + end + end +end + +""" + boundvalue(p::SPoly, values) + +Evaluate `p` with `values[s]` for each symbol `s`. +""" +boundvalue(p::SPoly, values) = sum((c*prod((values[s]^e for (s, e) in m); init=big(1)) for (m, c) in p.t); init=big(0)) + +""" + normbound(p::SPoly) + +Bound a scalar that sums over components by norms. For each group of terms +that differ only in their component index `i`, with the largest coefficient +`c`, `Σᵢ c|Xᵢ||Yᵢ|… ≤ c‖X‖‖Y‖…` (Cauchy-Schwarz, then `|Zᵢ| ≤ ‖Z‖`) and +`Σᵢ c|Xᵢ| ≤ c√n‖X‖` for `n` indices. Other terms use `|Xⱼ| ≤ ‖X‖` alone. +""" +function normbound(p::SPoly) + out = SPoly() + groups = Dict{Tuple{Vector{Pair{Symbol, Int}}, Mono}, Tuple{Q, Set{Int}}}() + for (m, c) in p.t + comps = [s => e for (s, e) in m if s.i > 0] + glob = Mono([s => e for (s, e) in m if s.i == 0]) + @assert all(>(0), last.(comps)) "negative exponent on a component" + idx = Set(s.i for (s, _) in comps) + if isempty(comps) + out += SPoly(Dict(m => c)) + elseif length(idx) == 1 + key = (sort!([s.base => e for (s, e) in comps]; by=first), glob) + cmax, seen = get(groups, key, (zero(Q), Set{Int}())) + groups[key] = (max(cmax, c), union(seen, idx)) + else + out += SPoly(Dict(monomul(glob, Mono(sort!([Sym(s.base, 0) => e for (s, e) in comps]; by=first))) => c)) + end + end + for ((bases, glob), (cmax, seen)) in groups + norms = Mono(sort!([Sym(b, 0) => e for (b, e) in bases]; by=first)) + degree = sum(last, bases) + # √n for a sum of single components, rounded up to a rational. + factor = degree ≥ 2 ? one(Q) : (length(seen) == 1 ? one(Q) : length(seen) == 2 ? Q(3//2) : Q(7//4)) + out += SPoly(Dict(monomul(glob, norms) => cmax*factor)) + end + out +end + +""" + vecnormbound(ps) + +Bound the Euclidean norm of a vector whose component `i` is at most `ps[i]`, +by Minkowski: group the terms by the part with index `i`, take the largest +coefficient, and bound the norm of `(|Xᵢ||Yᵢ|…)ᵢ` by `‖X‖‖Y‖…`. A term +without index `i` is a constant vector, with norm `√3` times its value. +""" +function vecnormbound(ps) + groups = Dict{Tuple{Vector{Pair{Symbol, Int}}, Mono}, Q}() + for (i, p) in enumerate(ps) + rests = Dict{Vector{Pair{Symbol, Int}}, SPoly}() + for (m, c) in p.t + own = sort!([s.base => e for (s, e) in m if s.i == i]; by=first) + rest = SPoly(Dict(Mono([s => e for (s, e) in m if s.i != i]) => c)) + rests[own] = get(rests, own, SPoly()) + rest + end + for (own, rest) in rests + # The rest no longer depends on `i`, so reduce it to norms. + for (m, c) in normbound(rest).t + key = (own, m) + groups[key] = max(get(groups, key, zero(Q)), c) + end + end + end + out = SPoly() + for ((own, m), c) in groups + norms = Mono(sort!([Sym(b, 0) => e for (b, e) in own]; by=first)) + out += SPoly(Dict(monomul(norms, m) => isempty(own) ? Q(7//4)*c : c)) + end + out +end + +""" + EB + +A traced number: `mag` bounds the absolute value of the exact result, and +`err` bounds the rounding error in units of `u`. If `rel` is set, the error +is also at most `rel*u` times the exact value. `exact` means `mag` is the +absolute value of the exact result, not just a bound, as division needs. + +`root`, if set, is `(abs, rel)` for a nonnegative result `ρ²`, such as a +squared distance: `|√computed - ρ| ≤ u*(abs + rel*ρ)`. +""" +struct EB <: Real + mag::SPoly + err::SPoly + rel::Union{Nothing, Q} + nonneg::Bool + exact::Bool + input::Union{Nothing, Sym} + con::Union{Nothing, Q} + root::Union{Nothing, Tuple{SPoly, Q}} +end + +EB(c::Real) = EB(SPoly(abs(c)), SPoly(), zero(Q), c ≥ 0, true, nothing, Q(c), nothing) +withrel(mag, rel, nonneg, exact) = EB(mag, rel*mag, rel, nonneg, exact, nothing, nothing, nothing) +withabs(mag, err, nonneg, exact) = EB(mag, err, nothing, nonneg, exact, nothing, nothing, nothing) +_withroot(x::EB, root) = EB(x.mag, x.err, x.rel, x.nonneg, x.exact, x.input, x.con, root) + +Base.zero(::Type{EB}) = EB(0) +Base.one(::Type{EB}) = EB(1) +Base.zero(::EB) = EB(0) +Base.one(::EB) = EB(1) +Base.convert(::Type{EB}, c::Real) = EB(c) +Base.convert(::Type{EB}, x::EB) = x +Base.promote_rule(::Type{EB}, ::Type{<:Real}) = EB +function Base.show(io::IO, x::EB) + print(io, "EB(mag = ", x.mag, ", err = (", x.err, ")·u") + x.rel === nothing || print(io, ", rel = ", x.rel, "·u") + x.root === nothing || print(io, ", root err = (", x.root[1], ")·u + ", x.root[2], "·u·ρ") + print(io, ")") +end + +""" + NAMES + +Names for sums and differences of exact values, keyed by +`(op, base_a, base_b)`, for example `(:-, :a, :x) => :P` for +`P = a - x`. Missing names are made up. +""" +const NAMES = Dict{Tuple{Symbol, Symbol, Symbol}, Symbol}() +const DEFS = Dict{Symbol, Tuple{Symbol, Symbol, Symbol}}() +"Names for the results of `cross3_accurate`, used in order, and their definitions." +const CROSS_NAMES = Symbol[] +const CROSS_DEFS = Dict{Symbol, Tuple{Symbol, Symbol}}() +""" +For a plain `cross3(a, b)`, `X => (a, b)`: `|Xᵢ| = |aⱼ||bₖ| + |aₖ||bⱼ|` bounds +each component, and `‖X‖ ≤ √2‖a‖‖b‖`. +""" +const ABSCROSS_DEFS = Dict{Symbol, Tuple{Symbol, Symbol}}() +"For each `inside` select, in order, the distance per unit of each distinct condition variable." +const JUMPS = Vector{Vector{SPoly}}() +const MOVES = Dict{Symbol, Tuple{Symbol, Symbol}}() +const MODE = Ref(:backward) +const ASSUMPTIONS = String[] +const NOTES = String[] + +""" + reset!(; names, cross=Symbol[], jumps=Vector{SPoly}[], mode=:backward) + +Clear the trace state and set the names and jump constants for one kernel. +""" +function reset!(; names=Dict{Tuple{Symbol, Symbol, Symbol}, Symbol}(), cross=Symbol[], jumps=Vector{SPoly}[], mode=:backward) + empty!(NAMES); merge!(NAMES, names) + empty!(DEFS); empty!(CROSS_DEFS); empty!(ABSCROSS_DEFS); empty!(MOVES) + empty!(CROSS_NAMES); append!(CROSS_NAMES, cross) + empty!(JUMPS); append!(JUMPS, jumps) + empty!(ASSUMPTIONS); empty!(NOTES) + MODE[] = mode + nothing +end + +""" + input_simplex(names...) + +Return a simplex of exact input vertices named `names`, with each +coordinate's size the symbol `|nameᵢ|`. +""" +function input_simplex(names::Symbol...) + SVector(map(names) do n + SVector(ntuple(i -> EB(SPoly(Sym(n, i)), SPoly(), zero(Q), false, true, Sym(n, i), nothing, nothing), 3)) + end) +end + +""" + vertex_moves(v::Symbol) + +In `:backward` mode, a bound on how far input vertex `v` moved, as one +`SPoly` per component in units of `u`, following the rounded differences +back to a vertex that did not move. +""" +function vertex_moves(v::Symbol) + haskey(MOVES, v) || return [SPoly() for _ in 1:3] + anchor, name = MOVES[v] + [m + SPoly(Sym(name, i)) for (i, m) in enumerate(vertex_moves(anchor))] +end + +# The symbol of an exact value that is a single size symbol, or `nothing`. +function _single(x::EB) + iszero(x.err) && x.exact && length(x.mag.t) == 1 || return nothing + (m, c), = x.mag.t + isone(c) && length(m) == 1 && m[1][2] == 1 && m[1][1].i > 0 ? m[1][1] : nothing +end + +# One rounding of the exact result `x`. +function _round(x::EB) + x.con === nothing || return x + x.rel === nothing ? withabs(x.mag, x.err + x.mag, x.nonneg, x.exact) : withrel(x.mag, x.rel + 1, x.nonneg, x.exact) +end + +function _neg(a::EB) + EB(a.mag, a.err, a.rel, iszero(a.mag), a.exact, nothing, a.con === nothing ? nothing : -a.con, nothing) +end + +function _add_exact(a::EB, b::EB) + a.con == 0 && return b + b.con == 0 && return a + mag = a.mag + b.mag + nonneg = a.nonneg && b.nonneg + exact = nonneg && a.exact && b.exact + # Each nonnegative term has relative error at most its `rel`, so their + # sum has relative error at most the larger one. + if nonneg && a.rel !== nothing && b.rel !== nothing + withrel(mag, max(a.rel, b.rel), true, exact) + else + withabs(mag, a.err + b.err, nonneg, exact) + end +end + +# A sum or difference of two exact values with single symbols of the same +# component is a new symbol, rounded once, so its error is relative to it. +function _addsub(op::Symbol, a::EB, b::EB) + sa, sb = _single(a), _single(b) + if sa !== nothing && sb !== nothing && sa.i == sb.i + name = get!(NAMES, (op, sa.base, sb.base), Symbol(sa.base, op == :- ? :_m_ : :_p_, sb.base)) + DEFS[name] = (op, sa.base, sb.base) + if op == :- && a.input !== nothing && b.input !== nothing && MODE[] == :backward + # The rounded difference is the exact difference of `a` moved. + get!(MOVES, sa.base, (sb.base, name)) == (sb.base, name) || error("vertex $(sa.base) moved twice") + return EB(SPoly(Sym(name, sa.i)), SPoly(), zero(Q), false, true, nothing, nothing, nothing) + end + return withrel(SPoly(Sym(name, sa.i)), one(Q), false, true) + end + _round(_add_exact(a, op == :- ? _neg(b) : b)) +end + +# Reduce a scalar that sums over components to norms before it scales a +# single component. +function _reduce(a::EB, b::EB) + length(indices(a.mag) ∪ indices(a.err)) > 1 && length(indices(b.mag) ∪ indices(b.err)) == 1 || return a + EB(normbound(a.mag), normbound(a.err), nothing, a.nonneg, false, nothing, nothing, nothing) +end + +function _mul_exact(a::EB, b::EB) + (a.con == 0 || b.con == 0) && return EB(0) + a.con == 1 && return b + b.con == 1 && return a + nonneg = a === b || (a.nonneg && b.nonneg) + a, b = _reduce(a, b), _reduce(b, a) + mag = a.mag * b.mag + exact = a.exact && b.exact + if a.rel !== nothing && b.rel !== nothing + withrel(mag, a.rel + b.rel, nonneg, exact) + else + withabs(mag, a.err*b.mag + a.mag*b.err, nonneg, exact) + end +end + +# `1/|B|` for the exact value of a divisor: a single monomial, or `Σᵢ Xᵢ²` +# for the squared norm `‖X‖²`. +function _inv_mag(p::SPoly) + if length(p.t) == 1 + (m, c), = p.t + return SPoly(Dict(Mono([s => -e for (s, e) in m]) => inv(c))) + end + bases = unique(s.base for m in keys(p.t) for (s, _) in m) + sq(b) = Dict([Sym(b, i) => 2] => one(Q) for i in 1:3) + length(bases) == 1 && p.t == sq(only(bases)) && return SPoly(Sym(only(bases), 0), -2) + error("cannot divide by an exact value of $p") +end + +function _div_exact(a::EB, b::EB) + b.nonneg && b.exact && b.rel !== nothing || error("divisor must be nonnegative with a known exact size and relative error") + invb = _inv_mag(b.mag) + mag = a.mag * invb + if a.rel !== nothing + withrel(mag, a.rel + b.rel, a.nonneg, a.exact) + else + # First order: a/(B(1 + β)) = a/B - (a/B)β. + withabs(mag, a.err*invb + b.rel*a.mag*invb, a.nonneg, a.exact) + end +end + +function Base.:*(a::EB, b::EB) + x = _round(_mul_exact(a, b)) + if a === b + # √(x̂²(1 + δ)) = |x̂|(1 + δ/2). + return _withroot(x, a.rel === nothing ? (a.err, Q(1//2)) : (SPoly(), a.rel + Q(1//2))) + end + # A squared distance times a nonnegative exact square, such as `1/‖N‖²`. + for (s, w) in ((a, b), (b, a)) + s.root === nothing && continue + w.nonneg && w.exact && w.rel !== nothing || continue + sq = _sqrtmag(w.mag) + sq === nothing && continue + return _withroot(x, (s.root[1]*sq, s.root[2] + w.rel/2 + Q(1//2))) + end + x +end +Base.:-(a::EB, b::EB) = _addsub(:-, a, b) +Base.:+(a::EB, b::EB) = _addsub(:+, a, b) +Base.:-(a::EB) = _neg(a) +Base.:/(a::EB, b::EB) = _round(_div_exact(a, b)) +Base.inv(b::EB) = EB(1)/b +Base.fma(a::EB, b::EB, c::EB) = _round(_add_exact(_mul_exact(a, b), c)) + +function SimplexCellLists.dot3(a::SVector{3, EB}, b::SVector{3, EB}) + x = fma(a[3], b[3], fma(a[2], b[2], a[1]*b[1])) + a === b || return x + # The sum of squares of the computed `a` has relative error γ₃, and + # `‖â‖` is within `‖â - a‖` of `‖a‖`. + _withroot(x, x.rel === nothing ? (vecnormbound([ai.err for ai in a]), Q(3//2)) : (SPoly(), x.rel/2)) +end + +function SimplexCellLists.cross3_accurate(a::SVector{3, EB}, b::SVector{3, EB}) + sa, sb = _single.(a), _single.(b) + if all(!isnothing, sa) && all(!isnothing, sb) && allequal(s.base for s in sa) && allequal(s.base for s in sb) + # Each component is Kahan's 2×2 determinant of exact values, within + # 2u of the exact component (Jeannerod, Louvet, Muller 2013). + name = isempty(CROSS_NAMES) ? Symbol(sa[1].base, :_x_, sb[1].base) : popfirst!(CROSS_NAMES) + CROSS_DEFS[name] = (sa[1].base, sb[1].base) + return SVector(ntuple(i -> withrel(SPoly(Sym(name, i)), Q(2), false, true), 3)) + end + det(p, q, u, v) = (x = _add_exact(_mul_exact(p, q), _neg(_mul_exact(u, v))); withabs(x.mag, x.err + 2*x.mag, false, false)) + SVector(det(a[2], b[3], a[3], b[2]), det(a[3], b[1], a[1], b[3]), det(a[1], b[2], a[2], b[1])) +end + +# The symbol and relative error of a value that is a single size symbol +# times `1 + rel*u`, or `nothing`. +function _symrel(x::EB) + x.exact && x.rel !== nothing && length(x.mag.t) == 1 || return nothing + (m, c), = x.mag.t + isone(c) && length(m) == 1 && m[1][2] == 1 && m[1][1].i > 0 ? (m[1][1], x.rel) : nothing +end + +function SimplexCellLists.cross3(a::SVector{3, EB}, b::SVector{3, EB}) + sa, sb = _symrel.(a), _symrel.(b) + if all(!isnothing, sa) && all(!isnothing, sb) && allequal(first(s).base for s in sa) && allequal(first(s).base for s in sb) + # Component i is fma(aⱼ, bₖ, -fl(aₖbⱼ)). With |Xᵢ| = |aⱼ||bₖ| + |aₖ||bⱼ|, + # the input errors give (rel_a + rel_b)|Xᵢ|, the product rounding at + # most |Xᵢ|, and the fma rounding at most |cᵢ| ≤ |Xᵢ|. + name = Symbol(:X_, first(sa[1]).base, :_, first(sb[1]).base) + ABSCROSS_DEFS[name] = (first(sa[1]).base, first(sb[1]).base) + rel = maximum(last, sa) + maximum(last, sb) + 2 + return SVector(ntuple(i -> withabs(SPoly(Sym(name, i)), rel*SPoly(Sym(name, i)), false, false), 3)) + end + SVector(fma(a[2], b[3], -(a[3]*b[2])), fma(a[3], b[1], -(a[1]*b[3])), fma(a[1], b[2], -(a[2]*b[1]))) +end + +""" + substitute_abscross(p::SPoly) + +Replace each `‖X‖` of a plain `cross3(a, b)` by its bound `√2‖a‖‖b‖`, +rounded up to `3/2‖a‖‖b‖`. Call after [`normbound`](@ref). +""" +function substitute_abscross(p::SPoly) + out = SPoly() + for (m, c) in p.t + term = SPoly(c) + for (s, e) in m + if s.i == 0 && haskey(ABSCROSS_DEFS, s.base) + e > 0 || error("‖$(s.base)‖ has a negative exponent") + a, b = ABSCROSS_DEFS[s.base] + term = term * SPoly(Q(3//2)^e) * SPoly(Sym(a, 0), e) * SPoly(Sym(b, 0), e) + else + term = term * SPoly(s, e) + end + end + out += term + end + out +end + +abstract type AbstractCond end +struct Cond <: AbstractCond + op::Symbol + lhs::EB + rhs::EB +end +struct CondAnd <: AbstractCond + parts::Vector{Cond} +end +_parts(c::Cond) = [c] +_parts(c::CondAnd) = c.parts +Base.:<(a::EB, b::EB) = Cond(:lt, a, b) +Base.:≤(a::EB, b::EB) = Cond(:le, a, b) +Base.:&(a::AbstractCond, b::AbstractCond) = CondAnd([_parts(a); _parts(b)]) + +struct NaNCond + x::EB +end +Base.isnan(x::EB) = NaNCond(x) +function SIMD.vifelse(c::NaNCond, a, b) + push!(ASSUMPTIONS, "a clamped value is never NaN (no zero length divisor)") + b +end +SIMD.vifelse(c::AbstractCond, a, b) = error("select on $c outside `_select_d2_r_s_t` is not traced") + +# Clamping is 1-Lipschitz, so it does not increase the error. +function Base.FastMath.max_fast(x::EB, z::EB) + z.con == 0 || error("max_fast is only traced against zero") + x.nonneg ? x : withabs(x.mag, x.err, true, false) +end +function Base.FastMath.min_fast(x::EB, o::EB) + o.con == 1 && x.nonneg && return withabs(SPoly(1), x.err, true, false) + # |min(x̂, ô) - min(x, o)| ≤ max(|x̂ - x|, |ô - o|). + withabs(maxpoly(x.mag, o.mag), maxpoly(x.err, o.err), x.nonneg && o.nonneg, false) +end + +# Join two arms: each bound is the larger of the two. +function _join(a::EB, b::EB) + root = a.root !== nothing && b.root !== nothing ? (maxpoly(a.root[1], b.root[1]), max(a.root[2], b.root[2])) : nothing + rel = a.rel !== nothing && b.rel !== nothing ? max(a.rel, b.rel) : nothing + EB(maxpoly(a.mag, b.mag), maxpoly(a.err, b.err), rel, a.nonneg && b.nonneg, false, nothing, nothing, root) +end + +function SimplexCellLists._select_d2_r_s_t(c::AbstractCond, a::Tuple, b::Tuple) + d2 = _join(a[1], b[1]) + if c isa Cond && c.op == :lt && c.lhs === a[1] && c.rhs === b[1] + # A running minimum: |min(â, b̂) - min(a, b)| ≤ max(|â - a|, |b̂ - b|), + # and either arm that can be chosen has a distance within the error + # of the minimum, so `rel*ρ` stays in terms of the minimum. + push!(NOTES, "running minimum: no jump in the distance; r, s, t are those of the chosen candidate") + else + vars = EB[] + for p in _parts(c) + v = p.lhs.con === nothing ? p.lhs : p.rhs + (p.lhs.con === nothing) ⊻ (p.rhs.con === nothing) || error("condition must compare a variable with a constant") + any(w -> w === v, vars) || push!(vars, v) + end + isempty(JUMPS) && error("no jump constants for this select") + Ls = popfirst!(JUMPS) + length(Ls) == length(vars) || error("$(length(vars)) condition variables, $(length(Ls)) jump constants") + jump = sum(v.err * L for (v, L) in zip(vars, Ls)) + d2.root === nothing && error("select arms have no distance bound") + d2 = _withroot(d2, (d2.root[1] + jump, d2.root[2])) + push!(NOTES, "inside select: jump $(jump) in the distance; r, s, t are those of the chosen arm") + end + r = SVector(ntuple(i -> _join(a[2][i], b[2][i]), 3)) + (d2, r, _join(a[3], b[3]), _join(a[4], b[4])) +end + +end diff --git a/error-analysis/analyze.jl b/error-analysis/analyze.jl new file mode 100644 index 0000000..a2baa20 --- /dev/null +++ b/error-analysis/analyze.jl @@ -0,0 +1,189 @@ +# Trace the first order error bounds of the distance kernels with +# ErrorBounds.jl, then check them against BigFloat on adversarial inputs. +# +# Run from the test environment, which has SimplexCellLists and StaticArrays: +# include("error-analysis/analyze.jl") + +using SimplexCellLists, StaticArrays, Random, LinearAlgebra +using SimplexCellLists: dist_sqr_r_s_t +include(joinpath(@__DIR__, "ErrorBounds.jl")) +using .ErrorBounds +using .ErrorBounds: DEFS + +setprecision(BigFloat, 512) + +norm_sym(n) = SPoly(Sym(n, 0)) +ratio_sym(a, b) = SPoly(Sym(a, 0)) * SPoly(Sym(b, 0), -1) + +const KERNELS = ( + point_point = (x = (:x,), y = (:y,), + names = Dict((:-, :y, :x) => :R)), + point_line = (x = (:x,), y = (:a, :b), + names = Dict((:-, :a, :x) => :P, (:-, :b, :a) => :R)), + point_triangle = (x = (:x,), y = (:a, :b, :c), + names = Dict((:-, :b, :a) => :E0, (:-, :c, :a) => :E1, (:-, :a, :x) => :BP, + (:-, :E1, :E0) => :E2, (:+, :BP, :E0) => :BP2), + cross = [:N], + # In plane distance from the boundary per unit of `sbar`, `tbar` and + # `sbar + tbar`: the heights of the triangle over its edges. + jumps = [[ratio_sym(:N, :E1), ratio_sym(:N, :E0), ratio_sym(:N, :E2)]]), + line_line = (x = (:x1, :x2), y = (:y1, :y2), + names = Dict((:-, :x2, :x1) => :P, (:-, :y2, :y1) => :Q, (:-, :y1, :x1) => :W, + (:+, :W, :Q) => :WQ, (:-, :W, :P) => :WP), + cross = [:n], + # In plane distance between the projected line segments per unit of + # `sbar` and `tbar`: `sin θ` times the length of the other segment. + jumps = [[ratio_sym(:n, :Q), ratio_sym(:n, :P)]]), +) + +""" + trace(k) + +Run kernel `k` on traced inputs. Return the distance bound +`|√d̂² - d| ≤ u*(abs + rel*d + moves)` as `(; abs, rel, moves)`, with `abs` +still in components, and the traced outputs. +""" +function trace(k) + reset!(; names=k.names, cross=get(k, :cross, Symbol[]), jumps=get(k, :jumps, Vector{SPoly}[])) + d2, r, s, t = dist_sqr_r_s_t(input_simplex(k.x...), input_simplex(k.y...)) + farthest(vs) = reduce(maxpoly, [vecnormbound(vertex_moves(v)) for v in vs]) + moves = farthest(k.x) + farthest(k.y) + # The bound in norms only, with the plain cross products bounded. + simple = substitute_abscross(normbound(d2.root[1])) + (; abs = d2.root[1], simple, rel = d2.root[2], moves, d2, r, s, t, + defs = copy(DEFS), cross_defs = copy(CROSS_DEFS), abscross_defs = copy(ABSCROSS_DEFS), + notes = unique(ErrorBounds.NOTES), assumptions = unique(ASSUMPTIONS)) +end + +# Values of every size symbol for the exact vertices. +function symvalues(k, tr, xs, ys) + vecs = Dict{Symbol, Vector{BigFloat}}() + for (n, v) in zip(k.x, xs); vecs[n] = big.(v); end + for (n, v) in zip(k.y, ys); vecs[n] = big.(v); end + function value(n) + get!(vecs, n) do + if haskey(tr.defs, n) + op, a, b = tr.defs[n] + op == :- ? value(a) - value(b) : value(a) + value(b) + else + a, b = tr.cross_defs[n] + cross(value(a), value(b)) + end + end + end + for (n, (a, b)) in tr.abscross_defs + va, vb = abs.(value(a)), abs.(value(b)) + vecs[n] = [va[2]*vb[3] + va[3]*vb[2], va[3]*vb[1] + va[1]*vb[3], va[1]*vb[2] + va[2]*vb[1]] + end + vals = Dict{Sym, BigFloat}() + for n in union(keys(vecs), keys(tr.defs), keys(tr.cross_defs)) + v = value(n) + vals[Sym(n, 0)] = norm(v) + for i in 1:3; vals[Sym(n, i)] = abs(v[i]); end + end + vals +end + +""" + check(k, tr, xs, ys) + +The error of the distance over its traced bound, for vertices `xs` and `ys`. +""" +function check(k, tr, xs::NTuple{N, SVector{3, T}}, ys::NTuple{M, SVector{3, T}}) where {N, M, T} + d2 = dist_sqr_r_s_t(SVector(xs), SVector(ys))[1] + d = sqrt(dist_sqr_r_s_t(SVector(map(v -> big.(v), xs)), SVector(map(v -> big.(v), ys)))[1]) + vals = symvalues(k, tr, xs, ys) + u = big(eps(T))/2 + bound = u*(boundvalue(tr.simple, vals) + tr.rel*d + boundvalue(tr.moves, vals)) + Float64(abs(sqrt(big(d2)) - d)/bound) +end + +function report(name, k, gens; n=20_000) + tr = trace(k) + println("=== $name ===") + println(" |√d̂² - d| ≤ u*(", tr.simple, " + ", tr.rel, "d) in the moved vertices") + println(" + u*(", tr.moves, ") for the vertex moves") + foreach(s -> println(" note: ", s), tr.notes) + foreach(s -> println(" assumes: ", s), tr.assumptions) + for T in (Float32, Float64), (gname, g) in gens + ratios = filter(isfinite, [check(k, tr, g(T)...) for _ in 1:n]) + println(" ", rpad("$T $gname", 40), " max error/bound ", round(maximum(ratios), sigdigits=3)) + end + tr +end + +randdir() = normalize(randn(3)) +vec3(T, v) = SVector{3, T}(v) +function inplane(n) + e1 = normalize(cross(n, randn(3))) + (e1, cross(n, e1)) +end + +const GENS = ( + point_line = ( + "random [0,100]" => T -> ((vec3(T, 100rand(3)),), (vec3(T, 100rand(3)), vec3(T, 100rand(3)))), + "point on the segment" => T -> begin + a = 100randn(3); b = 100randn(3); s = rand() + ((vec3(T, a .+ s .* (b .- a)),), (vec3(T, a), vec3(T, b))) + end, + "near an end, 1e4 from the origin" => T -> begin + c = 1e4randdir(); dd = randdir(); n = normalize(cross(dd, randn(3))) + s = rand((0.0, 1.0)) + 1e-6randn() + ((vec3(T, c .+ 0.3n),), (vec3(T, c .- 100s*dd), vec3(T, c .+ 100(1-s)*dd))) + end, + ), + point_triangle = ( + "random [0,100]" => T -> ((vec3(T, 100rand(3)),), Tuple(vec3(T, 100rand(3)) for _ in 1:3)), + "0.01 above, near an edge" => T -> begin + n = randdir(); e1, e2 = inplane(n); a = 100randn(3) + b = a .+ 100e1; c = a .+ 100e2; w = rand((0.0, 0.5)) + 1e-6randn() + ((vec3(T, a .+ w*100e1 .+ (0.5 - w)*100e2 .+ 0.01n),), (vec3(T, a), vec3(T, b), vec3(T, c))) + end, + "sliver" => T -> begin + dd = randdir(); n = normalize(cross(dd, randn(3))); a = 100randn(3) + b = a .+ 100dd; c = a .+ 50dd .+ 1e-3n + ((vec3(T, a .+ 40dd .+ 0.01randdir()),), (vec3(T, a), vec3(T, b), vec3(T, c))) + end, + "needle, near the short edge" => T -> begin + n = randdir(); e1, e2 = inplane(n); a = 100randn(3) + b = a .+ 100e1; c = b .+ 0.01e2; w = rand() + ((vec3(T, b .+ w*0.01e2 .+ 1e-7randn()*e1 .+ 0.01n),), (vec3(T, a), vec3(T, b), vec3(T, c))) + end, + "needle, 0.2 past the short edge" => T -> begin + n = randdir(); e1, e2 = inplane(n); a = 100randn(3) + b = a .+ 100e1; c = b .+ 0.001e2 + ((vec3(T, b .+ 0.001rand()*e2 .+ 0.2rand()*e1 .+ 0.01n),), (vec3(T, a), vec3(T, b), vec3(T, c))) + end, + "shared vertex pool" => T -> begin + pool = [vec3(T, 100rand(3)) for _ in 1:3] + ((rand(pool),), Tuple(rand(pool) for _ in 1:3)) + end, + ), + line_line = ( + "random [0,100]" => T -> (Tuple(vec3(T, 100rand(3)) for _ in 1:2), Tuple(vec3(T, 100rand(3)) for _ in 1:2)), + "crossing 0.01 apart, 1e4 from the origin" => T -> begin + c = 1e4randdir(); n = randdir(); e1, e2 = inplane(n) + d1 = normalize(randn()*e1 + randn()*e2); d2 = normalize(randn()*e1 + randn()*e2); s = rand(); t = rand() + ((vec3(T, c .- 100s*d1), vec3(T, c .+ 100(1-s)*d1)), (vec3(T, c .- 100t*d2 .+ 0.01n), vec3(T, c .+ 100(1-t)*d2 .+ 0.01n))) + end, + "nearly parallel" => T -> begin + dd = randdir(); n = normalize(cross(dd, randn(3))); e = normalize(cross(dd, n)); a = 100randn(3) + θ = 1e-4randn() + ((vec3(T, a), vec3(T, a .+ 100dd)), (vec3(T, a .+ 30dd .+ 0.01n .- 50θ*e), vec3(T, a .+ 130dd .+ 0.01n .+ 50θ*e))) + end, + "crossing near an end" => T -> begin + n = randdir(); e1, e2 = inplane(n); a = 100randn(3) + s = rand((0.0, 1.0)) + 1e-6randn(); d2 = normalize(e1 + e2) + ((vec3(T, a), vec3(T, a .+ 100e1)), (vec3(T, a .+ 100s*e1 .- 50d2 .+ 0.01n), vec3(T, a .+ 100s*e1 .+ 50d2 .+ 0.01n))) + end, + "shared vertex pool" => T -> begin + pool = [vec3(T, 100rand(3)) for _ in 1:3] + (Tuple(rand(pool) for _ in 1:2), Tuple(rand(pool) for _ in 1:2)) + end, + ), +) + +Random.seed!(4) +report("point-line", KERNELS.point_line, GENS.point_line) +report("point-triangle", KERNELS.point_triangle, GENS.point_triangle) +report("line-line", KERNELS.line_line, GENS.line_line) diff --git a/error-analysis/distances.md b/error-analysis/distances.md new file mode 100644 index 0000000..c7d55af --- /dev/null +++ b/error-analysis/distances.md @@ -0,0 +1,391 @@ +# Error analysis of `dist_sqr_r_s_t` + +This bounds the rounding error of each `dist_sqr_r_s_t` method in +[`src/distances.jl`](../src/distances.jl). The bounds compare the computed +results with the exact results for the *same* floating point inputs. The +inputs are arbitrary: no assumption is made that `x[1]` is zero, or that +the coordinates are relative to anything. + +Point-point and point-line are derived by hand. Point-triangle and line-line +are traced with [`ErrorBounds.jl`](ErrorBounds.jl), which runs the kernels +themselves on symbolic error bounds, described in +[Tracing the kernels](#tracing-the-kernels). [`analyze.jl`](analyze.jl) +prints the traced bounds and checks them against `BigFloat`. + +## Setting + +- `T` is `Float32` or `Float64`, rounding to nearest even. The unit + roundoff is `u = eps(T)/2`, `2^-24` for `Float32` and `2^-53` for `Float64`. +- Standard model: for `+`, `-`, `*` and `fma`, + `fl(a op b) = (a op b)(1 + δ)` with `|δ| ≤ u`, as long as the result + does not overflow. If the exact result is in the subnormal range, `+` and + `-` are exact, and `*` and `fma` have an absolute error of at most + `η = nextfloat(zero(T))/2` instead. +- `γₖ = k*u/(1 - k*u)`, so `(1 + u)^k - 1 ≤ γₖ`. +- Julia's `fma` always rounds once, in scalar code and in each lane of a + `SIMD.Vec`. The analysis relies on this; `muladd` may or may not fuse, so + a kernel that used it would have two different error bounds. +- `dist_sqr` returns the `d²` of `dist_sqr_r_s_t` bit for bit, and the SIMD + methods are bit identical to the scalar ones in each lane, so every bound + here applies to all of them. +- When a result can be bounded relative to its own size, it is. Otherwise + the bound is in units of `u*ℓ`, where `ℓ` is the largest absolute input + coordinate. + +## Point-point + +```julia +d = y[1] - x[1] +dot3(d, d), d, SVector{0,T}(), SVector{0,T}() +``` + +with `dot3(a, b) = fma(a[3], b[3], fma(a[2], b[2], a[1]*b[1]))`. + +Write `r = y[1] - x[1]` for the exact separation and `D = r⋅r` for the exact +squared distance. Hats mark computed values. + +### The separation `r` + +Each component is one correctly rounded subtraction: + + r̂ᵢ = rᵢ(1 + δᵢ), |δᵢ| ≤ u. + +So `|r̂ᵢ - rᵢ| ≤ u|rᵢ|` and `‖r̂ - r‖ ≤ u‖r‖`, a relative bound however +large the coordinates are. By Sterbenz's lemma the subtraction is exact +(`δᵢ = 0`) when `yᵢ/2 ≤ xᵢ ≤ 2yᵢ`, which covers points close together far +from the origin. + +### The squared distance `d²` + +`dot3` rounds `r̂₁²` once, then each `fma` once: + + p₁ = r̂₁²(1 + ε₁) + p₂ = (r̂₂² + p₁)(1 + ε₂) + d̂² = (r̂₃² + p₂)(1 + ε₃) + = r̂₃²(1 + ε₃) + r̂₂²(1 + ε₂)(1 + ε₃) + r̂₁²(1 + ε₁)(1 + ε₂)(1 + ε₃) + +with `|εⱼ| ≤ u`. Every term is nonnegative, so nothing cancels, and each +`r̂ᵢ² = rᵢ²(1 + δᵢ)²` adds two more factors. The first term has the most, +five, so + + (1 - u)⁵ D ≤ d̂² ≤ (1 + u)⁵ D, |d̂² - D| ≤ γ₅ D ≈ 5u D. + +In units of `eps(T)` that is `2.5 eps(T)` relative. For the distance, +`sqrt(d̂²)` is within about `2.5u` of `sqrt(D)` relative, plus `u` for the +rounding of the square root itself. + +The bound is relative, so it holds at any `ℓ` and implies any bound in +units of `u*ℓ`. + +### Backward error + +`r̂` is the exact separation from `x[1]` to a point `y′` with +`|y′ᵢ - yᵢ| ≤ u|rᵢ|`. Then `d̂² = ‖r̂‖²(1 + θ)` with `|θ| ≤ γ₃`, which is the +exact squared distance after scaling `r̂` by `sqrt(1 + θ)`. So `d̂²` is the +exact squared distance from `x[1]` to a point within about `2.5u‖r‖` of +`y[1]`. + +### Underflow and overflow + +- **Overflow** needs `|yᵢ - xᵢ| > floatmax(T)`, or `‖r‖` above about + `sqrt(floatmax(T))`: `1.8e19` for `Float32`, `1.3e154` for `Float64`. +- **Underflow** happens when a product falls below `floatmin(T)`, for + example `|rᵢ| < 1.1e-19` in `Float32`. Then a product or `fma` has an + absolute error of up to `η` instead of a relative one, and the bound + becomes `|d̂² - D| ≤ γ₅ D + 3(1 + u)²η`. In `Float32`, `3η` is about + `4.2e-45`. + +### Measured + +Maximum error against `BigFloat` on the same inputs, 200,000 pairs per +generator: + +| `T` | generator | `d²`, relative | `r`, componentwise relative | +|---|---|---|---| +| `Float32` | uniform in `[0, 100]³` | 4.06u | 1.0u | +| `Float32` | within 0.01 of each other, up to 1e4 from the origin | 1.39u | 0 | +| `Float32` | coordinates from 1e-3 to 1e3, either sign | 4.31u | 1.0u | +| `Float64` | uniform in `[0, 100]³` | 4.06u | 1.0u | +| `Float64` | within 0.01 of each other, up to 1e4 from the origin | 2.51u | 0 | +| `Float64` | coordinates from 1e-3 to 1e3, either sign | 4.19u | 1.0u | + +The bound of `5u` on `d²` is nearly reached. The `r` bound of `u` is +reached, and for points close together the subtraction is exact, as +Sterbenz's lemma predicts. + +## Point-line + +```julia +r = y[2] - y[1] +p = y[1] - x[1] +t = clamp01nan(-dot3(p, r)/dot3(r, r)) +d = p + scale3(t, r) +dot3(d, d), d, SVector{0,T}(), SVector{1,T}(t) +``` + +`scale3` multiplies without fusing, so `dᵢ = fl(pᵢ + fl(t*rᵢ))`. + +Exact, on the same inputs: + +- `R = y[2] - y[1]`, `P = y[1] - x[1]`, `N = P⋅R`, `C = R⋅R`. +- `T* = -N/C`, the closest point of the line, and `T = clamp(T*, 0, 1)`. +- `d = ‖P + T R‖`, the exact distance. +- `M = Σ|PᵢRᵢ|`. By Cauchy-Schwarz, `|N| ≤ M ≤ ‖P‖‖R‖`. + +### Result + +To first order in `u`, + + -u(3‖P‖ + 4.5d) ≤ sqrt(d̂²) - d ≤ u(3‖P‖ + 4.5d) + τ, + τ = min(11u‖P‖, (11u‖P‖)²/(2d)), + |t̂ - T| ≤ 11u‖P‖/‖R‖. + +Unlike point-point, the error is not relative to `d`: `p` and `t*r` cancel +when the point is near the line. It scales with `‖P‖`, the distance from +the point to `y[1]`, not with the distance from the origin. `τ` is the +error of `t̂`, which only makes the distance larger, and only to second +order unless `d` is below about `11u‖P‖`. + +### The parameter `t` + +The subtractions give `r̂ = R(1 + δ)` and `p̂ = P(1 + δ′)` componentwise. + +In `n̂ = dot3(p̂, r̂)`, each term `PᵢRᵢ` picks up at most five factors +`(1 + ε)`: two from the subtractions and up to three in the `fma` chain. +Measured against `M` instead of `N`, cancellation between the terms +doesn't matter, so `|n̂ - N| ≤ γ₅ M`. `ĉ = dot3(r̂, r̂)` is `C(1 + θ)` with +`|θ| ≤ γ₅`, as for point-point. + +The division rounds once, `t̃ = (-n̂/ĉ)(1 + ρ)`, so + + t̃ - T* = -((n̂ - N)/C)(1 + ρ)/(1 + θ) + T*((1 + ρ)/(1 + θ) - 1), + |t̃ - T*| ≤ 5u M/C + 6u|T*| ≤ 11u M/C ≤ 11u‖P‖/‖R‖, + +using `|T*| = |N|/C ≤ M/C`. Clamping can only bring `t̂` closer to `T`, so +`|t̂ - T| ≤ |t̃ - T*|`. If `n̂` is zero, `t̃` is exact zero and the same +bound holds. If `R` is zero, `ĉ` is zero, `t̂` is `0` from the `NaN`, and +the result is the point-point distance to `y[1]`, which is exact here. + +### How the error in `t` moves the distance + +Let `η = |t̃ - T*|`. Then + + d² ≤ ‖P + t̂R‖² ≤ d² + η²‖R‖². + +The lower bound holds because `T` minimizes `‖P + sR‖` over `s ∈ [0, 1]`, +and `t̂` is in `[0, 1]`. For the upper bound, expand +`‖P + sR‖² = d² + 2(s - T)(P + TR)⋅R + (s - T)²‖R‖²`: + +- **`T` inside:** `(P + TR)⋅R = 0`, and `|t̂ - T| ≤ η`. +- **`T = 0`:** then `T* ≤ 0`, so the cross term is `2t̂|T*|‖R‖²`. Since `t̂` + is at most `max(t̃, 0)`, `t̂ + |T*| ≤ η`, and so + `2t̂|T*| + t̂² ≤ (t̂ + |T*|)² ≤ η²`. +- **`T = 1`:** the same, mirrored. + +So `‖P + t̂R‖ - d ≤ min(η‖R‖, η²‖R‖²/(2d)) ≤ τ`. The error in `t` costs a +first order error only when the point is within about `11u‖P‖` of the +segment. + +### Forming the separation `d` + +With `mᵢ = t̂r̂ᵢ(1 + αᵢ)` and `d̂ᵢ = (p̂ᵢ + mᵢ)(1 + βᵢ)`, + + d̂ = P + t̂R + E, + |Eᵢ| ≤ u|Pᵢ| + 2u t̂|Rᵢ| + u|Pᵢ + t̂Rᵢ| + O(u²), + ‖E‖ ≤ u(‖P‖ + 2t̂‖R‖ + d) + O(u²) ≤ u(3‖P‖ + 3d) + O(u²), + +using `t̂‖R‖ ≤ ‖P‖ + ‖P + t̂R‖`. + +The last `dot3` adds a relative error of `γ₃` to `d̂²`, about `1.5u d` on +the distance. Adding `‖E‖`, `τ` and this gives the result above. + +The returned separation `r̂ = d̂` is within `‖E‖ + η‖R‖ ≤ u(14‖P‖ + 3d)` of +the exact `P + TR`. Its direction can be off by about `u‖P‖/d`, along the +segment, when the point is close to the segment. + +### Measured + +Maximum of the error over the bound, against `BigFloat` on the same inputs, +100,000 cases per generator: + +| generator | `Float32` distance | `Float32` `t` | `Float64` distance | `Float64` `t` | `Float32` distance in `u·ℓ` | +|---|---|---|---|---|---| +| uniform in `[0, 100]³` | 0.36 | 0.33 | 0.43 | 0.36 | 1.97 | +| 0.01 from a segment 50 long, 1e4 from the origin | 0.46 | 0.40 | 0.36 | 0.38 | 0.008 | +| projection within 1e-6 of an end | 0.60 | 0.44 | 0.54 | 0.36 | 2.4 | +| segment 0.01 long, point 1000 away | 0.23 | 0 | 0.23 | 0 | 2.57 | +| point on the segment | 0.31 | 0.40 | 0.32 | 0.45 | 7.07 | +| vertices from a pool of three | 0.38 | 0.38 | 0.40 | 0.33 | 1.78 | + +The bound holds with a margin of about two. The last column shows why it is +written in `‖P‖` rather than `ℓ`, the largest coordinate: far from the +origin, the error is much smaller than `u·ℓ`. + +## Tracing the kernels + +[`ErrorBounds.jl`](ErrorBounds.jl) defines a number type `EB` and runs +`dist_sqr_r_s_t` on it, so the analysis follows the code as written, with +nothing transcribed. An `EB` carries two bounds, as polynomials with +nonnegative coefficients in size symbols such as `|Pᵢ|`, `‖P‖` and `‖R‖⁻²`: +`mag` on the absolute value of the exact result, and `err` on the rounding +error, in units of `u`. Each operation applies a rule that holds for all +inputs, so the result is a bound for all inputs in terms of the sizes. Terms +of order `u²` are dropped. + +The rules: + +- **Sums and differences.** A sum or difference of two exact values, each a + single size symbol, is a new size symbol, rounded once, so its error is + relative to itself. For example `P = y[1] - x[1]` gives `|Pᵢ|`, and the + bounds are in the sizes of the differences, not of the coordinates. +- **Vertex moves.** A rounded difference of two input vertices is treated + as the exact difference after moving the first vertex. Following the + differences back gives how far each vertex moved, and the exact distance + changes by at most the largest move on each simplex. The rest of the + kernel is then analysed on the moved vertices. This is why the kernels + reuse the same rounded `E0` and `E1`: they compute the distance of a + nearby triangle accurately, even a sliver. +- **Nonnegative sums**, such as `dot3(r, r)`, keep a relative error: the + largest of their terms', plus one rounding. +- **Signed sums** are bounded by the sum of the magnitudes of their terms, + as in the hand analysis. Correlations survive because the magnitudes stay + symbolic. A sum over components that then scales one component, like `t` + in `p + t*r`, is reduced to norms with Cauchy-Schwarz, and vector errors + with Minkowski. +- **Division** needs an exact nonnegative divisor with a relative error, + such as `dot3(R, R) = ‖R‖²`. +- **`cross3_accurate`** of exact vectors has each component within `2u` of + the exact one (Kahan's 2×2 determinant, Jeannerod, Louvet and Muller 2013), + and its components become size symbols, such as `|Nᵢ|`. +- **Plain `cross3(a, b)`** has component `i` within + `(rel_a + rel_b + 2)u·|Xᵢ|`, where `|Xᵢ| = |aⱼ||bₖ| + |aₖ||bⱼ|` also bounds the + component, and `‖X‖ ≤ √2‖a‖‖b‖`. +- **Clamps and `min`** are 1-Lipschitz, so they don't add error. The `NaN` + branch of `clamp01nan` is assumed not to be taken: the divisor is not zero. +- **Squared distances** also carry `|√d̂² - ρ| ≤ u(abs + rel·ρ)` for their + exact root `ρ`, from `dot3(r, r)` and from `h*h/‖N‖²`. + +The kernels are branch free, so every select has both arms traced. A select +joins them, taking the larger bound of each term, and adds a jump in the +distance where the computed and exact conditions can disagree: + +- **Running minimum**, `cand[1] < best[1]`: no jump, because + `|min(â, b̂) - min(a, b)| ≤ max(|â - a|, |b̂ - b|)`. Either candidate that + can be chosen has a distance within the error of the minimum, so the + `rel·ρ` terms stay in terms of the final distance. +- **`inside` test**: a jump of `Σ err(v)·L(v)` over its distinct condition + variables `v`. `L(v)` is how far the boundary is, in distance in the plane, + per unit of `v`. It is the one hand derived fact for each kernel, given + below. + +For these selects, `r`, `s` and `t` are compared with the exact values of +the arm or candidate that was chosen, not of the exact choice: at a tie, or +across the `inside` boundary, the closest points can jump while the distance +does not. + +## Point-triangle + +With `B = y[1]`, `E0 = y[2] - B`, `E1 = y[3] - B`, `BP = B - x[1]`, +`E2 = E1 - E0`, `BP2 = BP + E0 = y[2] - x[1]` and `N = E0×E1`. + +### The jump at the inside test + +The test is `0 ≤ sbar`, `0 ≤ tbar` and `0 ≤ wbar`, the barycentric weights of +`y[2]`, `y[3]` and `B`. Let `Y` be the projection of `x[1]` onto the plane, at +height `h`. If `Y` is outside the triangle, at distance `g` in the plane, the +closest point is on an edge, and `d² = h² + g²`. So choosing the wrong arm +costs at most `g`. A weight `v` is the distance of `Y` from the line of the +opposite edge over the height of its vertex over that edge, so if the +computed and exact signs of `v` disagree, `Y` is within `err(v)·L(v)` of +that line, with + + L(sbar) = ‖N‖/‖E1‖, L(tbar) = ‖N‖/‖E0‖, L(wbar) = ‖N‖/‖E2‖. + +These are the heights of the triangle. The edge lengths would also be +bounds, but they would give a spurious `1/sin θ` for slivers. + +### The needle fix + +The kernel used to test `sbar + tbar ≤ 1` instead of `0 ≤ wbar`. The traced +bound then had a term proportional to `u‖BP‖(‖E0‖ + ‖E1‖)/‖E2‖`. The errors of `sbar` and +`tbar` are about `u‖BP‖‖E0‖/‖N‖` and `u‖BP‖‖E1‖/‖N‖`, and their sum was +tested against the height over the short edge `E2` of a needle. It is +real: for a needle 100 long with a short edge of 0.001, a point 0.01 above +the plane that projects 0.2 past the short edge was judged inside in +`Float32`, with a distance of 0.01 instead of 0.2. `wbar` is now computed from +its own edge, `N⋅(BP2×E2)/‖N‖²`, and its error over `L(wbar)` is about +`u‖BP2‖`. + +Inside, `sbar` and `sbar + tbar` can round above one, so the returned weights +are capped: `s = min(sbar, 1)` and `t = min(tbar, 1 - s)`. + +### Result + + |√d̂² - d| ≤ u(62‖BP‖ + 40‖BP2‖ + 3‖E0‖ + 3‖E1‖ + 3‖E2‖ + 5d) + +including the vertex moves, `u(‖BP‖ + ‖E0‖ + ‖E1‖)`. Most of it is the jump: +`(16 + 16)·(3/2)‖BP‖` and `18·(3/2)‖BP2‖` from the errors of the three weights. + +Largest error over the bound, 20,000 cases per generator: + +| generator | `Float32` | `Float64` | +|---|---|---| +| uniform in `[0, 100]³` | 0.022 | 0.023 | +| 0.01 above, projecting within 1e-6 of an edge | 0.011 | 0.011 | +| sliver | 0.010 | 0.011 | +| needle, near the short edge | 0.012 | 0.012 | +| needle, 0.2 past the short edge | 0.012 | 0.014 | +| vertices from a pool of three | 0.017 | 0.018 | + +The bound holds everywhere but is loose by a factor of about 50: it adds the +worst error of every weight at once. + +## Line-line + +With `P = x[2] - x[1]`, `Q = y[2] - y[1]`, `W = y[1] - x[1]`, +`WQ = W + Q = y[2] - x[1]`, `WP = W - P = y[1] - x[2]` and `n = P×Q`. + +### The jump at the inside test + +The test is `0 ≤ sbar ≤ 1` and `0 ≤ tbar ≤ 1`. Project both line segments onto +a plane normal to `n`. They cross there exactly when the closest points of +the lines are inside both segments, and then `d = h`, the distance along +`n`. Otherwise the closest points are an end point and its closest point on +the other segment, and `d² = h² + g²`, with `g` the distance in the plane +between the projected segments. If `sbar` is off by `err(sbar)`, the crossing +moves by `err(sbar)‖P‖` along `P`, which is `err(sbar)‖P‖ sin θ = err(sbar)‖n‖/‖Q‖` +from the projection of `y`. So + + L(sbar) = ‖n‖/‖Q‖, L(tbar) = ‖n‖/‖P‖. + +With `‖P‖` and `‖Q‖` instead, nearly parallel line segments would get a +spurious `1/sin θ`. + +### Result + + |√d̂² - d| ≤ u(10‖P‖ + 10‖Q‖ + 63‖W‖ + 2‖WP‖ + 2‖WQ‖ + 5d) + +including the vertex moves, `u(‖P‖ + ‖Q‖ + ‖W‖)`. Most of it is the jump, +`(16 + 16)·(3/2)‖W‖`. + +Largest error over the bound, 20,000 cases per generator: + +| generator | `Float32` | `Float64` | +|---|---|---| +| uniform in `[0, 100]³` | 0.028 | 0.028 | +| crossing 0.01 apart, 1e4 from the origin | 0.015 | 0.022 | +| nearly parallel, `θ ≈ 1e-4` | 0.031 | 0.022 | +| crossing within 1e-6 of an end | 0.007 | 0.006 | +| vertices from a pool of three | 0.039 | 0.039 | + +The bound has no `1/sin θ`, and nothing that grows with the distance from the +origin, but like point-triangle it is loose by a factor of about 30. + +## Point-line, traced + +For comparison with the hand analysis, the tracer gives + + |√d̂² - d| ≤ u(9‖P‖ + 3‖R‖ + 1.5d) + +including the vertex moves, `u(‖P‖ + ‖R‖)`. The largest error over this +bound was 0.35, for a point on the segment. diff --git a/src/distances.jl b/src/distances.jl index 8313598..4658d0b 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -22,242 +22,197 @@ function dist_sqr_r_s_t end dist_sqr(x::Simplex{N,T}, y::Simplex{M,T})::T Return the minimum squared distance between two simplexes. + +This is the `d²` of [`dist_sqr_r_s_t`](@ref), bit for bit. """ -function dist_sqr(x::Simplex{N, T}, y::Simplex{M, T}) where {T, N, M} - d2, r, s, t = @inline dist_sqr_r_s_t(y, x) +@inline function dist_sqr(x::Simplex{N, T}, y::Simplex{M, T}) where {T, N, M} + # Inlining even the large methods lets LLVM drop the unused `r, s, t`. + d2, r, s, t = @inline dist_sqr_r_s_t(x, y) d2 end # Dispatch N > M to N ≤ M -function dist_sqr_r_s_t(x::Simplex{3, T}, y::Simplex{2, T}) where T - d2, r, s, t = @inline dist_sqr_r_s_t(y, x) +@inline function dist_sqr_r_s_t(x::Simplex{3, T}, y::Simplex{2, T}) where T + d2, r, s, t = dist_sqr_r_s_t(y, x) d2, -r, t, s end -function dist_sqr_r_s_t(x::Simplex{3, T}, y::Simplex{1, T}) where T - d2, r, s, t = @inline dist_sqr_r_s_t(y, x) +@inline function dist_sqr_r_s_t(x::Simplex{3, T}, y::Simplex{1, T}) where T + d2, r, s, t = dist_sqr_r_s_t(y, x) d2, -r, t, s end -function dist_sqr_r_s_t(x::Simplex{2, T}, y::Simplex{1, T}) where T - d2, r, s, t = @inline dist_sqr_r_s_t(y, x) +@inline function dist_sqr_r_s_t(x::Simplex{2, T}, y::Simplex{1, T}) where T + d2, r, s, t = dist_sqr_r_s_t(y, x) d2, -r, t, s end -function dist_sqr(x::Simplex{3, T}, y::Simplex{2, T}) where T - @inline dist_sqr(y, x) -end -function dist_sqr(x::Simplex{3, T}, y::Simplex{1, T}) where T - @inline dist_sqr(y, x) -end -function dist_sqr(x::Simplex{2, T}, y::Simplex{1, T}) where T - @inline dist_sqr(y, x) -end -# Branch free update of the running minimum `r` and its `s` and `t`. -# Works on `SIMD.Vec` lanes. Ties keep the earlier minimum. -@inline function _min_r_s_t(minr, mins, mint, r, s, t) - better = r < minr - SIMD.vifelse(better, r, minr), SIMD.vifelse(better, s, mins), SIMD.vifelse(better, t, mint) +# Branch free selection of `(d2, r, s, t)` tuples, with `r` a `Vec3` and `s` +# and `t` scalars. Works on `SIMD.Vec` lanes. +@inline function _select_d2_r_s_t(c, (d2a, ra, sa, ta), (d2b, rb, sb, tb)) + ( + SIMD.vifelse(c, d2a, d2b), + SVector(SIMD.vifelse(c, ra[1], rb[1]), SIMD.vifelse(c, ra[2], rb[2]), SIMD.vifelse(c, ra[3], rb[3])), + SIMD.vifelse(c, sa, sb), + SIMD.vifelse(c, ta, tb), + ) end +# Branch free update of the running minimum `best` with `cand`. +# Ties keep the earlier minimum. +@inline _min_d2_r_s_t(best, cand) = _select_d2_r_s_t(cand[1] < best[1], cand, best) + # The methods -function dist_sqr_r_s_t(x::Point{T}, y::Point{T}) where T +@inline function dist_sqr_r_s_t(x::Point{T}, y::Point{T}) where T d = y[1] - x[1] dot3(d, d), d, SVector{0,T}(), SVector{0,T}() end -function dist_sqr_r_s_t(x::Point{T}, y::LineSeg{T}) where T +@inline function dist_sqr_r_s_t(x::Point{T}, y::LineSeg{T}) where T r = y[2] - y[1] - p = x[1] - y[1] - t = clamp01nan(dot3(p, r)/dot3(r, r)) - d = scale3(t, r) - p + # Written as `y - x[1]` so it folds to `y[1]` when `x[1]` is positive zero. + p = y[1] - x[1] + t = clamp01nan(-dot3(p, r)/dot3(r, r)) + d = p + scale3(t, r) dot3(d, d), d, SVector{0,T}(), SVector{1,T}(t) end #= -Based on https://www.geometrictools.com/Documentation/DistancePoint3Triangle3.pdf -But not fully optimized. -// David Eberly, Geometric Tools, Redmond WA 98052 -// Copyright (c) 1998-2022 -// Distributed under the Boost Software License, Version 1.0. -// https://www.boost.org/LICENSE_1_0.txt -// https://www.geometrictools.com/License/Boost/LICENSE_1_0.txt -// Version: 6.0.2022.01.06 +The closest point on the triangle is either the projection of the point onto +the plane of the triangle, if that is inside the triangle, or on an edge. + +This avoids expanding the squared distance as a quadratic in the barycentric +coordinates, which cancels: its absolute error scales with the squared size +of the triangle, not with the distance. Here the distance inside is along the +normal, and each edge candidate is `dot(r, r)` of its own separation `r`, so +the error of the distance is about a rounding of the coordinates. + +`N` is from `cross3_accurate`, so it is exactly zero for an exactly +degenerate triangle, which then always uses the edges. With `cross3`, `N` of +a triangle with two equal edges is a rounding error, and the inside test can +pass by mistake. Unlike `(b*e - c*d)/(a*c - b*b)`, the cross product form of +the barycentric coordinates is also reliable for slivers. + +Each barycentric coordinate is computed from its own edge, with an error of +about a rounding of `‖BP‖` times the length of that edge over `‖N‖`, so the +inside test is off by about a rounding of `‖BP‖` in distance from that edge. +Testing `sbar + tbar ≤ 1` instead puts the errors of `sbar` and `tbar`, which +scale with the long edges, against the short edge `E2` of a needle, and in +`Float32` a point 0.2 past the short edge of a needle 100 long could be +judged inside. + +The projection onto the plane is from Kahan, "Computing Cross-Products and +Rotations in 2- and 3-Dimensional Euclidean Spaces" (2016), §9, and the +edges as in Ericson, "Real-Time Collision Detection" (2005), §5.1.5. See the +`LineSeg`-`LineSeg` method for the references on `cross3_accurate`. =# -function dist_sqr_r_s_t(x::Point{T}, y::Triangle{T}) where T - P = x[1] +@inline function dist_sqr_r_s_t(x::Point{T}, y::Triangle{T}) where T B = y[1] E0 = y[2] - B E1 = y[3] - B - BP = B - P - a = dot3(E0, E0) - b = dot3(E0, E1) - c = dot3(E1, E1) - d = dot3(E0, BP) - e = dot3(E1, BP) - f = dot3(BP, BP) - Δ = a*c - b*b + # Written as `y - x[1]` so it folds to `B` when `x[1]` is positive zero. + BP = B - x[1] + N = cross3_accurate(E0, E1) + Δ = dot3(N, N) invΔ = inv(Δ) - sbar = clamp01nan((b*e - c*d)*invΔ) - tbar = clamp01nan((-a*e + b*d)*invΔ) - - # this ensures sbar and tbar are in the domain - # if they are out of the domain, the min distance is on a boundary - outside = sbar+tbar > one(sbar) - sbar = SIMD.vifelse(outside,zero(sbar),sbar) - tbar = SIMD.vifelse(outside,zero(tbar),tbar) - s0 = clamp01nan(-d/a) - t0 = clamp01nan(-e/c) - sd = clamp01nan(-(b-c+d-e)/(a-2b+c)) - td = one(sd) - sd - @inline r(s,t) = a*(s*s) + 2b*s*t + c*(t*t) + 2d*s + 2e*t - - mins = sbar - mint = tbar - minr = r(sbar,tbar) - minr, mins, mint = _min_r_s_t(minr, mins, mint, r(s0,zero(a)), s0, zero(a)) - minr, mins, mint = _min_r_s_t(minr, mins, mint, r(zero(a),t0), zero(a), t0) - minr, mins, mint = _min_r_s_t(minr, mins, mint, r(sd,td), sd, td) - d2cl = Base.FastMath.max_fast(zero(a),f+minr) - rcl = scale3(mins, E0) + scale3(mint, E1) + BP - d2cl, rcl, SVector{0,T}(), SVector{2,T}(mins, mint) + # The barycentric coordinates of the projection onto the plane, by + # Lagrange's identity, without the cancellation in `(b*e - c*d)/(a*c - b*b)`. + sbar = dot3(N, cross3(E1, BP))*invΔ + tbar = dot3(N, cross3(BP, E0))*invΔ + # The edge from `B + E0` to `B + E1`. + E2 = E1 - E0 + BP2 = BP + E0 + # The weight of `B`, `1 - sbar - tbar`, from its own edge. The sum + # `sbar + tbar` cancels when `E2` is much shorter than `E0` and `E1`. + wbar = dot3(N, cross3(BP2, E2))*invΔ + # False for NaN, from a degenerate triangle. + inside = (zero(T) ≤ sbar) & (zero(T) ≤ tbar) & (zero(T) ≤ wbar) + hN = dot3(BP, N) + rin = scale3(hN*invΔ, N) + d2in = hN*hN*invΔ + # The closest points on the edges from `B` along `E0` and `E1`, and from + # `B + E0` along `E2`. + u0 = clamp01nan(-dot3(E0, BP)/dot3(E0, E0)) + r0 = BP + scale3(u0, E0) + best = (dot3(r0, r0), r0, u0, zero(T)) + u1 = clamp01nan(-dot3(E1, BP)/dot3(E1, E1)) + r1 = BP + scale3(u1, E1) + best = _min_d2_r_s_t(best, (dot3(r1, r1), r1, zero(T), u1)) + u2 = clamp01nan(-dot3(BP2, E2)/dot3(E2, E2)) + r2 = BP2 + scale3(u2, E2) + best = _min_d2_r_s_t(best, (dot3(r2, r2), r2, one(T) - u2, u2)) + # Inside, `sbar` and `sbar + tbar` can round above one, so cap them to + # keep the weights a convex combination. + s_in = Base.FastMath.min_fast(sbar, one(T)) + t_in = Base.FastMath.min_fast(tbar, one(T) - s_in) + d2, r, s, t = _select_d2_r_s_t(inside, (d2in, rin, s_in, t_in), best) + d2, r, SVector{0,T}(), SVector{2,T}(s, t) end #= -Based on https://www.geometrictools.com/Documentation/DistancePoint3Triangle3.pdf -But not fully optimized. -// David Eberly, Geometric Tools, Redmond WA 98052 -// Copyright (c) 1998-2022 -// Distributed under the Boost Software License, Version 1.0. -// https://www.boost.org/LICENSE_1_0.txt -// https://www.geometrictools.com/License/Boost/LICENSE_1_0.txt -// Version: 6.0.2022.01.06 -=# -@inline function dist_sqr(x::Point{T}, y::Triangle{T}) where T - P = x[1] - B = y[1] - E0 = y[3] - B - E1 = y[2] - B - BP = B - P - a = dot3(E0, E0) - b = dot3(E0, E1) - c = dot3(E1, E1) - d = dot3(E0, BP) - e = dot3(E1, BP) - f = dot3(BP, BP) - Δ = a*c - b*b - invΔ = inv(Δ) - sbar = clamp01nan((b*e - c*d)*invΔ) - tbar = clamp01nan((-a*e + b*d)*invΔ) - - # this ensures sbar and tbar are in the domain - # if they are out of the domain, the min distance is on a boundary - outside = sbar+tbar > one(sbar) - sbar = SIMD.vifelse(outside,zero(sbar),sbar) - tbar = SIMD.vifelse(outside,zero(tbar),tbar) - s0 = clamp01nan(-d/a) - t0 = clamp01nan(-e/c) - sd = clamp01nan(-(b-c+d-e)/(a-2b+c)) - td = one(sd) - sd - @inline q(s,t) = a*(s*s) + 2b*s*t + c*(t*t) + 2d*s + 2e*t - - Base.FastMath.max_fast(zero(sd),f + Base.FastMath.min_fast( - q(s0,zero(a)), - q(zero(a),t0), - q(sd,td), - q(sbar,tbar), - )) -end +The line segments lie in parallel planes with normal `n = P×Q`. If their +projections onto one of these planes cross, the closest points are at the +crossing and the separation is along `n`. Otherwise the closest points are +an end point of one line segment and its closest point on the other. -#= -Using the simple algorithm and some comments from -https://www.geometrictools.com/Documentation/DistanceLine3Line3.pdf -// David Eberly, Geometric Tools, Redmond WA 98052 -// Copyright (c) 1998-2022 -// Distributed under the Boost Software License, Version 1.0. -// https://www.boost.org/LICENSE_1_0.txt -// https://www.geometrictools.com/License/Boost/LICENSE_1_0.txt -// Version: 6.0.2022.01.06 -Ignores the case of degenerate line segments. -=# -function dist_sqr_r_s_t(x::LineSeg{T}, y::LineSeg{T}) where T - P0 = x[1] - P1 = x[2] - Q0 = y[1] - Q1 = y[2] - P = P1-P0 - Q = Q1-Q0 - P0mQ0 = P0 - Q0 - a = dot3(P, P) - b = dot3(P, Q) - c = dot3(Q, Q) - d = dot3(P, P0mQ0) - e = dot3(Q, P0mQ0) - f = dot3(P0mQ0, P0mQ0) - Δ = a*c - b*b - #assuming both segments are not zero length - #critical points - s0 = clamp01nan(-d/a) - s1 = clamp01nan((b-d)/a) - t0 = clamp01nan(e/c) - t1 = clamp01nan((b+e)/c) - sbar = clamp01nan((b*e - c*d)/Δ) - tbar = clamp01nan((a*e - b*d)/Δ) - @inline r(s,t) = a*(s*s) - 2b*s*t + c*(t*t) + 2d*s - 2e*t - mins = sbar - mint = tbar - minr = r(sbar,tbar) - minr, mins, mint = _min_r_s_t(minr, mins, mint, r(s0,zero(a)), s0, zero(a)) - minr, mins, mint = _min_r_s_t(minr, mins, mint, r(s1,one(a)), s1, one(a)) - minr, mins, mint = _min_r_s_t(minr, mins, mint, r(zero(a),t0), zero(a), t0) - minr, mins, mint = _min_r_s_t(minr, mins, mint, r(one(a),t1), one(a), t1) - d2cl = Base.FastMath.max_fast(zero(a),f+minr) - rcl = (scale3(mint, Q) - scale3(mins, P)) - P0mQ0 - d2cl, rcl, SVector{1,T}(mins), SVector{1,T}(mint) -end +This is accurate for the same reason as the `Point`-`Triangle` method. `n` is +from `cross3_accurate`: with `cross3`, the error of the distance between +nearly parallel line segments grows like one over the angle between them. -#= -Using the simple algorithm and some comments from -https://www.geometrictools.com/Documentation/DistanceLine3Line3.pdf -// David Eberly, Geometric Tools, Redmond WA 98052 -// Copyright (c) 1998-2022 -// Distributed under the Boost Software License, Version 1.0. -// https://www.boost.org/LICENSE_1_0.txt -// https://www.geometrictools.com/License/Boost/LICENSE_1_0.txt -// Version: 6.0.2022.01.06 -Ignores the case of degenerate line segments. +The nearest points of the lines through the line segments are from Kahan, +"Computing Cross-Products and Rotations in 2- and 3-Dimensional Euclidean +Spaces" (2016), §9: the separation is along `n = P×Q`, and the parameters +are triple products over `|n|²` (Lagrange's identity), not the Gram +determinant `a*c - b*b` of Lumelsky (1985) and Ericson, "Real-Time +Collision Detection" (2005), §5.1.9, which cancels for nearly parallel line +segments. `n` is computed with Kahan's algorithm for 2×2 determinants +(Higham, "Accuracy and Stability of Numerical Algorithms", 2002, Problem +2.27; bound 2u from Jeannerod, Louvet, Muller, Math. Comp. 82, 2013). +For a different robust method see Eberly, "Robust Computation of Distance +Between Line Segments" (Geometric Tools, 2018, revised 2023). =# -@inline function dist_sqr(x::LineSeg{T}, y::LineSeg{T}) where T +@inline function dist_sqr_r_s_t(x::LineSeg{T}, y::LineSeg{T}) where T P0 = x[1] - P1 = x[2] Q0 = y[1] - Q1 = y[2] - P = P1-P0 - Q = Q1-Q0 - P0mQ0 = P0 - Q0 + P = x[2] - P0 + Q = y[2] - Q0 + # Written as `y - x[1]` so it folds to `Q0` when `x[1]` is positive zero. + Q0mP0 = Q0 - P0 a = dot3(P, P) b = dot3(P, Q) c = dot3(Q, Q) - d = dot3(P, P0mQ0) - e = dot3(Q, P0mQ0) - f = dot3(P0mQ0, P0mQ0) - Δ = a*c - b*b + d = dot3(P, Q0mP0) + e = dot3(Q, Q0mP0) + n = cross3_accurate(P, Q) + Δ = dot3(n, n) invΔ = inv(Δ) - #assuming both segments are not zero length - #critical points - s0 = clamp01nan(-d/a) - s1 = clamp01nan((b-d)/a) - t0 = clamp01nan(e/c) - t1 = clamp01nan((b+e)/c) - sbar = clamp01nan((b*e - c*d)*invΔ) - tbar = clamp01nan((a*e - b*d)*invΔ) - @inline r(s,t) = a*(s*s) - 2b*s*t + c*(t*t) + 2d*s - 2e*t - Base.FastMath.max_fast(zero(s0),f+Base.FastMath.min_fast( - r(s0,zero(a)), - r(s1,one(a)), - r(zero(a),t0), - r(one(a),t1), - r(sbar,tbar), - )) + # The closest points of the lines through the line segments, by + # Lagrange's identity, without the cancellation in `a*c - b*b`. + sbar = dot3(n, cross3(Q0mP0, Q))*invΔ + tbar = dot3(n, cross3(Q0mP0, P))*invΔ + # False for NaN, from parallel or zero length line segments. + inside = (zero(T) ≤ sbar) & (sbar ≤ one(T)) & (zero(T) ≤ tbar) & (tbar ≤ one(T)) + hn = dot3(Q0mP0, n) + rin = scale3(hn*invΔ, n) + d2in = hn*hn*invΔ + inva = inv(a) + invc = inv(c) + # The closest points on `x` of the end points of `y`, and on `y` of the + # end points of `x`. The separation between the points at `s` on `x` and + # `t` on `y` is `Q0mP0 + t*Q - s*P`. + s0 = clamp01nan(d*inva) + s1 = clamp01nan((b+d)*inva) + t0 = clamp01nan(-e*invc) + t1 = clamp01nan((b-e)*invc) + rs0 = Q0mP0 - scale3(s0, P) + best = (dot3(rs0, rs0), rs0, s0, zero(T)) + rs1 = (Q0mP0 + Q) - scale3(s1, P) + best = _min_d2_r_s_t(best, (dot3(rs1, rs1), rs1, s1, one(T))) + rt0 = Q0mP0 + scale3(t0, Q) + best = _min_d2_r_s_t(best, (dot3(rt0, rt0), rt0, zero(T), t0)) + rt1 = (Q0mP0 - P) + scale3(t1, Q) + best = _min_d2_r_s_t(best, (dot3(rt1, rt1), rt1, one(T), t1)) + d2, r, s, t = _select_d2_r_s_t(inside, (d2in, rin, sbar, tbar), best) + d2, r, SVector{1,T}(s), SVector{1,T}(t) end #= @@ -360,38 +315,10 @@ function dist_sqr_r_s_t(x::LineSeg{T}, y::Triangle{T}) where T end end -function dist_sqr(x::LineSeg{T}, y::Triangle{T})::T where T - fzero = zero(T) - o = x[1] - d = x[2] - x[1] - a = y[1] - b = y[2] - c = y[3] - result = moller_trumbore_intersect(o, d, a, b, c) - if result.intersect == true - fzero - else - # no intersection or seg and triangle are parallel. - # min distance is on an edge of the triangle. - dab = dist_sqr(x,SA[a,b]) - dbc = dist_sqr(x,SA[b,c]) - dca = dist_sqr(x,SA[c,a]) - dx1y = dist_sqr(SA[x[1],],y) - dx2y = dist_sqr(SA[x[2],],y) - Base.FastMath.min_fast( - dab, - dbc, - dca, - dx1y, - dx2y, - ) - end -end - function dist_sqr_r_s_t(x::Triangle{T}, y::Triangle{T}) where T fzero = zero(T) fone = one(T) - d2, minr, _s, mint = dist_sqr_r_s_t(SA[x[1],x[2]], y) + d2, minr, _s, mint = @inline dist_sqr_r_s_t(SA[x[1],x[2]], y) _s_all = SA[fzero, _s[1], fone - _s[1]] mins = SA[_s_all[2], _s_all[1]] iszero(d2) && return d2, minr, mins, mint @@ -399,7 +326,7 @@ function dist_sqr_r_s_t(x::Triangle{T}, y::Triangle{T}) where T bi = 3 ci = 1 for i in 2:3 - this_d2, _r, _s, _t = dist_sqr_r_s_t(SA[x[ai],x[bi]], y) + this_d2, _r, _s, _t = @inline dist_sqr_r_s_t(SA[x[ai],x[bi]], y) if this_d2 < d2 d2 = this_d2 minr = _r @@ -411,7 +338,7 @@ function dist_sqr_r_s_t(x::Triangle{T}, y::Triangle{T}) where T ai, bi, ci = bi, ci, ai end for i in 1:3 - this_d2, _r, _s, _t = dist_sqr_r_s_t(SA[y[ai],y[bi]], x) + this_d2, _r, _s, _t = @inline dist_sqr_r_s_t(SA[y[ai],y[bi]], x) if this_d2 < d2 d2 = this_d2 minr = -_r @@ -424,24 +351,3 @@ function dist_sqr_r_s_t(x::Triangle{T}, y::Triangle{T}) where T end return d2, minr, mins, mint end - -function dist_sqr(x::Triangle{T}, y::Triangle{T}) where T - d2 = dist_sqr(SA[x[1],x[2]], y) - iszero(d2) && return d2 - ai = 2 - bi = 3 - ci = 1 - for i in 2:3 - this_d2 = dist_sqr(SA[x[ai],x[bi]], y) - d2 = Base.FastMath.min_fast(d2, this_d2) - iszero(d2) && return d2 - ai, bi, ci = bi, ci, ai - end - for i in 1:3 - this_d2 = dist_sqr(SA[y[ai],y[bi]], x) - d2 = Base.FastMath.min_fast(d2, this_d2) - iszero(d2) && return d2 - ai, bi, ci = bi, ci, ai - end - return d2 -end diff --git a/src/util.jl b/src/util.jl index 7c0ec1a..d4abf19 100644 --- a/src/util.jl +++ b/src/util.jl @@ -26,6 +26,31 @@ Also works on `SVector{3, SIMD.Vec{N,T}}`, bit identical to the scalar version i ) end +""" + cross3_accurate(a::Vec3, b::Vec3) + +Return the cross product of two 3D vectors, each component accurate to about +one rounding, using Kahan's algorithm for `p*q - u*v` with fused multiply adds. + +[`cross3`](@ref) is cheaper, but for nearly parallel `a` and `b` its +components cancel, with an error of about a rounding of `norm(a)*norm(b)`. + +Also works on `SVector{3, SIMD.Vec{N,T}}`, bit identical to the scalar version in each lane. +""" +@inline function cross3_accurate(a::Vec3, b::Vec3) + SVector( + _det2_accurate(a[2], b[3], a[3], b[2]), + _det2_accurate(a[3], b[1], a[1], b[3]), + _det2_accurate(a[1], b[2], a[2], b[1]), + ) +end + +# `p*q - u*v`, with the rounding of `u*v` recovered by a fused multiply add. +@inline function _det2_accurate(p, q, u, v) + uv = u*v + fma(p, q, -uv) + fma(-u, v, uv) +end + """ scale3(t, v::Vec3) scale3(v::Vec3, t) @@ -51,7 +76,7 @@ Return the Euclidean norm of a 3D vector, using [`dot3`](@ref) and an unchecked Also works on `SVector{3, SIMD.Vec{N,T}}`. """ -norm_fast(v::Vec3) = _sqrt_fast(dot3(v, v)) +@inline norm_fast(v::Vec3) = _sqrt_fast(dot3(v, v)) # Return the zero based XYZ grid index. # `pos_norm` has an origin at the center of the grid diff --git a/test/test-mindistance.jl b/test/test-mindistance.jl index c2743f7..87e4dba 100644 --- a/test/test-mindistance.jl +++ b/test/test-mindistance.jl @@ -33,17 +33,19 @@ end Random.seed!(1234) -function distance_tests(as, bs) +function distance_tests(as, bs; check_contact_points=true) for i in eachindex(as, bs) local a = as[i] local b = bs[i] local d2, r, s, t = dist_sqr_r_s_t(a, b) @test refDist2(a, b) ≈ d2 atol = 1E-10 rtol = 1E-11 - @test d2 ≈ dist_sqr(a, b) atol = 1E-10 rtol = 1E-11 + @test d2 === dist_sqr(a, b) @test r ⋅ r ≈ d2 atol = 1E-10 rtol = 1E-11 local s_all = [one(eltype(s)) - sum(s); s;] local t_all = [one(eltype(t)) - sum(t); t;] - @test norm(r - (sum(b .* t_all) - sum(a .* s_all))) ≤ 1E-12 + if check_contact_points || !iszero(d2) + @test norm(r - (sum(b .* t_all) - sum(a .* s_all))) ≤ 1E-12 + end @test all(≥(0), s) @test all(≥(0), t) @test sum(s) ≤ 1 @@ -58,6 +60,29 @@ end distance_tests(as, bs) end +# Vertices drawn from a small pool, so simplexes share vertices and are often +# degenerate: zero length line segments, triangles with repeated vertices. +@testset "distance with shared vertices $N to $M" for N in 1:3, M in 1:3 + trials = 300 + pools = [rand(SVector{3,Float64}, 4) for _ in 1:trials] + as = [SVector{N}(ntuple(_ -> rand(pool), N)) for pool in pools] + bs = [SVector{M}(ntuple(_ -> rand(pool), M)) for pool in pools] + # `moller_trumbore_intersect` reports line segments in the plane of a + # triangle, such as its edges, as intersecting, with arbitrary `s` and `t`. + distance_tests(as, bs; check_contact_points = min(N, M) == 1 || max(N, M) < 3) +end + +@testset "Float32 distance with shared vertices $N to $M" for N in 1:3, M in 1:3 + for trial in 1:3000 + box = 50 + 100rand() + pool = [SVector{3, Float32}(rand(3) .* box) for _ in 1:6] + a = SVector{N}(ntuple(_ -> rand(pool), N)) + b = SVector{M}(ntuple(_ -> rand(pool), M)) + ref = sqrt(dist_sqr(map(p -> Float64.(p), a), map(p -> Float64.(p), b))) + @test abs(sqrt(dist_sqr(a, b)) - ref) ≤ 4*eps(Float32)*box + end +end + @testset "line to triangle edge case" begin a = SA[ SA[0.7962602087717146, 0.7678176789137129, 0.33365738129157096], @@ -76,6 +101,63 @@ end @test ref_d2min ≈ d2min atol = 1E-11 rtol = 1E-11 end +# Simplexes about 100 across, 0.01 apart. Expanding the squared distance, as +# Float64 does, gives a Float32 `d2` that is far off or zero here, and for +# nearly parallel line segments `a*c - b*b` cancels and moves the closest point. +@testset "Float32 large simplexes close together $name" for (name, a, b) in ( + ("point-triangle", + SA[SA[3f0, 2f0, 0.01f0]], + SA[SA[-23f0, -7f0, 0f0], SA[31f0, -9f0, 0f0], SA[2f0, 27f0, 0f0]]), + ("line-line crossing", + SA[SA[-37f0, 0f0, 0f0], SA[63f0, 0f0, 0f0]], + SA[SA[-20.5f0, -35.507042f0, 0.01f0], SA[29.5f0, 51.0955f0, 0.01f0]]), + ("line-line nearly parallel", + SA[SA[-50f0, 0f0, 0f0], SA[50f0, 0f0, 0f0]], + SA[SA[-30f0, -0.1f0, 0.01f0], SA[70f0, 0.1f0, 0.01f0]]), + ("line-line nearly parallel closest point", + SA[SA[-50f0, 0f0, 0f0], SA[50f0, 0f0, 0f0]], + SA[SA[-32.51f0, -0.17f0, 0.01f0], SA[67.49f0, 0.17f0, 0.01f0]]), + ("line-triangle", + SA[SA[-40f0, 3f0, 0.01f0], SA[60f0, -2f0, 0.02f0]], + SA[SA[-23f0, -7f0, 0f0], SA[31f0, -9f0, 0f0], SA[2f0, 27f0, 0f0]]), + ("triangle-triangle", + SA[SA[-20f0, -5f0, 0.01f0], SA[25f0, -6f0, 0.02f0], SA[0f0, 30f0, 0.015f0]], + SA[SA[-23f0, -7f0, 0f0], SA[31f0, -9f0, 0f0], SA[2f0, 27f0, 0f0]]), + ) + for (x, y) in ((a, b), (b, a)) + # Float64 of the same coordinates, which is accurate here. + ref = sqrt(dist_sqr(map(p -> Float64.(p), x), map(p -> Float64.(p), y))) + @test 0.0099 < ref < 0.013 + d2, r, s, t = dist_sqr_r_s_t(x, y) + @test sqrt(d2) ≈ ref rtol = 1E-5 + @test norm(r) ≈ ref rtol = 1E-5 + local s_all = [one(eltype(s)) - sum(s); s;] + local t_all = [one(eltype(t)) - sum(t); t;] + @test norm(r - (sum(y .* t_all) - sum(x .* s_all))) ≤ 1E-4 + end +end + +# A needle triangle 100 long with a short edge 0.001 long, and a point 0.01 +# above its plane that projects 0.2 past the short edge. Testing +# `sbar + tbar ≤ 1` judged it inside in `Float32`, giving a distance of 0.01. +@testset "Float32 point past the short edge of a needle" begin + Random.seed!(5) + for trial in 1:3000 + n = normalize(randn(3)) + e1 = normalize(cross(n, randn(3))) + e2 = cross(n, e1) + a = 100randn(3) + b = a .+ 100e1 + c = b .+ 0.001e2 + p = b .+ 0.001rand()*e2 .+ 0.2rand()*e1 .+ 0.01n + x = SA[SVector{3, Float32}(p)] + y = SA[SVector{3, Float32}(a), SVector{3, Float32}(b), SVector{3, Float32}(c)] + ref = sqrt(dist_sqr(map(v -> Float64.(v), x), map(v -> Float64.(v), y))) + @test abs(sqrt(dist_sqr(x, y)) - ref) ≤ 16*eps(Float32)*200 + @test abs(sqrt(dist_sqr(y, x)) - ref) ≤ 16*eps(Float32)*200 + end +end + # Return `W` simplices as one simplex with a lane per simplex. function simplex_lanes(xs::NTuple{W, SVector{N, SVector{3, T}}}) where {W, N, T} SVector{N}(ntuple(Val(N)) do v From dd4efb2dbc2ae6f766a27b46bcf93c741ee4a52e Mon Sep 17 00:00:00 2001 From: nhz2 Date: Wed, 28 Oct 2026 18:02:01 -0400 Subject: [PATCH 02/11] avoid overflow --- error-analysis/ErrorBounds.jl | 23 ++ error-analysis/analyze.jl | 9 +- error-analysis/ledger.jl | 488 ++++++++++++++++++++++++++++++++++ src/distances.jl | 14 +- test/test-mindistance.jl | 92 +++++++ 5 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 error-analysis/ledger.jl diff --git a/error-analysis/ErrorBounds.jl b/error-analysis/ErrorBounds.jl index a61c45b..2755b11 100644 --- a/error-analysis/ErrorBounds.jl +++ b/error-analysis/ErrorBounds.jl @@ -214,6 +214,10 @@ absolute value of the exact result, not just a bound, as division needs. `root`, if set, is `(abs, rel)` for a nonnegative result `ρ²`, such as a squared distance: `|√computed - ρ| ≤ u*(abs + rel*ρ)`. + +`sqscale`, if set, records that this is the product `r*w` with `w` a +nonnegative exact square, so that a later `*r` is recognized as a squared +distance, as in `(hN*invΔ)*hN`. """ struct EB <: Real mag::SPoly @@ -224,8 +228,11 @@ struct EB <: Real input::Union{Nothing, Sym} con::Union{Nothing, Q} root::Union{Nothing, Tuple{SPoly, Q}} + sqscale::Union{Nothing, Tuple{EB, EB}} end +EB(mag, err, rel, nonneg, exact, input, con, root) = + EB(mag, err, rel, nonneg, exact, input, con, root, nothing) EB(c::Real) = EB(SPoly(abs(c)), SPoly(), zero(Q), c ≥ 0, true, nothing, Q(c), nothing) withrel(mag, rel, nonneg, exact) = EB(mag, rel*mag, rel, nonneg, exact, nothing, nothing, nothing) withabs(mag, err, nonneg, exact) = EB(mag, err, nothing, nonneg, exact, nothing, nothing, nothing) @@ -419,6 +426,22 @@ function Base.:*(a::EB, b::EB) sq === nothing && continue return _withroot(x, (s.root[1]*sq, s.root[2] + w.rel/2 + Q(1//2))) end + # `(r*w)*r` with the recorded exact square `w`: the same two roundings as + # `(r*r)*w`, so the same distance bound as the two rules above combined. + for (s, o) in ((a, b), (b, a)) + s.sqscale === nothing && continue + r, w = s.sqscale + o === r || continue + sq = _sqrtmag(w.mag) + rt = r.rel === nothing ? (r.err, Q(1//2)) : (SPoly(), r.rel + Q(1//2)) + return _withroot(x, (rt[1]*sq, rt[2] + w.rel/2 + Q(1//2))) + end + # Remember `r*w` with `w` a nonnegative exact square for the rule above. + for (r, w) in ((a, b), (b, a)) + w.nonneg && w.exact && w.rel !== nothing || continue + _sqrtmag(w.mag) === nothing && continue + return EB(x.mag, x.err, x.rel, x.nonneg, x.exact, x.input, x.con, x.root, (r, w)) + end x end Base.:-(a::EB, b::EB) = _addsub(:-, a, b) diff --git a/error-analysis/analyze.jl b/error-analysis/analyze.jl index a2baa20..97d39ac 100644 --- a/error-analysis/analyze.jl +++ b/error-analysis/analyze.jl @@ -12,7 +12,6 @@ using .ErrorBounds: DEFS setprecision(BigFloat, 512) -norm_sym(n) = SPoly(Sym(n, 0)) ratio_sym(a, b) = SPoly(Sym(a, 0)) * SPoly(Sym(b, 0), -1) const KERNELS = ( @@ -120,6 +119,13 @@ function inplane(n) end const GENS = ( + point_point = ( + "random [0,100]" => T -> ((vec3(T, 100rand(3)),), (vec3(T, 100rand(3)),)), + "0.01 apart, 1e4 from the origin" => T -> begin + c = 1e4randdir() + ((vec3(T, c),), (vec3(T, c .+ 0.01randdir()),)) + end, + ), point_line = ( "random [0,100]" => T -> ((vec3(T, 100rand(3)),), (vec3(T, 100rand(3)), vec3(T, 100rand(3)))), "point on the segment" => T -> begin @@ -184,6 +190,7 @@ const GENS = ( ) Random.seed!(4) +report("point-point", KERNELS.point_point, GENS.point_point) report("point-line", KERNELS.point_line, GENS.point_line) report("point-triangle", KERNELS.point_triangle, GENS.point_triangle) report("line-line", KERNELS.line_line, GENS.line_line) diff --git a/error-analysis/ledger.jl b/error-analysis/ledger.jl new file mode 100644 index 0000000..b7e2f5b --- /dev/null +++ b/error-analysis/ledger.jl @@ -0,0 +1,488 @@ +# Per-domain error-claim ledgers for the distance kernels, with focused sampling. +# +# Inputs live in the box [-1, 1]^(3 vertices), so overflow is impossible and the +# claims are absolute multiples of u = eps(T)/2. Each branch combination of a +# kernel is one domain of input space; on it the exact output is a single +# rational function of the inputs, and the computed output adds one (1+δ) per +# rounding. A ledger has one entry per domain and per boundary: +# +# - claim: |√d̂² - d| ≤ u·bound(exact quantities), first order in u, on the +# samples the entry matches. +# - status: where the claim comes from and its fine print (the O(u²) +# remainder, and underflow of a denominator, are excluded from every +# first-order claim and get their own rows). +# - points: candidate worst-case inputs, where the per-domain analysis says +# the claim is tightest, with the predicted sharpness. +# +# The sampler perturbs every coordinate of each worst-case point with Gaussian +# noise at scales σ from ~30 ulps (holds the input still, scrambles the +# roundings) up to 1e-2 (wanders across nearby domain boundaries). Each sample +# is routed to every entry whose (exact branch pattern, computed branch +# pattern) pair it matches: domain entries take pe == pc == their pattern, +# boundary entries take the samples where rounding flipped the branch. The +# measured max error/bound must stay ≤ 1 everywhere, and should approach the +# predicted sharpness at the sharp points. +# +# Run from the test environment, which has SimplexCellLists and StaticArrays: +# include("error-analysis/ledger.jl") + +using SimplexCellLists, StaticArrays, Random, LinearAlgebra +using SimplexCellLists: dist_sqr_r_s_t, dot3, cross3, cross3_accurate, clamp01nan, scale3 + +setprecision(BigFloat, 512) + +# Entry matchers: a domain entry owns the samples where exact and computed +# arithmetic take the same branch pattern; a boundary entry owns the flips. +domain(pat) = (pe, pc) -> pe === pat && pc === pat +flip(a, b) = (pe, pc) -> (pe === a && pc === b) || (pe === b && pc === a) + +# ---- point-point ---- +# d = y[1] - x[1]; dot3(d, d). One domain, no branches. Per coordinate the +# rounding path is 2δ(sub) + δ(mul/fma) + the fma accumulations: 5u, 4u, 3u on +# d² for coordinates 1, 2, 3, so the claim 5u/2·d is attained only when one +# coordinate carries the whole separation. + +function pp_exact(xs, ys) + p = big.(ys[1]) - big.(xs[1]) + (; d = norm(p)), :all +end +pp_computed_pattern(xs, ys) = :all + +const POINT_POINT = (exact = pp_exact, computed = pp_computed_pattern, entries = [ + (name = "whole box", claim = "5/2·d", + status = "proved (distances.md; tracer: 3/2·d plus the ‖R‖ vertex move)", + bound = q -> 5//2*q.d, match = (pe, pc) -> true, + points = [ + (x = ((-1.0, 0.03, -0.02),), y = ((1.0, 0.06, 0.03),), + expect = "supremum 1: the first coordinate carries the whole 5u path; sampled max ~0.8-0.9"), + (x = ((-0.95, -1.0, 0.9),), y = ((0.9, 0.85, -1.0),), + expect = "supremum 0.8: equal coordinates average the 5u, 4u, 3u paths to 4u; sampled max ~0.45 (more independent roundings must align)"), + ]), +]) + +# ---- point-line ---- +# r = y[2] - y[1]; p = y[1] - x[1]; t = clamp01nan(-dot3(p,r)/dot3(r,r)); +# d = p + scale3(t, r); dot3(d, d). +# Exact domains in t* = -(p·r)/(r·r): t* ≤ 0 (c0 arm, d = p), 0 < t* < 1 +# (interior, d² = (|p|²|r|² - (p·r)²)/|r|², the Gram ratio), t* ≥ 1 (c1 arm, +# d = p + r). clamp01nan sends NaN and t ≤ 0 to the 0 arm, t ≥ 1 to the 1 arm. + +function pl_exact(xs, ys) + p = big.(ys[1]) - big.(xs[1]) + r = big.(ys[2]) - big.(ys[1]) + rr = dot(r, r) + pr = dot(p, r) + pattern = iszero(rr) || -pr <= 0 ? :c0 : (-pr >= rr ? :c1 : :interior) + tstar = pattern === :c0 ? big(0.0) : (pattern === :c1 ? big(1.0) : -pr/rr) + (; d = norm(p + tstar*r), np = norm(p), nr = sqrt(rr)), pattern +end + +# The branch pattern the floating point kernel takes, replicating its ops. +function pl_computed_pattern(xs, ys) + r = ys[2] - ys[1] + p = ys[1] - xs[1] + traw = -dot3(p, r)/dot3(r, r) + traw > 0 ? (traw < 1 ? :interior : :c1) : :c0 +end + +const POINT_LINE = (exact = pl_exact, computed = pl_computed_pattern, entries = [ + (name = "t clamped to 0 (t* < 0)", claim = "5/2·d", + status = "proved: scale3(0,r) = 0 exactly, so d = p̂ and the point-point argument applies", + bound = q -> 5//2*q.d, match = domain(:c0), + points = [ + (x = ((1.0, 0.12, -0.03),), y = ((-1.0, 0.0, 0.0), (-1.0, -1.0, 0.0)), + expect = "supremum 1: separation on the first coordinate, as in point-point; sampled max ~0.9"), + ]), + (name = "interior (0 < t* < 1)", claim = "9‖p‖ + 3‖r‖ + 3/2·d", + status = "proved by the tracer (analyze.jl, all domains joined); slack expected: it charges the t rounding linearly, but d ⊥ r at the exact minimum makes it second order", + bound = q -> 9*q.np + 3*q.nr + 3//2*q.d, match = domain(:interior), + points = [ + (x = ((1.0, 0.0, 0.05),), y = ((-1.0, 1.0, 0.0), (-1.0, -1.0, 0.0)), + expect = "≈0.2-0.3: generic midpoint, the 9‖p‖ term is mostly unspent"), + (x = ((1.0, 0.0, 0.0),), y = ((-1.0, 0.1, 0.02), (-1.0, -0.1, 0.02)), + expect = "low despite t error ~110u (‖p‖/‖r‖ = 10): gradient-zero kills it"), + ]), + (name = "t clamped to 1 (t* > 1)", claim = "‖p‖ + ‖r‖ + 5/2·d", + status = "derived here: d̂ᵢ = fl(p̂ᵢ + r̂ᵢ) has error ≤ u(|pᵢ| + |rᵢ| + |pᵢ+rᵢ|), then Minkowski and the 3u/2 dot-self root", + bound = q -> q.np + q.nr + 5//2*q.d, match = domain(:c1), + points = [ + (x = ((1.0, 1.0, 0.04),), y = ((-1.0, -1.0, 0.0), (0.98, 0.98, 0.0)), + expect = "supremum ≈1: p + r cancels to 0.03, so the input roundings u(‖p‖+‖r‖) dwarf d; sampled max ~0.6-0.8"), + ]), + (name = "boundary t* ≈ 0 (flips)", claim = "max of both arms", + status = "proved modulo O(u²): the clamp boundary is C¹, a flip at |t*| ≤ t-err costs t*²‖r‖² ≤ (11u‖p‖)² on d²", + bound = q -> max(5//2*q.d, 9*q.np + 3*q.nr + 3//2*q.d), match = flip(:c0, :interior), + nmul = 10, # flips are ~0.1% of samples, oversample to get hundreds + points = [ + # p·r = 0 through cancelling ±1 products, so the computed condition + # carries full u-scale error and actually flips. (With componentwise + # orthogonality, e.g. p ∥ e1 and r ∥ e2, the dot3 error is only + # u·Σ|pᵢrᵢ| ~ u·σ and flips essentially never happen.) + (x = ((1.0, 1.0, 0.0),), y = ((-1.0, 0.0, 0.0), (-0.5, -1.0, 0.0)), + expect = "flips only at small σ; ratio like the adjacent domains"), + ]), + (name = "boundary t* ≈ 1 (flips)", claim = "max of both arms", + status = "proved modulo O(u²): same clamp-quadratic argument at t* = 1", + bound = q -> max(q.np + q.nr + 5//2*q.d, 9*q.np + 3*q.nr + 3//2*q.d), match = flip(:c1, :interior), + nmul = 10, + points = [ + # -p·r = ‖r‖² = 2 with order-1 terms in both dots: a ~4u condition band. + (x = ((0.5, 0.5, 0.0),), y = ((-1.0, 1.0, 0.0), (0.0, 0.0, 0.0)), + expect = "flips only at small σ; ratio like the adjacent domains"), + ]), + (name = "degenerate (‖r‖² ≤ realmin)", claim = "not sampled", + status = "argued: r = 0 gives t = NaN → 0 arm, d = p̂, exact-correct; ‖r‖² subnormal can misclamp t, costing ≤ ‖r‖ ≤ √realmin absolute, below any u-scale budget on the box. Gaussian sampling cannot reach sin-type slivers this deep", + bound = q -> big(1.0), match = (pe, pc) -> false, points = NamedTuple[]), +]) + +# ---- point-triangle ---- +# Exact domains are the Voronoi regions of the triangle: :face (projection +# inside), :e0/:e1/:e2 (edge interiors), :va/:vb/:vc (vertices). The inside +# arm is d = |BP·N|/‖N‖ with N from cross3_accurate (2u per component), so +# its triple-product error is ≤ 5u·Σ|NᵢBPᵢ| ≤ 5u‖N‖‖BP‖ (Cauchy-Schwarz), +# Δ = dot3(N,N) is relative 7u, and the vertex moves add u(‖BP‖+‖E0‖+‖E1‖). +# Each edge candidate is literally the point-line kernel, so its traced bound +# applies, with extra u‖BP2‖, u‖E2‖ charges for edge 2's rounded sums. + +function pt_exact(xs, ys) + A, B, C = big.(ys[1]), big.(ys[2]), big.(ys[3]) + E0 = B - A; E1 = C - A; E2 = C - B + BP = A - big.(xs[1]); BP2 = B - big.(xs[1]) + N = cross(E0, E1) + Δ = dot(N, N) + q = (; d = big(0.0), nBP = norm(BP), nBP2 = norm(BP2), + nE0 = norm(E0), nE1 = norm(E1), nE2 = norm(E2)) + # The inside test on the exact signs of the barycentric numerators. + if !iszero(Δ) && dot(N, cross(E1, BP)) >= 0 && dot(N, cross(BP, E0)) >= 0 && + dot(N, cross(BP2, E2)) >= 0 + return merge(q, (; d = abs(dot(BP, N))/sqrt(Δ))), :face + end + f = big(Inf); pattern = :none + for (p, r, pat0, pat1, patmid) in + ((BP, E0, :va, :vb, :e0), (BP, E1, :va, :vc, :e1), (BP2, E2, :vb, :vc, :e2)) + rr = dot(r, r) + u = iszero(rr) ? big(0.0) : clamp(-dot(p, r)/rr, big(0.0), big(1.0)) + v = p + u*r + fv = dot(v, v) + # Ties keep the earlier candidate, as in _min_d2_r_s_t. + if fv < f + f = fv + pattern = iszero(u) ? pat0 : (isone(u) ? pat1 : patmid) + end + end + merge(q, (; d = sqrt(f))), pattern +end + +# The branch pattern the kernel takes, mirroring its ops (same helpers, same +# expression shapes, so the values are bit identical to the kernel's). +function pt_computed_pattern(xs, ys) + B = ys[1] + E0 = ys[2] - B + E1 = ys[3] - B + BP = B - xs[1] + N = cross3_accurate(E0, E1) + Δ = dot3(N, N) + invΔ = inv(Δ) + sbar = dot3(N, cross3(E1, BP))*invΔ + tbar = dot3(N, cross3(BP, E0))*invΔ + E2 = E1 - E0 + BP2 = BP + E0 + wbar = dot3(N, cross3(BP2, E2))*invΔ + T = eltype(eltype(xs)) + (zero(T) <= sbar) & (zero(T) <= tbar) & (zero(T) <= wbar) && return :face + f = T(Inf); pattern = :none + for (p, r, pat0, pat1, patmid) in + ((BP, E0, :va, :vb, :e0), (BP, E1, :va, :vc, :e1), (BP2, E2, :vb, :vc, :e2)) + u = clamp01nan(-dot3(r, p)/dot3(r, r)) + v = p + scale3(u, r) + fv = dot3(v, v) + if fv < f + f = fv + pattern = u == 0 ? pat0 : (u == 1 ? pat1 : patmid) + end + end + pattern +end + +pt_face_b(q) = 6*q.nBP + q.nE0 + q.nE1 + 8*q.d +pt_traced_b(q) = 62*q.nBP + 40*q.nBP2 + 3*q.nE0 + 3*q.nE1 + 3*q.nE2 + 5*q.d + +const POINT_TRIANGLE = (exact = pt_exact, computed = pt_computed_pattern, entries = [ + (name = "face (inside)", claim = "6‖BP‖ + ‖E0‖ + ‖E1‖ + 8d", + status = "derived here: hN err ≤ 5u·Σ|NᵢBPᵢ| ≤ 5u‖N‖‖BP‖ (Kahan 2u/comp + CS), Δ rel 7u, moves; d coefficient padded", + bound = pt_face_b, match = domain(:face), + points = [ + # Tilted triangle so N is generic and the hN terms can cancel. + (x = ((-0.01, 0.01, 0.29),), y = ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1)), + expect = "well below 1: CS equality needs BP aligned with N term by term"), + (x = ((-0.09, 0.03, 0.02),), y = ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1)), + expect = "the live point: d ≈ 0.02 and the hN terms (~0.5 each) cancel, so the ‖BP‖ term carries; CS is ~2x loose here and the moves/edge terms pad the denominator, sampled max ~0.1"), + ]), + (name = "edge 0 interior", claim = "9‖BP‖ + 3‖E0‖ + 3/2·d", + status = "the candidate is the point-line kernel verbatim, so its traced interior bound plus moves applies", + bound = q -> 9*q.nBP + 3*q.nE0 + 3//2*q.d, match = domain(:e0), + points = [ + (x = ((0.2, -0.9, 0.4),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), + expect = "supremum ~0.17 (bound padded for this geometry), sampled max ~0.07"), + ]), + (name = "edge 1 interior", claim = "9‖BP‖ + 3‖E1‖ + 3/2·d", + status = "by symmetry with edge 0; not sampled directly, catches strays", + bound = q -> 9*q.nBP + 3*q.nE1 + 3//2*q.d, match = domain(:e1), points = NamedTuple[]), + (name = "edge 2 interior", claim = "9‖BP2‖ + 3‖E2‖ + ‖BP‖ + ‖E0‖ + ‖E1‖ + 3/2·d", + status = "point-line bound on (BP2, E2) plus u‖BP2‖, u‖E2‖ for their rounded sums and the vertex moves", + bound = q -> 9*q.nBP2 + 3*q.nE2 + q.nBP + q.nE0 + q.nE1 + 3//2*q.d, match = domain(:e2), + points = [ + (x = ((0.56, 0.59, 0.3),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), + expect = "like edge 0 with the extra BP2/E2 charges, sampled max ~0.14"), + ]), + (name = "vertex a", claim = "5/2·d", + status = "proved: the winning candidate is BP + scale3(0, E) = BP exactly, point-point argument", + bound = q -> 5//2*q.d, match = domain(:va), + points = [ + (x = ((-1.0, -0.95, 0.35),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), + expect = "like point-point with balanced coordinates, sampled max ~0.4"), + ]), + (name = "vertex b", claim = "‖BP‖ + ‖E0‖ + 5/2·d", + status = "derived: both reachable candidates compute fl(BP + E0), error ≤ u(|BPᵢ|+|E0ᵢ|+|BP2ᵢ|) per component", + bound = q -> q.nBP + q.nE0 + 5//2*q.d, match = domain(:vb), + points = [ + (x = ((1.0, -0.45, 0.3),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), + expect = "sampled max well below 1 unless BP and E0 errors align with the small d"), + ]), + (name = "vertex c", claim = "‖BP‖ + ‖E1‖ + ‖BP2‖ + ‖E2‖ + ‖E0‖ + 5/2·d", + status = "covers whichever tied candidate wins (fl(BP+E1), or fl(fl(BP+E0)+fl(E1-E0))); not sampled directly", + bound = q -> q.nBP + q.nE1 + q.nBP2 + q.nE2 + q.nE0 + 5//2*q.d, match = domain(:vc), + points = NamedTuple[]), + (name = "inside-test boundary (flips)", claim = "tracer all-domain bound", + status = "proved by the tracer including the inside-select jump terms (heights ‖N‖/‖E‖); expect small ratios, the joined jump dominates the bound", + bound = pt_traced_b, match = (pe, pc) -> (pe === :face) ⊻ (pc === :face), + nmul = 10, + points = [ + # x over the midpoint of edge 0, so the tbar numerator is exactly zero. + (x = ((-0.05, -0.4, 0.6),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), + expect = "flips at small σ; ratio small against the joined bound"), + ]), + (name = "clamp and tie flips", claim = "tracer all-domain bound", + status = "clamp boundaries are C¹ (O(u²) flip cost) and min ties are 1-Lipschitz; the traced join covers every arm", + bound = pt_traced_b, match = (pe, pc) -> pe !== pc && pe !== :face && pc !== :face, + nmul = 10, + points = [ + # x exactly on the u0 = 1 plane through b: (x-a)·E0 = ‖E0‖² by construction. + (x = ((0.88, -0.54, 0.3),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), + expect = "edge0↔vertex b flips at small σ; O(u²) cost, tiny ratios"), + ]), + (name = "degenerate (‖N‖² ≤ realmin)", claim = "not sampled", + status = "argued: cross3_accurate is exactly zero for an exactly degenerate triangle, so inside is false and the edge candidates (their own ledger rows) answer; the subnormal-Δ sliver needs the inside test to fail safe, which zero/‖N‖² ≥ 0 and NaN→false give", + bound = q -> big(1.0), match = (pe, pc) -> false, points = NamedTuple[]), +]) + +# ---- line-line ---- +# Exact domains by the clamp state of (s*, t*), one letter each for s then t +# (z = clamped to 0, o = clamped to 1, i = interior): :ii (both interior, the +# crossing arm), :iz/:io/:zi/:oi (one endpoint against the other's interior, +# point-line candidates), :zz/:oz/:zo/:oo (corners). The crossing arm is +# d = |W·n|/‖n‖ with n = cross3_accurate(P, Q): same error shape as the +# triangle face, and no 1/sinθ anywhere — the nearly parallel point stresses +# exactly the cancellation this kernel was rewritten to avoid. + +function ll_exact(xs, ys) + P0, P1 = big.(xs[1]), big.(xs[2]) + Q0, Q1 = big.(ys[1]), big.(ys[2]) + P = P1 - P0; Q = Q1 - Q0; W = Q0 - P0 + q = (; d = big(0.0), nP = norm(P), nQ = norm(Q), nW = norm(W), + nWP = norm(W - P), nWQ = norm(W + Q)) + n = cross(P, Q) + Δ = dot(n, n) + if !iszero(Δ) + sbar = dot(n, cross(W, Q))/Δ + tbar = dot(n, cross(W, P))/Δ + if 0 <= sbar <= 1 && 0 <= tbar <= 1 + return merge(q, (; d = abs(dot(W, n))/sqrt(Δ))), :ii + end + end + a = dot(P, P); c = dot(Q, Q) + f = big(Inf); pattern = :none + for (den, num, vof, pat0, pat1, patmid) in ( + (a, dot(P, W), u -> W - u*P, :zz, :oz, :iz), + (a, dot(P, W + Q), u -> (W + Q) - u*P, :zo, :oo, :io), + (c, -dot(Q, W), u -> W + u*Q, :zz, :zo, :zi), + (c, dot(Q, P - W), u -> (W - P) + u*Q, :oz, :oo, :oi)) + u = iszero(den) ? big(0.0) : clamp(num/den, big(0.0), big(1.0)) + v = vof(u) + fv = dot(v, v) + if fv < f + f = fv + pattern = iszero(u) ? pat0 : (isone(u) ? pat1 : patmid) + end + end + merge(q, (; d = sqrt(f))), pattern +end + +function ll_computed_pattern(xs, ys) + P0 = xs[1] + Q0 = ys[1] + P = xs[2] - P0 + Q = ys[2] - Q0 + Q0mP0 = Q0 - P0 + a = dot3(P, P) + b = dot3(P, Q) + c = dot3(Q, Q) + d = dot3(P, Q0mP0) + e = dot3(Q, Q0mP0) + n = cross3_accurate(P, Q) + Δ = dot3(n, n) + invΔ = inv(Δ) + sbar = dot3(n, cross3(Q0mP0, Q))*invΔ + tbar = dot3(n, cross3(Q0mP0, P))*invΔ + T = eltype(eltype(xs)) + ((zero(T) <= sbar) & (sbar <= one(T)) & (zero(T) <= tbar) & (tbar <= one(T))) && return :ii + inva = inv(a) + invc = inv(c) + f = T(Inf); pattern = :none + for (u, v, pat0, pat1, patmid) in ( + (s0 = clamp01nan(d*inva); (s0, Q0mP0 - scale3(s0, P), :zz, :oz, :iz)), + (s1 = clamp01nan((b+d)*inva); (s1, (Q0mP0 + Q) - scale3(s1, P), :zo, :oo, :io)), + (t0 = clamp01nan(-e*invc); (t0, Q0mP0 + scale3(t0, Q), :zz, :zo, :zi)), + (t1 = clamp01nan((b-e)*invc); (t1, (Q0mP0 - P) + scale3(t1, Q), :oz, :oo, :oi))) + fv = dot3(v, v) + if fv < f + f = fv + pattern = u == 0 ? pat0 : (u == 1 ? pat1 : patmid) + end + end + pattern +end + +ll_ii_b(q) = 6*q.nW + q.nP + q.nQ + 8*q.d +ll_traced_b(q) = 63*q.nW + 10*q.nP + 10*q.nQ + 2*q.nWP + 2*q.nWQ + 5*q.d + +const LINE_LINE = (exact = ll_exact, computed = ll_computed_pattern, entries = [ + (name = "crossing (interior-interior)", claim = "6‖W‖ + ‖P‖ + ‖Q‖ + 8d", + status = "derived here: hn err ≤ 5u·Σ|nᵢWᵢ| ≤ 5u‖n‖‖W‖ (Kahan 2u/comp + CS), Δ rel 7u, moves; d coefficient padded", + bound = ll_ii_b, match = domain(:ii), + points = [ + (x = ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), y = ((-0.3, -1.0, 0.25), (0.3, 1.0, 0.25)), + expect = "generic crossing, well below 1"), + (x = ((-1.0, -0.004, 0.0), (1.0, 0.004, 0.0)), y = ((-1.0, 0.004, 0.15), (1.0, -0.004, 0.15)), + expect = "the stress point: sinθ ≈ 0.008, where the Gram form loses ~125×; Kahan cross keeps the claim"), + ]), + (name = "s interior, t = 0", claim = "9‖W‖ + 3‖P‖ + ‖Q‖ + 3/2·d", + status = "point-line traced bound on (W, P) plus moves", + bound = q -> 9*q.nW + 3*q.nP + q.nQ + 3//2*q.d, match = domain(:iz), + points = [ + (x = ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), y = ((0.1, 0.3, 0.3), (0.9, 0.9, 0.5)), + expect = "sampled max ~0.06: the 9‖W‖ bound is padded for this geometry"), + ]), + (name = "s = 0, t interior", claim = "9‖W‖ + 3‖Q‖ + ‖P‖ + 3/2·d", + status = "point-line traced bound on (W, Q) plus moves", + bound = q -> 9*q.nW + 3*q.nQ + q.nP + 3//2*q.d, match = domain(:zi), + points = [ + (x = ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), y = ((-0.31, -0.67, 0.25), (-0.73, -0.11, 0.25)), + expect = "sampled max ~0.08"), + ]), + (name = "s interior, t = 1", claim = "10‖WQ‖ + 3‖P‖ + ‖W‖ + ‖Q‖ + 3/2·d", + status = "point-line bound on (W+Q, P) plus the rounded sum and numerator charges; by symmetry, not sampled directly", + bound = q -> 10*q.nWQ + 3*q.nP + q.nW + q.nQ + 3//2*q.d, match = domain(:io), points = NamedTuple[]), + (name = "s = 1, t interior", claim = "10‖WP‖ + 3‖Q‖ + ‖W‖ + ‖P‖ + 3/2·d", + status = "point-line bound on (W-P, Q) plus the rounded sum and numerator charges; by symmetry, not sampled directly", + bound = q -> 10*q.nWP + 3*q.nQ + q.nW + q.nP + 3//2*q.d, match = domain(:oi), points = NamedTuple[]), + (name = "corner (0,0)", claim = "5/2·d", + status = "proved: the winning separation is W exactly, point-point argument", + bound = q -> 5//2*q.d, match = domain(:zz), + points = [ + (x = ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), y = ((-0.7, -0.8, 0.3), (-1.0, -0.9, 0.5)), + expect = "like point-point, sampled max ~0.4-0.9"), + ]), + (name = "corner (1,0)", claim = "‖W‖ + ‖P‖ + 5/2·d", + status = "derived: fl(W-P), error ≤ u(|Wᵢ|+|Pᵢ|+|WPᵢ|) per component; by symmetry, not sampled directly", + bound = q -> q.nW + q.nP + 5//2*q.d, match = domain(:oz), points = NamedTuple[]), + (name = "corner (0,1)", claim = "‖W‖ + ‖Q‖ + 5/2·d", + status = "derived: fl(W+Q); by symmetry, not sampled directly", + bound = q -> q.nW + q.nQ + 5//2*q.d, match = domain(:zo), points = NamedTuple[]), + (name = "corner (1,1)", claim = "‖W‖ + ‖P‖ + ‖Q‖ + ‖WP‖ + ‖WQ‖ + 5/2·d", + status = "covers whichever tied candidate wins (fl(fl(W+Q)-P) or fl(fl(W-P)+Q)); not sampled directly", + bound = q -> q.nW + q.nP + q.nQ + q.nWP + q.nWQ + 5//2*q.d, match = domain(:oo), points = NamedTuple[]), + (name = "inside-test boundary (flips)", claim = "tracer all-domain bound", + status = "proved by the tracer including the inside-select jump terms (heights ‖n‖/‖P‖, ‖n‖/‖Q‖)", + bound = ll_traced_b, match = (pe, pc) -> (pe === :ii) ⊻ (pc === :ii), + nmul = 10, + points = [ + # Q crosses exactly over P0: the sbar numerator is exactly zero. + (x = ((-0.8, -0.6, 0.0), (0.8, 0.6, 0.0)), y = ((-0.68, -0.76, 0.25), (-0.92, -0.44, 0.25)), + expect = "flips at small σ; ratio small against the joined bound"), + ]), + (name = "clamp and tie flips", claim = "tracer all-domain bound", + status = "clamp boundaries are C¹ (O(u²) flip cost) and min ties are 1-Lipschitz; the traced join covers every arm", + bound = ll_traced_b, match = (pe, pc) -> pe !== pc && pe !== :ii && pc !== :ii, + nmul = 10, + points = [ + # (Q0-P0) ⊥ Q exactly: the t* = 0 clamp boundary in the s* = 0 region. + (x = ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), y = ((-0.52, -0.39, 0.25), (-0.82, 0.01, 0.25)), + expect = "corner↔edge flips at small σ; O(u²) cost, tiny ratios"), + ]), + (name = "degenerate (‖n‖² ≤ realmin)", claim = "not sampled", + status = "argued: cross3_accurate is exactly zero for exactly parallel segments, inside is false (NaN→false), and the edge candidates answer; a wrong clamp from subnormal Δ costs ≤ the parallel gap, covered by the edge rows", + bound = q -> big(1.0), match = (pe, pc) -> false, points = NamedTuple[]), +]) + +function perturb(T, v, σ) + SVector{3, T}(T(v[1] + σ*randn()), T(v[2] + σ*randn()), T(v[3] + σ*randn())) +end + +function run_ledger(name, kernel, T; n=1000) + u = big(eps(T))/2 + σs = 10.0 .^ range(log10(4*eps(T)), -2; length=7) + entries = kernel.entries + counts = zeros(Int, length(entries)) + maxratio = zeros(length(entries)) + atσ = fill(NaN, length(entries)) + # Sharpness at each entry's own points: max ratio among samples that both + # originate from the point and match the entry. + ownmax = [zeros(length(e.points)) for e in entries] + unmatched = Dict{Tuple{Symbol, Symbol}, Int}() + for (oj, oe) in enumerate(entries), (opi, pt) in enumerate(oe.points), σ in σs, _ in 1:(n*get(oe, :nmul, 1)) + xs = map(v -> perturb(T, v, σ), pt.x) + ys = map(v -> perturb(T, v, σ), pt.y) + q, pe = kernel.exact(xs, ys) + pc = kernel.computed(xs, ys) + err = abs(sqrt(big(dist_sqr_r_s_t(SVector(xs), SVector(ys))[1])) - q.d) + routed = false + for (j, e) in enumerate(entries) + e.match(pe, pc) || continue + routed = true + b = u*e.bound(q) + iszero(b) && continue + ratio = Float64(err/b) + counts[j] += 1 + ratio > maxratio[j] && (maxratio[j] = ratio; atσ[j] = σ) + j == oj && ratio > ownmax[j][opi] && (ownmax[j][opi] = ratio) + end + routed || (unmatched[(pe, pc)] = get(unmatched, (pe, pc), 0) + 1) + end + println("=== $name ($T) ===") + for (j, e) in enumerate(entries) + if counts[j] > 0 + println(" ", rpad(e.name, 32), lpad(counts[j], 7), " samples max err/bound ", + rpad(round(maxratio[j], sigdigits=3), 9), " @σ=", round(atσ[j], sigdigits=2), + maxratio[j] > 1 ? " *** CLAIM VIOLATED ***" : "") + elseif isempty(e.points) + println(" ", rpad(e.name, 32), " not sampled") + else + println(" ", rpad(e.name, 32), " NO SAMPLES MATCHED — coverage gap") + end + println(" claim u·(", e.claim, ") — ", e.status) + for (opi, pt) in enumerate(e.points) + println(" point ", opi, " max ", rpad(round(ownmax[j][opi], sigdigits=3), 9), + " expect ", pt.expect) + end + end + isempty(unmatched) || println(" UNROUTED samples: ", sort!(collect(unmatched); by=last, rev=true)) +end + +Random.seed!(7) +for T in (Float32, Float64) + run_ledger("point-point", POINT_POINT, T) + run_ledger("point-line", POINT_LINE, T) + run_ledger("point-triangle", POINT_TRIANGLE, T) + run_ledger("line-line", LINE_LINE, T) +end diff --git a/src/distances.jl b/src/distances.jl index 4658d0b..4b7a0ff 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -127,8 +127,11 @@ edges as in Ericson, "Real-Time Collision Detection" (2005), §5.1.5. See the # False for NaN, from a degenerate triangle. inside = (zero(T) ≤ sbar) & (zero(T) ≤ tbar) & (zero(T) ≤ wbar) hN = dot3(BP, N) - rin = scale3(hN*invΔ, N) - d2in = hN*hN*invΔ + # `hN*invΔ` first: `hN ≈ d*‖N‖` can be near the square root of floatmax + # when `hN*hN` would overflow, while `hN*invΔ ≈ d/‖N‖` stays in range. + hNinvΔ = hN*invΔ + rin = scale3(hNinvΔ, N) + d2in = hNinvΔ*hN # The closest points on the edges from `B` along `E0` and `E1`, and from # `B + E0` along `E2`. u0 = clamp01nan(-dot3(E0, BP)/dot3(E0, E0)) @@ -192,8 +195,11 @@ Between Line Segments" (Geometric Tools, 2018, revised 2023). # False for NaN, from parallel or zero length line segments. inside = (zero(T) ≤ sbar) & (sbar ≤ one(T)) & (zero(T) ≤ tbar) & (tbar ≤ one(T)) hn = dot3(Q0mP0, n) - rin = scale3(hn*invΔ, n) - d2in = hn*hn*invΔ + # `hn*invΔ` first: `hn ≈ d*‖n‖` can be near the square root of floatmax + # when `hn*hn` would overflow, while `hn*invΔ ≈ d/‖n‖` stays in range. + hninvΔ = hn*invΔ + rin = scale3(hninvΔ, n) + d2in = hninvΔ*hn inva = inv(a) invc = inv(c) # The closest points on `x` of the end points of `y`, and on `y` of the diff --git a/test/test-mindistance.jl b/test/test-mindistance.jl index 87e4dba..f3273f9 100644 --- a/test/test-mindistance.jl +++ b/test/test-mindistance.jl @@ -158,6 +158,98 @@ end end end +# Worst-case accuracy points ported from error-analysis/ledger.jl. Each case +# pins one branch domain (or branch boundary) of a distance kernel at the +# ledger's sharpest known input, with the ledger's first-order claim +# |√d̂² - d| ≤ u·bound(exact sizes), u = eps(T)/2, evaluated at the point and +# rounded up ~1.3x as margin for how the sizes drift under the noise below. +# Each point is tested as given, then with Gaussian coordinate noise of a few +# ulps: enough to scramble every rounding, and at the boundary points to make +# the branch actually flip (their tolerance is the traced all-domain bound, +# which includes the branch jump terms). +const ledger_cases = [ + # point-point: 5u/2·d, attained when one coordinate carries the separation. + ("point-point, one coordinate separation", 7.0, + ((-1.0, 0.03, -0.02),), ((1.0, 0.06, 0.03),)), + ("point-point, balanced coordinates", 11.0, + ((-0.95, -1.0, 0.9),), ((0.9, 0.85, -1.0),)), + # point-line: t clamped to 0 is exactly point-point (5u/2·d); the interior + # arm has the traced bound u(9‖p‖+3‖r‖+3/2·d); t clamped to 1 computes + # fl(p+r), u(‖p‖+‖r‖+5/2·d), tight when p+r cancels; the t* = 0 and t* = 1 + # boundary points have exactly zero conditions built from cancelling + # order-1 products, so rounding picks either arm. + ("point-line, t clamped to 0", 7.0, + ((1.0, 0.12, -0.03),), ((-1.0, 0.0, 0.0), (-1.0, -1.0, 0.0))), + ("point-line, interior", 38.0, + ((1.0, 0.0, 0.05),), ((-1.0, 1.0, 0.0), (-1.0, -1.0, 0.0))), + ("point-line, interior, aspect 10", 29.0, + ((1.0, 0.0, 0.0),), ((-1.0, 0.1, 0.02), (-1.0, -0.1, 0.02))), + ("point-line, t clamped to 1, p + r cancels", 8.0, + ((1.0, 1.0, 0.04),), ((-1.0, -1.0, 0.0), (0.98, 0.98, 0.0))), + ("point-line, boundary t* = 0", 35.0, + ((1.0, 1.0, 0.0),), ((-1.0, 0.0, 0.0), (-0.5, -1.0, 0.0))), + ("point-line, boundary t* = 1", 26.0, + ((0.5, 0.5, 0.0),), ((-1.0, 1.0, 0.0), (0.0, 0.0, 0.0))), + # point-triangle: the face arm claim is u(6‖BP‖+‖E0‖+‖E1‖+8d); the tilted + # triangle makes N generic, so the terms of hN = dot3(BP, N) are order 0.5 + # and cancel — with a z = 0 triangle (as in the testsets above) hN is a + # single product and this error path is never exercised. Edges and + # vertices reuse the point-line rows on (BP, E) with rounded-sum charges. + ("point-triangle, face of a tilted triangle", 17.0, + ((-0.01, 0.01, 0.29),), ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1))), + ("point-triangle, near face of a tilted triangle, hN cancels", 13.0, + ((-0.09, 0.03, 0.02),), ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1))), + ("point-triangle, edge 0 interior", 23.0, + ((0.2, -0.9, 0.4),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), + ("point-triangle, edge 2 interior", 24.0, + ((0.56, 0.59, 0.3),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), + ("point-triangle, vertex a", 1.7, + ((-1.0, -0.95, 0.35),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), + ("point-triangle, vertex b", 6.5, + ((1.0, -0.45, 0.3),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), + # x over the midpoint of edge 0: the tbar numerator of the inside test is + # exactly zero, so rounding decides face vs edge. + ("point-triangle, inside-test boundary", 164.0, + ((-0.05, -0.4, 0.6),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), + # x exactly on the u0 = 1 plane through b: edge 0 vs vertex b flips. + ("point-triangle, clamp boundary at vertex b", 192.0, + ((0.88, -0.54, 0.3),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), + # line-line: the crossing arm claim is u(6‖W‖+‖P‖+‖Q‖+8d) with no 1/sinθ; + # the near parallel point (sinθ ≈ 0.008, where the Gram form a*c - b*b + # loses ~125x) must meet the same bound as the generic crossing. Edge and + # corner rows reuse the point-line rows on (W, P), (W, Q), etc. + ("line-line, generic crossing", 17.0, + ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), ((-0.3, -1.0, 0.25), (0.3, 1.0, 0.25))), + ("line-line, nearly parallel crossing", 8.0, + ((-1.0, -0.004, 0.0), (1.0, 0.004, 0.0)), ((-1.0, 0.004, 0.15), (1.0, -0.004, 0.15))), + ("line-line, s interior, t = 0", 25.0, + ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), ((0.1, 0.3, 0.3), (0.9, 0.9, 0.5))), + ("line-line, s = 0, t interior", 10.5, + ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), ((-0.31, -0.67, 0.25), (-0.73, -0.11, 0.25))), + ("line-line, corner (0,0)", 2.2, + ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), ((-0.7, -0.8, 0.3), (-1.0, -0.9, 0.5))), + # Q crosses exactly over P0: the sbar numerator of the inside test is zero. + ("line-line, inside-test boundary", 66.0, + ((-0.8, -0.6, 0.0), (0.8, 0.6, 0.0)), ((-0.68, -0.76, 0.25), (-0.92, -0.44, 0.25))), + # (Q0 - P0) ⊥ Q exactly: the t* = 0 clamp boundary in the s* = 0 region. + ("line-line, clamp boundary at corner (0,0)", 53.0, + ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), ((-0.52, -0.39, 0.25), (-0.82, 0.01, 0.25))), +] + +@testset "ledger worst-case point: $name" for (name, tolu, xt, yt) in ledger_cases + rng = Random.Xoshiro(2026) + for T in (Float32, Float64) + tol = tolu*eps(T)/2 + for σ in (0, 8, 64, 1024) .* eps(T), _ in 1:(iszero(σ) ? 1 : 100) + x = SVector(map(v -> SVector{3, T}(v .+ σ .* randn(rng, 3)), xt)) + y = SVector(map(v -> SVector{3, T}(v .+ σ .* randn(rng, 3)), yt)) + ref = sqrt(dist_sqr(map(v -> big.(v), x), map(v -> big.(v), y))) + @test abs(sqrt(dist_sqr(x, y)) - ref) ≤ tol + @test abs(sqrt(dist_sqr(y, x)) - ref) ≤ tol + end + end +end + # Return `W` simplices as one simplex with a lane per simplex. function simplex_lanes(xs::NTuple{W, SVector{N, SVector{3, T}}}) where {W, N, T} SVector{N}(ntuple(Val(N)) do v From e9d85895df580f383258edf2aa918cc821959375 Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 00:21:49 -0400 Subject: [PATCH 03/11] remove error-analysis --- error-analysis/ErrorBounds.jl | 590 ---------------------------------- error-analysis/analyze.jl | 196 ----------- error-analysis/distances.md | 391 ---------------------- error-analysis/ledger.jl | 488 ---------------------------- 4 files changed, 1665 deletions(-) delete mode 100644 error-analysis/ErrorBounds.jl delete mode 100644 error-analysis/analyze.jl delete mode 100644 error-analysis/distances.md delete mode 100644 error-analysis/ledger.jl diff --git a/error-analysis/ErrorBounds.jl b/error-analysis/ErrorBounds.jl deleted file mode 100644 index 2755b11..0000000 --- a/error-analysis/ErrorBounds.jl +++ /dev/null @@ -1,590 +0,0 @@ -""" -First order rounding error bounds for the distance kernels, valid for all -inputs, found by running the kernels on [`EB`](@ref) numbers. - -An `EB` holds upper bounds as polynomials in size symbols: `mag` on the -absolute value of the exact result, and `err` on the rounding error, in -units of `u`. Each operation applies a rule that holds for all inputs, so -the bounds are functions of the sizes, not of a particular input. Products -of two errors, `O(u²)`, are dropped. - -A sum or difference of two exact values, such as `y[1] - x[1]`, becomes a -new size symbol, for example `|Pᵢ|`, rounded once. So the bounds are in the -sizes of the differences, not of the coordinates. - -In `:backward` mode, the rounded differences of input vertices are instead -treated as exact differences of slightly moved vertices. The kernel is then -analysed on the moved vertices, and [`vertex_moves`](@ref) gives how far -each vertex moved, which changes the exact distance by at most the largest -move on each simplex. This is how the kernels use the differences: the same -rounded `E0`, `E1` everywhere, so the result is the accurate distance of a -nearby triangle, even when it is a sliver. - -The kernels are branch free, so tracing evaluates both arms of every -select. A select joins the bounds of its arms, and adds a jump term when -the computed condition can disagree with the exact one: - -- A running minimum, `cand[1] < best[1]`, has no jump, since `min` is - 1-Lipschitz. `r`, `s` and `t` are compared with the exact values of the - candidate that was chosen. -- An `inside` test has a jump of `Σ err(v) * L(v)` over its distinct - variables `v`, where `L(v)` comes from [`JUMPS`](@ref): a hand derived - bound on how far the boundary is, in distance, per unit of `v`. -""" -module ErrorBounds - -using SimplexCellLists: SimplexCellLists, SIMD -using StaticArrays: SVector - -export EB, SPoly, Sym, input_simplex, normbound, vecnormbound, maxpoly, boundvalue, - reset!, vertex_moves, substitute_abscross, NAMES, CROSS_DEFS, ABSCROSS_DEFS, JUMPS, ASSUMPTIONS, NOTES - -const Q = Rational{BigInt} - -# A size symbol: `|base_i|` for component `i` in 1:3, or `‖base‖` when `i == 0`. -struct Sym - base::Symbol - i::Int -end -Base.isless(a::Sym, b::Sym) = isless((a.base, a.i), (b.base, b.i)) - -# Sorted `Sym => exponent` pairs, with nonzero exponents. -const Mono = Vector{Pair{Sym, Int}} - -function monomul(a::Mono, b::Mono) - d = Dict{Sym, Int}() - for (s, e) in Iterators.flatten((a, b)) - d[s] = get(d, s, 0) + e - end - sort!([s => e for (s, e) in d if e != 0]; by=first) -end - -""" - SPoly - -A polynomial with nonnegative coefficients in size symbols, used as an -upper bound. Exponents may be negative for norms, as in `‖R‖⁻²`. -""" -struct SPoly - t::Dict{Mono, Q} -end -SPoly() = SPoly(Dict{Mono, Q}()) -SPoly(c::Real) = iszero(c) ? SPoly() : SPoly(Dict(Mono() => Q(c))) -SPoly(s::Sym, e::Int=1) = SPoly(Dict([s => e] => one(Q))) - -Base.iszero(p::SPoly) = isempty(p.t) -function Base.:+(a::SPoly, b::SPoly) - t = copy(a.t) - for (m, c) in b.t - t[m] = get(t, m, zero(Q)) + c - end - SPoly(t) -end -function Base.:*(a::SPoly, b::SPoly) - t = Dict{Mono, Q}() - for (ma, ca) in a.t, (mb, cb) in b.t - m = monomul(ma, mb) - t[m] = get(t, m, zero(Q)) + ca*cb - end - SPoly(t) -end -Base.:*(c::Real, p::SPoly) = iszero(c) ? SPoly() : SPoly(Dict(m => Q(c)*v for (m, v) in p.t)) - -""" - maxpoly(a::SPoly, b::SPoly) - -An upper bound on `max(a, b)`: the largest coefficient of each monomial. -""" -function maxpoly(a::SPoly, b::SPoly) - t = copy(a.t) - for (m, c) in b.t - t[m] = max(get(t, m, zero(Q)), c) - end - SPoly(t) -end - -# The square root of a monomial with even exponents and coefficient one. -function _sqrtmag(p::SPoly) - length(p.t) == 1 || return nothing - (m, c), = p.t - isone(c) && all(iseven ∘ last, m) || return nothing - SPoly(Dict(Mono([s => e ÷ 2 for (s, e) in m]) => one(Q))) -end - -# The component indices that appear in `p`. -indices(p::SPoly) = Set(s.i for m in keys(p.t) for (s, _) in m if s.i > 0) - -const SUPERSCRIPTS = Dict(zip("-0123456789", "⁻⁰¹²³⁴⁵⁶⁷⁸⁹")) -function Base.show(io::IO, p::SPoly) - iszero(p) && return print(io, "0") - terms = sort!(collect(p.t); by=x -> string(x[1])) - for (k, (m, c)) in enumerate(terms) - k > 1 && print(io, " + ") - (isone(c) && !isempty(m)) || print(io, isinteger(c) ? numerator(c) : c) - for (s, e) in m - print(io, s.i == 0 ? "‖$(s.base)‖" : "|$(s.base)$(('₀':'₉')[s.i + 1])|") - e == 1 || print(io, map(ch -> SUPERSCRIPTS[ch], string(e))) - end - end -end - -""" - boundvalue(p::SPoly, values) - -Evaluate `p` with `values[s]` for each symbol `s`. -""" -boundvalue(p::SPoly, values) = sum((c*prod((values[s]^e for (s, e) in m); init=big(1)) for (m, c) in p.t); init=big(0)) - -""" - normbound(p::SPoly) - -Bound a scalar that sums over components by norms. For each group of terms -that differ only in their component index `i`, with the largest coefficient -`c`, `Σᵢ c|Xᵢ||Yᵢ|… ≤ c‖X‖‖Y‖…` (Cauchy-Schwarz, then `|Zᵢ| ≤ ‖Z‖`) and -`Σᵢ c|Xᵢ| ≤ c√n‖X‖` for `n` indices. Other terms use `|Xⱼ| ≤ ‖X‖` alone. -""" -function normbound(p::SPoly) - out = SPoly() - groups = Dict{Tuple{Vector{Pair{Symbol, Int}}, Mono}, Tuple{Q, Set{Int}}}() - for (m, c) in p.t - comps = [s => e for (s, e) in m if s.i > 0] - glob = Mono([s => e for (s, e) in m if s.i == 0]) - @assert all(>(0), last.(comps)) "negative exponent on a component" - idx = Set(s.i for (s, _) in comps) - if isempty(comps) - out += SPoly(Dict(m => c)) - elseif length(idx) == 1 - key = (sort!([s.base => e for (s, e) in comps]; by=first), glob) - cmax, seen = get(groups, key, (zero(Q), Set{Int}())) - groups[key] = (max(cmax, c), union(seen, idx)) - else - out += SPoly(Dict(monomul(glob, Mono(sort!([Sym(s.base, 0) => e for (s, e) in comps]; by=first))) => c)) - end - end - for ((bases, glob), (cmax, seen)) in groups - norms = Mono(sort!([Sym(b, 0) => e for (b, e) in bases]; by=first)) - degree = sum(last, bases) - # √n for a sum of single components, rounded up to a rational. - factor = degree ≥ 2 ? one(Q) : (length(seen) == 1 ? one(Q) : length(seen) == 2 ? Q(3//2) : Q(7//4)) - out += SPoly(Dict(monomul(glob, norms) => cmax*factor)) - end - out -end - -""" - vecnormbound(ps) - -Bound the Euclidean norm of a vector whose component `i` is at most `ps[i]`, -by Minkowski: group the terms by the part with index `i`, take the largest -coefficient, and bound the norm of `(|Xᵢ||Yᵢ|…)ᵢ` by `‖X‖‖Y‖…`. A term -without index `i` is a constant vector, with norm `√3` times its value. -""" -function vecnormbound(ps) - groups = Dict{Tuple{Vector{Pair{Symbol, Int}}, Mono}, Q}() - for (i, p) in enumerate(ps) - rests = Dict{Vector{Pair{Symbol, Int}}, SPoly}() - for (m, c) in p.t - own = sort!([s.base => e for (s, e) in m if s.i == i]; by=first) - rest = SPoly(Dict(Mono([s => e for (s, e) in m if s.i != i]) => c)) - rests[own] = get(rests, own, SPoly()) + rest - end - for (own, rest) in rests - # The rest no longer depends on `i`, so reduce it to norms. - for (m, c) in normbound(rest).t - key = (own, m) - groups[key] = max(get(groups, key, zero(Q)), c) - end - end - end - out = SPoly() - for ((own, m), c) in groups - norms = Mono(sort!([Sym(b, 0) => e for (b, e) in own]; by=first)) - out += SPoly(Dict(monomul(norms, m) => isempty(own) ? Q(7//4)*c : c)) - end - out -end - -""" - EB - -A traced number: `mag` bounds the absolute value of the exact result, and -`err` bounds the rounding error in units of `u`. If `rel` is set, the error -is also at most `rel*u` times the exact value. `exact` means `mag` is the -absolute value of the exact result, not just a bound, as division needs. - -`root`, if set, is `(abs, rel)` for a nonnegative result `ρ²`, such as a -squared distance: `|√computed - ρ| ≤ u*(abs + rel*ρ)`. - -`sqscale`, if set, records that this is the product `r*w` with `w` a -nonnegative exact square, so that a later `*r` is recognized as a squared -distance, as in `(hN*invΔ)*hN`. -""" -struct EB <: Real - mag::SPoly - err::SPoly - rel::Union{Nothing, Q} - nonneg::Bool - exact::Bool - input::Union{Nothing, Sym} - con::Union{Nothing, Q} - root::Union{Nothing, Tuple{SPoly, Q}} - sqscale::Union{Nothing, Tuple{EB, EB}} -end - -EB(mag, err, rel, nonneg, exact, input, con, root) = - EB(mag, err, rel, nonneg, exact, input, con, root, nothing) -EB(c::Real) = EB(SPoly(abs(c)), SPoly(), zero(Q), c ≥ 0, true, nothing, Q(c), nothing) -withrel(mag, rel, nonneg, exact) = EB(mag, rel*mag, rel, nonneg, exact, nothing, nothing, nothing) -withabs(mag, err, nonneg, exact) = EB(mag, err, nothing, nonneg, exact, nothing, nothing, nothing) -_withroot(x::EB, root) = EB(x.mag, x.err, x.rel, x.nonneg, x.exact, x.input, x.con, root) - -Base.zero(::Type{EB}) = EB(0) -Base.one(::Type{EB}) = EB(1) -Base.zero(::EB) = EB(0) -Base.one(::EB) = EB(1) -Base.convert(::Type{EB}, c::Real) = EB(c) -Base.convert(::Type{EB}, x::EB) = x -Base.promote_rule(::Type{EB}, ::Type{<:Real}) = EB -function Base.show(io::IO, x::EB) - print(io, "EB(mag = ", x.mag, ", err = (", x.err, ")·u") - x.rel === nothing || print(io, ", rel = ", x.rel, "·u") - x.root === nothing || print(io, ", root err = (", x.root[1], ")·u + ", x.root[2], "·u·ρ") - print(io, ")") -end - -""" - NAMES - -Names for sums and differences of exact values, keyed by -`(op, base_a, base_b)`, for example `(:-, :a, :x) => :P` for -`P = a - x`. Missing names are made up. -""" -const NAMES = Dict{Tuple{Symbol, Symbol, Symbol}, Symbol}() -const DEFS = Dict{Symbol, Tuple{Symbol, Symbol, Symbol}}() -"Names for the results of `cross3_accurate`, used in order, and their definitions." -const CROSS_NAMES = Symbol[] -const CROSS_DEFS = Dict{Symbol, Tuple{Symbol, Symbol}}() -""" -For a plain `cross3(a, b)`, `X => (a, b)`: `|Xᵢ| = |aⱼ||bₖ| + |aₖ||bⱼ|` bounds -each component, and `‖X‖ ≤ √2‖a‖‖b‖`. -""" -const ABSCROSS_DEFS = Dict{Symbol, Tuple{Symbol, Symbol}}() -"For each `inside` select, in order, the distance per unit of each distinct condition variable." -const JUMPS = Vector{Vector{SPoly}}() -const MOVES = Dict{Symbol, Tuple{Symbol, Symbol}}() -const MODE = Ref(:backward) -const ASSUMPTIONS = String[] -const NOTES = String[] - -""" - reset!(; names, cross=Symbol[], jumps=Vector{SPoly}[], mode=:backward) - -Clear the trace state and set the names and jump constants for one kernel. -""" -function reset!(; names=Dict{Tuple{Symbol, Symbol, Symbol}, Symbol}(), cross=Symbol[], jumps=Vector{SPoly}[], mode=:backward) - empty!(NAMES); merge!(NAMES, names) - empty!(DEFS); empty!(CROSS_DEFS); empty!(ABSCROSS_DEFS); empty!(MOVES) - empty!(CROSS_NAMES); append!(CROSS_NAMES, cross) - empty!(JUMPS); append!(JUMPS, jumps) - empty!(ASSUMPTIONS); empty!(NOTES) - MODE[] = mode - nothing -end - -""" - input_simplex(names...) - -Return a simplex of exact input vertices named `names`, with each -coordinate's size the symbol `|nameᵢ|`. -""" -function input_simplex(names::Symbol...) - SVector(map(names) do n - SVector(ntuple(i -> EB(SPoly(Sym(n, i)), SPoly(), zero(Q), false, true, Sym(n, i), nothing, nothing), 3)) - end) -end - -""" - vertex_moves(v::Symbol) - -In `:backward` mode, a bound on how far input vertex `v` moved, as one -`SPoly` per component in units of `u`, following the rounded differences -back to a vertex that did not move. -""" -function vertex_moves(v::Symbol) - haskey(MOVES, v) || return [SPoly() for _ in 1:3] - anchor, name = MOVES[v] - [m + SPoly(Sym(name, i)) for (i, m) in enumerate(vertex_moves(anchor))] -end - -# The symbol of an exact value that is a single size symbol, or `nothing`. -function _single(x::EB) - iszero(x.err) && x.exact && length(x.mag.t) == 1 || return nothing - (m, c), = x.mag.t - isone(c) && length(m) == 1 && m[1][2] == 1 && m[1][1].i > 0 ? m[1][1] : nothing -end - -# One rounding of the exact result `x`. -function _round(x::EB) - x.con === nothing || return x - x.rel === nothing ? withabs(x.mag, x.err + x.mag, x.nonneg, x.exact) : withrel(x.mag, x.rel + 1, x.nonneg, x.exact) -end - -function _neg(a::EB) - EB(a.mag, a.err, a.rel, iszero(a.mag), a.exact, nothing, a.con === nothing ? nothing : -a.con, nothing) -end - -function _add_exact(a::EB, b::EB) - a.con == 0 && return b - b.con == 0 && return a - mag = a.mag + b.mag - nonneg = a.nonneg && b.nonneg - exact = nonneg && a.exact && b.exact - # Each nonnegative term has relative error at most its `rel`, so their - # sum has relative error at most the larger one. - if nonneg && a.rel !== nothing && b.rel !== nothing - withrel(mag, max(a.rel, b.rel), true, exact) - else - withabs(mag, a.err + b.err, nonneg, exact) - end -end - -# A sum or difference of two exact values with single symbols of the same -# component is a new symbol, rounded once, so its error is relative to it. -function _addsub(op::Symbol, a::EB, b::EB) - sa, sb = _single(a), _single(b) - if sa !== nothing && sb !== nothing && sa.i == sb.i - name = get!(NAMES, (op, sa.base, sb.base), Symbol(sa.base, op == :- ? :_m_ : :_p_, sb.base)) - DEFS[name] = (op, sa.base, sb.base) - if op == :- && a.input !== nothing && b.input !== nothing && MODE[] == :backward - # The rounded difference is the exact difference of `a` moved. - get!(MOVES, sa.base, (sb.base, name)) == (sb.base, name) || error("vertex $(sa.base) moved twice") - return EB(SPoly(Sym(name, sa.i)), SPoly(), zero(Q), false, true, nothing, nothing, nothing) - end - return withrel(SPoly(Sym(name, sa.i)), one(Q), false, true) - end - _round(_add_exact(a, op == :- ? _neg(b) : b)) -end - -# Reduce a scalar that sums over components to norms before it scales a -# single component. -function _reduce(a::EB, b::EB) - length(indices(a.mag) ∪ indices(a.err)) > 1 && length(indices(b.mag) ∪ indices(b.err)) == 1 || return a - EB(normbound(a.mag), normbound(a.err), nothing, a.nonneg, false, nothing, nothing, nothing) -end - -function _mul_exact(a::EB, b::EB) - (a.con == 0 || b.con == 0) && return EB(0) - a.con == 1 && return b - b.con == 1 && return a - nonneg = a === b || (a.nonneg && b.nonneg) - a, b = _reduce(a, b), _reduce(b, a) - mag = a.mag * b.mag - exact = a.exact && b.exact - if a.rel !== nothing && b.rel !== nothing - withrel(mag, a.rel + b.rel, nonneg, exact) - else - withabs(mag, a.err*b.mag + a.mag*b.err, nonneg, exact) - end -end - -# `1/|B|` for the exact value of a divisor: a single monomial, or `Σᵢ Xᵢ²` -# for the squared norm `‖X‖²`. -function _inv_mag(p::SPoly) - if length(p.t) == 1 - (m, c), = p.t - return SPoly(Dict(Mono([s => -e for (s, e) in m]) => inv(c))) - end - bases = unique(s.base for m in keys(p.t) for (s, _) in m) - sq(b) = Dict([Sym(b, i) => 2] => one(Q) for i in 1:3) - length(bases) == 1 && p.t == sq(only(bases)) && return SPoly(Sym(only(bases), 0), -2) - error("cannot divide by an exact value of $p") -end - -function _div_exact(a::EB, b::EB) - b.nonneg && b.exact && b.rel !== nothing || error("divisor must be nonnegative with a known exact size and relative error") - invb = _inv_mag(b.mag) - mag = a.mag * invb - if a.rel !== nothing - withrel(mag, a.rel + b.rel, a.nonneg, a.exact) - else - # First order: a/(B(1 + β)) = a/B - (a/B)β. - withabs(mag, a.err*invb + b.rel*a.mag*invb, a.nonneg, a.exact) - end -end - -function Base.:*(a::EB, b::EB) - x = _round(_mul_exact(a, b)) - if a === b - # √(x̂²(1 + δ)) = |x̂|(1 + δ/2). - return _withroot(x, a.rel === nothing ? (a.err, Q(1//2)) : (SPoly(), a.rel + Q(1//2))) - end - # A squared distance times a nonnegative exact square, such as `1/‖N‖²`. - for (s, w) in ((a, b), (b, a)) - s.root === nothing && continue - w.nonneg && w.exact && w.rel !== nothing || continue - sq = _sqrtmag(w.mag) - sq === nothing && continue - return _withroot(x, (s.root[1]*sq, s.root[2] + w.rel/2 + Q(1//2))) - end - # `(r*w)*r` with the recorded exact square `w`: the same two roundings as - # `(r*r)*w`, so the same distance bound as the two rules above combined. - for (s, o) in ((a, b), (b, a)) - s.sqscale === nothing && continue - r, w = s.sqscale - o === r || continue - sq = _sqrtmag(w.mag) - rt = r.rel === nothing ? (r.err, Q(1//2)) : (SPoly(), r.rel + Q(1//2)) - return _withroot(x, (rt[1]*sq, rt[2] + w.rel/2 + Q(1//2))) - end - # Remember `r*w` with `w` a nonnegative exact square for the rule above. - for (r, w) in ((a, b), (b, a)) - w.nonneg && w.exact && w.rel !== nothing || continue - _sqrtmag(w.mag) === nothing && continue - return EB(x.mag, x.err, x.rel, x.nonneg, x.exact, x.input, x.con, x.root, (r, w)) - end - x -end -Base.:-(a::EB, b::EB) = _addsub(:-, a, b) -Base.:+(a::EB, b::EB) = _addsub(:+, a, b) -Base.:-(a::EB) = _neg(a) -Base.:/(a::EB, b::EB) = _round(_div_exact(a, b)) -Base.inv(b::EB) = EB(1)/b -Base.fma(a::EB, b::EB, c::EB) = _round(_add_exact(_mul_exact(a, b), c)) - -function SimplexCellLists.dot3(a::SVector{3, EB}, b::SVector{3, EB}) - x = fma(a[3], b[3], fma(a[2], b[2], a[1]*b[1])) - a === b || return x - # The sum of squares of the computed `a` has relative error γ₃, and - # `‖â‖` is within `‖â - a‖` of `‖a‖`. - _withroot(x, x.rel === nothing ? (vecnormbound([ai.err for ai in a]), Q(3//2)) : (SPoly(), x.rel/2)) -end - -function SimplexCellLists.cross3_accurate(a::SVector{3, EB}, b::SVector{3, EB}) - sa, sb = _single.(a), _single.(b) - if all(!isnothing, sa) && all(!isnothing, sb) && allequal(s.base for s in sa) && allequal(s.base for s in sb) - # Each component is Kahan's 2×2 determinant of exact values, within - # 2u of the exact component (Jeannerod, Louvet, Muller 2013). - name = isempty(CROSS_NAMES) ? Symbol(sa[1].base, :_x_, sb[1].base) : popfirst!(CROSS_NAMES) - CROSS_DEFS[name] = (sa[1].base, sb[1].base) - return SVector(ntuple(i -> withrel(SPoly(Sym(name, i)), Q(2), false, true), 3)) - end - det(p, q, u, v) = (x = _add_exact(_mul_exact(p, q), _neg(_mul_exact(u, v))); withabs(x.mag, x.err + 2*x.mag, false, false)) - SVector(det(a[2], b[3], a[3], b[2]), det(a[3], b[1], a[1], b[3]), det(a[1], b[2], a[2], b[1])) -end - -# The symbol and relative error of a value that is a single size symbol -# times `1 + rel*u`, or `nothing`. -function _symrel(x::EB) - x.exact && x.rel !== nothing && length(x.mag.t) == 1 || return nothing - (m, c), = x.mag.t - isone(c) && length(m) == 1 && m[1][2] == 1 && m[1][1].i > 0 ? (m[1][1], x.rel) : nothing -end - -function SimplexCellLists.cross3(a::SVector{3, EB}, b::SVector{3, EB}) - sa, sb = _symrel.(a), _symrel.(b) - if all(!isnothing, sa) && all(!isnothing, sb) && allequal(first(s).base for s in sa) && allequal(first(s).base for s in sb) - # Component i is fma(aⱼ, bₖ, -fl(aₖbⱼ)). With |Xᵢ| = |aⱼ||bₖ| + |aₖ||bⱼ|, - # the input errors give (rel_a + rel_b)|Xᵢ|, the product rounding at - # most |Xᵢ|, and the fma rounding at most |cᵢ| ≤ |Xᵢ|. - name = Symbol(:X_, first(sa[1]).base, :_, first(sb[1]).base) - ABSCROSS_DEFS[name] = (first(sa[1]).base, first(sb[1]).base) - rel = maximum(last, sa) + maximum(last, sb) + 2 - return SVector(ntuple(i -> withabs(SPoly(Sym(name, i)), rel*SPoly(Sym(name, i)), false, false), 3)) - end - SVector(fma(a[2], b[3], -(a[3]*b[2])), fma(a[3], b[1], -(a[1]*b[3])), fma(a[1], b[2], -(a[2]*b[1]))) -end - -""" - substitute_abscross(p::SPoly) - -Replace each `‖X‖` of a plain `cross3(a, b)` by its bound `√2‖a‖‖b‖`, -rounded up to `3/2‖a‖‖b‖`. Call after [`normbound`](@ref). -""" -function substitute_abscross(p::SPoly) - out = SPoly() - for (m, c) in p.t - term = SPoly(c) - for (s, e) in m - if s.i == 0 && haskey(ABSCROSS_DEFS, s.base) - e > 0 || error("‖$(s.base)‖ has a negative exponent") - a, b = ABSCROSS_DEFS[s.base] - term = term * SPoly(Q(3//2)^e) * SPoly(Sym(a, 0), e) * SPoly(Sym(b, 0), e) - else - term = term * SPoly(s, e) - end - end - out += term - end - out -end - -abstract type AbstractCond end -struct Cond <: AbstractCond - op::Symbol - lhs::EB - rhs::EB -end -struct CondAnd <: AbstractCond - parts::Vector{Cond} -end -_parts(c::Cond) = [c] -_parts(c::CondAnd) = c.parts -Base.:<(a::EB, b::EB) = Cond(:lt, a, b) -Base.:≤(a::EB, b::EB) = Cond(:le, a, b) -Base.:&(a::AbstractCond, b::AbstractCond) = CondAnd([_parts(a); _parts(b)]) - -struct NaNCond - x::EB -end -Base.isnan(x::EB) = NaNCond(x) -function SIMD.vifelse(c::NaNCond, a, b) - push!(ASSUMPTIONS, "a clamped value is never NaN (no zero length divisor)") - b -end -SIMD.vifelse(c::AbstractCond, a, b) = error("select on $c outside `_select_d2_r_s_t` is not traced") - -# Clamping is 1-Lipschitz, so it does not increase the error. -function Base.FastMath.max_fast(x::EB, z::EB) - z.con == 0 || error("max_fast is only traced against zero") - x.nonneg ? x : withabs(x.mag, x.err, true, false) -end -function Base.FastMath.min_fast(x::EB, o::EB) - o.con == 1 && x.nonneg && return withabs(SPoly(1), x.err, true, false) - # |min(x̂, ô) - min(x, o)| ≤ max(|x̂ - x|, |ô - o|). - withabs(maxpoly(x.mag, o.mag), maxpoly(x.err, o.err), x.nonneg && o.nonneg, false) -end - -# Join two arms: each bound is the larger of the two. -function _join(a::EB, b::EB) - root = a.root !== nothing && b.root !== nothing ? (maxpoly(a.root[1], b.root[1]), max(a.root[2], b.root[2])) : nothing - rel = a.rel !== nothing && b.rel !== nothing ? max(a.rel, b.rel) : nothing - EB(maxpoly(a.mag, b.mag), maxpoly(a.err, b.err), rel, a.nonneg && b.nonneg, false, nothing, nothing, root) -end - -function SimplexCellLists._select_d2_r_s_t(c::AbstractCond, a::Tuple, b::Tuple) - d2 = _join(a[1], b[1]) - if c isa Cond && c.op == :lt && c.lhs === a[1] && c.rhs === b[1] - # A running minimum: |min(â, b̂) - min(a, b)| ≤ max(|â - a|, |b̂ - b|), - # and either arm that can be chosen has a distance within the error - # of the minimum, so `rel*ρ` stays in terms of the minimum. - push!(NOTES, "running minimum: no jump in the distance; r, s, t are those of the chosen candidate") - else - vars = EB[] - for p in _parts(c) - v = p.lhs.con === nothing ? p.lhs : p.rhs - (p.lhs.con === nothing) ⊻ (p.rhs.con === nothing) || error("condition must compare a variable with a constant") - any(w -> w === v, vars) || push!(vars, v) - end - isempty(JUMPS) && error("no jump constants for this select") - Ls = popfirst!(JUMPS) - length(Ls) == length(vars) || error("$(length(vars)) condition variables, $(length(Ls)) jump constants") - jump = sum(v.err * L for (v, L) in zip(vars, Ls)) - d2.root === nothing && error("select arms have no distance bound") - d2 = _withroot(d2, (d2.root[1] + jump, d2.root[2])) - push!(NOTES, "inside select: jump $(jump) in the distance; r, s, t are those of the chosen arm") - end - r = SVector(ntuple(i -> _join(a[2][i], b[2][i]), 3)) - (d2, r, _join(a[3], b[3]), _join(a[4], b[4])) -end - -end diff --git a/error-analysis/analyze.jl b/error-analysis/analyze.jl deleted file mode 100644 index 97d39ac..0000000 --- a/error-analysis/analyze.jl +++ /dev/null @@ -1,196 +0,0 @@ -# Trace the first order error bounds of the distance kernels with -# ErrorBounds.jl, then check them against BigFloat on adversarial inputs. -# -# Run from the test environment, which has SimplexCellLists and StaticArrays: -# include("error-analysis/analyze.jl") - -using SimplexCellLists, StaticArrays, Random, LinearAlgebra -using SimplexCellLists: dist_sqr_r_s_t -include(joinpath(@__DIR__, "ErrorBounds.jl")) -using .ErrorBounds -using .ErrorBounds: DEFS - -setprecision(BigFloat, 512) - -ratio_sym(a, b) = SPoly(Sym(a, 0)) * SPoly(Sym(b, 0), -1) - -const KERNELS = ( - point_point = (x = (:x,), y = (:y,), - names = Dict((:-, :y, :x) => :R)), - point_line = (x = (:x,), y = (:a, :b), - names = Dict((:-, :a, :x) => :P, (:-, :b, :a) => :R)), - point_triangle = (x = (:x,), y = (:a, :b, :c), - names = Dict((:-, :b, :a) => :E0, (:-, :c, :a) => :E1, (:-, :a, :x) => :BP, - (:-, :E1, :E0) => :E2, (:+, :BP, :E0) => :BP2), - cross = [:N], - # In plane distance from the boundary per unit of `sbar`, `tbar` and - # `sbar + tbar`: the heights of the triangle over its edges. - jumps = [[ratio_sym(:N, :E1), ratio_sym(:N, :E0), ratio_sym(:N, :E2)]]), - line_line = (x = (:x1, :x2), y = (:y1, :y2), - names = Dict((:-, :x2, :x1) => :P, (:-, :y2, :y1) => :Q, (:-, :y1, :x1) => :W, - (:+, :W, :Q) => :WQ, (:-, :W, :P) => :WP), - cross = [:n], - # In plane distance between the projected line segments per unit of - # `sbar` and `tbar`: `sin θ` times the length of the other segment. - jumps = [[ratio_sym(:n, :Q), ratio_sym(:n, :P)]]), -) - -""" - trace(k) - -Run kernel `k` on traced inputs. Return the distance bound -`|√d̂² - d| ≤ u*(abs + rel*d + moves)` as `(; abs, rel, moves)`, with `abs` -still in components, and the traced outputs. -""" -function trace(k) - reset!(; names=k.names, cross=get(k, :cross, Symbol[]), jumps=get(k, :jumps, Vector{SPoly}[])) - d2, r, s, t = dist_sqr_r_s_t(input_simplex(k.x...), input_simplex(k.y...)) - farthest(vs) = reduce(maxpoly, [vecnormbound(vertex_moves(v)) for v in vs]) - moves = farthest(k.x) + farthest(k.y) - # The bound in norms only, with the plain cross products bounded. - simple = substitute_abscross(normbound(d2.root[1])) - (; abs = d2.root[1], simple, rel = d2.root[2], moves, d2, r, s, t, - defs = copy(DEFS), cross_defs = copy(CROSS_DEFS), abscross_defs = copy(ABSCROSS_DEFS), - notes = unique(ErrorBounds.NOTES), assumptions = unique(ASSUMPTIONS)) -end - -# Values of every size symbol for the exact vertices. -function symvalues(k, tr, xs, ys) - vecs = Dict{Symbol, Vector{BigFloat}}() - for (n, v) in zip(k.x, xs); vecs[n] = big.(v); end - for (n, v) in zip(k.y, ys); vecs[n] = big.(v); end - function value(n) - get!(vecs, n) do - if haskey(tr.defs, n) - op, a, b = tr.defs[n] - op == :- ? value(a) - value(b) : value(a) + value(b) - else - a, b = tr.cross_defs[n] - cross(value(a), value(b)) - end - end - end - for (n, (a, b)) in tr.abscross_defs - va, vb = abs.(value(a)), abs.(value(b)) - vecs[n] = [va[2]*vb[3] + va[3]*vb[2], va[3]*vb[1] + va[1]*vb[3], va[1]*vb[2] + va[2]*vb[1]] - end - vals = Dict{Sym, BigFloat}() - for n in union(keys(vecs), keys(tr.defs), keys(tr.cross_defs)) - v = value(n) - vals[Sym(n, 0)] = norm(v) - for i in 1:3; vals[Sym(n, i)] = abs(v[i]); end - end - vals -end - -""" - check(k, tr, xs, ys) - -The error of the distance over its traced bound, for vertices `xs` and `ys`. -""" -function check(k, tr, xs::NTuple{N, SVector{3, T}}, ys::NTuple{M, SVector{3, T}}) where {N, M, T} - d2 = dist_sqr_r_s_t(SVector(xs), SVector(ys))[1] - d = sqrt(dist_sqr_r_s_t(SVector(map(v -> big.(v), xs)), SVector(map(v -> big.(v), ys)))[1]) - vals = symvalues(k, tr, xs, ys) - u = big(eps(T))/2 - bound = u*(boundvalue(tr.simple, vals) + tr.rel*d + boundvalue(tr.moves, vals)) - Float64(abs(sqrt(big(d2)) - d)/bound) -end - -function report(name, k, gens; n=20_000) - tr = trace(k) - println("=== $name ===") - println(" |√d̂² - d| ≤ u*(", tr.simple, " + ", tr.rel, "d) in the moved vertices") - println(" + u*(", tr.moves, ") for the vertex moves") - foreach(s -> println(" note: ", s), tr.notes) - foreach(s -> println(" assumes: ", s), tr.assumptions) - for T in (Float32, Float64), (gname, g) in gens - ratios = filter(isfinite, [check(k, tr, g(T)...) for _ in 1:n]) - println(" ", rpad("$T $gname", 40), " max error/bound ", round(maximum(ratios), sigdigits=3)) - end - tr -end - -randdir() = normalize(randn(3)) -vec3(T, v) = SVector{3, T}(v) -function inplane(n) - e1 = normalize(cross(n, randn(3))) - (e1, cross(n, e1)) -end - -const GENS = ( - point_point = ( - "random [0,100]" => T -> ((vec3(T, 100rand(3)),), (vec3(T, 100rand(3)),)), - "0.01 apart, 1e4 from the origin" => T -> begin - c = 1e4randdir() - ((vec3(T, c),), (vec3(T, c .+ 0.01randdir()),)) - end, - ), - point_line = ( - "random [0,100]" => T -> ((vec3(T, 100rand(3)),), (vec3(T, 100rand(3)), vec3(T, 100rand(3)))), - "point on the segment" => T -> begin - a = 100randn(3); b = 100randn(3); s = rand() - ((vec3(T, a .+ s .* (b .- a)),), (vec3(T, a), vec3(T, b))) - end, - "near an end, 1e4 from the origin" => T -> begin - c = 1e4randdir(); dd = randdir(); n = normalize(cross(dd, randn(3))) - s = rand((0.0, 1.0)) + 1e-6randn() - ((vec3(T, c .+ 0.3n),), (vec3(T, c .- 100s*dd), vec3(T, c .+ 100(1-s)*dd))) - end, - ), - point_triangle = ( - "random [0,100]" => T -> ((vec3(T, 100rand(3)),), Tuple(vec3(T, 100rand(3)) for _ in 1:3)), - "0.01 above, near an edge" => T -> begin - n = randdir(); e1, e2 = inplane(n); a = 100randn(3) - b = a .+ 100e1; c = a .+ 100e2; w = rand((0.0, 0.5)) + 1e-6randn() - ((vec3(T, a .+ w*100e1 .+ (0.5 - w)*100e2 .+ 0.01n),), (vec3(T, a), vec3(T, b), vec3(T, c))) - end, - "sliver" => T -> begin - dd = randdir(); n = normalize(cross(dd, randn(3))); a = 100randn(3) - b = a .+ 100dd; c = a .+ 50dd .+ 1e-3n - ((vec3(T, a .+ 40dd .+ 0.01randdir()),), (vec3(T, a), vec3(T, b), vec3(T, c))) - end, - "needle, near the short edge" => T -> begin - n = randdir(); e1, e2 = inplane(n); a = 100randn(3) - b = a .+ 100e1; c = b .+ 0.01e2; w = rand() - ((vec3(T, b .+ w*0.01e2 .+ 1e-7randn()*e1 .+ 0.01n),), (vec3(T, a), vec3(T, b), vec3(T, c))) - end, - "needle, 0.2 past the short edge" => T -> begin - n = randdir(); e1, e2 = inplane(n); a = 100randn(3) - b = a .+ 100e1; c = b .+ 0.001e2 - ((vec3(T, b .+ 0.001rand()*e2 .+ 0.2rand()*e1 .+ 0.01n),), (vec3(T, a), vec3(T, b), vec3(T, c))) - end, - "shared vertex pool" => T -> begin - pool = [vec3(T, 100rand(3)) for _ in 1:3] - ((rand(pool),), Tuple(rand(pool) for _ in 1:3)) - end, - ), - line_line = ( - "random [0,100]" => T -> (Tuple(vec3(T, 100rand(3)) for _ in 1:2), Tuple(vec3(T, 100rand(3)) for _ in 1:2)), - "crossing 0.01 apart, 1e4 from the origin" => T -> begin - c = 1e4randdir(); n = randdir(); e1, e2 = inplane(n) - d1 = normalize(randn()*e1 + randn()*e2); d2 = normalize(randn()*e1 + randn()*e2); s = rand(); t = rand() - ((vec3(T, c .- 100s*d1), vec3(T, c .+ 100(1-s)*d1)), (vec3(T, c .- 100t*d2 .+ 0.01n), vec3(T, c .+ 100(1-t)*d2 .+ 0.01n))) - end, - "nearly parallel" => T -> begin - dd = randdir(); n = normalize(cross(dd, randn(3))); e = normalize(cross(dd, n)); a = 100randn(3) - θ = 1e-4randn() - ((vec3(T, a), vec3(T, a .+ 100dd)), (vec3(T, a .+ 30dd .+ 0.01n .- 50θ*e), vec3(T, a .+ 130dd .+ 0.01n .+ 50θ*e))) - end, - "crossing near an end" => T -> begin - n = randdir(); e1, e2 = inplane(n); a = 100randn(3) - s = rand((0.0, 1.0)) + 1e-6randn(); d2 = normalize(e1 + e2) - ((vec3(T, a), vec3(T, a .+ 100e1)), (vec3(T, a .+ 100s*e1 .- 50d2 .+ 0.01n), vec3(T, a .+ 100s*e1 .+ 50d2 .+ 0.01n))) - end, - "shared vertex pool" => T -> begin - pool = [vec3(T, 100rand(3)) for _ in 1:3] - (Tuple(rand(pool) for _ in 1:2), Tuple(rand(pool) for _ in 1:2)) - end, - ), -) - -Random.seed!(4) -report("point-point", KERNELS.point_point, GENS.point_point) -report("point-line", KERNELS.point_line, GENS.point_line) -report("point-triangle", KERNELS.point_triangle, GENS.point_triangle) -report("line-line", KERNELS.line_line, GENS.line_line) diff --git a/error-analysis/distances.md b/error-analysis/distances.md deleted file mode 100644 index c7d55af..0000000 --- a/error-analysis/distances.md +++ /dev/null @@ -1,391 +0,0 @@ -# Error analysis of `dist_sqr_r_s_t` - -This bounds the rounding error of each `dist_sqr_r_s_t` method in -[`src/distances.jl`](../src/distances.jl). The bounds compare the computed -results with the exact results for the *same* floating point inputs. The -inputs are arbitrary: no assumption is made that `x[1]` is zero, or that -the coordinates are relative to anything. - -Point-point and point-line are derived by hand. Point-triangle and line-line -are traced with [`ErrorBounds.jl`](ErrorBounds.jl), which runs the kernels -themselves on symbolic error bounds, described in -[Tracing the kernels](#tracing-the-kernels). [`analyze.jl`](analyze.jl) -prints the traced bounds and checks them against `BigFloat`. - -## Setting - -- `T` is `Float32` or `Float64`, rounding to nearest even. The unit - roundoff is `u = eps(T)/2`, `2^-24` for `Float32` and `2^-53` for `Float64`. -- Standard model: for `+`, `-`, `*` and `fma`, - `fl(a op b) = (a op b)(1 + δ)` with `|δ| ≤ u`, as long as the result - does not overflow. If the exact result is in the subnormal range, `+` and - `-` are exact, and `*` and `fma` have an absolute error of at most - `η = nextfloat(zero(T))/2` instead. -- `γₖ = k*u/(1 - k*u)`, so `(1 + u)^k - 1 ≤ γₖ`. -- Julia's `fma` always rounds once, in scalar code and in each lane of a - `SIMD.Vec`. The analysis relies on this; `muladd` may or may not fuse, so - a kernel that used it would have two different error bounds. -- `dist_sqr` returns the `d²` of `dist_sqr_r_s_t` bit for bit, and the SIMD - methods are bit identical to the scalar ones in each lane, so every bound - here applies to all of them. -- When a result can be bounded relative to its own size, it is. Otherwise - the bound is in units of `u*ℓ`, where `ℓ` is the largest absolute input - coordinate. - -## Point-point - -```julia -d = y[1] - x[1] -dot3(d, d), d, SVector{0,T}(), SVector{0,T}() -``` - -with `dot3(a, b) = fma(a[3], b[3], fma(a[2], b[2], a[1]*b[1]))`. - -Write `r = y[1] - x[1]` for the exact separation and `D = r⋅r` for the exact -squared distance. Hats mark computed values. - -### The separation `r` - -Each component is one correctly rounded subtraction: - - r̂ᵢ = rᵢ(1 + δᵢ), |δᵢ| ≤ u. - -So `|r̂ᵢ - rᵢ| ≤ u|rᵢ|` and `‖r̂ - r‖ ≤ u‖r‖`, a relative bound however -large the coordinates are. By Sterbenz's lemma the subtraction is exact -(`δᵢ = 0`) when `yᵢ/2 ≤ xᵢ ≤ 2yᵢ`, which covers points close together far -from the origin. - -### The squared distance `d²` - -`dot3` rounds `r̂₁²` once, then each `fma` once: - - p₁ = r̂₁²(1 + ε₁) - p₂ = (r̂₂² + p₁)(1 + ε₂) - d̂² = (r̂₃² + p₂)(1 + ε₃) - = r̂₃²(1 + ε₃) + r̂₂²(1 + ε₂)(1 + ε₃) + r̂₁²(1 + ε₁)(1 + ε₂)(1 + ε₃) - -with `|εⱼ| ≤ u`. Every term is nonnegative, so nothing cancels, and each -`r̂ᵢ² = rᵢ²(1 + δᵢ)²` adds two more factors. The first term has the most, -five, so - - (1 - u)⁵ D ≤ d̂² ≤ (1 + u)⁵ D, |d̂² - D| ≤ γ₅ D ≈ 5u D. - -In units of `eps(T)` that is `2.5 eps(T)` relative. For the distance, -`sqrt(d̂²)` is within about `2.5u` of `sqrt(D)` relative, plus `u` for the -rounding of the square root itself. - -The bound is relative, so it holds at any `ℓ` and implies any bound in -units of `u*ℓ`. - -### Backward error - -`r̂` is the exact separation from `x[1]` to a point `y′` with -`|y′ᵢ - yᵢ| ≤ u|rᵢ|`. Then `d̂² = ‖r̂‖²(1 + θ)` with `|θ| ≤ γ₃`, which is the -exact squared distance after scaling `r̂` by `sqrt(1 + θ)`. So `d̂²` is the -exact squared distance from `x[1]` to a point within about `2.5u‖r‖` of -`y[1]`. - -### Underflow and overflow - -- **Overflow** needs `|yᵢ - xᵢ| > floatmax(T)`, or `‖r‖` above about - `sqrt(floatmax(T))`: `1.8e19` for `Float32`, `1.3e154` for `Float64`. -- **Underflow** happens when a product falls below `floatmin(T)`, for - example `|rᵢ| < 1.1e-19` in `Float32`. Then a product or `fma` has an - absolute error of up to `η` instead of a relative one, and the bound - becomes `|d̂² - D| ≤ γ₅ D + 3(1 + u)²η`. In `Float32`, `3η` is about - `4.2e-45`. - -### Measured - -Maximum error against `BigFloat` on the same inputs, 200,000 pairs per -generator: - -| `T` | generator | `d²`, relative | `r`, componentwise relative | -|---|---|---|---| -| `Float32` | uniform in `[0, 100]³` | 4.06u | 1.0u | -| `Float32` | within 0.01 of each other, up to 1e4 from the origin | 1.39u | 0 | -| `Float32` | coordinates from 1e-3 to 1e3, either sign | 4.31u | 1.0u | -| `Float64` | uniform in `[0, 100]³` | 4.06u | 1.0u | -| `Float64` | within 0.01 of each other, up to 1e4 from the origin | 2.51u | 0 | -| `Float64` | coordinates from 1e-3 to 1e3, either sign | 4.19u | 1.0u | - -The bound of `5u` on `d²` is nearly reached. The `r` bound of `u` is -reached, and for points close together the subtraction is exact, as -Sterbenz's lemma predicts. - -## Point-line - -```julia -r = y[2] - y[1] -p = y[1] - x[1] -t = clamp01nan(-dot3(p, r)/dot3(r, r)) -d = p + scale3(t, r) -dot3(d, d), d, SVector{0,T}(), SVector{1,T}(t) -``` - -`scale3` multiplies without fusing, so `dᵢ = fl(pᵢ + fl(t*rᵢ))`. - -Exact, on the same inputs: - -- `R = y[2] - y[1]`, `P = y[1] - x[1]`, `N = P⋅R`, `C = R⋅R`. -- `T* = -N/C`, the closest point of the line, and `T = clamp(T*, 0, 1)`. -- `d = ‖P + T R‖`, the exact distance. -- `M = Σ|PᵢRᵢ|`. By Cauchy-Schwarz, `|N| ≤ M ≤ ‖P‖‖R‖`. - -### Result - -To first order in `u`, - - -u(3‖P‖ + 4.5d) ≤ sqrt(d̂²) - d ≤ u(3‖P‖ + 4.5d) + τ, - τ = min(11u‖P‖, (11u‖P‖)²/(2d)), - |t̂ - T| ≤ 11u‖P‖/‖R‖. - -Unlike point-point, the error is not relative to `d`: `p` and `t*r` cancel -when the point is near the line. It scales with `‖P‖`, the distance from -the point to `y[1]`, not with the distance from the origin. `τ` is the -error of `t̂`, which only makes the distance larger, and only to second -order unless `d` is below about `11u‖P‖`. - -### The parameter `t` - -The subtractions give `r̂ = R(1 + δ)` and `p̂ = P(1 + δ′)` componentwise. - -In `n̂ = dot3(p̂, r̂)`, each term `PᵢRᵢ` picks up at most five factors -`(1 + ε)`: two from the subtractions and up to three in the `fma` chain. -Measured against `M` instead of `N`, cancellation between the terms -doesn't matter, so `|n̂ - N| ≤ γ₅ M`. `ĉ = dot3(r̂, r̂)` is `C(1 + θ)` with -`|θ| ≤ γ₅`, as for point-point. - -The division rounds once, `t̃ = (-n̂/ĉ)(1 + ρ)`, so - - t̃ - T* = -((n̂ - N)/C)(1 + ρ)/(1 + θ) + T*((1 + ρ)/(1 + θ) - 1), - |t̃ - T*| ≤ 5u M/C + 6u|T*| ≤ 11u M/C ≤ 11u‖P‖/‖R‖, - -using `|T*| = |N|/C ≤ M/C`. Clamping can only bring `t̂` closer to `T`, so -`|t̂ - T| ≤ |t̃ - T*|`. If `n̂` is zero, `t̃` is exact zero and the same -bound holds. If `R` is zero, `ĉ` is zero, `t̂` is `0` from the `NaN`, and -the result is the point-point distance to `y[1]`, which is exact here. - -### How the error in `t` moves the distance - -Let `η = |t̃ - T*|`. Then - - d² ≤ ‖P + t̂R‖² ≤ d² + η²‖R‖². - -The lower bound holds because `T` minimizes `‖P + sR‖` over `s ∈ [0, 1]`, -and `t̂` is in `[0, 1]`. For the upper bound, expand -`‖P + sR‖² = d² + 2(s - T)(P + TR)⋅R + (s - T)²‖R‖²`: - -- **`T` inside:** `(P + TR)⋅R = 0`, and `|t̂ - T| ≤ η`. -- **`T = 0`:** then `T* ≤ 0`, so the cross term is `2t̂|T*|‖R‖²`. Since `t̂` - is at most `max(t̃, 0)`, `t̂ + |T*| ≤ η`, and so - `2t̂|T*| + t̂² ≤ (t̂ + |T*|)² ≤ η²`. -- **`T = 1`:** the same, mirrored. - -So `‖P + t̂R‖ - d ≤ min(η‖R‖, η²‖R‖²/(2d)) ≤ τ`. The error in `t` costs a -first order error only when the point is within about `11u‖P‖` of the -segment. - -### Forming the separation `d` - -With `mᵢ = t̂r̂ᵢ(1 + αᵢ)` and `d̂ᵢ = (p̂ᵢ + mᵢ)(1 + βᵢ)`, - - d̂ = P + t̂R + E, - |Eᵢ| ≤ u|Pᵢ| + 2u t̂|Rᵢ| + u|Pᵢ + t̂Rᵢ| + O(u²), - ‖E‖ ≤ u(‖P‖ + 2t̂‖R‖ + d) + O(u²) ≤ u(3‖P‖ + 3d) + O(u²), - -using `t̂‖R‖ ≤ ‖P‖ + ‖P + t̂R‖`. - -The last `dot3` adds a relative error of `γ₃` to `d̂²`, about `1.5u d` on -the distance. Adding `‖E‖`, `τ` and this gives the result above. - -The returned separation `r̂ = d̂` is within `‖E‖ + η‖R‖ ≤ u(14‖P‖ + 3d)` of -the exact `P + TR`. Its direction can be off by about `u‖P‖/d`, along the -segment, when the point is close to the segment. - -### Measured - -Maximum of the error over the bound, against `BigFloat` on the same inputs, -100,000 cases per generator: - -| generator | `Float32` distance | `Float32` `t` | `Float64` distance | `Float64` `t` | `Float32` distance in `u·ℓ` | -|---|---|---|---|---|---| -| uniform in `[0, 100]³` | 0.36 | 0.33 | 0.43 | 0.36 | 1.97 | -| 0.01 from a segment 50 long, 1e4 from the origin | 0.46 | 0.40 | 0.36 | 0.38 | 0.008 | -| projection within 1e-6 of an end | 0.60 | 0.44 | 0.54 | 0.36 | 2.4 | -| segment 0.01 long, point 1000 away | 0.23 | 0 | 0.23 | 0 | 2.57 | -| point on the segment | 0.31 | 0.40 | 0.32 | 0.45 | 7.07 | -| vertices from a pool of three | 0.38 | 0.38 | 0.40 | 0.33 | 1.78 | - -The bound holds with a margin of about two. The last column shows why it is -written in `‖P‖` rather than `ℓ`, the largest coordinate: far from the -origin, the error is much smaller than `u·ℓ`. - -## Tracing the kernels - -[`ErrorBounds.jl`](ErrorBounds.jl) defines a number type `EB` and runs -`dist_sqr_r_s_t` on it, so the analysis follows the code as written, with -nothing transcribed. An `EB` carries two bounds, as polynomials with -nonnegative coefficients in size symbols such as `|Pᵢ|`, `‖P‖` and `‖R‖⁻²`: -`mag` on the absolute value of the exact result, and `err` on the rounding -error, in units of `u`. Each operation applies a rule that holds for all -inputs, so the result is a bound for all inputs in terms of the sizes. Terms -of order `u²` are dropped. - -The rules: - -- **Sums and differences.** A sum or difference of two exact values, each a - single size symbol, is a new size symbol, rounded once, so its error is - relative to itself. For example `P = y[1] - x[1]` gives `|Pᵢ|`, and the - bounds are in the sizes of the differences, not of the coordinates. -- **Vertex moves.** A rounded difference of two input vertices is treated - as the exact difference after moving the first vertex. Following the - differences back gives how far each vertex moved, and the exact distance - changes by at most the largest move on each simplex. The rest of the - kernel is then analysed on the moved vertices. This is why the kernels - reuse the same rounded `E0` and `E1`: they compute the distance of a - nearby triangle accurately, even a sliver. -- **Nonnegative sums**, such as `dot3(r, r)`, keep a relative error: the - largest of their terms', plus one rounding. -- **Signed sums** are bounded by the sum of the magnitudes of their terms, - as in the hand analysis. Correlations survive because the magnitudes stay - symbolic. A sum over components that then scales one component, like `t` - in `p + t*r`, is reduced to norms with Cauchy-Schwarz, and vector errors - with Minkowski. -- **Division** needs an exact nonnegative divisor with a relative error, - such as `dot3(R, R) = ‖R‖²`. -- **`cross3_accurate`** of exact vectors has each component within `2u` of - the exact one (Kahan's 2×2 determinant, Jeannerod, Louvet and Muller 2013), - and its components become size symbols, such as `|Nᵢ|`. -- **Plain `cross3(a, b)`** has component `i` within - `(rel_a + rel_b + 2)u·|Xᵢ|`, where `|Xᵢ| = |aⱼ||bₖ| + |aₖ||bⱼ|` also bounds the - component, and `‖X‖ ≤ √2‖a‖‖b‖`. -- **Clamps and `min`** are 1-Lipschitz, so they don't add error. The `NaN` - branch of `clamp01nan` is assumed not to be taken: the divisor is not zero. -- **Squared distances** also carry `|√d̂² - ρ| ≤ u(abs + rel·ρ)` for their - exact root `ρ`, from `dot3(r, r)` and from `h*h/‖N‖²`. - -The kernels are branch free, so every select has both arms traced. A select -joins them, taking the larger bound of each term, and adds a jump in the -distance where the computed and exact conditions can disagree: - -- **Running minimum**, `cand[1] < best[1]`: no jump, because - `|min(â, b̂) - min(a, b)| ≤ max(|â - a|, |b̂ - b|)`. Either candidate that - can be chosen has a distance within the error of the minimum, so the - `rel·ρ` terms stay in terms of the final distance. -- **`inside` test**: a jump of `Σ err(v)·L(v)` over its distinct condition - variables `v`. `L(v)` is how far the boundary is, in distance in the plane, - per unit of `v`. It is the one hand derived fact for each kernel, given - below. - -For these selects, `r`, `s` and `t` are compared with the exact values of -the arm or candidate that was chosen, not of the exact choice: at a tie, or -across the `inside` boundary, the closest points can jump while the distance -does not. - -## Point-triangle - -With `B = y[1]`, `E0 = y[2] - B`, `E1 = y[3] - B`, `BP = B - x[1]`, -`E2 = E1 - E0`, `BP2 = BP + E0 = y[2] - x[1]` and `N = E0×E1`. - -### The jump at the inside test - -The test is `0 ≤ sbar`, `0 ≤ tbar` and `0 ≤ wbar`, the barycentric weights of -`y[2]`, `y[3]` and `B`. Let `Y` be the projection of `x[1]` onto the plane, at -height `h`. If `Y` is outside the triangle, at distance `g` in the plane, the -closest point is on an edge, and `d² = h² + g²`. So choosing the wrong arm -costs at most `g`. A weight `v` is the distance of `Y` from the line of the -opposite edge over the height of its vertex over that edge, so if the -computed and exact signs of `v` disagree, `Y` is within `err(v)·L(v)` of -that line, with - - L(sbar) = ‖N‖/‖E1‖, L(tbar) = ‖N‖/‖E0‖, L(wbar) = ‖N‖/‖E2‖. - -These are the heights of the triangle. The edge lengths would also be -bounds, but they would give a spurious `1/sin θ` for slivers. - -### The needle fix - -The kernel used to test `sbar + tbar ≤ 1` instead of `0 ≤ wbar`. The traced -bound then had a term proportional to `u‖BP‖(‖E0‖ + ‖E1‖)/‖E2‖`. The errors of `sbar` and -`tbar` are about `u‖BP‖‖E0‖/‖N‖` and `u‖BP‖‖E1‖/‖N‖`, and their sum was -tested against the height over the short edge `E2` of a needle. It is -real: for a needle 100 long with a short edge of 0.001, a point 0.01 above -the plane that projects 0.2 past the short edge was judged inside in -`Float32`, with a distance of 0.01 instead of 0.2. `wbar` is now computed from -its own edge, `N⋅(BP2×E2)/‖N‖²`, and its error over `L(wbar)` is about -`u‖BP2‖`. - -Inside, `sbar` and `sbar + tbar` can round above one, so the returned weights -are capped: `s = min(sbar, 1)` and `t = min(tbar, 1 - s)`. - -### Result - - |√d̂² - d| ≤ u(62‖BP‖ + 40‖BP2‖ + 3‖E0‖ + 3‖E1‖ + 3‖E2‖ + 5d) - -including the vertex moves, `u(‖BP‖ + ‖E0‖ + ‖E1‖)`. Most of it is the jump: -`(16 + 16)·(3/2)‖BP‖` and `18·(3/2)‖BP2‖` from the errors of the three weights. - -Largest error over the bound, 20,000 cases per generator: - -| generator | `Float32` | `Float64` | -|---|---|---| -| uniform in `[0, 100]³` | 0.022 | 0.023 | -| 0.01 above, projecting within 1e-6 of an edge | 0.011 | 0.011 | -| sliver | 0.010 | 0.011 | -| needle, near the short edge | 0.012 | 0.012 | -| needle, 0.2 past the short edge | 0.012 | 0.014 | -| vertices from a pool of three | 0.017 | 0.018 | - -The bound holds everywhere but is loose by a factor of about 50: it adds the -worst error of every weight at once. - -## Line-line - -With `P = x[2] - x[1]`, `Q = y[2] - y[1]`, `W = y[1] - x[1]`, -`WQ = W + Q = y[2] - x[1]`, `WP = W - P = y[1] - x[2]` and `n = P×Q`. - -### The jump at the inside test - -The test is `0 ≤ sbar ≤ 1` and `0 ≤ tbar ≤ 1`. Project both line segments onto -a plane normal to `n`. They cross there exactly when the closest points of -the lines are inside both segments, and then `d = h`, the distance along -`n`. Otherwise the closest points are an end point and its closest point on -the other segment, and `d² = h² + g²`, with `g` the distance in the plane -between the projected segments. If `sbar` is off by `err(sbar)`, the crossing -moves by `err(sbar)‖P‖` along `P`, which is `err(sbar)‖P‖ sin θ = err(sbar)‖n‖/‖Q‖` -from the projection of `y`. So - - L(sbar) = ‖n‖/‖Q‖, L(tbar) = ‖n‖/‖P‖. - -With `‖P‖` and `‖Q‖` instead, nearly parallel line segments would get a -spurious `1/sin θ`. - -### Result - - |√d̂² - d| ≤ u(10‖P‖ + 10‖Q‖ + 63‖W‖ + 2‖WP‖ + 2‖WQ‖ + 5d) - -including the vertex moves, `u(‖P‖ + ‖Q‖ + ‖W‖)`. Most of it is the jump, -`(16 + 16)·(3/2)‖W‖`. - -Largest error over the bound, 20,000 cases per generator: - -| generator | `Float32` | `Float64` | -|---|---|---| -| uniform in `[0, 100]³` | 0.028 | 0.028 | -| crossing 0.01 apart, 1e4 from the origin | 0.015 | 0.022 | -| nearly parallel, `θ ≈ 1e-4` | 0.031 | 0.022 | -| crossing within 1e-6 of an end | 0.007 | 0.006 | -| vertices from a pool of three | 0.039 | 0.039 | - -The bound has no `1/sin θ`, and nothing that grows with the distance from the -origin, but like point-triangle it is loose by a factor of about 30. - -## Point-line, traced - -For comparison with the hand analysis, the tracer gives - - |√d̂² - d| ≤ u(9‖P‖ + 3‖R‖ + 1.5d) - -including the vertex moves, `u(‖P‖ + ‖R‖)`. The largest error over this -bound was 0.35, for a point on the segment. diff --git a/error-analysis/ledger.jl b/error-analysis/ledger.jl deleted file mode 100644 index b7e2f5b..0000000 --- a/error-analysis/ledger.jl +++ /dev/null @@ -1,488 +0,0 @@ -# Per-domain error-claim ledgers for the distance kernels, with focused sampling. -# -# Inputs live in the box [-1, 1]^(3 vertices), so overflow is impossible and the -# claims are absolute multiples of u = eps(T)/2. Each branch combination of a -# kernel is one domain of input space; on it the exact output is a single -# rational function of the inputs, and the computed output adds one (1+δ) per -# rounding. A ledger has one entry per domain and per boundary: -# -# - claim: |√d̂² - d| ≤ u·bound(exact quantities), first order in u, on the -# samples the entry matches. -# - status: where the claim comes from and its fine print (the O(u²) -# remainder, and underflow of a denominator, are excluded from every -# first-order claim and get their own rows). -# - points: candidate worst-case inputs, where the per-domain analysis says -# the claim is tightest, with the predicted sharpness. -# -# The sampler perturbs every coordinate of each worst-case point with Gaussian -# noise at scales σ from ~30 ulps (holds the input still, scrambles the -# roundings) up to 1e-2 (wanders across nearby domain boundaries). Each sample -# is routed to every entry whose (exact branch pattern, computed branch -# pattern) pair it matches: domain entries take pe == pc == their pattern, -# boundary entries take the samples where rounding flipped the branch. The -# measured max error/bound must stay ≤ 1 everywhere, and should approach the -# predicted sharpness at the sharp points. -# -# Run from the test environment, which has SimplexCellLists and StaticArrays: -# include("error-analysis/ledger.jl") - -using SimplexCellLists, StaticArrays, Random, LinearAlgebra -using SimplexCellLists: dist_sqr_r_s_t, dot3, cross3, cross3_accurate, clamp01nan, scale3 - -setprecision(BigFloat, 512) - -# Entry matchers: a domain entry owns the samples where exact and computed -# arithmetic take the same branch pattern; a boundary entry owns the flips. -domain(pat) = (pe, pc) -> pe === pat && pc === pat -flip(a, b) = (pe, pc) -> (pe === a && pc === b) || (pe === b && pc === a) - -# ---- point-point ---- -# d = y[1] - x[1]; dot3(d, d). One domain, no branches. Per coordinate the -# rounding path is 2δ(sub) + δ(mul/fma) + the fma accumulations: 5u, 4u, 3u on -# d² for coordinates 1, 2, 3, so the claim 5u/2·d is attained only when one -# coordinate carries the whole separation. - -function pp_exact(xs, ys) - p = big.(ys[1]) - big.(xs[1]) - (; d = norm(p)), :all -end -pp_computed_pattern(xs, ys) = :all - -const POINT_POINT = (exact = pp_exact, computed = pp_computed_pattern, entries = [ - (name = "whole box", claim = "5/2·d", - status = "proved (distances.md; tracer: 3/2·d plus the ‖R‖ vertex move)", - bound = q -> 5//2*q.d, match = (pe, pc) -> true, - points = [ - (x = ((-1.0, 0.03, -0.02),), y = ((1.0, 0.06, 0.03),), - expect = "supremum 1: the first coordinate carries the whole 5u path; sampled max ~0.8-0.9"), - (x = ((-0.95, -1.0, 0.9),), y = ((0.9, 0.85, -1.0),), - expect = "supremum 0.8: equal coordinates average the 5u, 4u, 3u paths to 4u; sampled max ~0.45 (more independent roundings must align)"), - ]), -]) - -# ---- point-line ---- -# r = y[2] - y[1]; p = y[1] - x[1]; t = clamp01nan(-dot3(p,r)/dot3(r,r)); -# d = p + scale3(t, r); dot3(d, d). -# Exact domains in t* = -(p·r)/(r·r): t* ≤ 0 (c0 arm, d = p), 0 < t* < 1 -# (interior, d² = (|p|²|r|² - (p·r)²)/|r|², the Gram ratio), t* ≥ 1 (c1 arm, -# d = p + r). clamp01nan sends NaN and t ≤ 0 to the 0 arm, t ≥ 1 to the 1 arm. - -function pl_exact(xs, ys) - p = big.(ys[1]) - big.(xs[1]) - r = big.(ys[2]) - big.(ys[1]) - rr = dot(r, r) - pr = dot(p, r) - pattern = iszero(rr) || -pr <= 0 ? :c0 : (-pr >= rr ? :c1 : :interior) - tstar = pattern === :c0 ? big(0.0) : (pattern === :c1 ? big(1.0) : -pr/rr) - (; d = norm(p + tstar*r), np = norm(p), nr = sqrt(rr)), pattern -end - -# The branch pattern the floating point kernel takes, replicating its ops. -function pl_computed_pattern(xs, ys) - r = ys[2] - ys[1] - p = ys[1] - xs[1] - traw = -dot3(p, r)/dot3(r, r) - traw > 0 ? (traw < 1 ? :interior : :c1) : :c0 -end - -const POINT_LINE = (exact = pl_exact, computed = pl_computed_pattern, entries = [ - (name = "t clamped to 0 (t* < 0)", claim = "5/2·d", - status = "proved: scale3(0,r) = 0 exactly, so d = p̂ and the point-point argument applies", - bound = q -> 5//2*q.d, match = domain(:c0), - points = [ - (x = ((1.0, 0.12, -0.03),), y = ((-1.0, 0.0, 0.0), (-1.0, -1.0, 0.0)), - expect = "supremum 1: separation on the first coordinate, as in point-point; sampled max ~0.9"), - ]), - (name = "interior (0 < t* < 1)", claim = "9‖p‖ + 3‖r‖ + 3/2·d", - status = "proved by the tracer (analyze.jl, all domains joined); slack expected: it charges the t rounding linearly, but d ⊥ r at the exact minimum makes it second order", - bound = q -> 9*q.np + 3*q.nr + 3//2*q.d, match = domain(:interior), - points = [ - (x = ((1.0, 0.0, 0.05),), y = ((-1.0, 1.0, 0.0), (-1.0, -1.0, 0.0)), - expect = "≈0.2-0.3: generic midpoint, the 9‖p‖ term is mostly unspent"), - (x = ((1.0, 0.0, 0.0),), y = ((-1.0, 0.1, 0.02), (-1.0, -0.1, 0.02)), - expect = "low despite t error ~110u (‖p‖/‖r‖ = 10): gradient-zero kills it"), - ]), - (name = "t clamped to 1 (t* > 1)", claim = "‖p‖ + ‖r‖ + 5/2·d", - status = "derived here: d̂ᵢ = fl(p̂ᵢ + r̂ᵢ) has error ≤ u(|pᵢ| + |rᵢ| + |pᵢ+rᵢ|), then Minkowski and the 3u/2 dot-self root", - bound = q -> q.np + q.nr + 5//2*q.d, match = domain(:c1), - points = [ - (x = ((1.0, 1.0, 0.04),), y = ((-1.0, -1.0, 0.0), (0.98, 0.98, 0.0)), - expect = "supremum ≈1: p + r cancels to 0.03, so the input roundings u(‖p‖+‖r‖) dwarf d; sampled max ~0.6-0.8"), - ]), - (name = "boundary t* ≈ 0 (flips)", claim = "max of both arms", - status = "proved modulo O(u²): the clamp boundary is C¹, a flip at |t*| ≤ t-err costs t*²‖r‖² ≤ (11u‖p‖)² on d²", - bound = q -> max(5//2*q.d, 9*q.np + 3*q.nr + 3//2*q.d), match = flip(:c0, :interior), - nmul = 10, # flips are ~0.1% of samples, oversample to get hundreds - points = [ - # p·r = 0 through cancelling ±1 products, so the computed condition - # carries full u-scale error and actually flips. (With componentwise - # orthogonality, e.g. p ∥ e1 and r ∥ e2, the dot3 error is only - # u·Σ|pᵢrᵢ| ~ u·σ and flips essentially never happen.) - (x = ((1.0, 1.0, 0.0),), y = ((-1.0, 0.0, 0.0), (-0.5, -1.0, 0.0)), - expect = "flips only at small σ; ratio like the adjacent domains"), - ]), - (name = "boundary t* ≈ 1 (flips)", claim = "max of both arms", - status = "proved modulo O(u²): same clamp-quadratic argument at t* = 1", - bound = q -> max(q.np + q.nr + 5//2*q.d, 9*q.np + 3*q.nr + 3//2*q.d), match = flip(:c1, :interior), - nmul = 10, - points = [ - # -p·r = ‖r‖² = 2 with order-1 terms in both dots: a ~4u condition band. - (x = ((0.5, 0.5, 0.0),), y = ((-1.0, 1.0, 0.0), (0.0, 0.0, 0.0)), - expect = "flips only at small σ; ratio like the adjacent domains"), - ]), - (name = "degenerate (‖r‖² ≤ realmin)", claim = "not sampled", - status = "argued: r = 0 gives t = NaN → 0 arm, d = p̂, exact-correct; ‖r‖² subnormal can misclamp t, costing ≤ ‖r‖ ≤ √realmin absolute, below any u-scale budget on the box. Gaussian sampling cannot reach sin-type slivers this deep", - bound = q -> big(1.0), match = (pe, pc) -> false, points = NamedTuple[]), -]) - -# ---- point-triangle ---- -# Exact domains are the Voronoi regions of the triangle: :face (projection -# inside), :e0/:e1/:e2 (edge interiors), :va/:vb/:vc (vertices). The inside -# arm is d = |BP·N|/‖N‖ with N from cross3_accurate (2u per component), so -# its triple-product error is ≤ 5u·Σ|NᵢBPᵢ| ≤ 5u‖N‖‖BP‖ (Cauchy-Schwarz), -# Δ = dot3(N,N) is relative 7u, and the vertex moves add u(‖BP‖+‖E0‖+‖E1‖). -# Each edge candidate is literally the point-line kernel, so its traced bound -# applies, with extra u‖BP2‖, u‖E2‖ charges for edge 2's rounded sums. - -function pt_exact(xs, ys) - A, B, C = big.(ys[1]), big.(ys[2]), big.(ys[3]) - E0 = B - A; E1 = C - A; E2 = C - B - BP = A - big.(xs[1]); BP2 = B - big.(xs[1]) - N = cross(E0, E1) - Δ = dot(N, N) - q = (; d = big(0.0), nBP = norm(BP), nBP2 = norm(BP2), - nE0 = norm(E0), nE1 = norm(E1), nE2 = norm(E2)) - # The inside test on the exact signs of the barycentric numerators. - if !iszero(Δ) && dot(N, cross(E1, BP)) >= 0 && dot(N, cross(BP, E0)) >= 0 && - dot(N, cross(BP2, E2)) >= 0 - return merge(q, (; d = abs(dot(BP, N))/sqrt(Δ))), :face - end - f = big(Inf); pattern = :none - for (p, r, pat0, pat1, patmid) in - ((BP, E0, :va, :vb, :e0), (BP, E1, :va, :vc, :e1), (BP2, E2, :vb, :vc, :e2)) - rr = dot(r, r) - u = iszero(rr) ? big(0.0) : clamp(-dot(p, r)/rr, big(0.0), big(1.0)) - v = p + u*r - fv = dot(v, v) - # Ties keep the earlier candidate, as in _min_d2_r_s_t. - if fv < f - f = fv - pattern = iszero(u) ? pat0 : (isone(u) ? pat1 : patmid) - end - end - merge(q, (; d = sqrt(f))), pattern -end - -# The branch pattern the kernel takes, mirroring its ops (same helpers, same -# expression shapes, so the values are bit identical to the kernel's). -function pt_computed_pattern(xs, ys) - B = ys[1] - E0 = ys[2] - B - E1 = ys[3] - B - BP = B - xs[1] - N = cross3_accurate(E0, E1) - Δ = dot3(N, N) - invΔ = inv(Δ) - sbar = dot3(N, cross3(E1, BP))*invΔ - tbar = dot3(N, cross3(BP, E0))*invΔ - E2 = E1 - E0 - BP2 = BP + E0 - wbar = dot3(N, cross3(BP2, E2))*invΔ - T = eltype(eltype(xs)) - (zero(T) <= sbar) & (zero(T) <= tbar) & (zero(T) <= wbar) && return :face - f = T(Inf); pattern = :none - for (p, r, pat0, pat1, patmid) in - ((BP, E0, :va, :vb, :e0), (BP, E1, :va, :vc, :e1), (BP2, E2, :vb, :vc, :e2)) - u = clamp01nan(-dot3(r, p)/dot3(r, r)) - v = p + scale3(u, r) - fv = dot3(v, v) - if fv < f - f = fv - pattern = u == 0 ? pat0 : (u == 1 ? pat1 : patmid) - end - end - pattern -end - -pt_face_b(q) = 6*q.nBP + q.nE0 + q.nE1 + 8*q.d -pt_traced_b(q) = 62*q.nBP + 40*q.nBP2 + 3*q.nE0 + 3*q.nE1 + 3*q.nE2 + 5*q.d - -const POINT_TRIANGLE = (exact = pt_exact, computed = pt_computed_pattern, entries = [ - (name = "face (inside)", claim = "6‖BP‖ + ‖E0‖ + ‖E1‖ + 8d", - status = "derived here: hN err ≤ 5u·Σ|NᵢBPᵢ| ≤ 5u‖N‖‖BP‖ (Kahan 2u/comp + CS), Δ rel 7u, moves; d coefficient padded", - bound = pt_face_b, match = domain(:face), - points = [ - # Tilted triangle so N is generic and the hN terms can cancel. - (x = ((-0.01, 0.01, 0.29),), y = ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1)), - expect = "well below 1: CS equality needs BP aligned with N term by term"), - (x = ((-0.09, 0.03, 0.02),), y = ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1)), - expect = "the live point: d ≈ 0.02 and the hN terms (~0.5 each) cancel, so the ‖BP‖ term carries; CS is ~2x loose here and the moves/edge terms pad the denominator, sampled max ~0.1"), - ]), - (name = "edge 0 interior", claim = "9‖BP‖ + 3‖E0‖ + 3/2·d", - status = "the candidate is the point-line kernel verbatim, so its traced interior bound plus moves applies", - bound = q -> 9*q.nBP + 3*q.nE0 + 3//2*q.d, match = domain(:e0), - points = [ - (x = ((0.2, -0.9, 0.4),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), - expect = "supremum ~0.17 (bound padded for this geometry), sampled max ~0.07"), - ]), - (name = "edge 1 interior", claim = "9‖BP‖ + 3‖E1‖ + 3/2·d", - status = "by symmetry with edge 0; not sampled directly, catches strays", - bound = q -> 9*q.nBP + 3*q.nE1 + 3//2*q.d, match = domain(:e1), points = NamedTuple[]), - (name = "edge 2 interior", claim = "9‖BP2‖ + 3‖E2‖ + ‖BP‖ + ‖E0‖ + ‖E1‖ + 3/2·d", - status = "point-line bound on (BP2, E2) plus u‖BP2‖, u‖E2‖ for their rounded sums and the vertex moves", - bound = q -> 9*q.nBP2 + 3*q.nE2 + q.nBP + q.nE0 + q.nE1 + 3//2*q.d, match = domain(:e2), - points = [ - (x = ((0.56, 0.59, 0.3),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), - expect = "like edge 0 with the extra BP2/E2 charges, sampled max ~0.14"), - ]), - (name = "vertex a", claim = "5/2·d", - status = "proved: the winning candidate is BP + scale3(0, E) = BP exactly, point-point argument", - bound = q -> 5//2*q.d, match = domain(:va), - points = [ - (x = ((-1.0, -0.95, 0.35),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), - expect = "like point-point with balanced coordinates, sampled max ~0.4"), - ]), - (name = "vertex b", claim = "‖BP‖ + ‖E0‖ + 5/2·d", - status = "derived: both reachable candidates compute fl(BP + E0), error ≤ u(|BPᵢ|+|E0ᵢ|+|BP2ᵢ|) per component", - bound = q -> q.nBP + q.nE0 + 5//2*q.d, match = domain(:vb), - points = [ - (x = ((1.0, -0.45, 0.3),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), - expect = "sampled max well below 1 unless BP and E0 errors align with the small d"), - ]), - (name = "vertex c", claim = "‖BP‖ + ‖E1‖ + ‖BP2‖ + ‖E2‖ + ‖E0‖ + 5/2·d", - status = "covers whichever tied candidate wins (fl(BP+E1), or fl(fl(BP+E0)+fl(E1-E0))); not sampled directly", - bound = q -> q.nBP + q.nE1 + q.nBP2 + q.nE2 + q.nE0 + 5//2*q.d, match = domain(:vc), - points = NamedTuple[]), - (name = "inside-test boundary (flips)", claim = "tracer all-domain bound", - status = "proved by the tracer including the inside-select jump terms (heights ‖N‖/‖E‖); expect small ratios, the joined jump dominates the bound", - bound = pt_traced_b, match = (pe, pc) -> (pe === :face) ⊻ (pc === :face), - nmul = 10, - points = [ - # x over the midpoint of edge 0, so the tbar numerator is exactly zero. - (x = ((-0.05, -0.4, 0.6),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), - expect = "flips at small σ; ratio small against the joined bound"), - ]), - (name = "clamp and tie flips", claim = "tracer all-domain bound", - status = "clamp boundaries are C¹ (O(u²) flip cost) and min ties are 1-Lipschitz; the traced join covers every arm", - bound = pt_traced_b, match = (pe, pc) -> pe !== pc && pe !== :face && pc !== :face, - nmul = 10, - points = [ - # x exactly on the u0 = 1 plane through b: (x-a)·E0 = ‖E0‖² by construction. - (x = ((0.88, -0.54, 0.3),), y = ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0)), - expect = "edge0↔vertex b flips at small σ; O(u²) cost, tiny ratios"), - ]), - (name = "degenerate (‖N‖² ≤ realmin)", claim = "not sampled", - status = "argued: cross3_accurate is exactly zero for an exactly degenerate triangle, so inside is false and the edge candidates (their own ledger rows) answer; the subnormal-Δ sliver needs the inside test to fail safe, which zero/‖N‖² ≥ 0 and NaN→false give", - bound = q -> big(1.0), match = (pe, pc) -> false, points = NamedTuple[]), -]) - -# ---- line-line ---- -# Exact domains by the clamp state of (s*, t*), one letter each for s then t -# (z = clamped to 0, o = clamped to 1, i = interior): :ii (both interior, the -# crossing arm), :iz/:io/:zi/:oi (one endpoint against the other's interior, -# point-line candidates), :zz/:oz/:zo/:oo (corners). The crossing arm is -# d = |W·n|/‖n‖ with n = cross3_accurate(P, Q): same error shape as the -# triangle face, and no 1/sinθ anywhere — the nearly parallel point stresses -# exactly the cancellation this kernel was rewritten to avoid. - -function ll_exact(xs, ys) - P0, P1 = big.(xs[1]), big.(xs[2]) - Q0, Q1 = big.(ys[1]), big.(ys[2]) - P = P1 - P0; Q = Q1 - Q0; W = Q0 - P0 - q = (; d = big(0.0), nP = norm(P), nQ = norm(Q), nW = norm(W), - nWP = norm(W - P), nWQ = norm(W + Q)) - n = cross(P, Q) - Δ = dot(n, n) - if !iszero(Δ) - sbar = dot(n, cross(W, Q))/Δ - tbar = dot(n, cross(W, P))/Δ - if 0 <= sbar <= 1 && 0 <= tbar <= 1 - return merge(q, (; d = abs(dot(W, n))/sqrt(Δ))), :ii - end - end - a = dot(P, P); c = dot(Q, Q) - f = big(Inf); pattern = :none - for (den, num, vof, pat0, pat1, patmid) in ( - (a, dot(P, W), u -> W - u*P, :zz, :oz, :iz), - (a, dot(P, W + Q), u -> (W + Q) - u*P, :zo, :oo, :io), - (c, -dot(Q, W), u -> W + u*Q, :zz, :zo, :zi), - (c, dot(Q, P - W), u -> (W - P) + u*Q, :oz, :oo, :oi)) - u = iszero(den) ? big(0.0) : clamp(num/den, big(0.0), big(1.0)) - v = vof(u) - fv = dot(v, v) - if fv < f - f = fv - pattern = iszero(u) ? pat0 : (isone(u) ? pat1 : patmid) - end - end - merge(q, (; d = sqrt(f))), pattern -end - -function ll_computed_pattern(xs, ys) - P0 = xs[1] - Q0 = ys[1] - P = xs[2] - P0 - Q = ys[2] - Q0 - Q0mP0 = Q0 - P0 - a = dot3(P, P) - b = dot3(P, Q) - c = dot3(Q, Q) - d = dot3(P, Q0mP0) - e = dot3(Q, Q0mP0) - n = cross3_accurate(P, Q) - Δ = dot3(n, n) - invΔ = inv(Δ) - sbar = dot3(n, cross3(Q0mP0, Q))*invΔ - tbar = dot3(n, cross3(Q0mP0, P))*invΔ - T = eltype(eltype(xs)) - ((zero(T) <= sbar) & (sbar <= one(T)) & (zero(T) <= tbar) & (tbar <= one(T))) && return :ii - inva = inv(a) - invc = inv(c) - f = T(Inf); pattern = :none - for (u, v, pat0, pat1, patmid) in ( - (s0 = clamp01nan(d*inva); (s0, Q0mP0 - scale3(s0, P), :zz, :oz, :iz)), - (s1 = clamp01nan((b+d)*inva); (s1, (Q0mP0 + Q) - scale3(s1, P), :zo, :oo, :io)), - (t0 = clamp01nan(-e*invc); (t0, Q0mP0 + scale3(t0, Q), :zz, :zo, :zi)), - (t1 = clamp01nan((b-e)*invc); (t1, (Q0mP0 - P) + scale3(t1, Q), :oz, :oo, :oi))) - fv = dot3(v, v) - if fv < f - f = fv - pattern = u == 0 ? pat0 : (u == 1 ? pat1 : patmid) - end - end - pattern -end - -ll_ii_b(q) = 6*q.nW + q.nP + q.nQ + 8*q.d -ll_traced_b(q) = 63*q.nW + 10*q.nP + 10*q.nQ + 2*q.nWP + 2*q.nWQ + 5*q.d - -const LINE_LINE = (exact = ll_exact, computed = ll_computed_pattern, entries = [ - (name = "crossing (interior-interior)", claim = "6‖W‖ + ‖P‖ + ‖Q‖ + 8d", - status = "derived here: hn err ≤ 5u·Σ|nᵢWᵢ| ≤ 5u‖n‖‖W‖ (Kahan 2u/comp + CS), Δ rel 7u, moves; d coefficient padded", - bound = ll_ii_b, match = domain(:ii), - points = [ - (x = ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), y = ((-0.3, -1.0, 0.25), (0.3, 1.0, 0.25)), - expect = "generic crossing, well below 1"), - (x = ((-1.0, -0.004, 0.0), (1.0, 0.004, 0.0)), y = ((-1.0, 0.004, 0.15), (1.0, -0.004, 0.15)), - expect = "the stress point: sinθ ≈ 0.008, where the Gram form loses ~125×; Kahan cross keeps the claim"), - ]), - (name = "s interior, t = 0", claim = "9‖W‖ + 3‖P‖ + ‖Q‖ + 3/2·d", - status = "point-line traced bound on (W, P) plus moves", - bound = q -> 9*q.nW + 3*q.nP + q.nQ + 3//2*q.d, match = domain(:iz), - points = [ - (x = ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), y = ((0.1, 0.3, 0.3), (0.9, 0.9, 0.5)), - expect = "sampled max ~0.06: the 9‖W‖ bound is padded for this geometry"), - ]), - (name = "s = 0, t interior", claim = "9‖W‖ + 3‖Q‖ + ‖P‖ + 3/2·d", - status = "point-line traced bound on (W, Q) plus moves", - bound = q -> 9*q.nW + 3*q.nQ + q.nP + 3//2*q.d, match = domain(:zi), - points = [ - (x = ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), y = ((-0.31, -0.67, 0.25), (-0.73, -0.11, 0.25)), - expect = "sampled max ~0.08"), - ]), - (name = "s interior, t = 1", claim = "10‖WQ‖ + 3‖P‖ + ‖W‖ + ‖Q‖ + 3/2·d", - status = "point-line bound on (W+Q, P) plus the rounded sum and numerator charges; by symmetry, not sampled directly", - bound = q -> 10*q.nWQ + 3*q.nP + q.nW + q.nQ + 3//2*q.d, match = domain(:io), points = NamedTuple[]), - (name = "s = 1, t interior", claim = "10‖WP‖ + 3‖Q‖ + ‖W‖ + ‖P‖ + 3/2·d", - status = "point-line bound on (W-P, Q) plus the rounded sum and numerator charges; by symmetry, not sampled directly", - bound = q -> 10*q.nWP + 3*q.nQ + q.nW + q.nP + 3//2*q.d, match = domain(:oi), points = NamedTuple[]), - (name = "corner (0,0)", claim = "5/2·d", - status = "proved: the winning separation is W exactly, point-point argument", - bound = q -> 5//2*q.d, match = domain(:zz), - points = [ - (x = ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), y = ((-0.7, -0.8, 0.3), (-1.0, -0.9, 0.5)), - expect = "like point-point, sampled max ~0.4-0.9"), - ]), - (name = "corner (1,0)", claim = "‖W‖ + ‖P‖ + 5/2·d", - status = "derived: fl(W-P), error ≤ u(|Wᵢ|+|Pᵢ|+|WPᵢ|) per component; by symmetry, not sampled directly", - bound = q -> q.nW + q.nP + 5//2*q.d, match = domain(:oz), points = NamedTuple[]), - (name = "corner (0,1)", claim = "‖W‖ + ‖Q‖ + 5/2·d", - status = "derived: fl(W+Q); by symmetry, not sampled directly", - bound = q -> q.nW + q.nQ + 5//2*q.d, match = domain(:zo), points = NamedTuple[]), - (name = "corner (1,1)", claim = "‖W‖ + ‖P‖ + ‖Q‖ + ‖WP‖ + ‖WQ‖ + 5/2·d", - status = "covers whichever tied candidate wins (fl(fl(W+Q)-P) or fl(fl(W-P)+Q)); not sampled directly", - bound = q -> q.nW + q.nP + q.nQ + q.nWP + q.nWQ + 5//2*q.d, match = domain(:oo), points = NamedTuple[]), - (name = "inside-test boundary (flips)", claim = "tracer all-domain bound", - status = "proved by the tracer including the inside-select jump terms (heights ‖n‖/‖P‖, ‖n‖/‖Q‖)", - bound = ll_traced_b, match = (pe, pc) -> (pe === :ii) ⊻ (pc === :ii), - nmul = 10, - points = [ - # Q crosses exactly over P0: the sbar numerator is exactly zero. - (x = ((-0.8, -0.6, 0.0), (0.8, 0.6, 0.0)), y = ((-0.68, -0.76, 0.25), (-0.92, -0.44, 0.25)), - expect = "flips at small σ; ratio small against the joined bound"), - ]), - (name = "clamp and tie flips", claim = "tracer all-domain bound", - status = "clamp boundaries are C¹ (O(u²) flip cost) and min ties are 1-Lipschitz; the traced join covers every arm", - bound = ll_traced_b, match = (pe, pc) -> pe !== pc && pe !== :ii && pc !== :ii, - nmul = 10, - points = [ - # (Q0-P0) ⊥ Q exactly: the t* = 0 clamp boundary in the s* = 0 region. - (x = ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), y = ((-0.52, -0.39, 0.25), (-0.82, 0.01, 0.25)), - expect = "corner↔edge flips at small σ; O(u²) cost, tiny ratios"), - ]), - (name = "degenerate (‖n‖² ≤ realmin)", claim = "not sampled", - status = "argued: cross3_accurate is exactly zero for exactly parallel segments, inside is false (NaN→false), and the edge candidates answer; a wrong clamp from subnormal Δ costs ≤ the parallel gap, covered by the edge rows", - bound = q -> big(1.0), match = (pe, pc) -> false, points = NamedTuple[]), -]) - -function perturb(T, v, σ) - SVector{3, T}(T(v[1] + σ*randn()), T(v[2] + σ*randn()), T(v[3] + σ*randn())) -end - -function run_ledger(name, kernel, T; n=1000) - u = big(eps(T))/2 - σs = 10.0 .^ range(log10(4*eps(T)), -2; length=7) - entries = kernel.entries - counts = zeros(Int, length(entries)) - maxratio = zeros(length(entries)) - atσ = fill(NaN, length(entries)) - # Sharpness at each entry's own points: max ratio among samples that both - # originate from the point and match the entry. - ownmax = [zeros(length(e.points)) for e in entries] - unmatched = Dict{Tuple{Symbol, Symbol}, Int}() - for (oj, oe) in enumerate(entries), (opi, pt) in enumerate(oe.points), σ in σs, _ in 1:(n*get(oe, :nmul, 1)) - xs = map(v -> perturb(T, v, σ), pt.x) - ys = map(v -> perturb(T, v, σ), pt.y) - q, pe = kernel.exact(xs, ys) - pc = kernel.computed(xs, ys) - err = abs(sqrt(big(dist_sqr_r_s_t(SVector(xs), SVector(ys))[1])) - q.d) - routed = false - for (j, e) in enumerate(entries) - e.match(pe, pc) || continue - routed = true - b = u*e.bound(q) - iszero(b) && continue - ratio = Float64(err/b) - counts[j] += 1 - ratio > maxratio[j] && (maxratio[j] = ratio; atσ[j] = σ) - j == oj && ratio > ownmax[j][opi] && (ownmax[j][opi] = ratio) - end - routed || (unmatched[(pe, pc)] = get(unmatched, (pe, pc), 0) + 1) - end - println("=== $name ($T) ===") - for (j, e) in enumerate(entries) - if counts[j] > 0 - println(" ", rpad(e.name, 32), lpad(counts[j], 7), " samples max err/bound ", - rpad(round(maxratio[j], sigdigits=3), 9), " @σ=", round(atσ[j], sigdigits=2), - maxratio[j] > 1 ? " *** CLAIM VIOLATED ***" : "") - elseif isempty(e.points) - println(" ", rpad(e.name, 32), " not sampled") - else - println(" ", rpad(e.name, 32), " NO SAMPLES MATCHED — coverage gap") - end - println(" claim u·(", e.claim, ") — ", e.status) - for (opi, pt) in enumerate(e.points) - println(" point ", opi, " max ", rpad(round(ownmax[j][opi], sigdigits=3), 9), - " expect ", pt.expect) - end - end - isempty(unmatched) || println(" UNROUTED samples: ", sort!(collect(unmatched); by=last, rev=true)) -end - -Random.seed!(7) -for T in (Float32, Float64) - run_ledger("point-point", POINT_POINT, T) - run_ledger("point-line", POINT_LINE, T) - run_ledger("point-triangle", POINT_TRIANGLE, T) - run_ledger("line-line", LINE_LINE, T) -end From a87327e2e908880957788068d928a94562c6d410 Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 01:00:00 -0400 Subject: [PATCH 04/11] =?UTF-8?q?Point-triangle=20as=20the=20closest=20of?= =?UTF-8?q?=20three=20candidates,=20without=20=CE=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point-triangle kernel formed `Δ = ‖E0×E1‖²`, and returned Inf for a point over the face when `inv(Δ)` overflowed, or NaN in the plane, even for a unit sliver. It now takes the closest of three points of the triangle: the closest points on edges AC and BC, and the projection with its weights clamped into the triangle. Each candidate is a point of the triangle, so none is closer than the true distance, and there is no inside test. The weights solve `v` first, as `N⋅(E0×(X - A))/(N⋅N)` with `N = E0×E1`, and then `u` by back substitution against `E1 = c1*E0 + e`, `e ⟂ E0`, so their errors don't grow like one over the sine of an angle. Clamping `v` before solving `u` makes the projection exactly the closest point on edge AB when it is past AB, so AB needs no candidate of its own. A first version solved `e` by Gram-Schmidt with a second pass. It was bound by its chain of dependent operations: every SIMD width took the same ~75 cycles per iteration. The two cross products don't wait on a division, and with `fma3` for the `p + t*r` updates and a `clamp01nan` that compiles to one max and one min, 10k pairs take 4.8 μs in Float32 ×16 SIMD (9.0 for the Gram-Schmidt version) and 9.8 μs in Float64 ×8 (18.7). Also states the contract every `dist_sqr_r_s_t` method must meet, and adds `tiny_normal_cases` to test it where squared lengths and areas are out of range. Co-Authored-By: Claude Opus 5.5 --- src/distances.jl | 167 +++++++++++++++++++++++---------------- src/util.jl | 14 +++- test/test-mindistance.jl | 56 +++++++++++++ 3 files changed, 167 insertions(+), 70 deletions(-) diff --git a/src/distances.jl b/src/distances.jl index 4b7a0ff..e2490a8 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -18,6 +18,36 @@ for a `LineSeg`, the closest point is `(1 - s[1])*x[1] + s[1]*x[2]`. """ function dist_sqr_r_s_t end +#= +What a `dist_sqr_r_s_t` method must do to be correct, for an element type `T` +(or `SIMD.Vec` lanes of it). Here `d` is the exact distance between the +simplexes as given, in `T`, and `ℓ` is the largest absolute coordinate. + +For finite coordinates below about `floatmax(T)^(1/4)` (4e9 in `Float32`, +1e77 in `Float64`): + +1. The result is finite: never `NaN` or `Inf`, even for degenerate simplexes + (repeated vertices, zero length edges, collinear triangles). +2. Each weight in `s` and `t` is in `[0, 1]`, and the computed `sum(s)` and + `sum(t)` are at most one. +3. `r` is about the separation between the points the weights give, and `d²` + is about `dot(r, r)`. Neither needs to be exact. +4. `|√d² - d| ≤ c₁*eps(T)*ℓ + c₂*floatmin(T)^(1/4)`, with `c₁` a small + constant (the tests allow 64 right now). + This holds for any shape, including slivers and nearly parallel line + segments: the error must not grow like one over an angle. + +The `floatmin(T)^(1/4)` term (3e-10 in `Float32`, 1e-77 in `Float64`) is only +for when something underflows. Then accuracy may be lost, but only by that +much. + +Above `floatmax(T)^(1/4)` anything goes. The methods must also be branch +free, with each `SIMD.Vec` lane bit identical to the scalar method. + +`tiny_normal_cases` and the ledger cases in test/test-mindistance.jl check +this at the edges of the range and at the worst case points. +=# + """ dist_sqr(x::Simplex{N,T}, y::Simplex{M,T})::T @@ -72,82 +102,77 @@ end # Written as `y - x[1]` so it folds to `y[1]` when `x[1]` is positive zero. p = y[1] - x[1] t = clamp01nan(-dot3(p, r)/dot3(r, r)) - d = p + scale3(t, r) + d = fma3(t, r, p) dot3(d, d), d, SVector{0,T}(), SVector{1,T}(t) end #= -The closest point on the triangle is either the projection of the point onto -the plane of the triangle, if that is inside the triangle, or on an edge. - -This avoids expanding the squared distance as a quadratic in the barycentric -coordinates, which cancels: its absolute error scales with the squared size -of the triangle, not with the distance. Here the distance inside is along the -normal, and each edge candidate is `dot(r, r)` of its own separation `r`, so -the error of the distance is about a rounding of the coordinates. - -`N` is from `cross3_accurate`, so it is exactly zero for an exactly -degenerate triangle, which then always uses the edges. With `cross3`, `N` of -a triangle with two equal edges is a rounding error, and the inside test can -pass by mistake. Unlike `(b*e - c*d)/(a*c - b*b)`, the cross product form of -the barycentric coordinates is also reliable for slivers. - -Each barycentric coordinate is computed from its own edge, with an error of -about a rounding of `‖BP‖` times the length of that edge over `‖N‖`, so the -inside test is off by about a rounding of `‖BP‖` in distance from that edge. -Testing `sbar + tbar ≤ 1` instead puts the errors of `sbar` and `tbar`, which -scale with the long edges, against the short edge `E2` of a needle, and in -`Float32` a point 0.2 past the short edge of a needle 100 long could be -judged inside. - -The projection onto the plane is from Kahan, "Computing Cross-Products and -Rotations in 2- and 3-Dimensional Euclidean Spaces" (2016), §9, and the -edges as in Ericson, "Real-Time Collision Detection" (2005), §5.1.5. See the -`LineSeg`-`LineSeg` method for the references on `cross3_accurate`. +For the triangle `A`, `B`, `C`, the closest point is the projection `X'` of +the point onto the plane if that is inside the triangle, and otherwise on an +edge. Rather than test which, this takes the closest of three points of the +triangle: the closest point on edges `AC` and `BC`, and `X'` with its weights +clamped into the triangle, which also covers edge `AB`. Each is a point of +the triangle, so none is closer than the true distance, and `d2` is +`dot(r, r)` of its own separation `r`, which does not cancel. + +The weights `(u, v)` of `X'` are a least squares solve. With +`E1 = c1*E0 + e` and `e ⟂ E0`, `X - A = (u + v*c1)*E0 + v*e` plus a normal +part, so `v = e⋅(X - A)/(e⋅e)` and `u = E0⋅(X - A)/(E0⋅E0) - v*c1`. Then +`u*E0 + v*E1 = (u + v*c1)*E0 + v*e`, so the errors of `u` and `v` move the +point by about a rounding of `‖X - A‖`, not over the sine of an angle. + +`e` itself is never formed. `E1 - c1*E0` cancels for a sliver, but with +`N = E0×E1`, `N×E0 = (E0⋅E0)*e`, so `v = N⋅(E0×(X - A))/(N⋅N)`. The rounding +of `E0×(X - A)` moves `X'` by about a rounding of `‖X - A‖`. The rounding of +`N` is about a rounding of `‖E0‖*‖E1‖`. Along `N` it only scales `v`, which +moves `X'` by at most about a rounding of `‖E1‖`. In the plane it tilts the +normal, which moves `X'` in the plane by about a rounding of the height over +the sine of the sliver's angle. That adds to the distance in second order, +and when it is large the sliver is thin and an edge is close: the smaller of +the two is at most about a rounding of `‖E1‖`. The two cross products don't +wait on each other or on a division, which keeps the chain of dependent +operations short. + +Edge `AB` is `v = 0`, and `u` is solved after `v` is clamped. So when `X'` +is past `AB`, `v` clamps to zero and `X'` becomes exactly the closest point +on `AB`, with an exact zero weight. `v = min(v, 1 - u)` then keeps `X'` in +the triangle. Past `AC` or `BC` it is just some point of the triangle, and +the edge candidates give the closest point. + +`N⋅N` and `N⋅(E0×(X - A))` are at most fourth powers of the coordinates, so +they are in range. When `N⋅N` underflows, every point of the triangle is +within about `floatmin^(1/4)` of edges `AC` and `BC`: within `‖E0‖/2` and +within the height over `AB`, whose product is `‖N‖/2`. A degenerate triangle +or edge gives NaN or infinite weights, which `clamp01nan` makes a point of +the triangle, and then the edges are exact or within the length of the edge. =# @inline function dist_sqr_r_s_t(x::Point{T}, y::Triangle{T}) where T - B = y[1] - E0 = y[2] - B - E1 = y[3] - B - # Written as `y - x[1]` so it folds to `B` when `x[1]` is positive zero. - BP = B - x[1] - N = cross3_accurate(E0, E1) - Δ = dot3(N, N) - invΔ = inv(Δ) - # The barycentric coordinates of the projection onto the plane, by - # Lagrange's identity, without the cancellation in `(b*e - c*d)/(a*c - b*b)`. - sbar = dot3(N, cross3(E1, BP))*invΔ - tbar = dot3(N, cross3(BP, E0))*invΔ - # The edge from `B + E0` to `B + E1`. + A = y[1] + E0 = y[2] - A + E1 = y[3] - A + # Written as `y - x[1]` so it folds to `A` when `x[1]` is positive zero. + AX = A - x[1] + ia = inv(dot3(E0, E0)) + c1 = dot3(E0, E1)*ia + N = cross3(E0, E1) + # The weights of `B` and `C` at `X'`, clamped into the triangle, `v` first. + v = clamp01nan(-dot3(N, cross3(E0, AX))/dot3(N, N)) + u = clamp01nan(fma(-v, c1, -dot3(E0, AX)*ia)) + # A select, as `Base.min` also checks for NaN, which can't happen here. + w = one(T) - u + v = SIMD.vifelse(v < w, v, w) + rin = fma3(v, E1, fma3(u, E0, AX)) + # The closest points on the edges `AC` and `BC`. + u1 = clamp01nan(-dot3(E1, AX)/dot3(E1, E1)) + r1 = fma3(u1, E1, AX) + best = (dot3(r1, r1), r1, zero(T), u1) E2 = E1 - E0 - BP2 = BP + E0 - # The weight of `B`, `1 - sbar - tbar`, from its own edge. The sum - # `sbar + tbar` cancels when `E2` is much shorter than `E0` and `E1`. - wbar = dot3(N, cross3(BP2, E2))*invΔ - # False for NaN, from a degenerate triangle. - inside = (zero(T) ≤ sbar) & (zero(T) ≤ tbar) & (zero(T) ≤ wbar) - hN = dot3(BP, N) - # `hN*invΔ` first: `hN ≈ d*‖N‖` can be near the square root of floatmax - # when `hN*hN` would overflow, while `hN*invΔ ≈ d/‖N‖` stays in range. - hNinvΔ = hN*invΔ - rin = scale3(hNinvΔ, N) - d2in = hNinvΔ*hN - # The closest points on the edges from `B` along `E0` and `E1`, and from - # `B + E0` along `E2`. - u0 = clamp01nan(-dot3(E0, BP)/dot3(E0, E0)) - r0 = BP + scale3(u0, E0) - best = (dot3(r0, r0), r0, u0, zero(T)) - u1 = clamp01nan(-dot3(E1, BP)/dot3(E1, E1)) - r1 = BP + scale3(u1, E1) - best = _min_d2_r_s_t(best, (dot3(r1, r1), r1, zero(T), u1)) - u2 = clamp01nan(-dot3(BP2, E2)/dot3(E2, E2)) - r2 = BP2 + scale3(u2, E2) + BX = AX + E0 + u2 = clamp01nan(-dot3(BX, E2)/dot3(E2, E2)) + r2 = fma3(u2, E2, BX) best = _min_d2_r_s_t(best, (dot3(r2, r2), r2, one(T) - u2, u2)) - # Inside, `sbar` and `sbar + tbar` can round above one, so cap them to - # keep the weights a convex combination. - s_in = Base.FastMath.min_fast(sbar, one(T)) - t_in = Base.FastMath.min_fast(tbar, one(T) - s_in) - d2, r, s, t = _select_d2_r_s_t(inside, (d2in, rin, s_in, t_in), best) + # Last, so a tie with an edge keeps the edge's exact zero weight. + d2, r, s, t = _min_d2_r_s_t(best, (dot3(rin, rin), rin, u, v)) d2, r, SVector{0,T}(), SVector{2,T}(s, t) end @@ -157,7 +182,11 @@ projections onto one of these planes cross, the closest points are at the crossing and the separation is along `n`. Otherwise the closest points are an end point of one line segment and its closest point on the other. -This is accurate for the same reason as the `Point`-`Triangle` method. `n` is +This avoids expanding the squared distance as a quadratic in the parameters, +which cancels: its absolute error scales with the squared length of the line +segments, not with the distance. Here the distance at a crossing is along +`n`, and each end point candidate is `dot(r, r)` of its own separation `r`, +so the error of the distance is about a rounding of the coordinates. `n` is from `cross3_accurate`: with `cross3`, the error of the distance between nearly parallel line segments grows like one over the angle between them. diff --git a/src/util.jl b/src/util.jl index d4abf19..3cf6aaf 100644 --- a/src/util.jl +++ b/src/util.jl @@ -1,5 +1,8 @@ +# Clamp `x` to `[0, 1]`, with NaN to zero. Written as selects that LLVM makes +# one max and one min, as `x > 0` is false for NaN. @inline function clamp01nan(x) - SIMD.vifelse(isnan(x), zero(x), Base.FastMath.min_fast(Base.FastMath.max_fast(x, zero(x)), one(x))) + y = SIMD.vifelse(x > zero(x), x, zero(x)) + SIMD.vifelse(y < one(x), y, one(x)) end """ @@ -62,6 +65,15 @@ Unlike `t*v`, this also works when `t` is a `SIMD.Vec{N,T}`. @inline scale3(t::Union{Number, SIMD.Vec}, v::Vec3) = SVector(t*v[1], t*v[2], t*v[3]) @inline scale3(v::Vec3, t::Union{Number, SIMD.Vec}) = SVector(v[1]*t, v[2]*t, v[3]*t) +""" + fma3(t, a::Vec3, b::Vec3) + +Return `t*a + b` elementwise, computed with fused multiply adds. + +Also works when `t` is a `SIMD.Vec{N,T}`, bit identical to the scalar version in each lane. +""" +@inline fma3(t::Union{Number, SIMD.Vec}, a::Vec3, b::Vec3) = SVector(fma(t, a[1], b[1]), fma(t, a[2], b[2]), fma(t, a[3], b[3])) + function relu(x::T)::T where T SIMD.vifelse(x > zero(x), x, zero(x)) end diff --git a/test/test-mindistance.jl b/test/test-mindistance.jl index f3273f9..8b9c9cc 100644 --- a/test/test-mindistance.jl +++ b/test/test-mindistance.jl @@ -250,6 +250,62 @@ const ledger_cases = [ end end +# Small or thin simplexes, where a squared length, or a squared area like +# `Δ = |n|²` in the line-line method, is out of range. When something +# underflows the distance may lose accuracy, but it must stay finite and off +# by at most about `floatmin^(1/4)`, which bounds the inradius of a triangle +# with `Δ` below `floatmin`. The point-triangle method used to form `Δ`, and +# returned `Inf` for a point over the face when `inv(Δ)` overflowed, or `NaN` +# in the plane, even for a unit sliver. When `Δ` underflows the line-line +# method skips the crossing, and when `‖r‖²` is subnormal the point-line +# quotient for `t` keeps only a few bits, but both are within the floor. +# Nearly parallel unit line segments do not reach this: their end points are +# within about ℓ·sinθ of the crossing. +const tiny_normal_cases = [ + ("point-triangle, tiny, inv(Δ) overflows", Float32, + ((0.25e-10, 0.25e-10, 1e-10),), ((0, 0, 0), (1e-10, 0, 0), (0, 1e-10, 0))), + ("point-triangle, tiny, Δ underflows", Float32, + ((0.25e-12, 0.25e-12, 1e-12),), ((0, 0, 0), (1e-12, 0, 0), (0, 1e-12, 0))), + ("point-triangle, unit sliver, inv(Δ) overflows", Float32, + ((1.5, 0.6e-20, 0.1),), ((0, 0, 0), (1, 0, 0), (2, 1e-20, 0))), + ("point-triangle, unit sliver, in plane", Float32, + ((1.5, 0.6e-20, 0),), ((0, 0, 0), (1, 0, 0), (2, 1e-20, 0))), + ("line-line, tiny crossing, inv(Δ) overflows", Float32, + ((-0.5e-10, 0, 0), (0.5e-10, 0, 0)), ((0, -0.5e-10, 1e-10), (0, 0.5e-10, 1e-10))), + ("line-line, tiny crossing, Δ underflows", Float32, + ((-0.5e-12, 0, 0), (0.5e-12, 0, 0)), ((0, -0.5e-12, 1e-12), (0, 0.5e-12, 1e-12))), + ("point-triangle, tiny, inv(Δ) overflows", Float64, + ((0.25e-80, 0.25e-80, 1e-80),), ((0, 0, 0), (1e-80, 0, 0), (0, 1e-80, 0))), + ("point-triangle, tiny, Δ underflows", Float64, + ((0.25e-90, 0.25e-90, 1e-90),), ((0, 0, 0), (1e-90, 0, 0), (0, 1e-90, 0))), + ("point-triangle, unit sliver, inv(Δ) overflows", Float64, + ((1.5, 0.6e-160, 0.1),), ((0, 0, 0), (1, 0, 0), (2, 1e-160, 0))), + ("point-triangle, unit sliver, in plane", Float64, + ((1.5, 0.6e-160, 0),), ((0, 0, 0), (1, 0, 0), (2, 1e-160, 0))), + ("line-line, tiny crossing, inv(Δ) overflows", Float64, + ((-0.5e-80, 0, 0), (0.5e-80, 0, 0)), ((0, -0.5e-80, 1e-80), (0, 0.5e-80, 1e-80))), + ("line-line, tiny crossing, Δ underflows", Float64, + ((-0.5e-90, 0, 0), (0.5e-90, 0, 0)), ((0, -0.5e-90, 1e-90), (0, 0.5e-90, 1e-90))), + ("point-line, tiny, subnormal norm", Float32, + ((0.5e-22, 1e-22, 0),), ((0, 0, 0), (1e-22, 0, 0))), + ("point-line, tiny, subnormal norm", Float64, + ((0.5e-160, 1e-160, 0),), ((0, 0, 0), (1e-160, 0, 0))), + ("line-line, tiny, inv of a norm overflows", Float32, + ((0, 0, 0), (1e-21, 0, 0)), ((0.5e-21, 1e-21, 0), (0.5e-21, 2e-21, 0))), + ("line-line, tiny, inv of a norm overflows", Float64, + ((0, 0, 0), (1e-155, 0, 0)), ((0.5e-155, 1e-155, 0), (0.5e-155, 2e-155, 0))), +] + +@testset "tiny normal: $name $T" for (name, T, xt, yt) in tiny_normal_cases + x = SVector(map(v -> SVector{3, T}(v), xt)) + y = SVector(map(v -> SVector{3, T}(v), yt)) + ref = sqrt(dist_sqr(map(v -> big.(v), x), map(v -> big.(v), y))) + # A few roundings of the largest coordinate, plus the underflow floor. + tol = 64*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) + floatmin(T)^(1/4) + @test abs(sqrt(dist_sqr(x, y)) - ref) ≤ tol + @test abs(sqrt(dist_sqr(y, x)) - ref) ≤ tol +end + # Return `W` simplices as one simplex with a lane per simplex. function simplex_lanes(xs::NTuple{W, SVector{N, SVector{3, T}}}) where {W, N, T} SVector{N}(ntuple(Val(N)) do v From a314cbb8ddb0e35eb631437e93a1637e301a420d Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 01:34:35 -0400 Subject: [PATCH 05/11] Line-line as the closest of three pairs of points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For line segments on the same line to within rounding, `n = P×Q` and `Q0mP0×Q` are both rounding errors, so `sbar` and `tbar` were arbitrary and the inside test could pass. The distance between disjoint segments 0.56 apart then came out as 3e-8, the height along `n`, which is not a distance between points of the segments. For nearly parallel overlapping segments the distance was right, but the weights gave points 0.57 apart with `r` 1.6e-7 long. Integer test coordinates missed this, as an exactly collinear `n` is zero. The kernel now takes the closest of three pairs of points: the closest points on `y` of the end points of `x`, and the closest point on `x` of the point at `t` on `y`, with `t = -n⋅(P×Q0mP0)/(n⋅n)` of the closest points of the lines, clamped. Each is a pair of points of the line segments, so none is closer than the true distance and `r` is the separation of the points the weights give. With `t` clamped to zero or one, `s` is the closest point on `x` of an end point of `y`, so those two candidates are gone. An error in `t` moves the point on `y` by about a rounding across the line through `x`, so it doesn't grow like one over the angle, and plain `cross3` is enough for `n`. 10k pairs take 4.5 μs in Float32 ×16 SIMD (5.9 before) and 9.2 μs in Float64 ×8 (12.4), and a scalar call is about 28% faster. Adds a test of nearly collinear and nearly parallel line segments, which checks the distance and that `r` matches the weights. Co-Authored-By: Claude Opus 5.5 --- src/distances.jl | 111 +++++++++++++++++++-------------------- test/test-mindistance.jl | 72 +++++++++++++++++++++---- 2 files changed, 116 insertions(+), 67 deletions(-) diff --git a/src/distances.jl b/src/distances.jl index e2490a8..c3bd32b 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -177,28 +177,45 @@ the triangle, and then the edges are exact or within the length of the edge. end #= -The line segments lie in parallel planes with normal `n = P×Q`. If their -projections onto one of these planes cross, the closest points are at the -crossing and the separation is along `n`. Otherwise the closest points are -an end point of one line segment and its closest point on the other. - -This avoids expanding the squared distance as a quadratic in the parameters, -which cancels: its absolute error scales with the squared length of the line -segments, not with the distance. Here the distance at a crossing is along -`n`, and each end point candidate is `dot(r, r)` of its own separation `r`, -so the error of the distance is about a rounding of the coordinates. `n` is -from `cross3_accurate`: with `cross3`, the error of the distance between -nearly parallel line segments grows like one over the angle between them. - -The nearest points of the lines through the line segments are from Kahan, -"Computing Cross-Products and Rotations in 2- and 3-Dimensional Euclidean -Spaces" (2016), §9: the separation is along `n = P×Q`, and the parameters -are triple products over `|n|²` (Lagrange's identity), not the Gram -determinant `a*c - b*b` of Lumelsky (1985) and Ericson, "Real-Time -Collision Detection" (2005), §5.1.9, which cancels for nearly parallel line -segments. `n` is computed with Kahan's algorithm for 2×2 determinants -(Higham, "Accuracy and Stability of Numerical Algorithms", 2002, Problem -2.27; bound 2u from Jeannerod, Louvet, Muller, Math. Comp. 82, 2013). +The separation between the points at `s` on `x` and `t` on `y` is +`Q0mP0 + t*Q - s*P`. Rather than test where the closest points are, this +takes the closest of three pairs of points: the closest points on `y` of the +end points of `x`, and the closest point on `x` of the point at `t` on `y`, +with `t` from the closest points of the lines through the line segments, +clamped. Each is a pair of points of the line segments, so none is closer +than the true distance, `d2` is `dot(r, r)` of its own separation `r`, which +does not cancel, and `r` is the separation of the points the weights give. + +With `t` clamped to zero or one, `s` is the closest point on `x` of an end +point of `y`, so those pairs need no candidates of their own. So if the +closest points have `s` at an end of `x`, an end point candidate has them, +and otherwise the pair from `t` does. + +`t = -n⋅(P×Q0mP0)/(n⋅n)` with `n = P×Q` is a triple product over `‖n‖²` +(Lagrange's identity), as in Kahan, "Computing Cross-Products and Rotations +in 2- and 3-Dimensional Euclidean Spaces" (2016), §9. Unlike the Gram +determinant `a*c - b*b` of Lumelsky (1985) and Ericson, "Real-Time Collision +Detection" (2005), §5.1.9, it doesn't cancel for nearly parallel line +segments. The rounding of `P×Q0mP0` moves `t` by about a rounding of +`‖Q0mP0‖` over `‖Q‖*sinθ`, which moves the point on `y` by about a rounding +of `‖Q0mP0‖` across the line through `x`. If that moves its closest point on +`x` off an end, the end point there is within as much of `y`. The rounding +of `n` is about a rounding of `‖P‖*‖Q‖`. Along `n` it only scales `t`, which +moves the point on `y` across the line through `x` by at most about a +rounding of `‖Q‖`. In the plane it moves that point by about a rounding of +the height over `sinθ`, which adds to the distance in second order. That is +at most `‖Q‖*sinθ` too, as the point stays on `y`, and the smaller of the +two adds at most about a rounding of `‖Q‖`. + +`n⋅n` and `n⋅(P×Q0mP0)` are at most fourth powers of the coordinates, so +they are in range. When `n⋅n` underflows, `‖P‖*‖Q‖*sinθ` is below about +`floatmin^(1/2)`, so `y` is shorter than about `floatmin^(1/4)`, or `x` is +within that of a line segment parallel to `y`. For parallel line segments +any `t` will do: if an end point of `x` is closest, an end point candidate +has it, and otherwise all of `y` is beside `x`. Zero length or parallel line +segments give NaN or infinite parameters, which `clamp01nan` makes points of +the line segments. + For a different robust method see Eberly, "Robust Computation of Distance Between Line Segments" (Geometric Tools, 2018, revised 2023). =# @@ -209,44 +226,24 @@ Between Line Segments" (Geometric Tools, 2018, revised 2023). Q = y[2] - Q0 # Written as `y - x[1]` so it folds to `Q0` when `x[1]` is positive zero. Q0mP0 = Q0 - P0 - a = dot3(P, P) b = dot3(P, Q) c = dot3(Q, Q) - d = dot3(P, Q0mP0) e = dot3(Q, Q0mP0) - n = cross3_accurate(P, Q) - Δ = dot3(n, n) - invΔ = inv(Δ) - # The closest points of the lines through the line segments, by - # Lagrange's identity, without the cancellation in `a*c - b*b`. - sbar = dot3(n, cross3(Q0mP0, Q))*invΔ - tbar = dot3(n, cross3(Q0mP0, P))*invΔ - # False for NaN, from parallel or zero length line segments. - inside = (zero(T) ≤ sbar) & (sbar ≤ one(T)) & (zero(T) ≤ tbar) & (tbar ≤ one(T)) - hn = dot3(Q0mP0, n) - # `hn*invΔ` first: `hn ≈ d*‖n‖` can be near the square root of floatmax - # when `hn*hn` would overflow, while `hn*invΔ ≈ d/‖n‖` stays in range. - hninvΔ = hn*invΔ - rin = scale3(hninvΔ, n) - d2in = hninvΔ*hn - inva = inv(a) - invc = inv(c) - # The closest points on `x` of the end points of `y`, and on `y` of the - # end points of `x`. The separation between the points at `s` on `x` and - # `t` on `y` is `Q0mP0 + t*Q - s*P`. - s0 = clamp01nan(d*inva) - s1 = clamp01nan((b+d)*inva) - t0 = clamp01nan(-e*invc) - t1 = clamp01nan((b-e)*invc) - rs0 = Q0mP0 - scale3(s0, P) - best = (dot3(rs0, rs0), rs0, s0, zero(T)) - rs1 = (Q0mP0 + Q) - scale3(s1, P) - best = _min_d2_r_s_t(best, (dot3(rs1, rs1), rs1, s1, one(T))) - rt0 = Q0mP0 + scale3(t0, Q) - best = _min_d2_r_s_t(best, (dot3(rt0, rt0), rt0, zero(T), t0)) - rt1 = (Q0mP0 - P) + scale3(t1, Q) - best = _min_d2_r_s_t(best, (dot3(rt1, rt1), rt1, one(T), t1)) - d2, r, s, t = _select_d2_r_s_t(inside, (d2in, rin, sbar, tbar), best) + n = cross3(P, Q) + # `t` of the closest points of the lines through the line segments, + # clamped, and the closest point on `x` of the point at `t` on `y`. + t = clamp01nan(-dot3(n, cross3(P, Q0mP0))/dot3(n, n)) + s = clamp01nan(fma(t, b, dot3(P, Q0mP0))/dot3(P, P)) + rin = fma3(-s, P, fma3(t, Q, Q0mP0)) + # The closest points on `y` of the end points of `x`. + t0 = clamp01nan(-e/c) + r0 = fma3(t0, Q, Q0mP0) + best = (dot3(r0, r0), r0, zero(T), t0) + t1 = clamp01nan((b - e)/c) + r1 = fma3(t1, Q, Q0mP0 - P) + best = _min_d2_r_s_t(best, (dot3(r1, r1), r1, one(T), t1)) + # Last, so a tie with an end point keeps its exact weight. + d2, r, s, t = _min_d2_r_s_t(best, (dot3(rin, rin), rin, s, t)) d2, r, SVector{1,T}(s), SVector{1,T}(t) end diff --git a/test/test-mindistance.jl b/test/test-mindistance.jl index 8b9c9cc..a3b4c79 100644 --- a/test/test-mindistance.jl +++ b/test/test-mindistance.jl @@ -158,6 +158,58 @@ end end end +# Line segments on nearly the same line, or nearly parallel. For collinear +# segments to within rounding, `n = P×Q` and `Q0mP0×Q` were both rounding +# errors, so `sbar` and `tbar` were arbitrary, the inside test could pass, +# and the distance between disjoint segments 0.56 apart came out as 3e-8. +# Nearly parallel overlapping segments got the distance right, but with +# weights giving points far apart. Integer coordinates miss this, as an +# exactly collinear `n` is zero. +@testset "$T nearly collinear and nearly parallel line segments" for T in (Float32, Float64) + rng = Random.Xoshiro(29) + # The case 0.56 apart, kept since the random ones below fail it only + # about once in 700 for the old method. + cases = Any[( + SA[SA[-1.1168967f0, -0.4735017f0, -0.30628234f0], SA[-0.43016562f0, 0.06529092f0, -0.054257788f0]], + SA[SA[0.96934664f0, 1.1633145f0, 0.45935148f0], SA[-0.007197912f0, 0.39714116f0, 0.10096781f0]], + )] + for trial in 1:10000 + q = normalize(randn(rng, 3)) + e1 = normalize(cross(q, randn(rng, 3))) + e2 = cross(q, e1) + o = randn(rng, 3) + if isodd(trial) + # Four points on a line, with offsets below a rounding or a few. + ts = randn(rng, 4) + pts = [o .+ t .* q .+ rand(rng, (0, 0.1, 4)) * eps(T) .* (randn(rng)*e1 .+ randn(rng)*e2) for t in ts] + xt, yt = (pts[1], pts[2]), (pts[3], pts[4]) + else + # Nearly parallel, offset along the line and across it. + θ = 10.0^(-rand(rng)*12) + dq = cos(θ)*q .+ sin(θ)*e1 + c = o .+ 2randn(rng)*q .+ 10.0^(-rand(rng)*6)*randn(rng)*e2 + xt = (o .- q, o .+ q) + yt = (c .- exp(randn(rng)) .* dq, c .+ exp(randn(rng)) .* dq) + end + push!(cases, (SVector{2}(map(v -> SVector{3, T}(v), xt)), SVector{2}(map(v -> SVector{3, T}(v), yt)))) + end + for (x, y) in cases + x = map(v -> SVector{3, T}(v), x) + y = map(v -> SVector{3, T}(v), y) + tol = 64*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) + for (a, b) in ((x, y), (y, x)) + ref = sqrt(dist_sqr(map(v -> big.(v), a), map(v -> big.(v), b))) + d2, r, s, t = dist_sqr_r_s_t(a, b) + @test abs(sqrt(d2) - ref) ≤ tol + # `r` is the separation of the points the weights give. + pa = (1 - big(s[1])) .* a[1] .+ big(s[1]) .* a[2] + pb = (1 - big(t[1])) .* b[1] .+ big(t[1]) .* b[2] + @test norm(r .- (pb .- pa)) ≤ tol + @test abs(sqrt(d2) - norm(r)) ≤ tol + end + end +end + # Worst-case accuracy points ported from error-analysis/ledger.jl. Each case # pins one branch domain (or branch boundary) of a distance kernel at the # ledger's sharpest known input, with the ledger's first-order claim @@ -251,16 +303,16 @@ const ledger_cases = [ end # Small or thin simplexes, where a squared length, or a squared area like -# `Δ = |n|²` in the line-line method, is out of range. When something -# underflows the distance may lose accuracy, but it must stay finite and off -# by at most about `floatmin^(1/4)`, which bounds the inradius of a triangle -# with `Δ` below `floatmin`. The point-triangle method used to form `Δ`, and -# returned `Inf` for a point over the face when `inv(Δ)` overflowed, or `NaN` -# in the plane, even for a unit sliver. When `Δ` underflows the line-line -# method skips the crossing, and when `‖r‖²` is subnormal the point-line -# quotient for `t` keeps only a few bits, but both are within the floor. -# Nearly parallel unit line segments do not reach this: their end points are -# within about ℓ·sinθ of the crossing. +# `Δ = |n|²`, is out of range. When something underflows the distance may +# lose accuracy, but it must stay finite and off by at most about +# `floatmin^(1/4)`, which bounds the inradius of a triangle with `Δ` below +# `floatmin`. The point-triangle method used to form `inv(Δ)`, and returned +# `Inf` for a point over the face when it overflowed, or `NaN` in the plane, +# even for a unit sliver. When `Δ` underflows the line-line `t` is arbitrary, +# and when `‖r‖²` is subnormal the point-line quotient for `t` keeps only a +# few bits, but both are within the floor. Nearly parallel unit line +# segments do not reach this: their end points are within about ℓ·sinθ of +# the crossing. const tiny_normal_cases = [ ("point-triangle, tiny, inv(Δ) overflows", Float32, ((0.25e-10, 0.25e-10, 1e-10),), ((0, 0, 0), (1e-10, 0, 0), (0, 1e-10, 0))), From 2fa9d0edb04d26edd22d1eba2fa32c3f6c9827e6 Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 01:45:23 -0400 Subject: [PATCH 06/11] Remove cross3_accurate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing uses it since the line-line kernel takes the closest of three pairs of points. The distance is now `dot(r, r)` of a pair of points, accurate whichever pair `n` picks, so `n = P×Q` no longer needs Kahan's 2×2 determinants. Co-Authored-By: Claude Opus 5.5 --- src/util.jl | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/util.jl b/src/util.jl index 3cf6aaf..916a9dc 100644 --- a/src/util.jl +++ b/src/util.jl @@ -29,31 +29,6 @@ Also works on `SVector{3, SIMD.Vec{N,T}}`, bit identical to the scalar version i ) end -""" - cross3_accurate(a::Vec3, b::Vec3) - -Return the cross product of two 3D vectors, each component accurate to about -one rounding, using Kahan's algorithm for `p*q - u*v` with fused multiply adds. - -[`cross3`](@ref) is cheaper, but for nearly parallel `a` and `b` its -components cancel, with an error of about a rounding of `norm(a)*norm(b)`. - -Also works on `SVector{3, SIMD.Vec{N,T}}`, bit identical to the scalar version in each lane. -""" -@inline function cross3_accurate(a::Vec3, b::Vec3) - SVector( - _det2_accurate(a[2], b[3], a[3], b[2]), - _det2_accurate(a[3], b[1], a[1], b[3]), - _det2_accurate(a[1], b[2], a[2], b[1]), - ) -end - -# `p*q - u*v`, with the rounding of `u*v` recovered by a fused multiply add. -@inline function _det2_accurate(p, q, u, v) - uv = u*v - fma(p, q, -uv) + fma(-u, v, uv) -end - """ scale3(t, v::Vec3) scale3(v::Vec3, t) From cec5ca316da409057c47e75dcf31268ad9aea89d Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 01:54:33 -0400 Subject: [PATCH 07/11] clean up comments --- src/distances.jl | 2 +- test/test-mindistance.jl | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/distances.jl b/src/distances.jl index c3bd32b..6a479a2 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -44,7 +44,7 @@ much. Above `floatmax(T)^(1/4)` anything goes. The methods must also be branch free, with each `SIMD.Vec` lane bit identical to the scalar method. -`tiny_normal_cases` and the ledger cases in test/test-mindistance.jl check +`tiny_normal_cases` and `worst_case_points` in test/test-mindistance.jl check this at the edges of the range and at the worst case points. =# diff --git a/test/test-mindistance.jl b/test/test-mindistance.jl index a3b4c79..80d4a98 100644 --- a/test/test-mindistance.jl +++ b/test/test-mindistance.jl @@ -210,16 +210,16 @@ end end end -# Worst-case accuracy points ported from error-analysis/ledger.jl. Each case -# pins one branch domain (or branch boundary) of a distance kernel at the -# ledger's sharpest known input, with the ledger's first-order claim -# |√d̂² - d| ≤ u·bound(exact sizes), u = eps(T)/2, evaluated at the point and -# rounded up ~1.3x as margin for how the sizes drift under the noise below. +# Worst-case accuracy points. Each case pins one branch domain (or branch +# boundary) of a distance kernel at the sharpest known input, with a +# first-order error bound |√d̂² - d| ≤ u·bound(exact sizes), u = eps(T)/2, +# evaluated at the point and rounded up ~1.3x as margin for how the sizes +# drift under the noise below. # Each point is tested as given, then with Gaussian coordinate noise of a few # ulps: enough to scramble every rounding, and at the boundary points to make # the branch actually flip (their tolerance is the traced all-domain bound, # which includes the branch jump terms). -const ledger_cases = [ +const worst_case_points = [ # point-point: 5u/2·d, attained when one coordinate carries the separation. ("point-point, one coordinate separation", 7.0, ((-1.0, 0.03, -0.02),), ((1.0, 0.06, 0.03),)), @@ -288,7 +288,7 @@ const ledger_cases = [ ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), ((-0.52, -0.39, 0.25), (-0.82, 0.01, 0.25))), ] -@testset "ledger worst-case point: $name" for (name, tolu, xt, yt) in ledger_cases +@testset "worst-case point: $name" for (name, tolu, xt, yt) in worst_case_points rng = Random.Xoshiro(2026) for T in (Float32, Float64) tol = tolu*eps(T)/2 From e0a53c3a1fbb92f1b659d379da50a6513e40700b Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 02:08:56 -0400 Subject: [PATCH 08/11] more inline --- src/distances.jl | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/distances.jl b/src/distances.jl index 6a479a2..f0b22d6 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -56,8 +56,7 @@ Return the minimum squared distance between two simplexes. This is the `d²` of [`dist_sqr_r_s_t`](@ref), bit for bit. """ @inline function dist_sqr(x::Simplex{N, T}, y::Simplex{M, T}) where {T, N, M} - # Inlining even the large methods lets LLVM drop the unused `r, s, t`. - d2, r, s, t = @inline dist_sqr_r_s_t(x, y) + d2, r, s, t = dist_sqr_r_s_t(x, y) d2 end @@ -300,7 +299,7 @@ function moller_trumbore_intersect(o, d, a, b, c) SegTriangleIntersectResult(true, t, u, v) end -function dist_sqr_r_s_t(x::LineSeg{T}, y::Triangle{T}) where T +@inline function dist_sqr_r_s_t(x::LineSeg{T}, y::Triangle{T}) where T fzero = zero(T) o = x[1] d = x[2] - x[1] @@ -347,7 +346,7 @@ function dist_sqr_r_s_t(x::LineSeg{T}, y::Triangle{T}) where T end end -function dist_sqr_r_s_t(x::Triangle{T}, y::Triangle{T}) where T +@inline function dist_sqr_r_s_t(x::Triangle{T}, y::Triangle{T}) where T fzero = zero(T) fone = one(T) d2, minr, _s, mint = @inline dist_sqr_r_s_t(SA[x[1],x[2]], y) From ad4f307f12e680a809d07e819af1b63939217ad9 Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 02:32:22 -0400 Subject: [PATCH 09/11] update overflow range --- src/distances.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/distances.jl b/src/distances.jl index f0b22d6..c1d75a8 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -23,8 +23,8 @@ What a `dist_sqr_r_s_t` method must do to be correct, for an element type `T` (or `SIMD.Vec` lanes of it). Here `d` is the exact distance between the simplexes as given, in `T`, and `ℓ` is the largest absolute coordinate. -For finite coordinates below about `floatmax(T)^(1/4)` (4e9 in `Float32`, -1e77 in `Float64`): +For finite coordinates below `(floatmax(T)^(1/4))/4` (1e9 in `Float32`, +3e76 in `Float64`): 1. The result is finite: never `NaN` or `Inf`, even for degenerate simplexes (repeated vertices, zero length edges, collinear triangles). From 626539941ea0884f84e55eeff37ef65a3448723b Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 10:47:57 -0400 Subject: [PATCH 10/11] Line-triangle and triangle-triangle as the closest of candidate pairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Möller–Trumbore decided the distance was zero from the signs of triple products, which are rounding errors when a line segment is in the plane of a triangle, so a line segment beside a triangle could get a distance of zero. Both kernels now take the closest of pairs of points of the simplexes: the edges, the vertices with the face, and where an edge meets the plane of the face, branch free. The normal for the crossing needs cross3_accurate, so it is back. Co-Authored-By: Claude Opus 5.5 --- src/distances.jl | 276 +++++++++++++++++++-------------------- src/util.jl | 25 ++++ test/test-mindistance.jl | 70 ++++++++-- 3 files changed, 221 insertions(+), 150 deletions(-) diff --git a/src/distances.jl b/src/distances.jl index c1d75a8..5c65f6c 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -74,15 +74,14 @@ end d2, -r, t, s end +# Branch free select, elementwise for an `SVector`. Works on `SIMD.Vec` lanes. +@inline _vifelse(c, a, b) = SIMD.vifelse(c, a, b) +@inline _vifelse(c, a::SVector{N}, b::SVector{N}) where N = SVector(ntuple(i -> SIMD.vifelse(c, a[i], b[i]), Val(N))) + # Branch free selection of `(d2, r, s, t)` tuples, with `r` a `Vec3` and `s` -# and `t` scalars. Works on `SIMD.Vec` lanes. +# and `t` scalars or `SVector`s. Works on `SIMD.Vec` lanes. @inline function _select_d2_r_s_t(c, (d2a, ra, sa, ta), (d2b, rb, sb, tb)) - ( - SIMD.vifelse(c, d2a, d2b), - SVector(SIMD.vifelse(c, ra[1], rb[1]), SIMD.vifelse(c, ra[2], rb[2]), SIMD.vifelse(c, ra[3], rb[3])), - SIMD.vifelse(c, sa, sb), - SIMD.vifelse(c, ta, tb), - ) + _vifelse(c, d2a, d2b), _vifelse(c, ra, rb), _vifelse(c, sa, sb), _vifelse(c, ta, tb) end # Branch free update of the running minimum `best` with `cand`. @@ -151,16 +150,7 @@ the triangle, and then the edges are exact or within the length of the edge. E1 = y[3] - A # Written as `y - x[1]` so it folds to `A` when `x[1]` is positive zero. AX = A - x[1] - ia = inv(dot3(E0, E0)) - c1 = dot3(E0, E1)*ia - N = cross3(E0, E1) - # The weights of `B` and `C` at `X'`, clamped into the triangle, `v` first. - v = clamp01nan(-dot3(N, cross3(E0, AX))/dot3(N, N)) - u = clamp01nan(fma(-v, c1, -dot3(E0, AX)*ia)) - # A select, as `Base.min` also checks for NaN, which can't happen here. - w = one(T) - u - v = SIMD.vifelse(v < w, v, w) - rin = fma3(v, E1, fma3(u, E0, AX)) + d2in, rin, u, v = _tri_face_candidate(AX, E0, E1, cross3(E0, E1)) # The closest points on the edges `AC` and `BC`. u1 = clamp01nan(-dot3(E1, AX)/dot3(E1, E1)) r1 = fma3(u1, E1, AX) @@ -171,10 +161,29 @@ the triangle, and then the edges are exact or within the length of the edge. r2 = fma3(u2, E2, BX) best = _min_d2_r_s_t(best, (dot3(r2, r2), r2, one(T) - u2, u2)) # Last, so a tie with an edge keeps the edge's exact zero weight. - d2, r, s, t = _min_d2_r_s_t(best, (dot3(rin, rin), rin, u, v)) + d2, r, s, t = _min_d2_r_s_t(best, (d2in, rin, u, v)) d2, r, SVector{0,T}(), SVector{2,T}(s, t) end +# The point `X'` of the triangle `A`, `B`, `C` for the point `X`, as above, +# with `AX = A - X`, `E0 = B - A`, `E1 = C - A`, and `N` about `E0×E1`. +# This is the point and triangle kernel without its edges, for the line +# segment and triangle kernels, which have closer candidates on the edges +# already and share `N` between calls. Return `(d2, r, u, v)`, with `r` from `X` to `X'`, `d2 = dot(r, r)`, and `u` +# and `v` the weights of `B` and `C`. +@inline function _tri_face_candidate(AX, E0, E1, N) + ia = inv(dot3(E0, E0)) + c1 = dot3(E0, E1)*ia + # The weights of `B` and `C` at `X'`, clamped into the triangle, `v` first. + v = clamp01nan(-dot3(N, cross3(E0, AX))/dot3(N, N)) + u = clamp01nan(fma(-v, c1, -dot3(E0, AX)*ia)) + # A select, as `Base.min` also checks for NaN, which can't happen here. + w = one(u) - u + v = SIMD.vifelse(v < w, v, w) + r = fma3(v, E1, fma3(u, E0, AX)) + dot3(r, r), r, u, v +end + #= The separation between the points at `s` on `x` and `t` on `y` is `Q0mP0 + t*Q - s*P`. Rather than test where the closest points are, this @@ -247,138 +256,121 @@ Between Line Segments" (Geometric Tools, 2018, revised 2023). end #= -Given point O, ray vector D, and a triangle with points (A,B,C), test whether the ray intersects the triangle. -If there is an intersection P. Then P can be represented as - - P = A + u(B-A) + v(C-A) = O + tD - -If there is not an intersection, `t`, `u` and `v` are undefined. -=# -struct SegTriangleIntersectResult{Float} - intersect::Bool - t::Float - u::Float - v::Float -end - -#= -A fast ray-triangle intersection algorithm by Moller and Trumbore. -Ref: Tomas Möller and Ben Trumbore, "Fast, minimum storage ray-triangle intersection" (1997) Journal of Graphics Tools. +The closest points of a line segment `x` and a triangle are on an edge of +the triangle, or are an end point of `x` over the face, or are where `x` +crosses the face, at distance zero. If `x` is parallel to the face, sliding +along it keeps the distance until an end point or an edge. So this takes the +closest of six pairs of points: the closest points of `x` and each edge, the +end points of `x` with their `X'` as for a point and a triangle, and the +point of `x` where its line meets the plane of the triangle, with `s` +clamped, with its `X'`. Each is a pair of points of the simplexes, so none is +closer than the true distance, and `d2` is `dot(r, r)` of its own separation +`r`, which does not cancel. An `X'` past an edge is some point of the +triangle, and the edge pair is at least as close. + +Testing whether `x` crosses the triangle instead, as Möller and Trumbore, +"Fast, minimum storage ray-triangle intersection" (1997), decides the +distance is zero from the signs of triple products, which are rounding +errors when `x` is in the plane of the triangle. Then a line segment beside +a triangle can get a distance of zero. + +`s = N⋅(A - P0)/(N⋅P)`. The rounding of the dot products moves the point +along `x` by about a rounding of `‖A - P0‖` or `s*‖P‖` over the sine of the +angle between `x` and the plane, which moves it off the plane by just a +rounding of those. A tilt of `N` by an angle `τ` moves the point off the +plane by about `τ*‖X - A‖`, and so the distance at first order, unlike for a +point. `cross3` tilts `N` by a rounding over the sine of the triangle's +smallest angle, which for a line segment through a sliver gave errors near +`√eps*ℓ`, so `N` is `cross3_accurate`, which tilts it by about a rounding. + +When `N⋅N` underflows the edges are within about `floatmin^(1/4)` of every +point of the triangle, as for a point and a triangle. A line segment +parallel to the plane gives NaN or infinite `s`, which `clamp01nan` makes an +end point. + +For two triangles, the closest points are on an edge of each, or are a +vertex of one over the face of the other, or are where an edge of one +crosses the other. So that takes the closest of the pairs of edges, and of +the vertices and the edges of each with the face of the other, in the same +way. =# -function moller_trumbore_intersect(o, d, a, b, c) - fzero = zero(eltype(o)) - seg_triangle_no_intersect = SegTriangleIntersectResult(false, fzero, fzero, fzero) - - rab = b - a - rac = c - a - cross_d_rac = cross3(d, rac) - det = dot3(cross_d_rac, rab) - - if det == zero(det) - return seg_triangle_no_intersect - end - - invdet = inv(det) - rao = o - a - u = dot3(cross_d_rac, rao) * invdet - if u < 0 || u > 1 - return seg_triangle_no_intersect - end - - cross_rao_rab = cross3(rao, rab) - v = dot3(cross_rao_rab, d) * invdet - if v < 0 || u + v > 1 - return seg_triangle_no_intersect - end - - t = dot3(cross_rao_rab, rac) * invdet - if t < 0 || t > 1 - return seg_triangle_no_intersect +@inline function dist_sqr_r_s_t(x::LineSeg{T}, y::Triangle{T}) where T + A = y[1] + E0 = y[2] - A + E1 = y[3] - A + N = cross3_accurate(E0, E1) + z = zero(T) + # The closest points of `x` and the edges. + d2, r, s, t = dist_sqr_r_s_t(x, SA[y[1], y[2]]) + best = (d2, r, s, SVector(t[1], z)) + d2, r, s, t = dist_sqr_r_s_t(x, SA[y[1], y[3]]) + best = _min_d2_r_s_t(best, (d2, r, s, SVector(z, t[1]))) + d2, r, s, t = dist_sqr_r_s_t(x, SA[y[2], y[3]]) + best = _min_d2_r_s_t(best, (d2, r, s, SVector(one(T) - t[1], t[1]))) + # The end points of `x` with the face. + for i in 1:2 + d2, r, u, v = _tri_face_candidate(A - x[i], E0, E1, N) + best = _min_d2_r_s_t(best, (d2, r, SVector(ifelse(i == 1, z, one(T))), SVector(u, v))) end + # Last, so a tie keeps an exact weight. + d2, r, s, u, v = _seg_tri_crossing_candidate(x[1], x[2], A, E0, E1, N) + _min_d2_r_s_t(best, (d2, r, SVector(s), SVector(u, v))) +end - SegTriangleIntersectResult(true, t, u, v) +# The point at `s` of the line segment from `P0` to `P1` where its line meets +# the plane of the triangle with `y[1] = A`, `E0`, `E1`, and `N` about +# `E0×E1`, with `s` clamped, and its `X'`. Return `(d2, r, s, u, v)`. +@inline function _seg_tri_crossing_candidate(P0, P1, A, E0, E1, N) + P = P1 - P0 + AP0 = A - P0 + s = clamp01nan(dot3(N, AP0)/dot3(N, P)) + d2, r, u, v = _tri_face_candidate(fma3(-s, P, AP0), E0, E1, N) + d2, r, s, u, v end -@inline function dist_sqr_r_s_t(x::LineSeg{T}, y::Triangle{T}) where T - fzero = zero(T) - o = x[1] - d = x[2] - x[1] - a = y[1] - b = y[2] - c = y[3] - result = moller_trumbore_intersect(o, d, a, b, c) - if result.intersect == true - fzero, zero(Vec3{T}), SA[result.t,], SA[result.u, result.v] - else - # no intersection or seg and triangle are parallel. - # min distance is on an edge of the triangle. - d2min, minr, mins, _tmin = dist_sqr_r_s_t(x,SA[a,b]) - mint = SA[_tmin[1], fzero] - d2, _rmin, _smin, _tmin = dist_sqr_r_s_t(x,SA[b,c]) - if d2 < d2min - d2min = d2 - minr = _rmin - mins = _smin - mint = SA[one(T) - _tmin[1], _tmin[1]] - end - d2, _rmin, _smin, _tmin = dist_sqr_r_s_t(x,SA[c,a]) - if d2 < d2min - d2min = d2 - minr = _rmin - mins = _smin - mint = SA[fzero, one(T) - _tmin[1]] - end - d2, _rmin, _, _tri_tmin = dist_sqr_r_s_t(SA[x[1],],y) - if d2 < d2min - d2min = d2 - minr = _rmin - mins = SA[fzero] - mint = _tri_tmin - end - d2, _rmin, _, _tri_tmin = dist_sqr_r_s_t(SA[x[2],],y) - if d2 < d2min - d2min = d2 - minr = _rmin - mins = SA[one(T)] - mint = _tri_tmin - end - d2min, minr, mins, mint - end +# The weights of vertices 2 and 3 of a triangle at the point +# `(1 - w)*V[i] + w*V[j]` on an edge, with `i < j`, or at `V[i]` with +# `i == j` and `w = 0`. +@inline function _tri_weights(i, j, w) + z = zero(w) + wi = one(w) - w + SVector(ifelse(i == 2, wi, ifelse(j == 2, w, z)), ifelse(i == 3, wi, ifelse(j == 3, w, z))) end @inline function dist_sqr_r_s_t(x::Triangle{T}, y::Triangle{T}) where T - fzero = zero(T) - fone = one(T) - d2, minr, _s, mint = @inline dist_sqr_r_s_t(SA[x[1],x[2]], y) - _s_all = SA[fzero, _s[1], fone - _s[1]] - mins = SA[_s_all[2], _s_all[1]] - iszero(d2) && return d2, minr, mins, mint - ai = 2 - bi = 3 - ci = 1 - for i in 2:3 - this_d2, _r, _s, _t = @inline dist_sqr_r_s_t(SA[x[ai],x[bi]], y) - if this_d2 < d2 - d2 = this_d2 - minr = _r - _s_all = SA[fzero, _s[1], fone - _s[1]] - mins = SA[_s_all[bi], _s_all[ai]] - mint = _t - end - iszero(d2) && return d2, minr, mins, mint - ai, bi, ci = bi, ci, ai + Ax = x[1] + E0x = x[2] - Ax + E1x = x[3] - Ax + Nx = cross3_accurate(E0x, E1x) + Ay = y[1] + E0y = y[2] - Ay + E1y = y[3] - Ay + Ny = cross3_accurate(E0y, E1y) + z = zero(T) + edges = ((1, 2), (1, 3), (2, 3)) + # A pair of vertices, to start. + r = Ay - Ax + best = (dot3(r, r), r, SVector(z, z), SVector(z, z)) + # The closest points of the pairs of edges. + for (i, j) in edges, (k, l) in edges + d2, r, s, t = dist_sqr_r_s_t(SA[x[i], x[j]], SA[y[k], y[l]]) + best = _min_d2_r_s_t(best, (d2, r, _tri_weights(i, j, s[1]), _tri_weights(k, l, t[1]))) end - for i in 1:3 - this_d2, _r, _s, _t = @inline dist_sqr_r_s_t(SA[y[ai],y[bi]], x) - if this_d2 < d2 - d2 = this_d2 - minr = -_r - _s_all = SA[fzero, _s[1], fone - _s[1]] - mint = SA[_s_all[bi], _s_all[ai]] - mins = _t - end - iszero(d2) && return d2, minr, mins, mint - ai, bi, ci = bi, ci, ai + # The vertices of each with the face of the other. + for k in 1:3 + d2, r, u, v = _tri_face_candidate(Ay - x[k], E0y, E1y, Ny) + best = _min_d2_r_s_t(best, (d2, r, _tri_weights(k, k, z), SVector(u, v))) + d2, r, u, v = _tri_face_candidate(Ax - y[k], E0x, E1x, Nx) + best = _min_d2_r_s_t(best, (d2, -r, SVector(u, v), _tri_weights(k, k, z))) end - return d2, minr, mins, mint + # Last, so a tie keeps an exact weight. The edges of each with the face + # of the other. + for (i, j) in edges + d2, r, s, u, v = _seg_tri_crossing_candidate(x[i], x[j], Ay, E0y, E1y, Ny) + best = _min_d2_r_s_t(best, (d2, r, _tri_weights(i, j, s), SVector(u, v))) + d2, r, s, u, v = _seg_tri_crossing_candidate(y[i], y[j], Ax, E0x, E1x, Nx) + best = _min_d2_r_s_t(best, (d2, -r, SVector(u, v), _tri_weights(i, j, s))) + end + best end + diff --git a/src/util.jl b/src/util.jl index 916a9dc..3cf6aaf 100644 --- a/src/util.jl +++ b/src/util.jl @@ -29,6 +29,31 @@ Also works on `SVector{3, SIMD.Vec{N,T}}`, bit identical to the scalar version i ) end +""" + cross3_accurate(a::Vec3, b::Vec3) + +Return the cross product of two 3D vectors, each component accurate to about +one rounding, using Kahan's algorithm for `p*q - u*v` with fused multiply adds. + +[`cross3`](@ref) is cheaper, but for nearly parallel `a` and `b` its +components cancel, with an error of about a rounding of `norm(a)*norm(b)`. + +Also works on `SVector{3, SIMD.Vec{N,T}}`, bit identical to the scalar version in each lane. +""" +@inline function cross3_accurate(a::Vec3, b::Vec3) + SVector( + _det2_accurate(a[2], b[3], a[3], b[2]), + _det2_accurate(a[3], b[1], a[1], b[3]), + _det2_accurate(a[1], b[2], a[2], b[1]), + ) +end + +# `p*q - u*v`, with the rounding of `u*v` recovered by a fused multiply add. +@inline function _det2_accurate(p, q, u, v) + uv = u*v + fma(p, q, -uv) + fma(-u, v, uv) +end + """ scale3(t, v::Vec3) scale3(v::Vec3, t) diff --git a/test/test-mindistance.jl b/test/test-mindistance.jl index 80d4a98..0c3ef5c 100644 --- a/test/test-mindistance.jl +++ b/test/test-mindistance.jl @@ -33,7 +33,7 @@ end Random.seed!(1234) -function distance_tests(as, bs; check_contact_points=true) +function distance_tests(as, bs) for i in eachindex(as, bs) local a = as[i] local b = bs[i] @@ -43,9 +43,7 @@ function distance_tests(as, bs; check_contact_points=true) @test r ⋅ r ≈ d2 atol = 1E-10 rtol = 1E-11 local s_all = [one(eltype(s)) - sum(s); s;] local t_all = [one(eltype(t)) - sum(t); t;] - if check_contact_points || !iszero(d2) - @test norm(r - (sum(b .* t_all) - sum(a .* s_all))) ≤ 1E-12 - end + @test norm(r - (sum(b .* t_all) - sum(a .* s_all))) ≤ 1E-12 @test all(≥(0), s) @test all(≥(0), t) @test sum(s) ≤ 1 @@ -67,9 +65,7 @@ end pools = [rand(SVector{3,Float64}, 4) for _ in 1:trials] as = [SVector{N}(ntuple(_ -> rand(pool), N)) for pool in pools] bs = [SVector{M}(ntuple(_ -> rand(pool), M)) for pool in pools] - # `moller_trumbore_intersect` reports line segments in the plane of a - # triangle, such as its edges, as intersecting, with arbitrary `s` and `t`. - distance_tests(as, bs; check_contact_points = min(N, M) == 1 || max(N, M) < 3) + distance_tests(as, bs) end @testset "Float32 distance with shared vertices $N to $M" for N in 1:3, M in 1:3 @@ -210,6 +206,64 @@ end end end +# Line segments in, or nearly in, the plane of a triangle beside it, and line +# segments through the face of a sliver, and triangles with those as an edge. +# Testing whether the line segment crosses the triangle, from the signs of +# triple products that are rounding errors in the plane, gave a distance of +# zero for line segments 1.7 from the triangle, about one time in 300. Through +# a sliver, `N = E0×E1` from `cross3` tilted the plane enough for errors near +# `√eps*ℓ`. +@testset "$T line segments and triangles near the plane of a triangle" for T in (Float32, Float64) + rng = Random.Xoshiro(31) + # The case 1.7 apart. + cases = Any[( + SA[SA[0.27440169104786705, -1.8352000938383282, -1.1414620437379672], SA[-0.4237474034660068, -1.74076257090927, -0.005187092841748675]], + SA[SA[-0.051006641386534685, 0.8264827967799175, 0.247674370931405], SA[0.3840271906908225, 0.44295798307158735, -0.5669754202169636], SA[-0.3934509020879434, -0.08075321479510644, 0.491917584990893]], + )] + for trial in 1:3000 + n = normalize(randn(rng, 3)) + e1 = normalize(cross(n, randn(rng, 3))) + e2 = cross(n, e1) + o = randn(rng, 3) + P(a, b, c = 0) = o .+ a .* e1 .+ b .* e2 .+ c .* n + θ = rand(rng, (0.0, 10.0^(-rand(rng)*12))) + if isodd(trial) + # A unit triangle, and a line segment beside it, tilted out of the + # plane by about θ. + yt = (P(0, 0), P(1, 0), P(0, 1)) + xt = (P(2 + rand(rng), -1 + 3rand(rng), -θ), P(-1 + 3rand(rng), 2 + rand(rng), θ)) + else + # A sliver, and a line segment through its face. + yt = (P(0, 0), P(1, 0), P(0.5 + rand(rng), 10.0^(-rand(rng)*12))) + w = rand(rng, 3) + X = sum(w ./ sum(w) .* collect(yt)) + d = normalize(randn(rng, 3)) + a = rand(rng) + xt = (X .- a .* d, X .+ (1 - a) .* d) + end + x = SVector{2}(map(v -> SVector{3, T}(v), xt)) + y = SVector{3}(map(v -> SVector{3, T}(v), yt)) + # A triangle with `x` as an edge, its third vertex 1 or θ away. + c = SVector{3, T}(xt[1] .+ ifelse(rand(rng, Bool), 1, θ) .* normalize(randn(rng, 3))) + push!(cases, (x, y), (SA[x[1], x[2], c], y)) + end + for (x, y) in cases + x = map(v -> SVector{3, T}(v), x) + y = map(v -> SVector{3, T}(v), y) + tol = 64*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) + for (a, b) in ((x, y), (y, x)) + ref = sqrt(dist_sqr(map(v -> big.(v), a), map(v -> big.(v), b))) + d2, r, s, t = dist_sqr_r_s_t(a, b) + @test abs(sqrt(d2) - ref) ≤ tol + @test all(≥(0), s) && all(≥(0), t) && sum(s) ≤ 1 && sum(t) ≤ 1 + # `r` is the separation of the points the weights give. + pa = (1 - sum(big.(s))) .* a[1] .+ sum(big.(s) .* a[2:end]) + pb = (1 - sum(big.(t))) .* b[1] .+ sum(big.(t) .* b[2:end]) + @test norm(r .- (pb .- pa)) ≤ tol + end + end +end + # Worst-case accuracy points. Each case pins one branch domain (or branch # boundary) of a distance kernel at the sharpest known input, with a # first-order error bound |√d̂² - d| ≤ u·bound(exact sizes), u = eps(T)/2, @@ -380,7 +434,7 @@ end # The grid neighbor list checks distances several pairs at a time, which # must give bit identical results to the scalar version. @testset "dist_sqr on SIMD lanes $T $N to $M" for T in (Float32, Float64), - (N, M) in ((1, 1), (1, 2), (2, 1), (1, 3), (3, 1), (2, 2)) + (N, M) in ((1, 1), (1, 2), (2, 1), (1, 3), (3, 1), (2, 2), (2, 3), (3, 2), (3, 3)) W = 8 for trial in 1:500 as = ntuple(_ -> rand_simplex(T, N), Val(W)) From 166140be6d048cdbdbbc35d2f7976fd0fdf48539 Mon Sep 17 00:00:00 2001 From: nhz2 Date: Thu, 29 Oct 2026 12:48:59 -0400 Subject: [PATCH 11/11] Tighten the distance contract and propagate NaN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State c₁ = 8 and c₂ = 1 in the accuracy bound, say exactly how d² follows from r and how close r is to the separation the weights give, and require a NaN d² for any NaN coordinate. The running minimum now keeps a NaN d². Tests: tolerances down from 64 to 8 eps, the old worst-case table renamed to hard_points with stale comments removed, and new testsets for coordinates near the top of the range and for NaN coordinates. Co-Authored-By: Claude Opus 5.5 --- src/distances.jl | 40 +++++------ test/test-mindistance.jl | 145 +++++++++++++++++++++++---------------- 2 files changed, 107 insertions(+), 78 deletions(-) diff --git a/src/distances.jl b/src/distances.jl index 5c65f6c..834c0c0 100644 --- a/src/distances.jl +++ b/src/distances.jl @@ -3,25 +3,24 @@ Return a tuple `(d², r, s, t)` of: 1. `d²`: the minimum squared distance between the two simplexes. -2. `r`: the minimum separation vector, pointing from the closest point on `x` to the closest point on `y`. -3. `s`: the barycentric coordinates of the closest point on `x`. -4. `t`: the barycentric coordinates of the closest point on `y`. +2. `r`: the minimum separation vector, pointing from a closest point on `x` to a closest point on `y`. +3. `s`: the barycentric coordinates of a closest point on `x`. +4. `t`: the barycentric coordinates of a closest point on `y`. `s` holds the barycentric weights of vertices `x[2:N]`; the weight of `x[1]` -is implied as `1 - sum(s)`, so the closest point on `x` is: +is implied as `1 - sum(s)`, so a closest point on `x` is: (1 - sum(s))*x[1] + sum(s[i]*x[i+1] for i in 1:N-1) Each weight is in `[0, 1]` with `sum(s) ≤ 1`. For a `Point`, `s` is empty; -for a `LineSeg`, the closest point is `(1 - s[1])*x[1] + s[1]*x[2]`. +for a `LineSeg`, a closest point is `(1 - s[1])*x[1] + s[1]*x[2]`. `t` works the same way for `y`. """ function dist_sqr_r_s_t end #= -What a `dist_sqr_r_s_t` method must do to be correct, for an element type `T` -(or `SIMD.Vec` lanes of it). Here `d` is the exact distance between the -simplexes as given, in `T`, and `ℓ` is the largest absolute coordinate. +What a `dist_sqr_r_s_t` method must do to be correct, for an element type `T`. Here `d` is the exact distance between the +simplexes as given, and `ℓ` is the largest absolute coordinate. For finite coordinates below `(floatmax(T)^(1/4))/4` (1e9 in `Float32`, 3e76 in `Float64`): @@ -30,22 +29,23 @@ For finite coordinates below `(floatmax(T)^(1/4))/4` (1e9 in `Float32`, (repeated vertices, zero length edges, collinear triangles). 2. Each weight in `s` and `t` is in `[0, 1]`, and the computed `sum(s)` and `sum(t)` are at most one. -3. `r` is about the separation between the points the weights give, and `d²` - is about `dot(r, r)`. Neither needs to be exact. -4. `|√d² - d| ≤ c₁*eps(T)*ℓ + c₂*floatmin(T)^(1/4)`, with `c₁` a small - constant (the tests allow 64 right now). - This holds for any shape, including slivers and nearly parallel line - segments: the error must not grow like one over an angle. +3. `d²` is computed from `r` alone, the same way in every method: + `fma(r[3], r[3], fma(r[2], r[2], r[1]*r[1]))`. +4. `|√d² - d| ≤ 8*eps(T)*ℓ + floatmin(T)^(1/4)`. +5. With `X(s)` and `Y(t)` the points the weights give, computed exactly, + `‖r - (Y(t) - X(s))‖` is within the same bound. The `floatmin(T)^(1/4)` term (3e-10 in `Float32`, 1e-77 in `Float64`) is only for when something underflows. Then accuracy may be lost, but only by that much. -Above `floatmax(T)^(1/4)` anything goes. The methods must also be branch -free, with each `SIMD.Vec` lane bit identical to the scalar method. +Above `(floatmax(T)^(1/4))/4` anything goes, except that for any +coordinates, if one is `NaN`, `d²` is `NaN`. -`tiny_normal_cases` and `worst_case_points` in test/test-mindistance.jl check -this at the edges of the range and at the worst case points. +The methods must also accept `SIMD.Vec` elements and be bit identical to the scalar method. + +`tiny_normal_cases` and `hard_points` in test/test-mindistance.jl partially check +this at the edges of the range and at hard points. =# """ @@ -85,8 +85,8 @@ end end # Branch free update of the running minimum `best` with `cand`. -# Ties keep the earlier minimum. -@inline _min_d2_r_s_t(best, cand) = _select_d2_r_s_t(cand[1] < best[1], cand, best) +# Ties keep the earlier minimum. A NaN `d2` is kept, so a NaN coordinate gives a NaN `d2`. +@inline _min_d2_r_s_t(best, cand) = _select_d2_r_s_t((cand[1] < best[1]) | isnan(cand[1]), cand, best) # The methods diff --git a/test/test-mindistance.jl b/test/test-mindistance.jl index 0c3ef5c..d47b585 100644 --- a/test/test-mindistance.jl +++ b/test/test-mindistance.jl @@ -192,7 +192,7 @@ end for (x, y) in cases x = map(v -> SVector{3, T}(v), x) y = map(v -> SVector{3, T}(v), y) - tol = 64*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) + tol = 8*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) for (a, b) in ((x, y), (y, x)) ref = sqrt(dist_sqr(map(v -> big.(v), a), map(v -> big.(v), b))) d2, r, s, t = dist_sqr_r_s_t(a, b) @@ -250,7 +250,7 @@ end for (x, y) in cases x = map(v -> SVector{3, T}(v), x) y = map(v -> SVector{3, T}(v), y) - tol = 64*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) + tol = 8*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) for (a, b) in ((x, y), (y, x)) ref = sqrt(dist_sqr(map(v -> big.(v), a), map(v -> big.(v), b))) d2, r, s, t = dist_sqr_r_s_t(a, b) @@ -264,91 +264,69 @@ end end end -# Worst-case accuracy points. Each case pins one branch domain (or branch -# boundary) of a distance kernel at the sharpest known input, with a -# first-order error bound |√d̂² - d| ≤ u·bound(exact sizes), u = eps(T)/2, -# evaluated at the point and rounded up ~1.3x as margin for how the sizes -# drift under the noise below. -# Each point is tested as given, then with Gaussian coordinate noise of a few -# ulps: enough to scramble every rounding, and at the boundary points to make -# the branch actually flip (their tolerance is the traced all-domain bound, -# which includes the branch jump terms). -const worst_case_points = [ - # point-point: 5u/2·d, attained when one coordinate carries the separation. - ("point-point, one coordinate separation", 7.0, +# Hard points: one coordinate carrying the separation, separations that +# cancel, nearly parallel line segments, and points on the border between +# two closest features, where rounding picks either. Each is tested as given, +# then with Gaussian noise of a few ulps on the coordinates, which scrambles +# the roundings and moves the border points to either side. +const hard_points = [ + ("point-point, one coordinate separation", ((-1.0, 0.03, -0.02),), ((1.0, 0.06, 0.03),)), - ("point-point, balanced coordinates", 11.0, + ("point-point, balanced coordinates", ((-0.95, -1.0, 0.9),), ((0.9, 0.85, -1.0),)), - # point-line: t clamped to 0 is exactly point-point (5u/2·d); the interior - # arm has the traced bound u(9‖p‖+3‖r‖+3/2·d); t clamped to 1 computes - # fl(p+r), u(‖p‖+‖r‖+5/2·d), tight when p+r cancels; the t* = 0 and t* = 1 - # boundary points have exactly zero conditions built from cancelling - # order-1 products, so rounding picks either arm. - ("point-line, t clamped to 0", 7.0, + ("point-line, closest at the first end point", ((1.0, 0.12, -0.03),), ((-1.0, 0.0, 0.0), (-1.0, -1.0, 0.0))), - ("point-line, interior", 38.0, + ("point-line, closest inside", ((1.0, 0.0, 0.05),), ((-1.0, 1.0, 0.0), (-1.0, -1.0, 0.0))), - ("point-line, interior, aspect 10", 29.0, + ("point-line, closest inside a short line segment", ((1.0, 0.0, 0.0),), ((-1.0, 0.1, 0.02), (-1.0, -0.1, 0.02))), - ("point-line, t clamped to 1, p + r cancels", 8.0, + # The second end point is near the point, so `y[2] - x` cancels. + ("point-line, closest at the second end point", ((1.0, 1.0, 0.04),), ((-1.0, -1.0, 0.0), (0.98, 0.98, 0.0))), - ("point-line, boundary t* = 0", 35.0, + ("point-line, border at the first end point", ((1.0, 1.0, 0.0),), ((-1.0, 0.0, 0.0), (-0.5, -1.0, 0.0))), - ("point-line, boundary t* = 1", 26.0, + ("point-line, border at the second end point", ((0.5, 0.5, 0.0),), ((-1.0, 1.0, 0.0), (0.0, 0.0, 0.0))), - # point-triangle: the face arm claim is u(6‖BP‖+‖E0‖+‖E1‖+8d); the tilted - # triangle makes N generic, so the terms of hN = dot3(BP, N) are order 0.5 - # and cancel — with a z = 0 triangle (as in the testsets above) hN is a - # single product and this error path is never exercised. Edges and - # vertices reuse the point-line rows on (BP, E) with rounded-sum charges. - ("point-triangle, face of a tilted triangle", 17.0, + # A tilted triangle, so no product in `N` or `N⋅(X - A)` is exact. + ("point-triangle, over the face of a tilted triangle", ((-0.01, 0.01, 0.29),), ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1))), - ("point-triangle, near face of a tilted triangle, hN cancels", 13.0, + ("point-triangle, near the face of a tilted triangle", ((-0.09, 0.03, 0.02),), ((-0.9, -0.6, 0.2), (0.8, -0.2, -0.3), (-0.2, 0.9, 0.1))), - ("point-triangle, edge 0 interior", 23.0, + ("point-triangle, closest inside edge AB", ((0.2, -0.9, 0.4),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), - ("point-triangle, edge 2 interior", 24.0, + ("point-triangle, closest inside edge BC", ((0.56, 0.59, 0.3),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), - ("point-triangle, vertex a", 1.7, + ("point-triangle, closest at vertex A", ((-1.0, -0.95, 0.35),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), - ("point-triangle, vertex b", 6.5, + ("point-triangle, closest at vertex B", ((1.0, -0.45, 0.3),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), - # x over the midpoint of edge 0: the tbar numerator of the inside test is - # exactly zero, so rounding decides face vs edge. - ("point-triangle, inside-test boundary", 164.0, + ("point-triangle, border of the face and edge AB, over its midpoint", ((-0.05, -0.4, 0.6),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), - # x exactly on the u0 = 1 plane through b: edge 0 vs vertex b flips. - ("point-triangle, clamp boundary at vertex b", 192.0, + ("point-triangle, border of edge AB and vertex B", ((0.88, -0.54, 0.3),), ((-0.9, -0.6, 0.0), (0.8, -0.2, 0.0), (-0.2, 0.9, 0.0))), - # line-line: the crossing arm claim is u(6‖W‖+‖P‖+‖Q‖+8d) with no 1/sinθ; - # the near parallel point (sinθ ≈ 0.008, where the Gram form a*c - b*b - # loses ~125x) must meet the same bound as the generic crossing. Edge and - # corner rows reuse the point-line rows on (W, P), (W, Q), etc. - ("line-line, generic crossing", 17.0, + ("line-line, crossing", ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), ((-0.3, -1.0, 0.25), (0.3, 1.0, 0.25))), - ("line-line, nearly parallel crossing", 8.0, + ("line-line, nearly parallel crossing", ((-1.0, -0.004, 0.0), (1.0, 0.004, 0.0)), ((-1.0, 0.004, 0.15), (1.0, -0.004, 0.15))), - ("line-line, s interior, t = 0", 25.0, + ("line-line, inside x and at the first end point of y", ((-1.0, -0.2, 0.0), (1.0, 0.2, 0.0)), ((0.1, 0.3, 0.3), (0.9, 0.9, 0.5))), - ("line-line, s = 0, t interior", 10.5, + ("line-line, at the first end point of x and inside y", ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), ((-0.31, -0.67, 0.25), (-0.73, -0.11, 0.25))), - ("line-line, corner (0,0)", 2.2, + ("line-line, at the first end points", ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), ((-0.7, -0.8, 0.3), (-1.0, -0.9, 0.5))), - # Q crosses exactly over P0: the sbar numerator of the inside test is zero. - ("line-line, inside-test boundary", 66.0, + ("line-line, border with y crossing over the first end point of x", ((-0.8, -0.6, 0.0), (0.8, 0.6, 0.0)), ((-0.68, -0.76, 0.25), (-0.92, -0.44, 0.25))), - # (Q0 - P0) ⊥ Q exactly: the t* = 0 clamp boundary in the s* = 0 region. - ("line-line, clamp boundary at corner (0,0)", 53.0, + ("line-line, border of the first end points and inside y", ((-0.4, -0.3, 0.0), (0.56, 0.42, 0.0)), ((-0.52, -0.39, 0.25), (-0.82, 0.01, 0.25))), ] -@testset "worst-case point: $name" for (name, tolu, xt, yt) in worst_case_points +@testset "hard point: $name" for (name, xt, yt) in hard_points rng = Random.Xoshiro(2026) for T in (Float32, Float64) - tol = tolu*eps(T)/2 for σ in (0, 8, 64, 1024) .* eps(T), _ in 1:(iszero(σ) ? 1 : 100) x = SVector(map(v -> SVector{3, T}(v .+ σ .* randn(rng, 3)), xt)) y = SVector(map(v -> SVector{3, T}(v .+ σ .* randn(rng, 3)), yt)) + tol = 8*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) ref = sqrt(dist_sqr(map(v -> big.(v), x), map(v -> big.(v), y))) @test abs(sqrt(dist_sqr(x, y)) - ref) ≤ tol @test abs(sqrt(dist_sqr(y, x)) - ref) ≤ tol @@ -407,7 +385,7 @@ const tiny_normal_cases = [ y = SVector(map(v -> SVector{3, T}(v), yt)) ref = sqrt(dist_sqr(map(v -> big.(v), x), map(v -> big.(v), y))) # A few roundings of the largest coordinate, plus the underflow floor. - tol = 64*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) + floatmin(T)^(1/4) + tol = 8*eps(T)*maximum(v -> maximum(abs, v), (x..., y...)) + floatmin(T)^(1/4) @test abs(sqrt(dist_sqr(x, y)) - ref) ≤ tol @test abs(sqrt(dist_sqr(y, x)) - ref) ≤ tol end @@ -443,4 +421,55 @@ end @test all(l -> d2[l] === dist_sqr(as[l], bs[l]), 1:W) end end + +# Coordinates just below `floatmax(T)^(1/4)/4`, the top of the range, where +# `N⋅N` and the triple products are near `floatmax`: the hard points scaled +# up, and `rand_simplex` scaled up, half of them with coordinates in +# `(-L, 0, L)`, where the edges are longest, and repeated vertices, parallel +# edges, and coplanar triangles are common. +@testset "$T near the top of the range" for T in (Float32, Float64) + L = prevfloat(floatmax(T)^T(1/4)/4) + cases = Any[] + for (name, xt, yt) in hard_points + push!(cases, (SVector(map(v -> SVector{3, T}(L .* v), xt)), SVector(map(v -> SVector{3, T}(L .* v), yt)))) + end + for N in 1:3, M in 1:3, _ in 1:300 + push!(cases, (rand_simplex(T, N) .* L, rand_simplex(T, M) .* L)) + end + for (x, y) in cases, (a, b) in ((x, y), (y, x)) + d2, r, s, t = dist_sqr_r_s_t(a, b) + @test isfinite(d2) && all(isfinite, r) + @test all(≥(0), s) && all(≥(0), t) && sum(s) ≤ 1 && sum(t) ≤ 1 + tol = 8*eps(T)*maximum(v -> maximum(abs, v), (a..., b...)) + ref = sqrt(dist_sqr(map(v -> big.(v), a), map(v -> big.(v), b))) + @test abs(sqrt(d2) - ref) ≤ tol + pa = (1 - sum(big.(s))) .* a[1] .+ sum(big.(s) .* a[2:end]; init = zero(SVector{3, BigFloat})) + pb = (1 - sum(big.(t))) .* b[1] .+ sum(big.(t) .* b[2:end]; init = zero(SVector{3, BigFloat})) + @test norm(r .- (pb .- pa)) ≤ tol + end +end + +# A NaN coordinate anywhere gives a NaN `d²`, whatever the candidate it would +# lose to, also in SIMD lanes next to lanes without NaN. The weights stay in +# range. +@testset "$T NaN coordinate $N to $M" for T in (Float32, Float64), N in 1:3, M in 1:3 + for _ in 1:20, i in 1:(N + M), k in 1:3 + x = rand_simplex(T, N) + y = rand_simplex(T, M) + if i ≤ N + x = setindex(x, setindex(x[i], T(NaN), k), i) + else + y = setindex(y, setindex(y[i - N], T(NaN), k), i - N) + end + for (a, b) in ((x, y), (y, x)) + d2, r, s, t = dist_sqr_r_s_t(a, b) + @test isnan(d2) + @test all(w -> 0 ≤ w ≤ 1, s) && all(w -> 0 ≤ w ≤ 1, t) + as = (a, ntuple(_ -> rand_simplex(T, length(a)), Val(7))...) + bs = (b, ntuple(_ -> rand_simplex(T, length(b)), Val(7))...) + d2s = dist_sqr(simplex_lanes(as), simplex_lanes(bs)) + @test isnan(d2s[1]) && all(l -> d2s[l] === dist_sqr(as[l], bs[l]), 2:8) + end + end +end nothing