Skip to content

Give the remaining partial sort comparators a total order - #953

Closed
danielepanozzo wants to merge 9 commits into
danielepanozzo/concurrency-cleanupfrom
danielepanozzo/sort-total-order
Closed

Give the remaining partial sort comparators a total order#953
danielepanozzo wants to merge 9 commits into
danielepanozzo/concurrency-cleanupfrom
danielepanozzo/sort-total-order

Conversation

@danielepanozzo

Copy link
Copy Markdown
Contributor

Give the remaining partial sort comparators a total order

A sweep of every std::sort / std::unique / priority-queue comparator in src/wmtk and the components, looking for the failure this repository keeps hitting.

The bug class. std::sort is not stable. When a comparator treats two distinct elements as equivalent, the order it leaves them in is unspecified — and differs between libc++ (macOS), libstdc++ (Linux) and MSVC. In each case below that order reaches the output, so the same input produces a different mesh depending on the platform. This has already been hit four times before: TetMesh::get_edges, igl::sortrows in the vertex dedup, sort_edges_by_length, and the morton partition sort in #952.

Six sites

Site What the comparator ignored How the order reaches the output
TetMesh::get_edges the tet id — every tet incident to an edge contributes an entry, so an edge shared by k tets appears k times std::unique kept an arbitrary one, so which tet the returned tuple lives in was unspecified; get_edges feeds the operation queues
TetMeshTriangleInsertionConn old_face_vids (tid, l_fid) of {v0,v1,v2,tid,l_fid} the survivor is consumed as tuple_from_face(info[3], info[4]) — the discarded fields are the output
TetMeshTriangleInsertionConn new_face_vids same survivor and residual order become the face tuples handed to triangle_insertion_after
orig/EdgeSplitter.h cmp_es v_ids, keeping only the edge length split priority queue: equal-length edges popped in an unspecified order, which is the order they are split in
TopoOffsetTetMesh.h sort_edges_by_length everything but the length marching-tets split order → new vertex ids and the frontier list that labels offset tets
TopoOffsetTriMesh.h sort_edges_by_length same (2D path) same

Two things the sweep surfaced

Four of the six share one idiom: sort on a partial key, std::unique to collapse duplicates, then read the very fields the comparator ignored. It reads as correct — the grouping works and the deduplication works; only the choice of which duplicate survives is left to the standard library. Where the fix widens the sort comparator to the whole array, the unique predicate still compares the vids alone, so the grouping it relies on is unchanged and the wider comparison only orders within each group.

cmp_es had two siblings that were already right. cmp_ec (EdgeCollapser.h) and cmp_er (EdgeRemover.h) both tie-break on v_ids; only the splitter was missed. That is an oversight with a clear fingerprint rather than a deliberate choice.

Scope

~150 std::sort call sites were examined; six were changed. Sorts whose comparator is the default one over a fully ordered type (size_t, std::array<size_t,N>, std::pair) are deliberately left alone: equivalent elements there are indistinguishable, so no order over them is observable, and tie-breaking them would be noise. Two further sites were examined and left: unique_face_tuples already uses std::stable_sort (so its survivor is deterministic without a key), and unique_directed_edge_tuples is dead code — both lambdas throw on entry.

A note for whoever merges this with #949

The tie-breaks here are unconditional. On danielepanozzo/integration-test-hashes (#949), the equivalents for TetMesh::get_edges and sort_edges_by_length sit behind #ifdef WMTK_FP_STRICT — that macro does not exist on this stack at all. Those hunks will conflict textually, and the merge forces a policy decision: is a total-order comparator a correctness property (always on) or a reproducibility one (strict builds only)? The cost is one integer comparison on a tie; the symptom is a mesh that differs by platform.

Testing

51/51 unit tests and Integration_Tests pass.

Worth being explicit about what that does not show: these fixes are reasoned, not measured. The golden-hash tests are what would prove them, and they live on #949 — a different stack — while most of these paths are multithreaded-sensitive anyway. Integration_Tests only checks termination, so green means nothing regressed, not that determinism improved.

Stacked on #952.

🤖 Generated with Claude Code

danielepanozzo and others added 4 commits July 20, 2026 23:25
A sweep of every std::sort / std::unique / priority-queue comparator in src/wmtk
and the components, looking for the failure this repository keeps hitting: a
comparator that treats two *distinct* elements as equivalent. std::sort is not
stable, so the order it leaves such elements in is unspecified and differs between
libc++, libstdc++ and MSVC -- and in each case below that order reaches the output.

Six sites. Four share one idiom: sort on a partial key, std::unique to collapse
duplicates, and then read the very fields the comparator ignored. That reads as
correct, because the grouping and the deduplication both work; only the choice of
which duplicate survives is left to the standard library.

  TetMesh::get_edges -- entries are (v0, v1, tuple) and every tet incident to an
  edge contributes one, so an edge shared by k tets appears k times. The unique
  below kept an arbitrary one, so *which tet the returned tuple lives in* was
  unspecified, and get_edges feeds the operation queues. Tie-break on the tuple,
  whose operator< already orders totally.

  TetMeshTriangleInsertionConn, old_face_vids and new_face_vids -- arrays of
  {v0,v1,v2,tid,l_fid} ordered on the vids alone. The survivor is consumed as
  tuple_from_face(info[3], info[4]), so the discarded fields are the output.
  Comparing the whole array keeps the vid grouping the unique relies on -- the
  first three elements still dominate -- and adds (tid, l_fid) as the tie-break.

  orig/EdgeSplitter.h, cmp_es -- the split priority queue ordered on edge length
  and ignored v_ids, so equal-length edges, which symmetric input produces in
  quantity, were popped in an unspecified order, and that is the order they are
  split in. Its siblings cmp_ec and cmp_er already tie-break on v_ids; this one
  was missed.

  TopoOffsetTetMesh.h and TopoOffsetTriMesh.h, sort_edges_by_length -- ordered on
  length alone, and the order is the marching-tets split order, so it decides new
  vertex ids and the frontier vertex list that labels offset tets.

Sorts whose comparator is the default one over a fully ordered type are left alone:
equivalent elements there are indistinguishable, so no order over them is
observable. That covers the great majority of the ~150 call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously only the 3->2 edge swap could flip a surface edge (a surface
diagonal flip); 4-4 and 5-6 rejected surface edges outright, so slivers
touching the surface that need those flips could not be improved.

The surface change is always the same 2D Pachner 2-2 move of the two
incident surface faces (a,b,c),(a,b,d) -> (a,c,d),(b,c,d). What differs per
swap is the volumetric retetrahedralization that realizes edge (c,d):

- Core TetMesh: add two default-true virtual predicates
  swap_edge_44_accept_case(new_edge) / swap_edge_56_accept_case(new_face)
  that filter candidate cases before the min-energy selection. Default true
  leaves interior edges and other meshes (e.g. SimWild) unchanged; tetwild
  overrides them to force the case that creates the surface diagonal (c,d).
  The 5-6 predicate keys on the fan apex, not "contains edge (c,d)", so a
  pre-existing link edge cannot spuriously match.

- tetwild: generalize prepare_surface_flip to any ring size (it finds the
  two surface apexes c,d and runs the manifold guards: open-boundary reject,
  exactly two incident surface faces, (c,d) link condition via a direct
  incident-surface-face count, new faces not already surface). 4-4/5-6
  _before route surface edges through it; _after envelope-checks the two new
  surface faces, retags them, and bumps per-type counters.

- Routing hardening: new edge_incident_surface_face_count (face-based, so it
  ignores possibly-stale m_is_on_surface vertex flags) decides surface-vs-
  interior in all three swaps, so a genuine surface edge is never silently
  torn by the interior path. It runs only after the incident-tet-count gate.

- The 2->3 face swap stays surface-forbidden (removing a surface triangle
  tears the surface; not topology-preserving) -- covered by a test.

- swap_all_edges_44/56 now also wrapped in the check_surface_topology
  signature net; per-type counters cnt_surface_swap_32/_44/_56 added/logged.

Tests: test_surface_swap.cpp gains 4-4/5-6 accept, adjacent-apex reject,
apex-vs-link-edge guard, non-manifold reject, disabled-param, stale-flag
routing, interior-unchanged, and face-swap-excluded cases. Interior 4-4/5-6
behavior is unchanged (core [tuple_operation] suite still green).

Validated on sphere/100071_sf/37989_sf/Octocat/bunny with surface swaps and
the surface-topology check on: all three swap types fire on the surface and
the surface topology signature is unchanged across every swap pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Running tetwild over Thingi10K surfaced two ways an invalid input mesh crashes
the process instead of failing with a message.

  * Empty mesh -> SIGSEGV. A binary STL that declares zero triangles is read by
    igl::read_triangle_mesh as success with V and F empty. clean_triangle_mesh then
    evaluates V.colwise().minCoeff() to size the dedup tolerance, and minCoeff on a
    zero-row matrix is undefined -- in a release build, where Eigen's asserts are
    compiled out, it reads out of bounds and segfaults. Guard the empty case at the
    top of clean_triangle_mesh and throw a clear error.

  * Unreadable mesh -> SIGABRT. When igl cannot parse a file, read_triangle_mesh
    already throws ("Could not read mesh ..."), but main runs the component outside
    any try/catch, so the exception escapes to std::terminate and aborts. Wrap the
    component call so any thrown error becomes a logged message and exit code 1.

Neither changes which inputs are rejected -- empty and unreadable meshes are still
errors -- only how: a readable message and a nonzero exit rather than a crash (and,
on macOS, one saved crash report per failure).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously only the 3->2 edge swap could flip a surface edge (a surface
diagonal flip); 4-4 and 5-6 rejected surface edges outright, so slivers
touching the surface that need those flips could not be improved.

The surface change is always the same 2D Pachner 2-2 move of the two
incident surface faces (a,b,c),(a,b,d) -> (a,c,d),(b,c,d). What differs per
swap is the volumetric retetrahedralization that realizes edge (c,d):

- Core TetMesh: add two default-true virtual predicates
  swap_edge_44_accept_case(new_edge) / swap_edge_56_accept_case(new_face)
  that filter candidate cases before the min-energy selection. Default true
  leaves interior edges and other meshes (e.g. SimWild) unchanged; tetwild
  overrides them to force the case that creates the surface diagonal (c,d).
  The 5-6 predicate keys on the fan apex, not "contains edge (c,d)", so a
  pre-existing link edge cannot spuriously match.

- tetwild: generalize prepare_surface_flip to any ring size (it finds the
  two surface apexes c,d and runs the manifold guards: open-boundary reject,
  exactly two incident surface faces, (c,d) link condition via a direct
  incident-surface-face count, new faces not already surface). 4-4/5-6
  _before route surface edges through it; _after envelope-checks the two new
  surface faces, retags them, and bumps per-type counters.

- Routing hardening: new edge_incident_surface_face_count (face-based, so it
  ignores possibly-stale m_is_on_surface vertex flags) decides surface-vs-
  interior in all three swaps, so a genuine surface edge is never silently
  torn by the interior path. It runs only after the incident-tet-count gate.

- The 2->3 face swap stays surface-forbidden (removing a surface triangle
  tears the surface; not topology-preserving) -- covered by a test.

- swap_all_edges_44/56 now also wrapped in the check_surface_topology
  signature net; per-type counters cnt_surface_swap_32/_44/_56 added/logged.

Tests: test_surface_swap.cpp gains 4-4/5-6 accept, adjacent-apex reject,
apex-vs-link-edge guard, non-manifold reject, disabled-param, stale-flag
routing, interior-unchanged, and face-swap-excluded cases. Interior 4-4/5-6
behavior is unchanged (core [tuple_operation] suite still green).

Validated on sphere/100071_sf/37989_sf/Octocat/bunny with surface swaps and
the surface-topology check on: all three swap types fire on the surface and
the surface topology signature is unchanged across every swap pass.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens several comparator call sites to ensure a total order (and therefore deterministic results) across standard library implementations, preventing platform-dependent behavior from std::sort / std::unique and priority queues in mesh processing.

Changes:

  • Make TetMeshTriangleInsertionConn face-vid sorts total by comparing full std::array<size_t,5> keys.
  • Add a deterministic tie-break to TetMesh::get_edges() so std::unique keeps a consistent representative tuple per edge.
  • Add length-then-edge tie-breaks in topological offset edge sorting, and add a tie-break in TetWild’s edge-splitting queue comparator.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/wmtk/TetMeshTriangleInsertionConn.cpp Ensures deterministic sorting of face-vid records so unique retains consistent representatives and order.
src/wmtk/TetMesh.cpp Adds a Tuple tie-break to make edge representative selection deterministic across platforms.
components/topological_offset/.../TopoOffsetTriMesh.h Makes edge-length sorting deterministic by breaking ties with simplex::Edge ordering.
components/topological_offset/.../TopoOffsetTetMesh.h Same as above for the tet-mesh offset path, avoiding platform-dependent split ordering.
components/tetwild/.../orig/EdgeSplitter.h Adds a deterministic tie-break for equal-weight edges in the splitter priority queue.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +41 to +42
if (e1.weight == e2.weight) return e1.v_ids < e2.v_ids;
return e1.weight < e2.weight; ///choose longer edge for splitting
danielepanozzo and others added 5 commits August 1, 2026 20:49
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
clang-format v21.1.8 (what pre-commit pins) wants the @PARAM line for
swap_edge_44_accept_case wrapped at the column limit. Comment only.

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

Fail cleanly on empty / unreadable input instead of crashing
tetwild: extend 4-4 and 5-6 edge swaps to the tracked surface
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