Skip to content

Deterministic, cross-platform-reproducible integration tests (WMTK_FP_STRICT) - #949

Open
danielepanozzo wants to merge 23 commits into
mainfrom
danielepanozzo/integration-test-hashes
Open

Deterministic, cross-platform-reproducible integration tests (WMTK_FP_STRICT)#949
danielepanozzo wants to merge 23 commits into
mainfrom
danielepanozzo/integration-test-hashes

Conversation

@danielepanozzo

@danielepanozzo danielepanozzo commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Deterministic, cross-platform-reproducible integration tests

Adds a golden-hash integration-test framework and the determinism fixes needed to make the integration outputs bit-identical across OS, compiler, and CPU architecture (macOS arm64/libc++, Linux x86-64/libstdc++, Windows MSVC), gated behind a new WMTK_FP_STRICT CMake option so normal builds pay nothing for it.

All 32 integration tests are verified byte-identical on macOS, Linux, and Windows by CI.

The framework

  • tests/integration_hashes.py — runs each blessed test through wmtk_app single-threaded, sha256s its outputs, and checks them against reference hashes stored inside data/integration_tests/integration_tests.json (schema: bare string = unblessed, {"file","hashes"} = blessed). Subcommands: generate / check / list.
  • tests/README.md — how to (re)generate hashes, the schema, and the data2 workflow.
  • Registered as the integration_hashes ctest, only when WMTK_FP_STRICT=ON (a default build's fast paths are intentionally not cross-OS reproducible). The Release integration CI jobs build with -DWMTK_FP_STRICT=ON so the check runs on all three OSes.
  • Output hashes normalize line endings for text files, so a Windows .obj (written in text mode → CRLF) matches the Unix output; binary outputs (.msh/.vtu) are hashed verbatim.

WMTK_FP_STRICT (new, OFF by default)

Global, applied before any dependency is configured:

  • -ffp-contract=off -fno-fast-math (GCC/Clang) / /fp:strict (MSVC) — kills FMA-contraction divergence.
  • EIGEN_DONT_VECTORIZE — fixes SIMD reduction order (ARM NEON vs x86 SSE/AVX differ ~1 ULP).
  • Defines the WMTK_FP_STRICT macro, which switches the wrappers/paths below from fast to deterministic.

Determinism root causes fixed

Each was a fast-but-non-reproducible operation; the FP/sort ones are gated so the default build keeps the faster path:

  • libm transcendentals (cbrt, atan2) are not correctly-rounded and differ across platforms → wmtk::cbrt/atan2/atan wrappers (Transcendentals.hpp) use std:: by default, hand-rolled deterministic versions (IEEE ops only) under strict. The winding number is a direct solid-angle sum (copied from libigl, MPL-2.0) reduced to that one atan2 gate.
  • TetMesh::get_edges — non-stable std::sort left std::unique a different representative edge per STL, flipping the whole 3D scheduler → stable_sort under strict.
  • igl::sortrows in the input vertex dedup — non-stable sort picked a different survivor per STL for vertices merged within the 2e-3 grid (this is why octocat diverged but sphere didn't) → deterministic dedup under strict.
  • sort_edges_by_length (topological_offset) — equal-length edges split in an implementation-defined order → simplex::Edge tie-break under strict.
  • 2D constrained DelaunayGEO::ExactCDT2d + canonical triangle ordering.
  • manifold_extraction_3dTopoOffsetTetMesh::m_has_envelope was never initialized when the caller passed an empty envelope, and write_msh_groups branches on it to decide whether to emit an EnvelopeSurface group; the garbage value was ASLR-dependent, so the output entity partition (3 vs 4 node blocks) varied run-to-run. Found with valgrind --track-origins; fixed by initializing the member (an unconditional correctness fix, not gated).

Performance

Crown model (76k verts / 171k faces), tetwild, strict (deterministic) vs default (fast) build: negligible overhead (macOS +1.4%; on Linux the two builds' different convergence dominates the measurement). cbrt/atan2 are a tiny fraction of runtime and EIGEN_DONT_VECTORIZE barely affects the small fixed-size matrices.

Notes for review

🤖 Generated with Claude Code

danielepanozzo and others added 18 commits July 18, 2026 20:01
Adds an opt-in, reproducible integration-test check on top of the existing
integration test list (data/integration_tests/integration_tests.json):

* tests/integration_hashes.py drives each test through wmtk_app in an isolated
  working directory, forcing single-threaded execution, and SHA-256 hashes every
  output file. `generate` blesses tests (stores hashes in integration_tests.json);
  `check` re-runs and verifies, exiting non-zero on any mismatch. Entries in the
  test list may be a plain string (not yet blessed; run for termination only) or
  an object {"file", "hashes"} (blessed; hash-checked), so tests can be added to
  the golden set one at a time.
* WMTK_FP_STRICT CMake option compiles the toolkit with no FMA contraction /
  no fast-math (MSVC /fp:strict) so outputs are bit-identical across compilers
  and operating systems.
* The `integration_hashes` ctest runs the blessed checks (Python3 + wmtk_app).

Golden hashes live in the data2 repo's integration_tests.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e hashes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-arch testing (macOS-arm64 vs Linux-x86_64) showed that -ffp-contract=off
alone leaves ~1 ULP differences because ARM (NEON) and x86 (SSE/AVX) sum SIMD
reductions in different orders, which can cascade into different meshes. Adding
EIGEN_DONT_VECTORIZE forces Eigen's scalar path so the op order is identical on
every architecture. Because that also changes Eigen's alignment/ABI, the whole
fp-strict block is now applied globally (before any target is configured) so the
toolkit, components, app and third-party code (Eigen, libigl, geogram, polysolve)
are all built consistently. With this, the fast surface-op integration tests are
byte-identical across macOS-arm64 and Linux-x86_64.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
constrained_delaunay2D used geogram's plain CDT2d, which breaks ties between the
two valid diagonals of a cocircular quad with floating-point-dependent logic, so
it produced different (but equally valid) triangulations of the same input on
different CPU architectures (e.g. arm64 vs x86_64). That cascaded into a
completely different output mesh, making triwild non-reproducible.

Two changes make it deterministic:

* Use GEO::ExactCDT2d (exact predicates + symbolic perturbation) so the
  triangulation is the same set of triangles everywhere. This needs geogram's
  predicate kernel initialized, so add the GEO::initialize() call the plain
  CDT2d did not require (its absence aborted on Linux).
* Canonicalize the output triangle order: even with an identical triangulation,
  geogram stores the triangles in an order that depends on the floating-point
  point-location walk. Rotate each triangle so its smallest vertex is first
  (preserving orientation) and sort the rows, so the mesh built from them is
  identical on every machine.

With this, the triwild integration tests are byte-identical across macOS-arm64
and Linux-x86_64 (verified). Note ExactCDT2d yields a different (and, on the
multi-input case, somewhat heavier-to-optimize) triangulation than the old CDT2d,
so triwild output meshes change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 3D AMIPS energy/gradient/hessian need det^(2/3) (a cube root). std::cbrt and
std::pow are not correctly rounded and differ by ~1 ULP between libm
implementations (macOS libm vs glibc), which makes the iterative optimization
take divergent discrete decisions on different OSes/architectures.

Add wmtk::utils::deterministic_cbrt: range reduction with frexp/ldexp (exact) plus
a fixed number of Newton iterations using only the IEEE-754 correctly-rounded ops
(+ - * /), so the result is bit-identical everywhere (within ~1 ULP of std::cbrt,
~3x slower per call but a small fraction of a full run). Replace std::cbrt and
pow(x, -1/3) in AMIPS.cpp/.h, and the decision/output std::cbrt in TetWildMesh.cpp
and SimWildMesh.cpp.

This is one necessary component of cross-platform-deterministic 3D meshing (the
operation scheduler's non-determinism is addressed separately by the TBB removal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_edges() sorts the per-tet edge tuples by their (v0, v1) vertex pair and
then std::unique's them down to one tuple per undirected edge. The comparator
orders only by (v0, v1), so the several tuples representing the same edge (one
per incident tet) compare equal. std::sort leaves equal elements in an
implementation-defined order, so std::unique kept a different representative
tuple under libc++ vs libstdc++. That representative feeds the operation
scheduler's priority-queue tie-break, making the whole 3D optimization take
divergent decisions across platforms.

stable_sort keeps the equal tuples in their deterministic enumeration order
(by tet id, then local edge), so the kept representative -- and every downstream
decision -- is identical on every compiler/OS/CPU. This is the core fix that
makes tetwild output bit-identical across architectures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
libm's cbrt/atan2 are not correctly rounded and differ by a few ULP between
platforms (macOS libm vs glibc, x86 SSE/AVX vs ARM NEON); feeding their result
into a discrete decision (energy comparison, winding-number sign) makes the
pipeline diverge across OSes. Rather than always pay for determinism, add thin
wrappers wmtk::cbrt / wmtk::atan2 / wmtk::atan (Transcendentals.hpp) that use the
fast architecture-specific std:: functions by default and switch to hand-rolled
deterministic implementations only when the WMTK_FP_STRICT CMake option is set
(it now also defines a WMTK_FP_STRICT macro).

  * deterministic_atan2.hpp: new, fdlibm-derived atan/atan2 using only IEEE-754
    correctly-rounded ops (max abs err ~4.4e-16 vs std::atan2). deterministic_cbrt
    already existed.
  * WindingNumber.hpp: the winding number is now always the direct solid-angle
    sum (copied from igl::solid_angle, MPL-2.0) whose only transcendental is
    routed through wmtk::atan2 -- so it reduces to the same atan2 gate. The
    non-deterministic igl AABB accelerator is dropped.
  * AMIPS / SimWildMesh / TetWildMesh: call wmtk::cbrt instead of
    deterministic_cbrt directly, so the fast path is used unless WMTK_FP_STRICT.

Under WMTK_FP_STRICT the output is bit-identical to the previous always-
deterministic build (tetwild_sphere out_final.msh unchanged: 284996d9...).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
clean_triangle_mesh merged near-duplicate input vertices with
igl::remove_duplicate_vertices, which rounds vertices to a grid and keeps one
representative per cell using igl::sortrows. sortrows sorts with a std::sort
whose comparator compares only the (rounded) coordinates, so vertices that fall
in the SAME cell compare equal and std::sort leaves them in an
implementation-defined order -- a different vertex then survives under libc++
(macOS) vs libstdc++ (Linux), and the cleaned mesh (hence the whole tetwild
pipeline) diverges across OSes. Meshes with no in-cell duplicates were
unaffected (distinct keys sort deterministically), which is why sphere and
double_sphere already reproduced but octocat did not.

Replace the igl call with a local deterministic dedup that reproduces igl's
result exactly -- same grid rounding, unique cells emitted in ascending
coordinate order, representative position taken verbatim from V -- but breaks
ties in the sort on the original vertex index, so the lowest-indexed vertex in a
cell always survives and the output is bit-identical on every platform. tetwild
octocat now produces identical out_final.msh / out_surface.obj across macOS
(arm64/libc++) and Linux (x86-64/libstdc++).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
execute_offset splits edges in order of decreasing length, sorting the
candidate edges with sort_edges_by_length. The std::sort comparator ordered
purely on edge length, so edges that share the exact same length compared equal
and std::sort left them in an implementation-defined order (libc++ vs
libstdc++) -- a cross-compiler non-determinism in the split order.

Break ties on simplex::Edge's total order (its vertex pair) in both the tet and
tri variants so the split order is identical on every STL. (Note: this removes
the cross-compiler sort ambiguity; manifold_extraction_3d has a separate,
address/ASLR-dependent non-determinism in its own path and is not yet part of
the reproducible golden-hash set.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… runner

The golden-hash framework rewrites each entry of integration_tests.json from a
bare filename to a {"file": ..., "hashes": {...}} object once blessed. The C++
Integration_Tests runner deserialized the list straight into
std::vector<std::string>, which throws on the object form -- so the test would
fail as soon as any entry was blessed. Parse each entry as either a string or an
object with a "file" field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…shes

Points wmtk_data at wildmeshing/data2@9d8232b (branch
danielepanozzo/integration-test-hashes), which adds the per-test output hashes
that tests/integration_hashes.py checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The three cross-platform-determinism sort fixes each cost performance, so gate
them on WMTK_FP_STRICT like the transcendental wrappers -- the default build
keeps the faster original path, and only a reproducible build pays for
determinism:

  * TetMesh::get_edges     -- std::stable_sort (strict) vs std::sort (default)
  * sort_edges_by_length   -- Edge-index tie-break (strict) vs plain length
    comparator (default), for both the tet and tri offset meshes
  * read_triangle_mesh     -- deterministic vertex dedup (strict) vs
    igl::remove_duplicate_vertices (default)

The strict build's output is unchanged (tetwild_sphere out_final.msh still
284996d9...), so the blessed integration hashes stay valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The integration_hashes golden-hash ctest is only meaningful in a reproducible
build, so register it only when WMTK_FP_STRICT is ON, and build the Release
integration-test CI configs (Linux, macOS, Windows) with -DWMTK_FP_STRICT=ON so
the check runs and validates bit-identical output across OS/compiler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply clang-format v21.1.8 (the pre-commit-pinned version) to the files touched
by the determinism work so the pre-commit hook passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
libigl's writeOBJ (and other text writers) open output files in text mode, so on
Windows every '\n' is written as '\r\n' and the raw bytes -- hence the sha256 --
differ from the Unix output. Normalize CRLF -> LF before hashing, but only for
text outputs: binary outputs (.msh / .vtu) legitimately contain 0x0D 0x0A byte
pairs as data and are hashed verbatim (detected by the presence of a NUL byte).
On Unix this is a no-op, so the stored hashes are unchanged; on Windows the .obj
outputs now match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
macos-latest already runs on Apple Silicon, so the separate macos-14 build/test
(continuous.yml) and pip (pip.yml) jobs are redundant. The release wheel build
(wheels.yml) keeps macos-14 for the macosx_arm64 wheel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion_3d)

TopoOffsetTetMesh::m_has_envelope had no initializer, and init_from_image only
sets it (to true) inside `if (V_env.rows() > 0)` -- there is no else. Callers
that pass an empty envelope (manifold_extraction) therefore left it
uninitialized, and write_msh_groups branches on it (`if (m_has_envelope)`) to
decide whether to emit the EnvelopeSurface physical group. The garbage value
depended on stack/ASLR state, so manifold_extraction_3d produced a different
entity partition (3 vs 4 node blocks) from run to run and across machines --
deterministic only with ASLR disabled. Found with valgrind --track-origins.
topological_offset always passes a real envelope, which is why it was unaffected.

Default m_has_envelope (and m_tags_count, same pattern) to a defined value.
manifold_extraction_3d is now bit-stable across runs and machines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All 32 integration tests are now bit-reproducible; point wmtk_data at
wildmeshing/data2@8f0081f, which blesses the last remaining test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants