Skip to content

Math correctness foundation - #147

Merged
nnewson merged 5 commits into
mainfrom
math-correctness-foundation
Sep 13, 2026
Merged

nnewson merged 5 commits into
mainfrom
math-correctness-foundation

Conversation

@nnewson

@nnewson nnewson commented Sep 13, 2026

Copy link
Copy Markdown
Owner

No description provided.

Every approximate comparison in the engine — VecBase, Mat3, Mat4,
Quaternion — was the same four lines:

    const float diff = a - b;
    if (diff > eps || diff < -eps) { return false; }

Both comparisons are false when diff is NaN, so a NaN compared
approximately equal to anything, itself included. Two equal infinities
subtract to NaN and passed the same way, which reads as correct until
you notice that opposite infinities do too. A test asserting a transform
had stayed finite would pass on a transform that was entirely NaN:
precisely the failures tests exist to catch, hidden by the thing
catching them.

math/scalar.hpp is now the only place that decides whether two numbers
are close. It asks `a == b` first, so identical values are equal
whatever they are — including equal infinities, which the old form got
right by accident rather than by rule. Any remaining non-finite operand
is unequal. Finite values compare against the larger of an absolute and
a relative tolerance, in double intermediates: `a - b` in float
overflows for values near FLT_MAX of opposite sign, so the difference
itself could be non-finite for two perfectly finite inputs and the
comparison would answer about a number neither caller mentioned.

THREE OVERLOADS, because a tolerance a caller spells out must mean what
it says. No tolerance takes both defaults; one tolerance is ABSOLUTE
ONLY, exactly the historical comparison; two are both terms, stated. A
single function with a defaulted relative term would have made
approxEqual(rhs, 1e-9f) admit a 1e-7 difference at magnitude 1.0 — an
explicit tolerance silently overridden by an implicit one. The existing
strictness tests are unchanged, which is the evidence that the overloads
did their job.

TOLERANCES ARE VALIDATED, not reinterpreted. A negative, NaN or infinite
tolerance returns false, and the check runs before the `a == b` shortcut
so equal operands cannot hide it. Treating a negative as "a term that
cannot be satisfied" is mathematically defensible and diagnostically
useless: it converts a broken configuration into a different valid
policy and lets the run continue looking healthy.

float_relative_epsilon is its own constant. float_epsilon was already
serving as absolute tolerance and degeneracy threshold; a third job
would have made it a number nobody could change safely.
Mat3::inverse() failed by returning the zero matrix — both a legitimate
value and an error report, so a caller could not tell "singular" from
"the answer is zero" without asking again. Its threshold was ABSOLUTE
(|det| <= 1e-12), which rejects transforms that are perfectly well
conditioned merely for being small: a uniform scale of 1e-5 has
determinant 1e-15 and an exact inverse of 1e5, and the old test called
it singular.

VDPM had already worked around that with its own scale-invariant
predicate, then called the absolute-threshold inverse anyway — so
`coneUsable` reported true while `cameraObj` was a zero matrix times a
camera offset. Two invertibility decisions, disagreeing, in adjacent
lines.

tryInverse() returns std::optional<Mat3> and is the only one. It
normalises the matrix by its largest absolute component before taking
the determinant, so the question asked is conditioning rather than size;
it computes in double; it refuses non-finite input rather than producing
a matrix of NaNs that every later operation spreads silently.

Two things the optional means that a determinant test alone does not. An
invalid tolerance is refused before the matrix is examined — `magnitude
> tolerance` is TRUE for a zero determinant against a negative
threshold, so a singular matrix would otherwise be accepted and then
divided by its own zero. And every element is checked finite and within
float's range in double BEFORE any conversion: a uniform scale of 1e-39
is perfectly conditioned and its inverse is 1e39, which float cannot
hold, so converting would hand back an engaged optional full of
infinities. The optional means "a representable inverse exists" or it
means nothing.

determinant() now returns double, and VDPM is why. A reflected instance
with a tiny uniform scale has a usable inverse and a determinant that
underflows float to -0.0f — which is not less than zero, so the facing
sign says "winding preserved" for a transform that reverses it, and the
cone culls the side that should be visible. The threshold for the
underflow is lower than it looks: 1e-5 cubed is fine, 1e-16 cubed is
not.

VDPM's conditioning POLICY is reproduced exactly rather than inherited.
tryInverse normalises by largest component while the old predicate used
σ_max, and the two differ by (σ_max/maxComponent)³ ∈ [1, 27]; passing a
constant would have relaxed the policy by up to 27× (or 27000× with the
permissive default), and a blanket conservative constant would have
REJECTED shapes the old test accepted. Scaling the tolerance by that
exact ratio gives |det| > 1e-6·σ_max³ again — one decision, same answer,
both sides of the boundary pinned by tests.

The three physics inversions — the D block and Schur complement inside
SpatialMatrix::inverse(), and invertDof's 3-DOF path — have a genuine
invariant, so they go through a physics-local invertInvariant(): assert,
and in release a logged reason and abort. An assert alone would leave
*inverse dereferencing an empty optional in exactly the situation where
the solver state is already corrupt. The helper stays in physics
deliberately; in math/ it would recreate the unconditional inversion API
this removes.

Note for anyone reading the goldens: Determinism.GoldenHash builds a
floor and three rigid boxes and never constructs an articulation, so it
does not exercise these paths. The articulation suite is the relevant
coverage.
bitwiseEqual() was `return self() == rhs` — ordinary floating-point
comparison wearing a name that promised bit patterns. It was wrong in
both directions it could be: -0.0f and +0.0f have different bits and
compare EQUAL, while a NaN has bits identical to itself and compares
UNEQUAL. It also duplicated operator== exactly, so it offered a choice
between two spellings of one thing, one of which lied.

Nothing outside math/ ever called it. Deleted rather than corrected: a
method that means what the operator means earns its place only by being
clearer, and this one was less clear.

The comments claiming "strict bit-for-bit equality" are corrected on all
four types — including Mat3, which made the claim without the method —
and now state the two IEEE cases outright, plus what a caller would
actually need if a determinism diagnostic ever wants real bit comparison
(std::bit_cast, explicitly, at the point that needs it).

The three tests were named BitwiseEqualMatchesOperator and asserted that
the method agreed with the operator it forwarded to, which could not
fail. They now assert the semantics the corrected comments claim: signed
zeros compare equal, NaNs do not compare equal to themselves, and — for
Quaternion — q and -q compare unequal despite being the same rotation,
which is the seam the rotation redesign in phase 2 has to close.
sqrt(dot(v, v)) fails at both ends. Components above ~1.8e19 square to
infinity, so a vector whose length is perfectly representable reports
infinity and then normalises to zero; components below ~1e-22 square to
zero, so a small but ordinary vector reports a length of zero and loses
its direction entirely. Neither case announces itself as a failure —
both return a plausible number.

Both failures DO announce themselves in the sum, which comes back
infinite, NaN, zero, or subnormal. So the naive sum is computed first
and trusted whenever it is finite and NORMAL, and the scaled fallback
runs only where there was no correct answer before it. That ordering is
the whole design:

- Ordinary vectors take the arithmetic the engine always used, bit for
bit. Neither physics golden moves. A change about extreme values has no
business perturbing a box stack, and this one does not.
- The robust path is confined to inputs that were already wrong.

Normal, not merely positive, and that distinction is the difference
between covering the underflow region and covering only its endpoint. A
sum that has gone subnormal is finite and nonzero and has already lost
most of its precision: (3e-23, 3e-23, 0) sums to 2.8e-45, carries about
two significant bits, and answered 5.29e-23 against a true 4.24e-23 —
24.8% high, from a fast path that believed itself safe because the sum
looked usable. A finite subnormal sum does reach the scaled fallback in
normalise() as well; it simply cannot produce a normalised result,
because such a magnitude is necessarily below float_normalise_cutoff and
the configured degenerate value comes back instead.

normalise() divides ONCE per component wherever the length is
representable. The first version of this commit divided twice — by the
largest component, then by a scaled norm — which is an extra ulp per
component and looked harmless. It delayed a settling box stack from step
169 to 425 on macOS and from 167 to 1309 on Linux, against a 600-step
budget, so the sleep demo failed on Linux only and looked for all the
world like a marginal test. It was not marginal: friction directions
that no longer cancel keep feeding the contact solver. A solver notices
an ulp; that is what a solver is.

The two-step form survives only for the case that needs it — a vector of
finite components whose norm exceeds float's range, where dividing by
the (correctly infinite) length would answer zero and throw away an
ordinary direction.

Three answers from normalise, and the middle one is the one that
matters: a non-finite component yields NaNs, VISIBLY invalid; a
magnitude below float_normalise_cutoff yields the zero vector or the
identity rotation, as it always has; anything else is normalised.
Laundering a corrupt input into the degenerate answer would turn a NaN
orientation into a confident "no rotation" and surface it three seconds
later somewhere unrelated.

float_normalise_cutoff is its own constant at the same numeric value
float_epsilon had. One number was serving as absolute comparison
tolerance and degeneracy threshold at once; the rename moves no
behaviour and stops the next person changing one policy while meaning
the other.

Measured, 2M calls: Quaternion::normalise 2.39 -> 2.49 ms (parity),
Vec3::magnitude 3.27 -> 5.59, Vec3::normalise 2.68 -> 4.07. The residual
on Vec3 is the guard branch defeating vectorisation in a synthetic
back-to-back loop; swapping isfinite for a bare comparison measured
identically (5.67 vs 5.59), so the clearer form stayed. Engine-level
physics workloads are unchanged.
Demos.Sleep checked that everything was asleep by step 600. That
deadline was generous enough never to flake and therefore generous
enough to hide a regression: when the tier-0 norm work added an ulp of
error per component, the settle time went from step 169 to 425 on macOS
and the test still passed, with 175 steps to spare. Only Linux failed,
because its trajectory was slower still — 1309 — and it looked exactly
like a marginal test on a slow platform. The obvious response, widening
the budget, would have buried the defect permanently.

The step count is now the evidence. The test records the first
post-impact step at which the whole island — the three-box stack AND the
striker — is asleep, and bounds it at 260.

That number is measured, not chosen for comfort: healthy runs settle at
167 (macOS/arm64) and 172 (Linux/x86_64), so 260 leaves better than 50%
headroom over the slower platform while sitting decisively below the 425
a real regression produced. The gap between those two figures is what
makes the assertion worth having; a bound inside it can distinguish
them.

It then steps a further 120 and re-checks, which separates "crossed the
sleep threshold once" from "came to rest". A body oscillating around the
threshold satisfies the first and fails the second.

Widen the bound only with a measurement showing the engine legitimately
got slower, and never to make a red test green — the comment in the test
says so, because that is exactly the pressure this assertion will apply.

The count is in ABSOLUTE simulation steps, and that is not incidental. Every
figure here and in the branch history — 167, 172, 425, 1309 — is an absolute
step index, so a loop counting from the end of the impact window would mean
something quite different: 260 iterations starting after step 150 is a bound
of 410, which is above the regression this test exists to catch. It reported
settling at 19 where every measurement says 169. The loop therefore runs
from kImpactWindowEnd + 1 to kSettleByStep and reports the absolute step.

Also closes tier-0 phase 1 in the docs: docs/codereview.md records the
resolution against findings 1, 2, 4 and 5 as five commits (this one
included, since it exists only because of how the norms regression was
caught), and docs/roadmap.md drops phase 1 entirely — it indexes open
work, and this phase is no longer open.
@nnewson
nnewson merged commit 460f0ae into main Sep 13, 2026
5 checks passed
@nnewson
nnewson deleted the math-correctness-foundation branch September 13, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant