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/spatial.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from .config import LayerSpec
8
+ from .geometry import PolygonBuffer
9
+
10
+ BBox = tuple[int, int, int, int]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SpatialIndex:
15
+ entries_by_layer: dict[LayerSpec, tuple[tuple[BBox, int], ...]]
16
+ bbox_arrays_by_layer: dict[LayerSpec, np.ndarray] | None = None
17
+ index_arrays_by_layer: dict[LayerSpec, np.ndarray] | None = None
18
+
19
+ @classmethod
20
+ def from_buffer(cls, buffer: PolygonBuffer) -> "SpatialIndex":
21
+ entries: dict[LayerSpec, list[tuple[BBox, int]]] = {}
22
+ for index in range(buffer.polygon_count):
23
+ layer = buffer.layer_table.layers[int(buffer.layer_ids[index])]
24
+ bbox = tuple(int(value) for value in buffer.bboxes[index])
25
+ entries.setdefault(layer, []).append((bbox, index))
26
+ sorted_entries = {layer: tuple(sorted(items, key=lambda item: (item[0], item[1]))) for layer, items in entries.items()}
27
+ bbox_arrays = {
28
+ layer: np.asarray([item[0] for item in items], dtype=np.int64).reshape((-1, 4))
29
+ for layer, items in sorted_entries.items()
30
+ }
31
+ index_arrays = {
32
+ layer: np.asarray([item[1] for item in items], dtype=np.int64)
33
+ for layer, items in sorted_entries.items()
34
+ }
35
+ return cls(sorted_entries, bbox_arrays_by_layer=bbox_arrays, index_arrays_by_layer=index_arrays)
36
+
37
+ def query(self, layer: LayerSpec, bbox: BBox) -> tuple[int, ...]:
38
+ if self.bbox_arrays_by_layer is not None and self.index_arrays_by_layer is not None:
39
+ bboxes = self.bbox_arrays_by_layer.get(layer)
40
+ indices = self.index_arrays_by_layer.get(layer)
41
+ if bboxes is None or indices is None or len(bboxes) == 0:
42
+ return ()
43
+ mask = (
44
+ (bboxes[:, 0] <= bbox[2])
45
+ & (bboxes[:, 2] >= bbox[0])
46
+ & (bboxes[:, 1] <= bbox[3])
47
+ & (bboxes[:, 3] >= bbox[1])
48
+ )
49
+ return tuple(int(index) for index in indices[np.nonzero(mask)[0]])
50
+ return tuple(index for candidate_bbox, index in self.entries_by_layer.get(layer, ()) if bboxes_intersect(candidate_bbox, bbox))
51
+
52
+
53
+ def bboxes_intersect(left: BBox, right: BBox) -> bool:
54
+ return left[0] <= right[2] and left[2] >= right[0] and left[1] <= right[3] and left[3] >= right[1]
55
+
56
+
57
+ def bbox_union(bboxes: list[BBox] | tuple[BBox, ...]) -> BBox:
58
+ if not bboxes:
59
+ raise ValueError("bbox_union requires at least one bbox")
60
+ return (
61
+ min(bbox[0] for bbox in bboxes),
62
+ min(bbox[1] for bbox in bboxes),
63
+ max(bbox[2] for bbox in bboxes),
64
+ max(bbox[3] for bbox in bboxes),
65
+ )
66
+
67
+
68
+ def expand_bbox(bbox: BBox, amount: int) -> BBox:
69
+ if amount < 0:
70
+ raise ValueError("expansion amount must be nonnegative")
71
+ return bbox[0] - amount, bbox[1] - amount, bbox[2] + amount, bbox[3] + amount
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import struct
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ class StructuralGuardUnavailable(ValueError):
9
+ pass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CellStructure:
14
+ name: str
15
+ local_shape_count: int = 0
16
+ references: tuple[tuple[str, int], ...] = field(default_factory=tuple)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class StructuralSummary:
21
+ path: Path
22
+ selected_top: str
23
+ cell_count: int
24
+ flattened_shape_count: int
25
+ reachable_cell_count: int
26
+
27
+ def to_json(self) -> dict[str, object]:
28
+ return {
29
+ "path": str(self.path),
30
+ "selected_top": self.selected_top,
31
+ "cell_count": self.cell_count,
32
+ "flattened_shape_count": self.flattened_shape_count,
33
+ "reachable_cell_count": self.reachable_cell_count,
34
+ }
35
+
36
+
37
+ def summarize_gds_structure(path: str | Path, *, top_name: str | None = None) -> StructuralSummary:
38
+ path = Path(path)
39
+ cells = _parse_cells(path)
40
+ if not cells:
41
+ raise StructuralGuardUnavailable("no cells found")
42
+ top = _select_top(cells, top_name)
43
+ visiting: set[str] = set()
44
+ seen: set[str] = set()
45
+ count = _flattened_shape_count(top, cells, visiting, seen)
46
+ return StructuralSummary(
47
+ path=path,
48
+ selected_top=top,
49
+ cell_count=len(cells),
50
+ flattened_shape_count=count,
51
+ reachable_cell_count=len(seen),
52
+ )
53
+
54
+
55
+ def _parse_cells(path: Path) -> dict[str, CellStructure]:
56
+ cells: dict[str, CellStructure] = {}
57
+ current_name: str | None = None
58
+ local_shape_count = 0
59
+ references: list[tuple[str, int]] = []
60
+ element_kind: int | None = None
61
+ pending_sname: str | None = None
62
+ pending_multiplier = 1
63
+
64
+ data = path.read_bytes()
65
+ offset = 0
66
+ size = len(data)
67
+ while offset + 4 <= size:
68
+ record_len, record_type, _data_type = struct.unpack_from(">HBB", data, offset)
69
+ if record_len < 4 or offset + record_len > size:
70
+ raise StructuralGuardUnavailable("invalid GDS record length")
71
+ payload = data[offset + 4 : offset + record_len]
72
+ offset += record_len
73
+
74
+ if record_type == 0x05: # BGNSTR
75
+ current_name = None
76
+ local_shape_count = 0
77
+ references = []
78
+ elif record_type == 0x06: # STRNAME
79
+ current_name = _decode_gds_string(payload)
80
+ elif record_type in (0x08, 0x09): # BOUNDARY, PATH
81
+ element_kind = record_type
82
+ elif record_type in (0x0A, 0x0B): # SREF, AREF
83
+ element_kind = record_type
84
+ pending_sname = None
85
+ pending_multiplier = 1
86
+ elif record_type == 0x12 and element_kind in (0x0A, 0x0B): # SNAME
87
+ pending_sname = _decode_gds_string(payload)
88
+ elif record_type == 0x13 and element_kind == 0x0B: # COLROW
89
+ if len(payload) < 4:
90
+ raise StructuralGuardUnavailable("invalid AREF COLROW record")
91
+ columns, rows = struct.unpack_from(">hh", payload, 0)
92
+ if columns <= 0 or rows <= 0:
93
+ raise StructuralGuardUnavailable("invalid AREF repetition")
94
+ pending_multiplier = int(columns) * int(rows)
95
+ elif record_type == 0x11: # ENDEL
96
+ if element_kind in (0x08, 0x09):
97
+ local_shape_count += 1
98
+ elif element_kind in (0x0A, 0x0B):
99
+ if pending_sname is None:
100
+ raise StructuralGuardUnavailable("reference missing SNAME")
101
+ references.append((pending_sname, pending_multiplier))
102
+ element_kind = None
103
+ pending_sname = None
104
+ pending_multiplier = 1
105
+ elif record_type == 0x07: # ENDSTR
106
+ if current_name is None:
107
+ raise StructuralGuardUnavailable("cell missing STRNAME")
108
+ cells[current_name] = CellStructure(
109
+ name=current_name,
110
+ local_shape_count=local_shape_count,
111
+ references=tuple(references),
112
+ )
113
+ current_name = None
114
+ local_shape_count = 0
115
+ references = []
116
+
117
+ if offset != size:
118
+ raise StructuralGuardUnavailable("trailing partial GDS record")
119
+ return cells
120
+
121
+
122
+ def _select_top(cells: dict[str, CellStructure], top_name: str | None) -> str:
123
+ if top_name is not None:
124
+ if top_name not in cells:
125
+ raise StructuralGuardUnavailable(f"top cell {top_name!r} not found")
126
+ return top_name
127
+ referenced = {child for cell in cells.values() for child, _multiplier in cell.references}
128
+ tops = sorted(set(cells) - referenced)
129
+ if len(tops) != 1:
130
+ raise StructuralGuardUnavailable(f"expected exactly one top cell, found {len(tops)}")
131
+ return tops[0]
132
+
133
+
134
+ def _flattened_shape_count(
135
+ name: str,
136
+ cells: dict[str, CellStructure],
137
+ visiting: set[str],
138
+ seen: set[str],
139
+ memo: dict[str, int] | None = None,
140
+ ) -> int:
141
+ memo = memo if memo is not None else {}
142
+ if name in memo:
143
+ seen.add(name)
144
+ return memo[name]
145
+ if name in visiting:
146
+ raise StructuralGuardUnavailable("cell reference cycle detected")
147
+ cell = cells.get(name)
148
+ if cell is None:
149
+ raise StructuralGuardUnavailable(f"unresolved reference to {name!r}")
150
+ visiting.add(name)
151
+ seen.add(name)
152
+ total = cell.local_shape_count
153
+ for child_name, multiplier in cell.references:
154
+ total += multiplier * _flattened_shape_count(child_name, cells, visiting, seen, memo)
155
+ visiting.remove(name)
156
+ memo[name] = total
157
+ return total
158
+
159
+
160
+ def _decode_gds_string(payload: bytes) -> str:
161
+ return payload.rstrip(b"\0").decode("ascii")
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from time import perf_counter
6
+
7
+ from .canonicalize import intern_polygons
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 build_record_delta_surplus_components
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SurplusCoverageStats:
16
+ old_surplus_count: int
17
+ new_surplus_count: int
18
+ changed_layer_count: int
19
+ conservative_overlap_layer_count: int
20
+ component_count: int
21
+ overlap_component_count: int
22
+ max_fragments_per_component: int
23
+ max_vertices_per_component: int
24
+ coverage_exact_proven: bool
25
+ canonicalize_s: float
26
+ record_emit_s: float
27
+
28
+ def to_json(self) -> dict[str, object]:
29
+ return {
30
+ "old_surplus_count": self.old_surplus_count,
31
+ "new_surplus_count": self.new_surplus_count,
32
+ "changed_layer_count": self.changed_layer_count,
33
+ "conservative_overlap_layer_count": self.conservative_overlap_layer_count,
34
+ "component_count": self.component_count,
35
+ "overlap_component_count": self.overlap_component_count,
36
+ "max_fragments_per_component": self.max_fragments_per_component,
37
+ "max_vertices_per_component": self.max_vertices_per_component,
38
+ "coverage_exact_proven": self.coverage_exact_proven,
39
+ "canonicalize_s": self.canonicalize_s,
40
+ "record_emit_s": self.record_emit_s,
41
+ }
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SurplusCoverageResult:
46
+ exact_result: ExactOracleResult
47
+ stats: SurplusCoverageStats
48
+
49
+
50
+ def run_canonical_surplus_coverage_probe(old_buffer: PolygonBuffer, new_buffer: PolygonBuffer) -> SurplusCoverageResult:
51
+ """Emit directional component records from canonical source-polygon surplus.
52
+
53
+ This is a coverage probe, not the final arbitrary-polygon Boolean engine. It is exact only
54
+ when the surplus polygons are conservatively proven independent by positive bbox overlap.
55
+ """
56
+
57
+ canonicalize_start = perf_counter()
58
+ old_intern = intern_polygons(old_buffer)
59
+ new_intern = intern_polygons(new_buffer)
60
+ canonicalize_s = perf_counter() - canonicalize_start
61
+
62
+ old_by_key = _records_by_key(old_buffer, old_intern.records)
63
+ new_by_key = _records_by_key(new_buffer, new_intern.records)
64
+
65
+ emit_start = perf_counter()
66
+ old_surplus: dict[LayerSpec, list[DiffFragment]] = defaultdict(list)
67
+ new_surplus: dict[LayerSpec, list[DiffFragment]] = defaultdict(list)
68
+ for key in sorted(set(old_by_key) | set(new_by_key), key=_key_sort):
69
+ old_records = old_by_key.get(key, ())
70
+ new_records = new_by_key.get(key, ())
71
+ delta = len(old_records) - len(new_records)
72
+ if delta > 0:
73
+ layer = key[0]
74
+ old_surplus[layer].extend(_fragment_from_buffer(old_buffer, record.polygon_index) for record in old_records[:delta])
75
+ elif delta < 0:
76
+ layer = key[0]
77
+ new_surplus[layer].extend(_fragment_from_buffer(new_buffer, record.polygon_index) for record in new_records[: -delta])
78
+
79
+ layers = tuple(sorted(set(old_surplus) | set(new_surplus)))
80
+ layer_diffs = tuple(
81
+ LayerExactDiff(
82
+ layer=layer,
83
+ old_minus_new=tuple(sorted(old_surplus.get(layer, ()), key=_fragment_sort_key)),
84
+ new_minus_old=tuple(sorted(new_surplus.get(layer, ()), key=_fragment_sort_key)),
85
+ xor=tuple(
86
+ sorted(
87
+ tuple(old_surplus.get(layer, ())) + tuple(new_surplus.get(layer, ())),
88
+ key=_fragment_sort_key,
89
+ )
90
+ ),
91
+ )
92
+ for layer in layers
93
+ )
94
+ overlap_layers = tuple(
95
+ layer
96
+ for layer in layers
97
+ if _has_positive_bbox_overlap(tuple(old_surplus.get(layer, ())) + tuple(new_surplus.get(layer, ())))
98
+ )
99
+ component_stats = _component_stats(old_surplus, new_surplus)
100
+ record_emit_s = perf_counter() - emit_start
101
+ return SurplusCoverageResult(
102
+ exact_result=ExactOracleResult(layer_diffs),
103
+ stats=SurplusCoverageStats(
104
+ old_surplus_count=sum(len(fragments) for fragments in old_surplus.values()),
105
+ new_surplus_count=sum(len(fragments) for fragments in new_surplus.values()),
106
+ changed_layer_count=len(layers),
107
+ conservative_overlap_layer_count=len(overlap_layers),
108
+ component_count=component_stats["component_count"],
109
+ overlap_component_count=component_stats["overlap_component_count"],
110
+ max_fragments_per_component=component_stats["max_fragments_per_component"],
111
+ max_vertices_per_component=component_stats["max_vertices_per_component"],
112
+ coverage_exact_proven=len(overlap_layers) == 0,
113
+ canonicalize_s=canonicalize_s,
114
+ record_emit_s=record_emit_s,
115
+ ),
116
+ )
117
+
118
+
119
+ def run_record_delta_surplus_coverage_probe(old_buffer: PolygonBuffer, new_buffer: PolygonBuffer, record_delta) -> SurplusCoverageResult:
120
+ """Emit source-surplus component records from GPU record-delta rows.
121
+
122
+ The CUDA record-delta path uses canonical orientation-insensitive hashes and returns a
123
+ representative source polygon index for each changed key. This removes the full CPU
124
+ canonicalization pass, but changed coverage is not marked exact until the true polygon
125
+ Boolean engine exists.
126
+ """
127
+
128
+ emit_start = perf_counter()
129
+ component_result = build_record_delta_surplus_components(old_buffer, new_buffer, record_delta)
130
+ old_surplus: dict[LayerSpec, list[DiffFragment]] = defaultdict(list)
131
+ new_surplus: dict[LayerSpec, list[DiffFragment]] = defaultdict(list)
132
+ for component in component_result.components:
133
+ for polygon in component.polygons:
134
+ if polygon.side == "old":
135
+ old_surplus[component.layer].append(polygon.fragment)
136
+ else:
137
+ new_surplus[component.layer].append(polygon.fragment)
138
+
139
+ layers = tuple(sorted(set(old_surplus) | set(new_surplus)))
140
+ layer_diffs = tuple(
141
+ LayerExactDiff(
142
+ layer=layer,
143
+ old_minus_new=tuple(sorted(old_surplus.get(layer, ()), key=_fragment_sort_key)),
144
+ new_minus_old=tuple(sorted(new_surplus.get(layer, ()), key=_fragment_sort_key)),
145
+ xor=tuple(
146
+ sorted(
147
+ tuple(old_surplus.get(layer, ())) + tuple(new_surplus.get(layer, ())),
148
+ key=_fragment_sort_key,
149
+ )
150
+ ),
151
+ )
152
+ for layer in layers
153
+ )
154
+ overlap_layers = tuple(
155
+ layer
156
+ for layer in layers
157
+ if _has_positive_bbox_overlap(tuple(old_surplus.get(layer, ())) + tuple(new_surplus.get(layer, ())))
158
+ )
159
+ record_emit_s = perf_counter() - emit_start
160
+ changed_count = sum(len(fragments) for fragments in old_surplus.values()) + sum(
161
+ len(fragments) for fragments in new_surplus.values()
162
+ )
163
+ return SurplusCoverageResult(
164
+ exact_result=ExactOracleResult(layer_diffs),
165
+ stats=SurplusCoverageStats(
166
+ old_surplus_count=sum(len(fragments) for fragments in old_surplus.values()),
167
+ new_surplus_count=sum(len(fragments) for fragments in new_surplus.values()),
168
+ changed_layer_count=len(layers),
169
+ conservative_overlap_layer_count=len(overlap_layers),
170
+ component_count=component_result.stats.component_count,
171
+ overlap_component_count=component_result.stats.overlap_component_count,
172
+ max_fragments_per_component=component_result.stats.max_fragments_per_component,
173
+ max_vertices_per_component=component_result.stats.max_vertices_per_component,
174
+ coverage_exact_proven=changed_count == 0,
175
+ canonicalize_s=0.0,
176
+ record_emit_s=record_emit_s,
177
+ ),
178
+ )
179
+ def _records_by_key(buffer: PolygonBuffer, records) -> dict[tuple[LayerSpec, bytes], tuple[object, ...]]:
180
+ by_key: dict[tuple[LayerSpec, bytes], list[object]] = defaultdict(list)
181
+ for record in records:
182
+ layer = buffer.layer_table.layers[int(buffer.layer_ids[record.polygon_index])]
183
+ by_key[(layer, record.blob)].append(record)
184
+ return {key: tuple(records) for key, records in by_key.items()}
185
+
186
+
187
+ def _fragment_from_buffer(buffer: PolygonBuffer, index: int) -> DiffFragment:
188
+ points = tuple((int(x), int(y)) for x, y in buffer.polygon_vertices(index))
189
+ return DiffFragment(points=points, bbox=polygon_bbox(points), twice_area=polygon_twice_area(points))
190
+
191
+
192
+ def _has_positive_bbox_overlap(fragments: tuple[DiffFragment, ...]) -> bool:
193
+ for left_index, left in enumerate(fragments):
194
+ for right in fragments[left_index + 1 :]:
195
+ if _positive_overlap(left.bbox, right.bbox):
196
+ return True
197
+ return False
198
+
199
+
200
+ def _component_stats(
201
+ old_surplus: dict[LayerSpec, list[DiffFragment]],
202
+ new_surplus: dict[LayerSpec, list[DiffFragment]],
203
+ ) -> dict[str, int]:
204
+ component_count = 0
205
+ overlap_component_count = 0
206
+ max_fragments = 0
207
+ max_vertices = 0
208
+ for layer in sorted(set(old_surplus) | set(new_surplus)):
209
+ fragments = tuple(old_surplus.get(layer, ())) + tuple(new_surplus.get(layer, ()))
210
+ if not fragments:
211
+ continue
212
+ components = _fragment_components(fragments)
213
+ component_count += len(components)
214
+ for component in components:
215
+ max_fragments = max(max_fragments, len(component))
216
+ max_vertices = max(max_vertices, sum(len(fragments[index].points) for index in component))
217
+ if _has_positive_bbox_overlap(tuple(fragments[index] for index in component)):
218
+ overlap_component_count += 1
219
+ return {
220
+ "component_count": component_count,
221
+ "overlap_component_count": overlap_component_count,
222
+ "max_fragments_per_component": max_fragments,
223
+ "max_vertices_per_component": max_vertices,
224
+ }
225
+
226
+
227
+ def _fragment_components(fragments: tuple[DiffFragment, ...]) -> tuple[tuple[int, ...], ...]:
228
+ remaining = set(range(len(fragments)))
229
+ components: list[tuple[int, ...]] = []
230
+ while remaining:
231
+ seed = remaining.pop()
232
+ component = {seed}
233
+ stack = [seed]
234
+ while stack:
235
+ left = stack.pop()
236
+ touched = [right for right in remaining if _positive_overlap(fragments[left].bbox, fragments[right].bbox)]
237
+ for right in touched:
238
+ remaining.remove(right)
239
+ component.add(right)
240
+ stack.append(right)
241
+ components.append(tuple(sorted(component)))
242
+ return tuple(sorted(components))
243
+
244
+
245
+ def _positive_overlap(left: tuple[int, int, int, int], right: tuple[int, int, int, int]) -> bool:
246
+ return max(left[0], right[0]) < min(left[2], right[2]) and max(left[1], right[1]) < min(left[3], right[3])
247
+
248
+
249
+ def _key_sort(key: tuple[LayerSpec, bytes]) -> tuple[int, int, bytes]:
250
+ layer, blob = key
251
+ return layer.layer, layer.datatype, blob
252
+
253
+
254
+ def _fragment_sort_key(fragment: DiffFragment) -> tuple[tuple[int, int, int, int], int, tuple[tuple[int, int], ...]]:
255
+ return fragment.bbox, fragment.twice_area, fragment.points