gdsdiff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. gdsdiff/__init__.py +148 -0
  2. gdsdiff/__main__.py +7 -0
  3. gdsdiff/_environment.py +12 -0
  4. gdsdiff/_version.py +34 -0
  5. gdsdiff/acceptance_evidence.py +530 -0
  6. gdsdiff/api.py +2955 -0
  7. gdsdiff/backends/__init__.py +26 -0
  8. gdsdiff/backends/base.py +77 -0
  9. gdsdiff/backends/boundary_loop_ctypes.py +191 -0
  10. gdsdiff/backends/cpu.py +20 -0
  11. gdsdiff/backends/cpu_ctypes.py +255 -0
  12. gdsdiff/backends/cuda.py +40 -0
  13. gdsdiff/backends/cuda_ctypes.py +1297 -0
  14. gdsdiff/backends/exact.py +424 -0
  15. gdsdiff/backends/geometry_ctypes.py +252 -0
  16. gdsdiff/backends/identity.py +53 -0
  17. gdsdiff/backends/metal.py +198 -0
  18. gdsdiff/backends/metal_ctypes.py +866 -0
  19. gdsdiff/backends/polygon_extract_ctypes.py +233 -0
  20. gdsdiff/backends/structural_ctypes.py +130 -0
  21. gdsdiff/boundary_loops.py +616 -0
  22. gdsdiff/cache.py +544 -0
  23. gdsdiff/canonicalize.py +176 -0
  24. gdsdiff/cli.py +352 -0
  25. gdsdiff/config.py +257 -0
  26. gdsdiff/cuda_setup.py +174 -0
  27. gdsdiff/design_history.py +65 -0
  28. gdsdiff/diagnostics.py +42 -0
  29. gdsdiff/diff_gds.py +66 -0
  30. gdsdiff/exact_diff_markdown.py +979 -0
  31. gdsdiff/exact_oracle.py +649 -0
  32. gdsdiff/extract.py +376 -0
  33. gdsdiff/gds_metadata.py +43 -0
  34. gdsdiff/geometry.py +112 -0
  35. gdsdiff/grid.py +143 -0
  36. gdsdiff/hierarchy.py +215 -0
  37. gdsdiff/hierarchy_extract.py +263 -0
  38. gdsdiff/inventory.py +153 -0
  39. gdsdiff/multiprocessing_support.py +39 -0
  40. gdsdiff/native.py +135 -0
  41. gdsdiff/native_cache.py +265 -0
  42. gdsdiff/native_src/cpu_prefilter.cpp +349 -0
  43. gdsdiff/native_src/gds_boundary_loops.cpp +505 -0
  44. gdsdiff/native_src/gds_geometry_scan.cpp +601 -0
  45. gdsdiff/native_src/gds_polygon_extract.cpp +594 -0
  46. gdsdiff/native_src/gds_structural_scan.cpp +335 -0
  47. gdsdiff/native_src/gdsdiff/CMakeLists.txt +74 -0
  48. gdsdiff/native_src/gdsdiff/core.cpp +191 -0
  49. gdsdiff/native_src/gdsdiff/core.hpp +96 -0
  50. gdsdiff/native_src/gdsdiff/cuda_assignment.cu +304 -0
  51. gdsdiff/native_src/gdsdiff/cuda_assignment.cuh +33 -0
  52. gdsdiff/native_src/gdsdiff/cuda_candidates.cu +133 -0
  53. gdsdiff/native_src/gdsdiff/cuda_candidates.cuh +17 -0
  54. gdsdiff/native_src/gdsdiff/cuda_ctypes.cu +4528 -0
  55. gdsdiff/native_src/gdsdiff/cuda_memory.cu +194 -0
  56. gdsdiff/native_src/gdsdiff/cuda_memory.cuh +34 -0
  57. gdsdiff/native_src/gdsdiff/metal_ctypes.mm +2791 -0
  58. gdsdiff/native_src/gdsdiff/python_bindings.cpp +21 -0
  59. gdsdiff/native_src/gdsdiff/test_core.cpp +93 -0
  60. gdsdiff/native_src/gdsdiff/test_cuda_assignment.cu +109 -0
  61. gdsdiff/native_src/gdsdiff/test_cuda_candidates.cu +94 -0
  62. gdsdiff/native_src/gdsdiff/test_cuda_memory.cu +97 -0
  63. gdsdiff/policy.py +305 -0
  64. gdsdiff/polygon_components.py +860 -0
  65. gdsdiff/profiles.py +152 -0
  66. gdsdiff/py.typed +1 -0
  67. gdsdiff/rect_coverage.py +384 -0
  68. gdsdiff/report.py +354 -0
  69. gdsdiff/schemas/gdsdiff_report-v1.schema.json +92 -0
  70. gdsdiff/semantics.py +75 -0
  71. gdsdiff/source_edge_diff.py +1120 -0
  72. gdsdiff/spatial.py +71 -0
  73. gdsdiff/structural_guard.py +161 -0
  74. gdsdiff/surplus_coverage.py +255 -0
  75. gdsdiff/synthetic_suite.py +1643 -0
  76. gdsdiff/templates.py +709 -0
  77. gdsdiff/tiling.py +153 -0
  78. gdsdiff/windowed_exact.py +270 -0
  79. gdsdiff/windows.py +184 -0
  80. gdsdiff-0.1.0.dist-info/METADATA +135 -0
  81. gdsdiff-0.1.0.dist-info/RECORD +85 -0
  82. gdsdiff-0.1.0.dist-info/WHEEL +5 -0
  83. gdsdiff-0.1.0.dist-info/entry_points.txt +4 -0
  84. gdsdiff-0.1.0.dist-info/licenses/LICENSE +674 -0
  85. gdsdiff-0.1.0.dist-info/top_level.txt +1 -0
gdsdiff/profiles.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+ import gdstk
9
+
10
+ from .api import CompareRunResult
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AssemblyProfile:
15
+ name: str
16
+ gds_path: Path
17
+ expected_top: str | None = None
18
+ expected_reference_count: int | None = None
19
+ expected_layers: tuple[tuple[int, int], ...] = ()
20
+ expected_files: tuple[Path, ...] = ()
21
+ manifest_path: Path | None = None
22
+ manifest_required_columns: tuple[str, ...] = ()
23
+ manifest_expected_rows: int | None = None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ProfileCheck:
28
+ name: str
29
+ passed: bool
30
+ observed: object
31
+ expected: object = None
32
+ message: str = ""
33
+
34
+ def to_json(self) -> dict[str, object]:
35
+ return {
36
+ "name": self.name,
37
+ "passed": self.passed,
38
+ "observed": self.observed,
39
+ "expected": self.expected,
40
+ "message": self.message,
41
+ }
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ProfileResult:
46
+ profile_name: str
47
+ checks: tuple[ProfileCheck, ...] = field(default_factory=tuple)
48
+
49
+ @property
50
+ def passed(self) -> bool:
51
+ return all(check.passed for check in self.checks)
52
+
53
+ def to_json(self) -> dict[str, object]:
54
+ return {
55
+ "profile_name": self.profile_name,
56
+ "passed": self.passed,
57
+ "checks": [check.to_json() for check in self.checks],
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CombinedProfileSummary:
63
+ geometry: CompareRunResult | None
64
+ assembly: ProfileResult | None
65
+
66
+ @property
67
+ def passed(self) -> bool:
68
+ geometry_ok = True if self.geometry is None else self.geometry.exit_code == 0
69
+ assembly_ok = True if self.assembly is None else self.assembly.passed
70
+ return geometry_ok and assembly_ok
71
+
72
+ def render_markdown(self) -> str:
73
+ lines = ["### GDS Comparator Profile Summary", ""]
74
+ if self.geometry is not None:
75
+ lines.extend(
76
+ [
77
+ f"- Geometry status: {self.geometry.status.value}.",
78
+ f"- Geometry exit code: {self.geometry.exit_code}.",
79
+ ]
80
+ )
81
+ if self.assembly is not None:
82
+ lines.append(f"- Assembly profile: {self.assembly.profile_name}.")
83
+ lines.append(f"- Assembly checks passed: {self.assembly.passed}.")
84
+ for check in self.assembly.checks:
85
+ status = "PASS" if check.passed else "FAIL"
86
+ lines.append(f" - {status} {check.name}: observed={check.observed!r} expected={check.expected!r}")
87
+ return "\n".join(lines) + "\n"
88
+
89
+
90
+ def check_assembly_profile(profile: AssemblyProfile) -> ProfileResult:
91
+ checks: list[ProfileCheck] = []
92
+ for path in profile.expected_files:
93
+ checks.append(ProfileCheck(name=f"file:{path.name}", passed=path.exists(), observed=str(path), expected="exists"))
94
+ if profile.gds_path.exists():
95
+ lib = gdstk.read_gds(str(profile.gds_path))
96
+ tops = lib.top_level()
97
+ top = _resolve_top(tops, profile.expected_top)
98
+ checks.append(ProfileCheck(name="top", passed=top is not None, observed=None if top is None else top.name, expected=profile.expected_top))
99
+ if top is not None:
100
+ if profile.expected_reference_count is not None:
101
+ observed = len(top.references)
102
+ checks.append(
103
+ ProfileCheck(
104
+ name="top_reference_count",
105
+ passed=observed == profile.expected_reference_count,
106
+ observed=observed,
107
+ expected=profile.expected_reference_count,
108
+ )
109
+ )
110
+ if profile.expected_layers:
111
+ observed_layers = {(polygon.layer, polygon.datatype) for polygon in top.get_polygons(apply_repetitions=True)}
112
+ expected_layers = set(profile.expected_layers)
113
+ checks.append(
114
+ ProfileCheck(
115
+ name="expected_layers",
116
+ passed=expected_layers <= observed_layers,
117
+ observed=sorted(observed_layers),
118
+ expected=sorted(expected_layers),
119
+ )
120
+ )
121
+ else:
122
+ checks.append(ProfileCheck(name="gds_path", passed=False, observed=str(profile.gds_path), expected="exists"))
123
+ if profile.manifest_path is not None:
124
+ checks.extend(_check_manifest(profile.manifest_path, profile.manifest_required_columns, profile.manifest_expected_rows))
125
+ return ProfileResult(profile.name, tuple(checks))
126
+
127
+
128
+ def combined_profile_summary(geometry: CompareRunResult | None = None, assembly: ProfileResult | None = None) -> CombinedProfileSummary:
129
+ return CombinedProfileSummary(geometry=geometry, assembly=assembly)
130
+
131
+
132
+ def _resolve_top(tops: list[gdstk.Cell], expected_top: str | None) -> gdstk.Cell | None:
133
+ if expected_top is None:
134
+ return tops[0] if len(tops) == 1 else None
135
+ for top in tops:
136
+ if top.name == expected_top:
137
+ return top
138
+ return None
139
+
140
+
141
+ def _check_manifest(path: Path, required_columns: Iterable[str], expected_rows: int | None) -> tuple[ProfileCheck, ...]:
142
+ if not path.exists():
143
+ return (ProfileCheck(name="manifest_path", passed=False, observed=str(path), expected="exists"),)
144
+ with path.open(newline="") as handle:
145
+ rows = list(csv.DictReader(handle))
146
+ checks = []
147
+ columns = set(rows[0]) if rows else set()
148
+ required = set(required_columns)
149
+ checks.append(ProfileCheck(name="manifest_columns", passed=required <= columns, observed=sorted(columns), expected=sorted(required)))
150
+ if expected_rows is not None:
151
+ checks.append(ProfileCheck(name="manifest_row_count", passed=len(rows) == expected_rows, observed=len(rows), expected=expected_rows))
152
+ return tuple(checks)
gdsdiff/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,384 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from time import perf_counter
5
+
6
+ import numpy as np
7
+
8
+ from .config import LayerSpec
9
+ from .exact_oracle import DiffFragment, ExactOracleResult, LayerExactDiff
10
+ from .geometry import PolygonBuffer, polygon_bbox, polygon_twice_area
11
+ from .polygon_components import _fragment_sort_key, _merge_layer_side_fragments
12
+
13
+
14
+ class RectCoverageUnsupported(RuntimeError):
15
+ pass
16
+
17
+
18
+ Rect = tuple[int, int, int, int]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RectCoverageStats:
23
+ component_count: int
24
+ cuda_task_count: int
25
+ old_rect_count: int
26
+ new_rect_count: int
27
+ dirty_rect_count: int
28
+ max_old_rects_per_component: int
29
+ max_new_rects_per_component: int
30
+ transferred_bytes: int
31
+ cuda_kernel_count: int
32
+ h2d_upload_s: float
33
+ gpu_xor_s: float
34
+ gpu_record_emit_s: float
35
+ accelerator: str = "cuda"
36
+ metal_task_count: int = 0
37
+
38
+ def to_json(self) -> dict[str, object]:
39
+ return {
40
+ "accelerator": self.accelerator,
41
+ "component_count": self.component_count,
42
+ "cuda_task_count": self.cuda_task_count,
43
+ "metal_task_count": self.metal_task_count,
44
+ "old_rect_count": self.old_rect_count,
45
+ "new_rect_count": self.new_rect_count,
46
+ "dirty_rect_count": self.dirty_rect_count,
47
+ "max_old_rects_per_component": self.max_old_rects_per_component,
48
+ "max_new_rects_per_component": self.max_new_rects_per_component,
49
+ "transferred_bytes": self.transferred_bytes,
50
+ "cuda_kernel_count": self.cuda_kernel_count,
51
+ "h2d_upload_s": self.h2d_upload_s,
52
+ "gpu_xor_s": self.gpu_xor_s,
53
+ "gpu_record_emit_s": self.gpu_record_emit_s,
54
+ }
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class RectCoverageResult:
59
+ exact_result: ExactOracleResult
60
+ stats: RectCoverageStats
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class _RectComponent:
65
+ layer: LayerSpec
66
+ old_rects: tuple[Rect, ...]
67
+ new_rects: tuple[Rect, ...]
68
+ bbox: Rect
69
+
70
+
71
+ def run_cuda_rectangle_coverage_diff(
72
+ old_buffer: PolygonBuffer,
73
+ new_buffer: PolygonBuffer,
74
+ *,
75
+ max_rects_per_component: int = 16,
76
+ max_fragments_per_component: int = 4096,
77
+ ) -> RectCoverageResult:
78
+ old_by_layer = _rectangles_by_layer(old_buffer)
79
+ new_by_layer = _rectangles_by_layer(new_buffer)
80
+ layers = tuple(sorted(set(old_by_layer) | set(new_by_layer)))
81
+ components: list[_RectComponent] = []
82
+ dirty_rect_count = 0
83
+ for layer in layers:
84
+ old_rects = tuple(sorted(set(old_by_layer.get(layer, ()))))
85
+ new_rects = tuple(sorted(set(new_by_layer.get(layer, ()))))
86
+ dirty = tuple(sorted((set(old_rects) ^ set(new_rects))))
87
+ dirty_rect_count += len(dirty)
88
+ components.extend(_dirty_components(layer, old_rects, new_rects, dirty))
89
+
90
+ if not components:
91
+ return RectCoverageResult(
92
+ exact_result=ExactOracleResult(tuple(LayerExactDiff(layer=layer) for layer in layers)),
93
+ stats=RectCoverageStats(
94
+ component_count=0,
95
+ cuda_task_count=0,
96
+ old_rect_count=sum(len(rects) for rects in old_by_layer.values()),
97
+ new_rect_count=sum(len(rects) for rects in new_by_layer.values()),
98
+ dirty_rect_count=0,
99
+ max_old_rects_per_component=0,
100
+ max_new_rects_per_component=0,
101
+ transferred_bytes=0,
102
+ cuda_kernel_count=0,
103
+ h2d_upload_s=0.0,
104
+ gpu_xor_s=0.0,
105
+ gpu_record_emit_s=0.0,
106
+ ),
107
+ )
108
+
109
+ max_old = max(len(component.old_rects) for component in components)
110
+ max_new = max(len(component.new_rects) for component in components)
111
+ max_rects = max(max_old, max_new)
112
+ if max_rects > max_rects_per_component:
113
+ raise RectCoverageUnsupported(
114
+ f"rectangle coverage component exceeds CUDA rectangle limit: {max_rects} > {max_rects_per_component}"
115
+ )
116
+
117
+ from .backends.cuda_ctypes import CudaCtypesUnavailable, run_cuda_rect_set_exact_fragments
118
+
119
+ old_array = np.zeros((len(components), max_rects, 4), dtype=np.int64)
120
+ new_array = np.zeros((len(components), max_rects, 4), dtype=np.int64)
121
+ old_counts = np.zeros((len(components),), dtype=np.uint8)
122
+ new_counts = np.zeros((len(components),), dtype=np.uint8)
123
+ for row, component in enumerate(components):
124
+ old_counts[row] = len(component.old_rects)
125
+ new_counts[row] = len(component.new_rects)
126
+ for col, rect in enumerate(component.old_rects):
127
+ old_array[row, col] = rect
128
+ for col, rect in enumerate(component.new_rects):
129
+ new_array[row, col] = rect
130
+
131
+ transferred_bytes = int(old_array.nbytes + new_array.nbytes + old_counts.nbytes + new_counts.nbytes)
132
+ start = perf_counter()
133
+ try:
134
+ rects, counts, _areas = run_cuda_rect_set_exact_fragments(
135
+ old_array,
136
+ new_array,
137
+ old_counts,
138
+ new_counts,
139
+ max_fragments=max_fragments_per_component,
140
+ )
141
+ except CudaCtypesUnavailable as exc:
142
+ raise RectCoverageUnsupported(str(exc)) from exc
143
+ gpu_s = perf_counter() - start
144
+
145
+ diffs_by_layer: dict[LayerSpec, list[LayerExactDiff]] = {layer: [] for layer in layers}
146
+ emit_start = perf_counter()
147
+ for row, component in enumerate(components):
148
+ old_fragments = tuple(_fragment_from_rect(tuple(int(v) for v in rects[row, 0, index])) for index in range(int(counts[row, 0])))
149
+ new_fragments = tuple(_fragment_from_rect(tuple(int(v) for v in rects[row, 1, index])) for index in range(int(counts[row, 1])))
150
+ diffs_by_layer[component.layer].append(
151
+ LayerExactDiff(
152
+ layer=component.layer,
153
+ old_minus_new=old_fragments,
154
+ new_minus_old=new_fragments,
155
+ xor=old_fragments + new_fragments,
156
+ )
157
+ )
158
+ emit_s = perf_counter() - emit_start
159
+ layer_diffs = tuple(_merge_layer_diffs(layer, diffs_by_layer[layer]) for layer in layers)
160
+ return RectCoverageResult(
161
+ exact_result=ExactOracleResult(layer_diffs),
162
+ stats=RectCoverageStats(
163
+ component_count=len(components),
164
+ cuda_task_count=len(components),
165
+ old_rect_count=sum(len(rects) for rects in old_by_layer.values()),
166
+ new_rect_count=sum(len(rects) for rects in new_by_layer.values()),
167
+ dirty_rect_count=dirty_rect_count,
168
+ max_old_rects_per_component=max_old,
169
+ max_new_rects_per_component=max_new,
170
+ transferred_bytes=transferred_bytes,
171
+ cuda_kernel_count=1,
172
+ h2d_upload_s=0.0,
173
+ gpu_xor_s=gpu_s,
174
+ gpu_record_emit_s=emit_s,
175
+ ),
176
+ )
177
+
178
+
179
+ def run_metal_rectangle_coverage_diff(
180
+ old_buffer: PolygonBuffer,
181
+ new_buffer: PolygonBuffer,
182
+ *,
183
+ max_rects_per_component: int = 16,
184
+ max_fragments_per_component: int = 4096,
185
+ ) -> RectCoverageResult:
186
+ old_by_layer = _rectangles_by_layer(old_buffer)
187
+ new_by_layer = _rectangles_by_layer(new_buffer)
188
+ layers = tuple(sorted(set(old_by_layer) | set(new_by_layer)))
189
+ components: list[_RectComponent] = []
190
+ dirty_rect_count = 0
191
+ for layer in layers:
192
+ old_rects = tuple(sorted(set(old_by_layer.get(layer, ()))))
193
+ new_rects = tuple(sorted(set(new_by_layer.get(layer, ()))))
194
+ dirty = tuple(sorted((set(old_rects) ^ set(new_rects))))
195
+ dirty_rect_count += len(dirty)
196
+ components.extend(_dirty_components(layer, old_rects, new_rects, dirty))
197
+
198
+ if not components:
199
+ return RectCoverageResult(
200
+ exact_result=ExactOracleResult(tuple(LayerExactDiff(layer=layer) for layer in layers)),
201
+ stats=RectCoverageStats(
202
+ component_count=0,
203
+ cuda_task_count=0,
204
+ old_rect_count=sum(len(rects) for rects in old_by_layer.values()),
205
+ new_rect_count=sum(len(rects) for rects in new_by_layer.values()),
206
+ dirty_rect_count=0,
207
+ max_old_rects_per_component=0,
208
+ max_new_rects_per_component=0,
209
+ transferred_bytes=0,
210
+ cuda_kernel_count=0,
211
+ h2d_upload_s=0.0,
212
+ gpu_xor_s=0.0,
213
+ gpu_record_emit_s=0.0,
214
+ accelerator="metal",
215
+ metal_task_count=0,
216
+ ),
217
+ )
218
+
219
+ max_old = max(len(component.old_rects) for component in components)
220
+ max_new = max(len(component.new_rects) for component in components)
221
+ max_rects = max(max_old, max_new)
222
+ if max_rects > max_rects_per_component:
223
+ raise RectCoverageUnsupported(
224
+ f"rectangle coverage component exceeds Metal rectangle limit: {max_rects} > {max_rects_per_component}"
225
+ )
226
+
227
+ from .backends.metal_ctypes import MetalCtypesUnavailable, run_metal_rect_set_exact_fragments
228
+
229
+ old_array = np.zeros((len(components), max_rects, 4), dtype=np.int64)
230
+ new_array = np.zeros((len(components), max_rects, 4), dtype=np.int64)
231
+ old_counts = np.zeros((len(components),), dtype=np.uint8)
232
+ new_counts = np.zeros((len(components),), dtype=np.uint8)
233
+ for row, component in enumerate(components):
234
+ old_counts[row] = len(component.old_rects)
235
+ new_counts[row] = len(component.new_rects)
236
+ for col, rect in enumerate(component.old_rects):
237
+ old_array[row, col] = rect
238
+ for col, rect in enumerate(component.new_rects):
239
+ new_array[row, col] = rect
240
+
241
+ transferred_bytes = int(old_array.nbytes + new_array.nbytes + old_counts.nbytes + new_counts.nbytes)
242
+ start = perf_counter()
243
+ try:
244
+ rects, counts, _areas = run_metal_rect_set_exact_fragments(
245
+ old_array,
246
+ new_array,
247
+ old_counts,
248
+ new_counts,
249
+ max_fragments=max_fragments_per_component,
250
+ )
251
+ except MetalCtypesUnavailable as exc:
252
+ raise RectCoverageUnsupported(str(exc)) from exc
253
+ gpu_s = perf_counter() - start
254
+
255
+ diffs_by_layer: dict[LayerSpec, list[LayerExactDiff]] = {layer: [] for layer in layers}
256
+ emit_start = perf_counter()
257
+ for row, component in enumerate(components):
258
+ old_fragments = tuple(_fragment_from_rect(tuple(int(v) for v in rects[row, 0, index])) for index in range(int(counts[row, 0])))
259
+ new_fragments = tuple(_fragment_from_rect(tuple(int(v) for v in rects[row, 1, index])) for index in range(int(counts[row, 1])))
260
+ diffs_by_layer[component.layer].append(
261
+ LayerExactDiff(
262
+ layer=component.layer,
263
+ old_minus_new=old_fragments,
264
+ new_minus_old=new_fragments,
265
+ xor=old_fragments + new_fragments,
266
+ )
267
+ )
268
+ emit_s = perf_counter() - emit_start
269
+ layer_diffs = tuple(_merge_layer_diffs(layer, diffs_by_layer[layer]) for layer in layers)
270
+ return RectCoverageResult(
271
+ exact_result=ExactOracleResult(layer_diffs),
272
+ stats=RectCoverageStats(
273
+ component_count=len(components),
274
+ cuda_task_count=0,
275
+ old_rect_count=sum(len(rects) for rects in old_by_layer.values()),
276
+ new_rect_count=sum(len(rects) for rects in new_by_layer.values()),
277
+ dirty_rect_count=dirty_rect_count,
278
+ max_old_rects_per_component=max_old,
279
+ max_new_rects_per_component=max_new,
280
+ transferred_bytes=transferred_bytes,
281
+ cuda_kernel_count=0,
282
+ h2d_upload_s=0.0,
283
+ gpu_xor_s=gpu_s,
284
+ gpu_record_emit_s=emit_s,
285
+ accelerator="metal",
286
+ metal_task_count=len(components),
287
+ ),
288
+ )
289
+
290
+
291
+ def _rectangles_by_layer(buffer: PolygonBuffer) -> dict[LayerSpec, tuple[Rect, ...]]:
292
+ by_layer: dict[LayerSpec, list[Rect]] = {}
293
+ for index, layer_id in enumerate(buffer.layer_ids):
294
+ if int(buffer.flags[index]) != 0:
295
+ continue
296
+ layer = buffer.layer_table.layers[int(layer_id)]
297
+ rect = _as_rect(tuple((int(x), int(y)) for x, y in buffer.polygon_vertices(index)))
298
+ if rect is None:
299
+ raise RectCoverageUnsupported("non-rectangle polygon present in rectangle coverage path")
300
+ by_layer.setdefault(layer, []).append(rect)
301
+ return {layer: tuple(rects) for layer, rects in by_layer.items()}
302
+
303
+
304
+ def _as_rect(points: tuple[tuple[int, int], ...]) -> Rect | None:
305
+ if len(points) != 4:
306
+ return None
307
+ xs = sorted({point[0] for point in points})
308
+ ys = sorted({point[1] for point in points})
309
+ if len(xs) != 2 or len(ys) != 2:
310
+ return None
311
+ if set(points) != {(xs[0], ys[0]), (xs[0], ys[1]), (xs[1], ys[0]), (xs[1], ys[1])}:
312
+ return None
313
+ if xs[0] >= xs[1] or ys[0] >= ys[1]:
314
+ return None
315
+ return xs[0], ys[0], xs[1], ys[1]
316
+
317
+
318
+ def _dirty_components(layer: LayerSpec, old_rects: tuple[Rect, ...], new_rects: tuple[Rect, ...], dirty: tuple[Rect, ...]) -> list[_RectComponent]:
319
+ if not dirty:
320
+ return []
321
+ candidates = tuple(sorted(set(old_rects) | set(new_rects)))
322
+ components: list[set[Rect]] = [{rect} for rect in dirty]
323
+ changed = True
324
+ while changed:
325
+ changed = False
326
+ for component in components:
327
+ bbox = _bbox_union(tuple(component))
328
+ for rect in candidates:
329
+ if rect not in component and _positive_overlap(bbox, rect):
330
+ component.add(rect)
331
+ changed = True
332
+ merged: list[set[Rect]] = []
333
+ while components:
334
+ component = components.pop(0)
335
+ merged_any = False
336
+ for other in merged:
337
+ if _positive_overlap(_bbox_union(tuple(component)), _bbox_union(tuple(other))):
338
+ other.update(component)
339
+ merged_any = True
340
+ changed = True
341
+ break
342
+ if not merged_any:
343
+ merged.append(component)
344
+ components = merged
345
+ out = []
346
+ old_set = set(old_rects)
347
+ new_set = set(new_rects)
348
+ for component in components:
349
+ old_component = tuple(sorted(rect for rect in component if rect in old_set))
350
+ new_component = tuple(sorted(rect for rect in component if rect in new_set))
351
+ out.append(_RectComponent(layer=layer, old_rects=old_component, new_rects=new_component, bbox=_bbox_union(tuple(component))))
352
+ return sorted(out, key=lambda item: (item.layer, item.bbox))
353
+
354
+
355
+ def _positive_overlap(left: Rect, right: Rect) -> bool:
356
+ return max(left[0], right[0]) < min(left[2], right[2]) and max(left[1], right[1]) < min(left[3], right[3])
357
+
358
+
359
+ def _bbox_union(rects: tuple[Rect, ...]) -> Rect:
360
+ return (
361
+ min(rect[0] for rect in rects),
362
+ min(rect[1] for rect in rects),
363
+ max(rect[2] for rect in rects),
364
+ max(rect[3] for rect in rects),
365
+ )
366
+
367
+
368
+ def _fragment_from_rect(rect: Rect) -> DiffFragment:
369
+ x0, y0, x1, y1 = rect
370
+ points = ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
371
+ return DiffFragment(points=points, bbox=polygon_bbox(points), twice_area=polygon_twice_area(points))
372
+
373
+
374
+ def _merge_layer_diffs(layer: LayerSpec, diffs: list[LayerExactDiff]) -> LayerExactDiff:
375
+ old_minus = tuple(fragment for diff in diffs for fragment in diff.old_minus_new)
376
+ new_minus = tuple(fragment for diff in diffs for fragment in diff.new_minus_old)
377
+ merged_old_minus = _merge_layer_side_fragments(tuple(sorted(old_minus, key=_fragment_sort_key)))
378
+ merged_new_minus = _merge_layer_side_fragments(tuple(sorted(new_minus, key=_fragment_sort_key)))
379
+ return LayerExactDiff(
380
+ layer=layer,
381
+ old_minus_new=merged_old_minus,
382
+ new_minus_old=merged_new_minus,
383
+ xor=tuple(sorted(merged_old_minus + merged_new_minus, key=_fragment_sort_key)),
384
+ )