From 9e8d033e34c389808206e1d0644a7cd58b88336e Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 1 Aug 2026 07:01:41 -0700 Subject: [PATCH 1/6] fix(deps): prevent stale vcpkg --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9c88b2307..3815fd2f8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -31,6 +31,9 @@ updates: interval: "weekly" cooldown: default-days: 7 + # vcpkg updates are atomic baseline snapshots; do not delay them. + exclude: + - "*" open-pull-requests-limit: 10 groups: dependencies: From 96313355bc851ae05f5787e95839d9e619a39d2c Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 1 Aug 2026 07:31:53 -0700 Subject: [PATCH 2/6] build(deps): automate vcpkg tool pin sync Add a Just command that derives the vcpkg tool release from the exact manifest baseline and refreshes trusted Windows asset hashes atomically.\n\nKeep existing pins unchanged when metadata or asset downloads cannot be completed and validated.\n\nCo-Authored-By: Oz --- Justfile | 6 + README.md | 14 +- scripts/sync_vcpkg_tool_pins.py | 199 +++++++++++++++++++++ scripts/tests/test_sync_vcpkg_tool_pins.py | 109 +++++++++++ 4 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 scripts/sync_vcpkg_tool_pins.py create mode 100644 scripts/tests/test_sync_vcpkg_tool_pins.py diff --git a/Justfile b/Justfile index f701de32b..0fbb4bd55 100644 --- a/Justfile +++ b/Justfile @@ -277,6 +277,7 @@ python-entrypoint-test: _sync-python-dev uv run --no-sync cdt-optimize-initialize --help >/dev/null uv run --no-sync cdt-mnist-experiment --help >/dev/null uv run --no-sync cdt-tag-release --help >/dev/null + uv run --no-sync python scripts/sync_vcpkg_tool_pins.py --help >/dev/null # Synchronize the lightweight Python development environment from the lockfile. [group('workflows')] @@ -306,6 +307,11 @@ update-actions: just _action-lint just _zizmor +# Synchronize the trusted vcpkg tool release and Windows hashes with the manifest baseline. +[group('workflows')] +sync-vcpkg-tool-pins: _sync-python-dev + uv run --no-sync python scripts/sync_vcpkg_tool_pins.py + [default] [private] default: diff --git a/README.md b/README.md index f38ef6f8c..9b3245042 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,7 @@ just release-check # Validate release metadata and citation fields just changelog-unreleased vX.Y.Z # Generate a pending release changelog just tag-check vX.Y.Z # Preview and validate an annotated release tag just update-actions # Update and repin Actions with pinact, then validate +just sync-vcpkg-tool-pins # Sync the vcpkg tool release and Windows hashes just python-sync # Install the locked Python development environment just python-check # Check Python formatting, lint, and types just python-fix # Apply safe Ruff fixes and formatting @@ -344,21 +345,28 @@ actionlint uses its pinned upstream version, and zizmor uses its pinned PyPI whe The native build entry points delegate checkout provenance, baseline, and executable-integrity validation directly to `scripts/bootstrap_vcpkg.py`, whose cross-platform fixtures run under `just check`. -To update dependencies intentionally, bootstrap the current checkout, run the vcpkg baseline updater, review the -manifest diff, and then rerun the complete build: +To update dependencies intentionally, bootstrap the current checkout, run the vcpkg baseline updater, synchronize +the independently reviewed tool pins, review both diffs, and then rerun the complete build: ```bash python3 scripts/bootstrap_vcpkg.py export VCPKG_ROOT="$PWD/.cache/vcpkg" "$VCPKG_ROOT/vcpkg" x-update-baseline +just sync-vcpkg-tool-pins ./scripts/build.sh ``` +`just sync-vcpkg-tool-pins` reads the new manifest baseline, fetches that exact upstream commit's tool metadata, +downloads the official Windows amd64 and arm64 release assets, and atomically updates the release and SHA-256 pins in +`scripts/bootstrap_vcpkg.py`. It leaves the existing pins unchanged if any input cannot be fetched or validated, and +is a no-op when the baseline still uses the currently pinned tool release. + On Windows, invoke the same implementation with `python.exe scripts\bootstrap_vcpkg.py`; `scripts\build.bat` and `scripts\fast-build.bat` already do this directly. CI uses `lukka/run-vcpkg`, which derives the vcpkg checkout commit from the same manifest baseline and supplies a -binary cache. No separately maintained repository variable is required. +binary cache. No separately maintained checkout SHA is required; the bootstrap script retains an independent tool +release and Windows executable hashes as a supply-chain review gate. CodeQL keeps third-party implementation findings out of CDT++ results through a two-phase manual build. `just codeql-prepare` configures the project, installs manifest dependencies before CodeQL starts tracing, and uses a diff --git a/scripts/sync_vcpkg_tool_pins.py b/scripts/sync_vcpkg_tool_pins.py new file mode 100644 index 000000000..049fa100a --- /dev/null +++ b/scripts/sync_vcpkg_tool_pins.py @@ -0,0 +1,199 @@ +"""Synchronize trusted vcpkg tool pins with the manifest's exact baseline.""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import os +import re +import stat +import sys +import tempfile +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + +METADATA_URL = "https://raw.githubusercontent.com/microsoft/vcpkg/{baseline}/scripts/vcpkg-tool-metadata.txt" +WINDOWS_TOOL_URLS = { + "amd64": "https://github.com/microsoft/vcpkg-tool/releases/download/{release}/vcpkg.exe", + "arm64": "https://github.com/microsoft/vcpkg-tool/releases/download/{release}/vcpkg-arm64.exe", +} + + +class PinSyncError(RuntimeError): + """Report an unusable baseline, metadata file, or tool release.""" + + +@dataclass(frozen=True) +class ToolPins: + """Describe the trusted Windows assets for one vcpkg tool release.""" + + release: str + windows_sha256: dict[str, str] + + +def _download(url: str) -> bytes: + """Download one official vcpkg resource.""" + request = urllib.request.Request( # noqa: S310 - callers use fixed official HTTPS URL templates. + url, + headers={"User-Agent": "CDT-plusplus-vcpkg-pin-sync"}, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - URLs are fixed to official HTTPS hosts. + return response.read() + except (OSError, urllib.error.URLError) as error: + message = f"Unable to download {url}: {error}" + raise PinSyncError(message) from error + + +def _read_baseline(manifest: Path) -> str: + """Read the exact vcpkg registry baseline from the project manifest.""" + try: + document = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + message = f"Unable to read {manifest}: {error}" + raise PinSyncError(message) from error + baseline = document.get("builtin-baseline") if isinstance(document, dict) else None + if not isinstance(baseline, str) or re.fullmatch(r"[0-9a-f]{40}", baseline) is None: + message = f"Unable to read a 40-character builtin-baseline from {manifest}." + raise PinSyncError(message) + return baseline + + +def _read_tool_release(metadata: bytes, source: str) -> str: + """Extract and validate the vcpkg tool release tag from upstream metadata.""" + try: + lines = metadata.decode("utf-8").splitlines() + except UnicodeDecodeError as error: + message = f"Unable to decode vcpkg tool metadata from {source}." + raise PinSyncError(message) from error + values = dict(line.partition("=")[::2] for line in lines if "=" in line) + release = values.get("VCPKG_TOOL_RELEASE_TAG", "") + if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", release) is None: + message = f"Invalid VCPKG_TOOL_RELEASE_TAG in {source}." + raise PinSyncError(message) + return release + + +def _collect_pins(baseline: str, download: Callable[[str], bytes]) -> ToolPins: + """Download all inputs and calculate pins without modifying the repository.""" + metadata_url = METADATA_URL.format(baseline=baseline) + release = _read_tool_release(download(metadata_url), metadata_url) + windows_sha256 = {architecture: hashlib.sha256(download(url.format(release=release))).hexdigest() for architecture, url in WINDOWS_TOOL_URLS.items()} + return ToolPins(release, windows_sha256) + + +def _assignment_nodes(source: str) -> dict[str, ast.Assign]: + """Locate the two bootstrap assignments that the synchronizer owns.""" + try: + tree = ast.parse(source) + except SyntaxError as error: + message = f"Unable to parse scripts/bootstrap_vcpkg.py: {error}" + raise PinSyncError(message) from error + wanted = {"VCPKG_TOOL_RELEASE", "WINDOWS_TOOL_SHA256"} + found: dict[str, ast.Assign] = {} + for node in tree.body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name): + continue + name = node.targets[0].id + if name in wanted: + if name in found: + message = f"scripts/bootstrap_vcpkg.py defines {name} more than once." + raise PinSyncError(message) + found[name] = node + missing = wanted - found.keys() + if missing: + message = f"scripts/bootstrap_vcpkg.py is missing pin assignment(s): {', '.join(sorted(missing))}." + raise PinSyncError(message) + return found + + +def _render_bootstrap(source: str, pins: ToolPins) -> str: + """Render both trusted pin assignments while preserving unrelated source.""" + replacements = { + "VCPKG_TOOL_RELEASE": f'VCPKG_TOOL_RELEASE = "{pins.release}"\n', + "WINDOWS_TOOL_SHA256": ( + f'WINDOWS_TOOL_SHA256 = {{\n "amd64": "{pins.windows_sha256["amd64"]}",\n "arm64": "{pins.windows_sha256["arm64"]}",\n}}\n' + ), + } + lines = source.splitlines(keepends=True) + assignments = _assignment_nodes(source) + for name, node in sorted(assignments.items(), key=lambda item: item[1].lineno, reverse=True): + if node.end_lineno is None: + message = f"Unable to locate the end of {name} in scripts/bootstrap_vcpkg.py." + raise PinSyncError(message) + lines[node.lineno - 1 : node.end_lineno] = [replacements[name]] + return "".join(lines) + + +def _atomic_write(path: Path, content: str) -> None: + """Replace one source file atomically while preserving its permissions.""" + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + temporary.write(content) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path.chmod(stat.S_IMODE(path.stat().st_mode)) + temporary_path.replace(path) + except OSError as error: + message = f"Unable to update {path}: {error}" + raise PinSyncError(message) from error + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def sync_vcpkg_tool_pins(repository_root: Path, *, download: Callable[[str], bytes] = _download) -> ToolPins: + """Synchronize the bootstrap tool release and hashes for the manifest baseline.""" + baseline = _read_baseline(repository_root / "vcpkg.json") + pins = _collect_pins(baseline, download) + bootstrap = repository_root / "scripts" / "bootstrap_vcpkg.py" + try: + original = bootstrap.read_text(encoding="utf-8") + except OSError as error: + message = f"Unable to read {bootstrap}: {error}" + raise PinSyncError(message) from error + updated = _render_bootstrap(original, pins) + if updated != original: + _atomic_write(bootstrap, updated) + return pins + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + """Parse the intentionally argument-free synchronization command.""" + parser = argparse.ArgumentParser(description=__doc__) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Synchronize pins for the repository containing this script.""" + _parse_args(argv) + repository_root = Path(__file__).resolve().parent.parent + try: + pins = sync_vcpkg_tool_pins(repository_root) + except PinSyncError as error: + print(error, file=sys.stderr) + return 1 + print(f"Pinned vcpkg tool {pins.release} for Windows amd64 and arm64.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_sync_vcpkg_tool_pins.py b/scripts/tests/test_sync_vcpkg_tool_pins.py new file mode 100644 index 000000000..a7e815e62 --- /dev/null +++ b/scripts/tests/test_sync_vcpkg_tool_pins.py @@ -0,0 +1,109 @@ +"""Tests for atomic vcpkg tool-pin synchronization.""" + +from __future__ import annotations + +import hashlib +import tempfile +import unittest +from pathlib import Path + +from scripts import sync_vcpkg_tool_pins + + +class SyncVcpkgToolPinsTests(unittest.TestCase): + """Exercise exact-baseline fetching and failure-safe source updates.""" + + baseline = "1" * 40 + release = "2026-07-27" + original_bootstrap = ( + f'VCPKG_TOOL_RELEASE = "2026-07-13"\nUNCHANGED = "preserve me"\nWINDOWS_TOOL_SHA256 = {{\n "amd64": "{"2" * 64}",\n "arm64": "{"3" * 64}",\n}}\n' + ) + + def _make_repository(self, root: Path) -> Path: + """Create the manifest and bootstrap source owned by the synchronizer.""" + (root / "vcpkg.json").write_text(f'{{"builtin-baseline": "{self.baseline}"}}\n', encoding="utf-8") + scripts = root / "scripts" + scripts.mkdir() + bootstrap = scripts / "bootstrap_vcpkg.py" + bootstrap.write_text(self.original_bootstrap, encoding="utf-8") + return bootstrap + + def test_syncs_release_and_both_hashes_from_exact_baseline(self) -> None: + """The manifest commit selects metadata and both official Windows assets.""" + amd64 = b"amd64 executable" + arm64 = b"arm64 executable" + requested: list[str] = [] + + def download(url: str) -> bytes: + requested.append(url) + if url == sync_vcpkg_tool_pins.METADATA_URL.format(baseline=self.baseline): + return f"VCPKG_TOOL_RELEASE_TAG={self.release}\n".encode() + if url.endswith("/vcpkg-arm64.exe"): + return arm64 + if url.endswith("/vcpkg.exe"): + return amd64 + message = f"Unexpected URL: {url}" + raise AssertionError(message) + + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + + pins = sync_vcpkg_tool_pins.sync_vcpkg_tool_pins(repository_root, download=download) + + updated = bootstrap.read_text(encoding="utf-8") + + self.assertEqual(pins.release, self.release) + self.assertEqual( + requested, + [ + sync_vcpkg_tool_pins.METADATA_URL.format(baseline=self.baseline), + sync_vcpkg_tool_pins.WINDOWS_TOOL_URLS["amd64"].format(release=self.release), + sync_vcpkg_tool_pins.WINDOWS_TOOL_URLS["arm64"].format(release=self.release), + ], + ) + self.assertIn(f'VCPKG_TOOL_RELEASE = "{self.release}"', updated) + self.assertIn(hashlib.sha256(amd64).hexdigest(), updated) + self.assertIn(hashlib.sha256(arm64).hexdigest(), updated) + self.assertIn('UNCHANGED = "preserve me"', updated) + + def test_download_failure_preserves_existing_pins(self) -> None: + """A partial asset download cannot publish a partial source update.""" + calls = 0 + + def download(_url: str) -> bytes: + nonlocal calls + calls += 1 + if calls == 1: + return f"VCPKG_TOOL_RELEASE_TAG={self.release}\n".encode() + if calls == 2: + return b"amd64 executable" + message = "simulated arm64 download failure" + raise sync_vcpkg_tool_pins.PinSyncError(message) + + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + + with self.assertRaisesRegex(sync_vcpkg_tool_pins.PinSyncError, "arm64 download failure"): + sync_vcpkg_tool_pins.sync_vcpkg_tool_pins(repository_root, download=download) + + self.assertEqual(bootstrap.read_text(encoding="utf-8"), self.original_bootstrap) + + def test_invalid_metadata_preserves_existing_pins(self) -> None: + """Malformed upstream metadata is rejected before any source update.""" + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + + with self.assertRaisesRegex(sync_vcpkg_tool_pins.PinSyncError, "Invalid VCPKG_TOOL_RELEASE_TAG"): + sync_vcpkg_tool_pins.sync_vcpkg_tool_pins( + repository_root, + download=lambda _url: b"VCPKG_TOOL_RELEASE_TAG=not-a-release\n", + ) + + self.assertEqual(bootstrap.read_text(encoding="utf-8"), self.original_bootstrap) + + +if __name__ == "__main__": + unittest.main() From 3f72a16c636a21a05c7273f4667efc0b8c919288 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 1 Aug 2026 09:31:55 -0700 Subject: [PATCH 3/6] fix(persistence)!: preserve coincident causal identities Persist vertex and cell metadata by payload index instead of coordinate-derived keys. Make topology fingerprints incidence-aware when distinct vertices share coordinates, and reject invalid v2 metadata indices. BREAKING CHANGE: v1 causal-data trailers are no longer readable; persisted causal metadata now uses the v2 payload-indexed format. --- docs/reproducibility.md | 4 +- include/Utilities.hpp | 275 +++++++++++++++++++++++++++++---------- tests/Utilities_test.cpp | 120 +++++++++++++++++ 3 files changed, 331 insertions(+), 68 deletions(-) diff --git a/docs/reproducibility.md b/docs/reproducibility.md index 6e523be61..52f331b97 100644 --- a/docs/reproducibility.md +++ b/docs/reproducibility.md @@ -90,14 +90,14 @@ manifest. The manifest records: - a canonical placement fingerprint derived from sorted finite vertices and their timeslices; - a canonical topology fingerprint derived from sorted vertices, causal - metadata, and finite cells; + metadata, and finite-cell incidence; - the CDT++ version, compiler, build configuration, standard library, operating system, architecture, C++ standard, and CGAL version; - the payload byte count and FNV-1a corruption checksum. The triangulation remains a CGAL-readable payload; provenance is in the sidecar rather than prepended to the CGAL stream. Because CGAL's native triangulation -stream omits `info()` fields, CDT++ appends a versioned, canonically ordered +stream omits `info()` fields, CDT++ appends a versioned, payload-indexed causal-data trailer that preserves every finite vertex timeslice and cell type. Legacy streams without this trailer remain topology-readable. Before publication, CDT++ serializes with round-trip floating-point precision to a diff --git a/include/Utilities.hpp b/include/Utilities.hpp index 0a66cd6d8..bb458dd22 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -145,7 +145,7 @@ namespace cdt::utilities namespace detail { inline constexpr std::string_view CAUSAL_INFO_HEADER{ - "cdt-plusplus-causal-info-v1"}; + "cdt-plusplus-causal-info-v2"}; struct Payload_integrity { @@ -221,7 +221,7 @@ namespace cdt::utilities CGAL::to_double(point.z())); } - [[nodiscard]] inline auto cell_key(auto const& cell) -> std::string + [[nodiscard]] inline auto point_cell_key(auto const& cell) -> std::string { std::array points{point_key(cell->vertex(0)->point()), point_key(cell->vertex(1)->point()), @@ -232,6 +232,38 @@ namespace cdt::utilities points[3]); } + [[nodiscard]] inline auto canonical_colors( + std::vector const& signatures) -> std::vector + { + auto ordered = signatures; + std::ranges::sort(ordered); + ordered.erase(std::unique(ordered.begin(), ordered.end()), ordered.end()); + + std::vector colors; + colors.reserve(signatures.size()); + for (auto const& signature : signatures) + { + colors.push_back(static_cast( + std::lower_bound(ordered.begin(), ordered.end(), signature) - + ordered.begin())); + } + return colors; + } + + [[nodiscard]] inline auto refined_signature( + std::string const& base, std::size_t const self_color, + std::vector neighboring_colors) -> std::string + { + std::ranges::sort(neighboring_colors); + auto signature = fmt::format("{}:self={}:neighbors=", base, self_color); + for (auto const color : neighboring_colors) + { + signature.append(std::to_string(color)); + signature.push_back(';'); + } + return signature; + } + template inline constexpr bool HAS_CAUSAL_INFO = requires(TriangulationType const& triangulation) { @@ -257,6 +289,115 @@ namespace cdt::utilities return records; } + template + [[nodiscard]] auto has_coincident_vertices( + TriangulationType const& triangulation) -> bool + { + std::map point_counts; + for (auto const vertex : triangulation.finite_vertex_handles()) + { + if (++point_counts[point_key(vertex->point())] > 1) { return true; } + } + return false; + } + + template + [[nodiscard]] auto incidence_topology_records( + TriangulationType const& triangulation) -> std::vector + { + auto const finite_vertices = triangulation.finite_vertex_handles(); + using Vertex_handle = + std::remove_cvref_t; + std::vector vertices(finite_vertices.begin(), + finite_vertices.end()); + + auto const finite_cells = triangulation.finite_cell_handles(); + using Cell_handle = std::remove_cvref_t; + std::vector cells(finite_cells.begin(), finite_cells.end()); + + std::map vertex_indices; + std::vector vertex_bases; + vertex_bases.reserve(vertices.size()); + for (std::size_t index = 0; index < vertices.size(); ++index) + { + vertex_indices.emplace(vertices[index], index); + vertex_bases.emplace_back( + fmt::format("v:{}:{}", point_key(vertices[index]->point()), + vertices[index]->info())); + } + + std::vector cell_bases; + cell_bases.reserve(cells.size()); + std::vector> cell_vertices(cells.size()); + std::vector> vertex_cells(vertices.size()); + for (std::size_t cell_index = 0; cell_index < cells.size(); ++cell_index) + { + cell_bases.emplace_back(fmt::format("c:{}", cells[cell_index]->info())); + for (std::size_t local_index = 0; local_index < 4; ++local_index) + { + auto const vertex_index = vertex_indices.at( + cells[cell_index]->vertex(static_cast(local_index))); + cell_vertices[cell_index][local_index] = vertex_index; + vertex_cells[vertex_index].push_back(cell_index); + } + } + + auto vertex_colors = canonical_colors(vertex_bases); + auto cell_colors = canonical_colors(cell_bases); + std::vector vertex_signatures; + std::vector cell_signatures; + auto const entity_count = vertices.size() + cells.size(); + for (std::size_t iteration = 0; iteration < entity_count; ++iteration) + { + vertex_signatures.clear(); + vertex_signatures.reserve(vertices.size()); + for (std::size_t vertex_index = 0; vertex_index < vertices.size(); + ++vertex_index) + { + std::vector neighboring_colors; + neighboring_colors.reserve(vertex_cells[vertex_index].size()); + for (auto const cell_index : vertex_cells[vertex_index]) + { + neighboring_colors.push_back(cell_colors[cell_index]); + } + vertex_signatures.emplace_back(refined_signature( + vertex_bases[vertex_index], vertex_colors[vertex_index], + std::move(neighboring_colors))); + } + + cell_signatures.clear(); + cell_signatures.reserve(cells.size()); + for (std::size_t cell_index = 0; cell_index < cells.size(); + ++cell_index) + { + std::vector neighboring_colors; + neighboring_colors.reserve(cell_vertices[cell_index].size()); + for (auto const vertex_index : cell_vertices[cell_index]) + { + neighboring_colors.push_back(vertex_colors[vertex_index]); + } + cell_signatures.emplace_back( + refined_signature(cell_bases[cell_index], cell_colors[cell_index], + std::move(neighboring_colors))); + } + + auto refined_vertex_colors = canonical_colors(vertex_signatures); + auto refined_cell_colors = canonical_colors(cell_signatures); + if (refined_vertex_colors == vertex_colors && + refined_cell_colors == cell_colors) + { + break; + } + vertex_colors = std::move(refined_vertex_colors); + cell_colors = std::move(refined_cell_colors); + } + + vertex_signatures.insert(vertex_signatures.end(), cell_signatures.begin(), + cell_signatures.end()); + std::ranges::sort(vertex_signatures); + return vertex_signatures; + } + [[nodiscard]] inline auto fingerprint_records( std::vector const& records) -> std::uint64_t { @@ -283,6 +424,11 @@ namespace cdt::utilities [[nodiscard]] auto canonical_topology_fingerprint( TriangulationType const& triangulation) -> std::uint64_t { + if (has_coincident_vertices(triangulation)) + { + return fingerprint_records(incidence_topology_records(triangulation)); + } + auto records = vertex_records(triangulation); records.reserve( static_cast(triangulation.number_of_vertices() + @@ -290,7 +436,7 @@ namespace cdt::utilities for (auto const cell : triangulation.finite_cell_handles()) { records.emplace_back( - fmt::format("c:{}:{}", cell_key(cell), cell->info())); + fmt::format("c:{}:{}", point_cell_key(cell), cell->info())); } std::ranges::sort(records); return fingerprint_records(records); @@ -302,25 +448,29 @@ namespace cdt::utilities { if constexpr (HAS_CAUSAL_INFO) { + // CGAL writes and recreates vertices and cells in container order. + // Persist those payload indices because distinct TDS vertices may be + // geometrically coincident after topological moves. std::vector vertices; vertices.reserve( static_cast(triangulation.number_of_vertices())); + std::uint64_t vertex_index{}; for (auto const vertex : triangulation.finite_vertex_handles()) { vertices.emplace_back( - fmt::format("{}|{}", point_key(vertex->point()), vertex->info())); + fmt::format("{}|{}", vertex_index, vertex->info())); + ++vertex_index; } - std::ranges::sort(vertices); std::vector cells; cells.reserve( static_cast(triangulation.number_of_finite_cells())); + std::uint64_t cell_index{}; for (auto const cell : triangulation.finite_cell_handles()) { - cells.emplace_back( - fmt::format("{}|{}", cell_key(cell), cell->info())); + cells.emplace_back(fmt::format("{}|{}", cell_index, cell->info())); + ++cell_index; } - std::ranges::sort(cells); output << '\n' << CAUSAL_INFO_HEADER << '\n'; output << "vertices=" << vertices.size() << '\n'; @@ -525,6 +675,48 @@ namespace cdt::utilities path); } + [[nodiscard]] inline auto read_indexed_info( + std::istream& input, std::string_view const prefix, + std::uint64_t const count, std::filesystem::path const& path) + -> std::vector + { + std::vector> indexed( + static_cast(count)); + std::string line; + for (std::uint64_t record_index = 0; record_index < count; ++record_index) + { + if (!std::getline(input, line)) + { + throw std::filesystem::filesystem_error( + "Truncated causal triangulation metadata", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + auto const [key, value] = parse_record(line, prefix, path); + auto const index = parse_unsigned(key, 10, path); + if (index >= count || indexed[static_cast(index)]) + { + throw std::filesystem::filesystem_error( + "Duplicate or out-of-range causal metadata index", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + indexed[static_cast(index)] = value; + } + + std::vector values; + values.reserve(indexed.size()); + for (auto const& value : indexed) + { + if (!value) + { + throw std::filesystem::filesystem_error( + "Missing causal metadata index", path, + std::make_error_code(std::errc::illegal_byte_sequence)); + } + values.push_back(*value); + } + return values; + } + template void read_causal_info(std::istream& input, TriangulationType& triangulation, std::filesystem::path const& path) @@ -552,23 +744,8 @@ namespace cdt::utilities std::make_error_code(std::errc::illegal_byte_sequence)); } - std::map vertex_info; - for (std::uint64_t index = 0; index < vertex_count; ++index) - { - if (!std::getline(input, line)) - { - throw std::filesystem::filesystem_error( - "Truncated causal vertex metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - auto [key, value] = parse_record(line, "v=", path); - if (!vertex_info.emplace(std::move(key), value).second) - { - throw std::filesystem::filesystem_error( - "Duplicate causal vertex metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - } + auto const vertex_info = + read_indexed_info(input, "v=", vertex_count, path); if (!std::getline(input, line)) { @@ -585,53 +762,19 @@ namespace cdt::utilities std::make_error_code(std::errc::illegal_byte_sequence)); } - std::map cell_info; - for (std::uint64_t index = 0; index < cell_count; ++index) - { - if (!std::getline(input, line)) - { - throw std::filesystem::filesystem_error( - "Truncated causal cell metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - auto [key, value] = parse_record(line, "c=", path); - if (!cell_info.emplace(std::move(key), value).second) - { - throw std::filesystem::filesystem_error( - "Duplicate causal cell metadata", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - } + auto const cell_info = read_indexed_info(input, "c=", cell_count, path); + std::size_t vertex_index{}; for (auto const vertex : triangulation.finite_vertex_handles()) { - auto const found = vertex_info.find(point_key(vertex->point())); - if (found == vertex_info.end()) - { - throw std::filesystem::filesystem_error( - "Causal vertex metadata does not match triangulation", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - vertex->info() = found->second; - vertex_info.erase(found); + vertex->info() = vertex_info[vertex_index]; + ++vertex_index; } + std::size_t cell_index{}; for (auto const cell : triangulation.finite_cell_handles()) { - auto const found = cell_info.find(cell_key(cell)); - if (found == cell_info.end()) - { - throw std::filesystem::filesystem_error( - "Causal cell metadata does not match triangulation", path, - std::make_error_code(std::errc::illegal_byte_sequence)); - } - cell->info() = found->second; - cell_info.erase(found); - } - if (!vertex_info.empty() || !cell_info.empty()) - { - throw std::filesystem::filesystem_error( - "Causal metadata contains records outside the triangulation", path, - std::make_error_code(std::errc::illegal_byte_sequence)); + cell->info() = cell_info[cell_index]; + ++cell_index; } } diff --git a/tests/Utilities_test.cpp b/tests/Utilities_test.cpp index 6e6770f8b..9c3f11316 100644 --- a/tests/Utilities_test.cpp +++ b/tests/Utilities_test.cpp @@ -328,6 +328,126 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * utilities::detail::canonical_topology_fingerprint(annotated)); } } + WHEN("Distinct causal vertices occupy the same geometric point") + { + TemporaryDirectory const directory; + auto const filename = directory.file("coincident.off"); + auto annotated = manifold.delaunay_snapshot(); + auto const vertices = annotated.finite_vertex_handles(); + auto first = vertices.begin(); + auto second = std::next(first); + REQUIRE(second != vertices.end()); + (*second)->set_point((*first)->point()); + + Int_precision vertex_info{10}; + for (auto const vertex : annotated.finite_vertex_handles()) + { + vertex->info() = vertex_info++; + } + Int_precision cell_info{31}; + for (auto const cell : annotated.finite_cell_handles()) + { + cell->info() = cell_info++; + } + + write_file(filename, annotated); + auto const restored = read_file>(filename); + + THEN("payload indices preserve distinct causal identities") + { + CHECK_EQ(utilities::detail::canonical_topology_fingerprint(restored), + utilities::detail::canonical_topology_fingerprint(annotated)); + + auto original_vertex = annotated.finite_vertex_handles().begin(); + auto restored_vertex = restored.finite_vertex_handles().begin(); + for (; original_vertex != annotated.finite_vertex_handles().end(); + ++original_vertex, ++restored_vertex) + { + REQUIRE(restored_vertex != restored.finite_vertex_handles().end()); + CHECK_EQ((*restored_vertex)->info(), (*original_vertex)->info()); + } + CHECK(restored_vertex == restored.finite_vertex_handles().end()); + + auto original_cell = annotated.finite_cell_handles().begin(); + auto restored_cell = restored.finite_cell_handles().begin(); + for (; original_cell != annotated.finite_cell_handles().end(); + ++original_cell, ++restored_cell) + { + REQUIRE(restored_cell != restored.finite_cell_handles().end()); + CHECK_EQ((*restored_cell)->info(), (*original_cell)->info()); + } + CHECK(restored_cell == restored.finite_cell_handles().end()); + } + } + WHEN( + "Coincident vertices exchange causal labels across distinct cell stars") + { + auto triangulation_with_interior = manifold.delaunay_snapshot(); + triangulation_with_interior.insert(Point_t<3>(0.25, 0.25, 0.25)); + + Delaunay_t<3>::Cell_handle first_cell; + Delaunay_t<3>::Cell_handle second_cell; + for (auto const cell : triangulation_with_interior.finite_cell_handles()) + { + for (int index = 0; index < 4; ++index) + { + auto const neighbor = cell->neighbor(index); + if (!triangulation_with_interior.is_infinite(neighbor)) + { + first_cell = cell; + second_cell = neighbor; + break; + } + } + if (first_cell != Delaunay_t<3>::Cell_handle{}) { break; } + } + REQUIRE(first_cell != Delaunay_t<3>::Cell_handle{}); + REQUIRE(second_cell != Delaunay_t<3>::Cell_handle{}); + + auto unique_vertex = [](Delaunay_t<3>::Cell_handle const cell, + Delaunay_t<3>::Cell_handle const other) { + for (int cell_index = 0; cell_index < 4; ++cell_index) + { + auto const candidate = cell->vertex(cell_index); + bool shared{}; + for (int other_index = 0; other_index < 4; ++other_index) + { + shared = shared || candidate == other->vertex(other_index); + } + if (!shared) { return candidate; } + } + return Delaunay_t<3>::Vertex_handle{}; + }; + auto const first_vertex = unique_vertex(first_cell, second_cell); + auto const second_vertex = unique_vertex(second_cell, first_cell); + REQUIRE(first_vertex != Delaunay_t<3>::Vertex_handle{}); + REQUIRE(second_vertex != Delaunay_t<3>::Vertex_handle{}); + REQUIRE(first_vertex != second_vertex); + second_vertex->set_point(first_vertex->point()); + + Int_precision vertex_info{10}; + for (auto const vertex : + triangulation_with_interior.finite_vertex_handles()) + { + vertex->info() = vertex_info++; + } + Int_precision cell_info{31}; + for (auto const cell : triangulation_with_interior.finite_cell_handles()) + { + cell->info() = cell_info++; + } + + auto const fingerprint_before = + utilities::detail::canonical_topology_fingerprint( + triangulation_with_interior); + std::swap(first_vertex->info(), second_vertex->info()); + auto const fingerprint_after = + utilities::detail::canonical_topology_fingerprint( + triangulation_with_interior); + + THEN("the topology fingerprint detects the changed incidence") + { CHECK_NE(fingerprint_before, fingerprint_after); } + } WHEN("A stochastic artifact is written with reproducibility metadata") { TemporaryDirectory const directory; From 3a66693a35b27d766b8ac022fd53eccc51f39f09 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 1 Aug 2026 10:07:53 -0700 Subject: [PATCH 4/6] fix: harden persistence and vcpkg pin synchronization Keep vertex and cell attributes out of incidence-refinement cycles while retaining them in final topology records, and use checked metadata restoration. Preserve bootstrap line endings during vcpkg pin updates and clarify the render-based update and Windows invocation contracts. --- README.md | 6 ++-- docs/reproducibility.md | 4 ++- include/Utilities.hpp | 36 ++++++++++++++-------- scripts/sync_vcpkg_tool_pins.py | 12 ++++++-- scripts/tests/test_sync_vcpkg_tool_pins.py | 29 +++++++++++++++++ tests/Utilities_test.cpp | 9 ++++-- 6 files changed, 74 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9b3245042..90f30b6af 100644 --- a/README.md +++ b/README.md @@ -359,10 +359,10 @@ just sync-vcpkg-tool-pins `just sync-vcpkg-tool-pins` reads the new manifest baseline, fetches that exact upstream commit's tool metadata, downloads the official Windows amd64 and arm64 release assets, and atomically updates the release and SHA-256 pins in `scripts/bootstrap_vcpkg.py`. It leaves the existing pins unchanged if any input cannot be fetched or validated, and -is a no-op when the baseline still uses the currently pinned tool release. +only writes when the rendered release and hash assignments differ from the bootstrap source. -On Windows, invoke the same implementation with `python.exe scripts\bootstrap_vcpkg.py`; `scripts\build.bat` and -`scripts\fast-build.bat` already do this directly. +On Windows, invoke the synchronizer with `python.exe scripts\sync_vcpkg_tool_pins.py`; `scripts\build.bat` and +`scripts\fast-build.bat` already invoke the bootstrap implementation directly. CI uses `lukka/run-vcpkg`, which derives the vcpkg checkout commit from the same manifest baseline and supplies a binary cache. No separately maintained checkout SHA is required; the bootstrap script retains an independent tool diff --git a/docs/reproducibility.md b/docs/reproducibility.md index 52f331b97..4a188cd36 100644 --- a/docs/reproducibility.md +++ b/docs/reproducibility.md @@ -99,7 +99,9 @@ The triangulation remains a CGAL-readable payload; provenance is in the sidecar rather than prepended to the CGAL stream. Because CGAL's native triangulation stream omits `info()` fields, CDT++ appends a versioned, payload-indexed causal-data trailer that preserves every finite vertex timeslice and cell type. -Legacy streams without this trailer remain topology-readable. Before +Payloads using the older `cdt-plusplus-causal-info-v1` trailer are rejected +entirely by `read_causal_info` rather than loaded with their causal metadata +discarded. Legacy streams without a trailer remain topology-readable. Before publication, CDT++ serializes with round-trip floating-point precision to a temporary file, flushes and closes it, parses the complete CGAL stream and causal trailer, rejects any other trailing data, validates its triangulation diff --git a/include/Utilities.hpp b/include/Utilities.hpp index bb458dd22..3785fa82b 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -251,11 +251,11 @@ namespace cdt::utilities } [[nodiscard]] inline auto refined_signature( - std::string const& base, std::size_t const self_color, + std::size_t const self_color, std::vector neighboring_colors) -> std::string { std::ranges::sort(neighboring_colors); - auto signature = fmt::format("{}:self={}:neighbors=", base, self_color); + auto signature = fmt::format("self={}:neighbors=", self_color); for (auto const color : neighboring_colors) { signature.append(std::to_string(color)); @@ -361,8 +361,7 @@ namespace cdt::utilities neighboring_colors.push_back(cell_colors[cell_index]); } vertex_signatures.emplace_back(refined_signature( - vertex_bases[vertex_index], vertex_colors[vertex_index], - std::move(neighboring_colors))); + vertex_colors[vertex_index], std::move(neighboring_colors))); } cell_signatures.clear(); @@ -376,9 +375,8 @@ namespace cdt::utilities { neighboring_colors.push_back(vertex_colors[vertex_index]); } - cell_signatures.emplace_back( - refined_signature(cell_bases[cell_index], cell_colors[cell_index], - std::move(neighboring_colors))); + cell_signatures.emplace_back(refined_signature( + cell_colors[cell_index], std::move(neighboring_colors))); } auto refined_vertex_colors = canonical_colors(vertex_signatures); @@ -392,10 +390,20 @@ namespace cdt::utilities cell_colors = std::move(refined_cell_colors); } - vertex_signatures.insert(vertex_signatures.end(), cell_signatures.begin(), - cell_signatures.end()); - std::ranges::sort(vertex_signatures); - return vertex_signatures; + std::vector records; + records.reserve(vertices.size() + cells.size()); + for (std::size_t index = 0; index < vertices.size(); ++index) + { + records.emplace_back(fmt::format("{}:{}", vertex_bases[index], + vertex_signatures[index])); + } + for (std::size_t index = 0; index < cells.size(); ++index) + { + records.emplace_back( + fmt::format("{}:{}", cell_bases[index], cell_signatures[index])); + } + std::ranges::sort(records); + return records; } [[nodiscard]] inline auto fingerprint_records( @@ -424,6 +432,8 @@ namespace cdt::utilities [[nodiscard]] auto canonical_topology_fingerprint( TriangulationType const& triangulation) -> std::uint64_t { + // topology.fnv1a64 uses different record schemes in these branches, so + // coincident-coordinate and point-keyed digests are not comparable. if (has_coincident_vertices(triangulation)) { return fingerprint_records(incidence_topology_records(triangulation)); @@ -767,13 +777,13 @@ namespace cdt::utilities std::size_t vertex_index{}; for (auto const vertex : triangulation.finite_vertex_handles()) { - vertex->info() = vertex_info[vertex_index]; + vertex->info() = vertex_info.at(vertex_index); ++vertex_index; } std::size_t cell_index{}; for (auto const cell : triangulation.finite_cell_handles()) { - cell->info() = cell_info[cell_index]; + cell->info() = cell_info.at(cell_index); ++cell_index; } } diff --git a/scripts/sync_vcpkg_tool_pins.py b/scripts/sync_vcpkg_tool_pins.py index 049fa100a..f6312e1f4 100644 --- a/scripts/sync_vcpkg_tool_pins.py +++ b/scripts/sync_vcpkg_tool_pins.py @@ -117,10 +117,15 @@ def _assignment_nodes(source: str) -> dict[str, ast.Assign]: def _render_bootstrap(source: str, pins: ToolPins) -> str: """Render both trusted pin assignments while preserving unrelated source.""" + line_ending_match = re.search(r"\r\n|\n|\r", source) + line_ending = line_ending_match.group(0) if line_ending_match is not None else "\n" replacements = { - "VCPKG_TOOL_RELEASE": f'VCPKG_TOOL_RELEASE = "{pins.release}"\n', + "VCPKG_TOOL_RELEASE": f'VCPKG_TOOL_RELEASE = "{pins.release}"{line_ending}', "WINDOWS_TOOL_SHA256": ( - f'WINDOWS_TOOL_SHA256 = {{\n "amd64": "{pins.windows_sha256["amd64"]}",\n "arm64": "{pins.windows_sha256["arm64"]}",\n}}\n' + f"WINDOWS_TOOL_SHA256 = {{{line_ending}" + f' "amd64": "{pins.windows_sha256["amd64"]}",{line_ending}' + f' "arm64": "{pins.windows_sha256["arm64"]}",{line_ending}' + f"}}{line_ending}" ), } lines = source.splitlines(keepends=True) @@ -166,7 +171,8 @@ def sync_vcpkg_tool_pins(repository_root: Path, *, download: Callable[[str], byt pins = _collect_pins(baseline, download) bootstrap = repository_root / "scripts" / "bootstrap_vcpkg.py" try: - original = bootstrap.read_text(encoding="utf-8") + with bootstrap.open("r", encoding="utf-8", newline="") as source: + original = source.read() except OSError as error: message = f"Unable to read {bootstrap}: {error}" raise PinSyncError(message) from error diff --git a/scripts/tests/test_sync_vcpkg_tool_pins.py b/scripts/tests/test_sync_vcpkg_tool_pins.py index a7e815e62..f305d6d76 100644 --- a/scripts/tests/test_sync_vcpkg_tool_pins.py +++ b/scripts/tests/test_sync_vcpkg_tool_pins.py @@ -90,6 +90,35 @@ def download(_url: str) -> bytes: self.assertEqual(bootstrap.read_text(encoding="utf-8"), self.original_bootstrap) + def test_sync_preserves_crlf_and_unrelated_bytes(self) -> None: + """A CRLF bootstrap retains its line endings and unrelated source.""" + amd64 = b"amd64 executable" + arm64 = b"arm64 executable" + + def download(url: str) -> bytes: + if url == sync_vcpkg_tool_pins.METADATA_URL.format(baseline=self.baseline): + return f"VCPKG_TOOL_RELEASE_TAG={self.release}\n".encode() + return arm64 if url.endswith("/vcpkg-arm64.exe") else amd64 + + original = self.original_bootstrap.replace("\n", "\r\n").encode() + expected = ( + f'VCPKG_TOOL_RELEASE = "{self.release}"\r\n' + 'UNCHANGED = "preserve me"\r\n' + "WINDOWS_TOOL_SHA256 = {\r\n" + f' "amd64": "{hashlib.sha256(amd64).hexdigest()}",\r\n' + f' "arm64": "{hashlib.sha256(arm64).hexdigest()}",\r\n' + "}\r\n" + ).encode() + + with tempfile.TemporaryDirectory() as temp_dir: + repository_root = Path(temp_dir) + bootstrap = self._make_repository(repository_root) + bootstrap.write_bytes(original) + + sync_vcpkg_tool_pins.sync_vcpkg_tool_pins(repository_root, download=download) + + self.assertEqual(bootstrap.read_bytes(), expected) + def test_invalid_metadata_preserves_existing_pins(self) -> None: """Malformed upstream metadata is rejected before any source update.""" with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/Utilities_test.cpp b/tests/Utilities_test.cpp index 9c3f11316..562a13425 100644 --- a/tests/Utilities_test.cpp +++ b/tests/Utilities_test.cpp @@ -335,9 +335,11 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * auto annotated = manifold.delaunay_snapshot(); auto const vertices = annotated.finite_vertex_handles(); auto first = vertices.begin(); - auto second = std::next(first); + REQUIRE(first != vertices.end()); + auto second = std::next(first); REQUIRE(second != vertices.end()); (*second)->set_point((*first)->point()); + REQUIRE((*second)->point() == (*first)->point()); Int_precision vertex_info{10}; for (auto const vertex : annotated.finite_vertex_handles()) @@ -353,11 +355,14 @@ SCENARIO("Reading and writing Delaunay triangulations to files" * write_file(filename, annotated); auto const restored = read_file>(filename); - THEN("payload indices preserve distinct causal identities") + THEN("The topology fingerprint remains stable across the round trip") { CHECK_EQ(utilities::detail::canonical_topology_fingerprint(restored), utilities::detail::canonical_topology_fingerprint(annotated)); + } + THEN("Payload indices preserve all vertex and cell metadata") + { auto original_vertex = annotated.finite_vertex_handles().begin(); auto restored_vertex = restored.finite_vertex_handles().begin(); for (; original_vertex != annotated.finite_vertex_handles().end(); From 52f9a58e06898b563168dd75525ed53c02cd3ca4 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 1 Aug 2026 10:43:34 -0700 Subject: [PATCH 5/6] fix(persistence): canonicalize complete vertex-cell incidence Encode individual vertex-to-cell connectivity for coincident triangulations so degree-equivalent, non-isomorphic topologies produce distinct fingerprints. Preserve ordering invariance by resolving ambiguous incidence colors before selecting the canonical encoding. --- include/Utilities.hpp | 211 ++++++++++++++++++++++++--------------- tests/Utilities_test.cpp | 47 +++++++++ 2 files changed, 180 insertions(+), 78 deletions(-) diff --git a/include/Utilities.hpp b/include/Utilities.hpp index 3785fa82b..d9e01b4f1 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include // H. Hinnant date and time library @@ -232,8 +233,9 @@ namespace cdt::utilities points[3]); } - [[nodiscard]] inline auto canonical_colors( - std::vector const& signatures) -> std::vector + template + [[nodiscard]] auto canonical_colors( + std::vector const& signatures) -> std::vector { auto ordered = signatures; std::ranges::sort(ordered); @@ -250,18 +252,133 @@ namespace cdt::utilities return colors; } - [[nodiscard]] inline auto refined_signature( - std::size_t const self_color, - std::vector neighboring_colors) -> std::string + [[nodiscard]] inline auto refine_incidence_colors( + std::vector> const& adjacency, + std::vector colors) -> std::vector { - std::ranges::sort(neighboring_colors); - auto signature = fmt::format("self={}:neighbors=", self_color); - for (auto const color : neighboring_colors) + for (std::size_t iteration = 0; iteration < adjacency.size(); ++iteration) { - signature.append(std::to_string(color)); - signature.push_back(';'); + std::vector> signatures; + signatures.reserve(adjacency.size()); + for (std::size_t node = 0; node < adjacency.size(); ++node) + { + std::vector neighboring_colors; + neighboring_colors.reserve(adjacency[node].size()); + for (auto const neighbor : adjacency[node]) + { + neighboring_colors.push_back(colors.at(neighbor)); + } + std::ranges::sort(neighboring_colors); + + std::vector signature; + signature.reserve(neighboring_colors.size() + 1); + signature.push_back(colors[node]); + signature.insert(signature.end(), neighboring_colors.begin(), + neighboring_colors.end()); + signatures.push_back(std::move(signature)); + } + + auto refined = canonical_colors(signatures); + if (refined == colors) { break; } + colors = std::move(refined); } - return signature; + return colors; + } + + [[nodiscard]] inline auto incidence_records_for_coloring( + std::vector const& bases, + std::vector> const& adjacency, + std::vector const& colors) -> std::vector + { + std::vector nodes_by_color(colors.size()); + for (std::size_t node = 0; node < colors.size(); ++node) + { + nodes_by_color.at(colors[node]) = node; + } + + std::vector records; + records.reserve(bases.size()); + for (auto const node : nodes_by_color) + { + std::vector neighboring_colors; + neighboring_colors.reserve(adjacency[node].size()); + for (auto const neighbor : adjacency[node]) + { + neighboring_colors.push_back(colors.at(neighbor)); + } + std::ranges::sort(neighboring_colors); + + auto record = + fmt::format("{}:{}:neighbors=", bases[node].size(), bases[node]); + for (auto const color : neighboring_colors) + { + record.append(std::to_string(color)); + record.push_back(';'); + } + records.push_back(std::move(record)); + } + return records; + } + + [[nodiscard]] inline auto canonical_incidence_search( + std::vector const& bases, + std::vector> const& adjacency, + std::vector colors) -> std::vector + { + colors = refine_incidence_colors(adjacency, std::move(colors)); + + std::vector color_counts(colors.size()); + for (auto const color : colors) { ++color_counts.at(color); } + auto const ambiguous = std::ranges::find_if( + color_counts, [](std::size_t const count) { return count > 1; }); + if (ambiguous == color_counts.end()) + { + return incidence_records_for_coloring(bases, adjacency, colors); + } + + auto const ambiguous_color = + static_cast(ambiguous - color_counts.begin()); + auto const individualized_color = *std::ranges::max_element(colors) + 1; + std::optional> best; + for (std::size_t node = 0; node < colors.size(); ++node) + { + if (colors[node] != ambiguous_color) { continue; } + auto individualized = colors; + individualized[node] = individualized_color; + auto candidate = canonical_incidence_search(bases, adjacency, + std::move(individualized)); + if (!best || candidate < *best) { best = std::move(candidate); } + } + return std::move(*best); + } + + [[nodiscard]] inline auto canonical_bipartite_incidence_records( + std::vector const& vertex_bases, + std::vector const& cell_bases, + std::vector> const& cell_vertices) + -> std::vector + { + if (cell_bases.size() != cell_vertices.size()) + { + throw std::invalid_argument{ + "Cell bases and incidence records must have equal sizes"}; + } + + auto bases = vertex_bases; + bases.insert(bases.end(), cell_bases.begin(), cell_bases.end()); + std::vector> adjacency(bases.size()); + for (std::size_t cell = 0; cell < cell_vertices.size(); ++cell) + { + auto const cell_node = vertex_bases.size() + cell; + for (auto const vertex : cell_vertices[cell]) + { + adjacency.at(vertex).push_back(cell_node); + adjacency[cell_node].push_back(vertex); + } + } + if (bases.empty()) { return {}; } + return canonical_incidence_search(bases, adjacency, + canonical_colors(bases)); } template @@ -328,82 +445,20 @@ namespace cdt::utilities std::vector cell_bases; cell_bases.reserve(cells.size()); - std::vector> cell_vertices(cells.size()); - std::vector> vertex_cells(vertices.size()); + std::vector> cell_vertices(cells.size()); for (std::size_t cell_index = 0; cell_index < cells.size(); ++cell_index) { cell_bases.emplace_back(fmt::format("c:{}", cells[cell_index]->info())); + cell_vertices[cell_index].reserve(4); for (std::size_t local_index = 0; local_index < 4; ++local_index) { auto const vertex_index = vertex_indices.at( cells[cell_index]->vertex(static_cast(local_index))); - cell_vertices[cell_index][local_index] = vertex_index; - vertex_cells[vertex_index].push_back(cell_index); - } - } - - auto vertex_colors = canonical_colors(vertex_bases); - auto cell_colors = canonical_colors(cell_bases); - std::vector vertex_signatures; - std::vector cell_signatures; - auto const entity_count = vertices.size() + cells.size(); - for (std::size_t iteration = 0; iteration < entity_count; ++iteration) - { - vertex_signatures.clear(); - vertex_signatures.reserve(vertices.size()); - for (std::size_t vertex_index = 0; vertex_index < vertices.size(); - ++vertex_index) - { - std::vector neighboring_colors; - neighboring_colors.reserve(vertex_cells[vertex_index].size()); - for (auto const cell_index : vertex_cells[vertex_index]) - { - neighboring_colors.push_back(cell_colors[cell_index]); - } - vertex_signatures.emplace_back(refined_signature( - vertex_colors[vertex_index], std::move(neighboring_colors))); - } - - cell_signatures.clear(); - cell_signatures.reserve(cells.size()); - for (std::size_t cell_index = 0; cell_index < cells.size(); - ++cell_index) - { - std::vector neighboring_colors; - neighboring_colors.reserve(cell_vertices[cell_index].size()); - for (auto const vertex_index : cell_vertices[cell_index]) - { - neighboring_colors.push_back(vertex_colors[vertex_index]); - } - cell_signatures.emplace_back(refined_signature( - cell_colors[cell_index], std::move(neighboring_colors))); + cell_vertices[cell_index].push_back(vertex_index); } - - auto refined_vertex_colors = canonical_colors(vertex_signatures); - auto refined_cell_colors = canonical_colors(cell_signatures); - if (refined_vertex_colors == vertex_colors && - refined_cell_colors == cell_colors) - { - break; - } - vertex_colors = std::move(refined_vertex_colors); - cell_colors = std::move(refined_cell_colors); } - - std::vector records; - records.reserve(vertices.size() + cells.size()); - for (std::size_t index = 0; index < vertices.size(); ++index) - { - records.emplace_back(fmt::format("{}:{}", vertex_bases[index], - vertex_signatures[index])); - } - for (std::size_t index = 0; index < cells.size(); ++index) - { - records.emplace_back( - fmt::format("{}:{}", cell_bases[index], cell_signatures[index])); - } - std::ranges::sort(records); - return records; + return canonical_bipartite_incidence_records(vertex_bases, cell_bases, + cell_vertices); } [[nodiscard]] inline auto fingerprint_records( diff --git a/tests/Utilities_test.cpp b/tests/Utilities_test.cpp index 562a13425..14abf5f19 100644 --- a/tests/Utilities_test.cpp +++ b/tests/Utilities_test.cpp @@ -255,6 +255,53 @@ SCENARIO("Printing Delaunay triangulations" * doctest::test_suite("utilities")) } } +SCENARIO("Canonical incidence records distinguish degree-equivalent graphs" * + doctest::test_suite("utilities")) +{ + GIVEN("A bipartite cycle and two components with the same node degrees") + { + std::vector const vertex_bases(4, "v:coincident:0"); + std::vector const cell_bases(4, "c:0"); + std::vector> const cycle{ + {0, 1}, + {1, 2}, + {2, 3}, + {3, 0} + }; + std::vector> const reordered_cycle{ + {0, 2}, + {1, 3}, + {1, 2}, + {0, 3} + }; + std::vector> const two_components{ + {0, 1}, + {0, 1}, + {2, 3}, + {2, 3} + }; + + WHEN("Their complete bipartite incidence is canonicalized") + { + auto const cycle_records = + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, cycle); + auto const reordered_records = + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, reordered_cycle); + auto const component_records = + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, two_components); + + THEN("Ordering changes preserve identity but connectivity changes do not") + { + CHECK(cycle_records == reordered_records); + CHECK(cycle_records != component_records); + } + } + } +} + SCENARIO("Reading and writing Delaunay triangulations to files" * doctest::test_suite("utilities")) { From 307737ce2c5c79af376d7f38b4183a70925992d2 Mon Sep 17 00:00:00 2001 From: Adam Getchell Date: Sat, 1 Aug 2026 11:28:45 -0700 Subject: [PATCH 6/6] fix(persistence): bound incidence fingerprint canonicalization - Cap recursive individualization with a shared, diagnosable work budget. - Reject malformed cell incidence indices before constructing adjacency. - Handle empty incidence inputs explicitly. --- include/Utilities.hpp | 44 ++++++++++++++++++++++----- tests/Utilities_test.cpp | 64 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/include/Utilities.hpp b/include/Utilities.hpp index d9e01b4f1..b2c64a366 100644 --- a/include/Utilities.hpp +++ b/include/Utilities.hpp @@ -252,12 +252,26 @@ namespace cdt::utilities return colors; } + inline constexpr std::size_t CANONICAL_INCIDENCE_WORK_BUDGET{100'000}; + + inline void consume_canonical_incidence_work(std::size_t& budget) + { + if (budget == 0) + { + throw std::runtime_error{ + "Canonical incidence search exceeded its work budget"}; + } + --budget; + } + [[nodiscard]] inline auto refine_incidence_colors( std::vector> const& adjacency, - std::vector colors) -> std::vector + std::vector colors, std::size_t& budget) + -> std::vector { for (std::size_t iteration = 0; iteration < adjacency.size(); ++iteration) { + consume_canonical_incidence_work(budget); std::vector> signatures; signatures.reserve(adjacency.size()); for (std::size_t node = 0; node < adjacency.size(); ++node) @@ -285,6 +299,8 @@ namespace cdt::utilities return colors; } + /// @pre `colors` is a permutation of `[0, colors.size())`, assigning every + /// node a unique, valid color index. [[nodiscard]] inline auto incidence_records_for_coloring( std::vector const& bases, std::vector> const& adjacency, @@ -323,9 +339,11 @@ namespace cdt::utilities [[nodiscard]] inline auto canonical_incidence_search( std::vector const& bases, std::vector> const& adjacency, - std::vector colors) -> std::vector + std::vector colors, std::size_t& budget) + -> std::vector { - colors = refine_incidence_colors(adjacency, std::move(colors)); + consume_canonical_incidence_work(budget); + colors = refine_incidence_colors(adjacency, std::move(colors), budget); std::vector color_counts(colors.size()); for (auto const color : colors) { ++color_counts.at(color); } @@ -345,8 +363,8 @@ namespace cdt::utilities if (colors[node] != ambiguous_color) { continue; } auto individualized = colors; individualized[node] = individualized_color; - auto candidate = canonical_incidence_search(bases, adjacency, - std::move(individualized)); + auto candidate = canonical_incidence_search( + bases, adjacency, std::move(individualized), budget); if (!best || candidate < *best) { best = std::move(candidate); } } return std::move(*best); @@ -363,9 +381,21 @@ namespace cdt::utilities throw std::invalid_argument{ "Cell bases and incidence records must have equal sizes"}; } + for (auto const& incident_vertices : cell_vertices) + { + for (auto const vertex : incident_vertices) + { + if (vertex >= vertex_bases.size()) + { + throw std::invalid_argument{ + "Cell incidence records must reference known vertices"}; + } + } + } auto bases = vertex_bases; bases.insert(bases.end(), cell_bases.begin(), cell_bases.end()); + if (bases.empty()) { return {}; } std::vector> adjacency(bases.size()); for (std::size_t cell = 0; cell < cell_vertices.size(); ++cell) { @@ -376,9 +406,9 @@ namespace cdt::utilities adjacency[cell_node].push_back(vertex); } } - if (bases.empty()) { return {}; } + auto budget = CANONICAL_INCIDENCE_WORK_BUDGET; return canonical_incidence_search(bases, adjacency, - canonical_colors(bases)); + canonical_colors(bases), budget); } template diff --git a/tests/Utilities_test.cpp b/tests/Utilities_test.cpp index 14abf5f19..5863a033a 100644 --- a/tests/Utilities_test.cpp +++ b/tests/Utilities_test.cpp @@ -293,10 +293,68 @@ SCENARIO("Canonical incidence records distinguish degree-equivalent graphs" * utilities::detail::canonical_bipartite_incidence_records( vertex_bases, cell_bases, two_components); - THEN("Ordering changes preserve identity but connectivity changes do not") + THEN("Reordered equivalent edges produce identical records") + { CHECK(cycle_records == reordered_records); } + + THEN("A degree-equivalent connectivity change produces different records") + { CHECK(cycle_records != component_records); } + } + + WHEN("The cell bases and incidence records have unequal sizes") + { + std::vector> const mismatched_incidence{ + {0, 1} + }; + + THEN("Canonicalization rejects the inconsistent input") + { + CHECK_THROWS_AS( + static_cast( + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, mismatched_incidence)), + std::invalid_argument); + } + } + + WHEN("A cell incidence record references a non-vertex node") + { + auto invalid_indices = cycle; + invalid_indices.front().front() = vertex_bases.size(); + + THEN("Canonicalization rejects the invalid vertex index") + { + CHECK_THROWS_AS( + static_cast( + utilities::detail::canonical_bipartite_incidence_records( + vertex_bases, cell_bases, invalid_indices)), + std::invalid_argument); + } + } + + WHEN("Only one canonical search work unit remains") + { + std::vector const ambiguous_bases{"node", "node"}; + std::vector> const adjacency{{1}, {0}}; + std::vector const colors{0, 0}; + std::size_t budget{1}; + + THEN("Refinement reports budget exhaustion") + { + CHECK_THROWS_WITH_AS( + static_cast(utilities::detail::canonical_incidence_search( + ambiguous_bases, adjacency, colors, budget)), + "Canonical incidence search exceeded its work budget", + std::runtime_error); + } + } + + WHEN("No vertices or cells are supplied") + { + THEN("Canonicalization returns no records") { - CHECK(cycle_records == reordered_records); - CHECK(cycle_records != component_records); + CHECK( + utilities::detail::canonical_bipartite_incidence_records({}, {}, {}) + .empty()); } } }