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/templates.py ADDED
@@ -0,0 +1,709 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from decimal import Decimal
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ import gdstk
11
+ import numpy as np
12
+
13
+ from .config import LayerSpec, parse_layer_selector
14
+ from .exact_oracle import ExactOracleResult, format_twice_area, run_full_exact_oracle
15
+ from .extract import extract_gds_polygons
16
+ from .gds_metadata import decimal_from_number
17
+ from .geometry import (
18
+ LayerTable,
19
+ PolygonBuffer,
20
+ normalize_polygon_points,
21
+ polygon_bbox,
22
+ polygon_flags,
23
+ polygon_twice_area,
24
+ )
25
+ from .grid import snap_to_grid
26
+ from .hierarchy_extract import extract_hierarchy_window
27
+ from .spatial import BBox, bboxes_intersect
28
+
29
+
30
+ USER_BBOX = tuple[float, float, float, float]
31
+ TRANSFORM_TRANSLATE = "translate"
32
+ TRANSFORM_ROT90 = "rot90"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class TemplateExtractionResult:
37
+ output_gds: Path
38
+ metadata_path: Path | None
39
+ metadata: dict[str, object]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TemplateFindResult:
44
+ output_dir: Path
45
+ matches_json: Path
46
+ matches_markdown: Path
47
+ result: dict[str, object]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _GdsScale:
52
+ unit: Decimal
53
+ precision: Decimal
54
+
55
+ @property
56
+ def dbu_to_user(self) -> Decimal:
57
+ return self.precision / self.unit
58
+
59
+ @property
60
+ def user_to_dbu(self) -> Decimal:
61
+ return self.unit / self.precision
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class _PolygonRecord:
66
+ index: int
67
+ layer: LayerSpec
68
+ points: tuple[tuple[int, int], ...]
69
+ bbox: BBox
70
+ twice_area: int
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class _TransformVariant:
75
+ name: str
76
+ records: tuple[_PolygonRecord, ...]
77
+ bbox: BBox
78
+ anchor: _PolygonRecord
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class _MatchCandidate:
83
+ status: str
84
+ transform: str
85
+ origin_dbu: tuple[int, int]
86
+ bbox_dbu: BBox
87
+ polygon_count: int
88
+ target_polygon_count: int
89
+ layers: tuple[dict[str, object], ...]
90
+ exact: ExactOracleResult = field(repr=False)
91
+
92
+
93
+ def extract_template(
94
+ source_gds: str | Path,
95
+ *,
96
+ output_gds: str | Path,
97
+ metadata_path: str | Path | None = None,
98
+ top_name: str | None = None,
99
+ layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
100
+ bbox_user: USER_BBOX,
101
+ template_top: str = "TEMPLATE",
102
+ ) -> TemplateExtractionResult:
103
+ """Extract a bbox-selected template from a GDS into a standalone GDS."""
104
+
105
+ source_path = Path(source_gds)
106
+ output_path = Path(output_gds)
107
+ metadata = Path(metadata_path) if metadata_path is not None else None
108
+
109
+ lib = gdstk.read_gds(str(source_path))
110
+ scale = _scale_from_library(lib)
111
+ bbox_dbu = _user_bbox_to_dbu(bbox_user, scale)
112
+ extracted = extract_hierarchy_window(
113
+ lib,
114
+ window_bbox=bbox_dbu,
115
+ top_name=top_name,
116
+ layers=layers,
117
+ meters_per_dbu=scale.precision,
118
+ )
119
+ normalized = _translate_buffer(extracted.buffer, -bbox_dbu[0], -bbox_dbu[1])
120
+ _write_buffer_gds(normalized, output_path, cell_name=template_top, scale=scale)
121
+
122
+ payload = _template_metadata(
123
+ source_path=source_path,
124
+ output_path=output_path,
125
+ source_top=extracted.top_name,
126
+ template_top=template_top,
127
+ layers=layers,
128
+ source_bbox_user=bbox_user,
129
+ source_bbox_dbu=bbox_dbu,
130
+ normalized=normalized,
131
+ scale=scale,
132
+ extraction_stats=extracted.stats.to_json(),
133
+ )
134
+ if metadata is not None:
135
+ metadata.parent.mkdir(parents=True, exist_ok=True)
136
+ metadata.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
137
+ return TemplateExtractionResult(output_gds=output_path, metadata_path=metadata, metadata=payload)
138
+
139
+
140
+ def find_template(
141
+ template_gds: str | Path,
142
+ target_gds: str | Path,
143
+ *,
144
+ output_dir: str | Path,
145
+ template_top: str = "TEMPLATE",
146
+ target_top: str | None = None,
147
+ layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all",
148
+ transforms: Iterable[str] = (TRANSFORM_TRANSLATE,),
149
+ allow_mirror: bool = False,
150
+ ) -> TemplateFindResult:
151
+ """Find exact normalized copies of a template inside a target GDS."""
152
+
153
+ template_path = Path(template_gds)
154
+ target_path = Path(target_gds)
155
+ output = Path(output_dir)
156
+ output.mkdir(parents=True, exist_ok=True)
157
+
158
+ template_extraction = extract_gds_polygons(template_path, top_name=template_top, layers=layers)
159
+ target_extraction = extract_gds_polygons(target_path, top_name=target_top, layers=layers)
160
+ template_records = _records_from_buffer(template_extraction.buffer)
161
+ target_records = _records_from_buffer(target_extraction.buffer)
162
+ if not template_records:
163
+ raise ValueError("template contains no selected polygons")
164
+
165
+ matrices = _transform_matrices(tuple(transforms), allow_mirror=allow_mirror)
166
+ target_by_anchor = _target_anchor_index(target_records)
167
+ candidates: list[_MatchCandidate] = []
168
+ seen: set[tuple[BBox, str]] = set()
169
+
170
+ for transform_name, matrix in matrices:
171
+ variant = _transform_template_records(template_records, transform_name, matrix)
172
+ anchor_key = _anchor_key(variant.anchor)
173
+ for target_anchor in target_by_anchor.get(anchor_key, ()):
174
+ origin = (
175
+ target_anchor.bbox[0] - variant.anchor.bbox[0],
176
+ target_anchor.bbox[1] - variant.anchor.bbox[1],
177
+ )
178
+ candidate_bbox = _shift_bbox(variant.bbox, origin)
179
+ seen_key = (candidate_bbox, transform_name)
180
+ if seen_key in seen:
181
+ continue
182
+ seen.add(seen_key)
183
+ placed_template = _shift_records(variant.records, origin)
184
+ target_subset = tuple(record for record in target_records if bboxes_intersect(record.bbox, candidate_bbox))
185
+ template_buffer = _buffer_from_records(placed_template)
186
+ target_buffer = _buffer_from_records(target_subset)
187
+ exact = run_full_exact_oracle(template_buffer, target_buffer, workers=1)
188
+ candidates.append(
189
+ _MatchCandidate(
190
+ status="identical" if exact.equivalent else "differences-found",
191
+ transform=transform_name,
192
+ origin_dbu=origin,
193
+ bbox_dbu=candidate_bbox,
194
+ polygon_count=len(placed_template),
195
+ target_polygon_count=len(target_subset),
196
+ layers=_candidate_layer_summary(template_buffer, target_buffer, exact),
197
+ exact=exact,
198
+ )
199
+ )
200
+
201
+ candidates.sort(key=lambda item: (item.status != "identical", item.bbox_dbu, item.transform))
202
+ result = _find_result_payload(
203
+ template_path=template_path,
204
+ target_path=target_path,
205
+ template_top=template_extraction.top_name,
206
+ target_top=target_extraction.top_name,
207
+ layers=layers,
208
+ transforms=tuple(name for name, _matrix in matrices),
209
+ allow_mirror=allow_mirror,
210
+ template_buffer=template_extraction.buffer,
211
+ target_buffer=target_extraction.buffer,
212
+ candidates=tuple(candidates),
213
+ )
214
+ matches_json = output / "matches.json"
215
+ matches_markdown = output / "matches.md"
216
+ matches_json.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
217
+ matches_markdown.write_text(_render_matches_markdown(result), encoding="utf-8")
218
+ return TemplateFindResult(
219
+ output_dir=output,
220
+ matches_json=matches_json,
221
+ matches_markdown=matches_markdown,
222
+ result=result,
223
+ )
224
+
225
+
226
+ def _scale_from_library(lib: gdstk.Library) -> _GdsScale:
227
+ return _GdsScale(unit=decimal_from_number(lib.unit), precision=decimal_from_number(lib.precision))
228
+
229
+
230
+ def _user_bbox_to_dbu(bbox: USER_BBOX, scale: _GdsScale) -> BBox:
231
+ if len(bbox) != 4:
232
+ raise ValueError("bbox must contain x0,y0,x1,y1")
233
+ x0, y0, x1, y1 = (decimal_from_number(value) for value in bbox)
234
+ if x0 >= x1 or y0 >= y1:
235
+ raise ValueError("bbox must satisfy x0 < x1 and y0 < y1")
236
+ return (
237
+ snap_to_grid(x0 * scale.unit, scale.precision),
238
+ snap_to_grid(y0 * scale.unit, scale.precision),
239
+ snap_to_grid(x1 * scale.unit, scale.precision),
240
+ snap_to_grid(y1 * scale.unit, scale.precision),
241
+ )
242
+
243
+
244
+ def _bbox_dbu_to_user(bbox: BBox, scale: _GdsScale) -> list[float]:
245
+ factor = scale.dbu_to_user
246
+ return [float(Decimal(value) * factor) for value in bbox]
247
+
248
+
249
+ def _points_to_user(points: tuple[tuple[int, int], ...], scale: _GdsScale) -> list[tuple[float, float]]:
250
+ factor = scale.dbu_to_user
251
+ return [(float(Decimal(x) * factor), float(Decimal(y) * factor)) for x, y in points]
252
+
253
+
254
+ def _translate_buffer(buffer: PolygonBuffer, dx: int, dy: int) -> PolygonBuffer:
255
+ vertices = buffer.vertices_xy.copy()
256
+ if len(vertices):
257
+ vertices[:, 0] += dx
258
+ vertices[:, 1] += dy
259
+ bboxes = buffer.bboxes.copy()
260
+ if len(bboxes):
261
+ bboxes[:, 0] += dx
262
+ bboxes[:, 2] += dx
263
+ bboxes[:, 1] += dy
264
+ bboxes[:, 3] += dy
265
+ return PolygonBuffer(
266
+ vertices_xy=vertices,
267
+ polygon_offsets=buffer.polygon_offsets.copy(),
268
+ layer_ids=buffer.layer_ids.copy(),
269
+ bboxes=bboxes,
270
+ flags=buffer.flags.copy(),
271
+ source_ids=buffer.source_ids.copy(),
272
+ layer_table=buffer.layer_table,
273
+ polygon_hashes=buffer.polygon_hashes.copy() if buffer.polygon_hashes is not None else None,
274
+ )
275
+
276
+
277
+ def _write_buffer_gds(buffer: PolygonBuffer, path: Path, *, cell_name: str, scale: _GdsScale) -> None:
278
+ path.parent.mkdir(parents=True, exist_ok=True)
279
+ lib = gdstk.Library(unit=float(scale.unit), precision=float(scale.precision))
280
+ cell = lib.new_cell(cell_name)
281
+ for record in _records_from_buffer(buffer):
282
+ cell.add(
283
+ gdstk.Polygon(
284
+ _points_to_user(record.points, scale),
285
+ layer=record.layer.layer,
286
+ datatype=record.layer.datatype,
287
+ )
288
+ )
289
+ lib.write_gds(str(path))
290
+
291
+
292
+ def _records_from_buffer(buffer: PolygonBuffer) -> tuple[_PolygonRecord, ...]:
293
+ records = []
294
+ for index in range(buffer.polygon_count):
295
+ layer = buffer.layer_table.layers[int(buffer.layer_ids[index])]
296
+ points = tuple((int(x), int(y)) for x, y in buffer.polygon_vertices(index))
297
+ records.append(
298
+ _PolygonRecord(
299
+ index=index,
300
+ layer=layer,
301
+ points=points,
302
+ bbox=tuple(int(value) for value in buffer.bboxes[index]),
303
+ twice_area=polygon_twice_area(points),
304
+ )
305
+ )
306
+ return tuple(records)
307
+
308
+
309
+ def _buffer_from_records(records: Iterable[_PolygonRecord]) -> PolygonBuffer:
310
+ record_tuple = tuple(records)
311
+ layers = tuple(sorted({record.layer for record in record_tuple}))
312
+ layer_table = LayerTable(layers)
313
+ layer_index = layer_table.index_by_layer
314
+ vertices: list[tuple[int, int]] = []
315
+ offsets = [0]
316
+ layer_ids = []
317
+ bboxes = []
318
+ flags = []
319
+ source_ids = []
320
+ for source_id, record in enumerate(record_tuple):
321
+ vertices.extend(record.points)
322
+ offsets.append(len(vertices))
323
+ layer_ids.append(layer_index[record.layer])
324
+ bboxes.append(record.bbox)
325
+ flags.append(polygon_flags(record.points))
326
+ source_ids.append(source_id)
327
+ return PolygonBuffer(
328
+ vertices_xy=np.asarray(vertices, dtype=np.int64).reshape((-1, 2)),
329
+ polygon_offsets=np.asarray(offsets, dtype=np.uint64),
330
+ layer_ids=np.asarray(layer_ids, dtype=np.uint32),
331
+ bboxes=np.asarray(bboxes, dtype=np.int64).reshape((len(record_tuple), 4)),
332
+ flags=np.asarray(flags, dtype=np.uint32),
333
+ source_ids=np.asarray(source_ids, dtype=np.uint64),
334
+ layer_table=layer_table,
335
+ )
336
+
337
+
338
+ def _template_metadata(
339
+ *,
340
+ source_path: Path,
341
+ output_path: Path,
342
+ source_top: str,
343
+ template_top: str,
344
+ layers,
345
+ source_bbox_user: USER_BBOX,
346
+ source_bbox_dbu: BBox,
347
+ normalized: PolygonBuffer,
348
+ scale: _GdsScale,
349
+ extraction_stats: dict[str, object],
350
+ ) -> dict[str, object]:
351
+ bbox = _buffer_bbox(normalized)
352
+ return {
353
+ "mode": "gdsdiff-template-extract-v1",
354
+ "source": {
355
+ "path": str(source_path),
356
+ "top": source_top,
357
+ "bbox_user": list(source_bbox_user),
358
+ "bbox_dbu": list(source_bbox_dbu),
359
+ "layers": _layers_json(layers),
360
+ "selection_mode": "intersects",
361
+ },
362
+ "template": {
363
+ "path": str(output_path),
364
+ "top": template_top,
365
+ "normalization_origin_dbu": [source_bbox_dbu[0], source_bbox_dbu[1]],
366
+ "bbox_dbu": list(bbox) if bbox is not None else None,
367
+ "bbox_user": _bbox_dbu_to_user(bbox, scale) if bbox is not None else None,
368
+ "polygon_count": normalized.polygon_count,
369
+ "vertex_count": normalized.vertex_count,
370
+ "layer_summary": _buffer_layer_summary(normalized, scale=scale),
371
+ "signature_sha256": _buffer_signature(normalized),
372
+ },
373
+ "gds": {"unit": str(scale.unit), "precision": str(scale.precision)},
374
+ "extraction": extraction_stats,
375
+ }
376
+
377
+
378
+ def _layers_json(layers) -> object:
379
+ parsed = parse_layer_selector(layers)
380
+ if parsed == "all":
381
+ return "all"
382
+ return [layer.to_json() for layer in parsed]
383
+
384
+
385
+ def _buffer_bbox(buffer: PolygonBuffer) -> BBox | None:
386
+ if buffer.polygon_count == 0:
387
+ return None
388
+ return (
389
+ int(buffer.bboxes[:, 0].min()),
390
+ int(buffer.bboxes[:, 1].min()),
391
+ int(buffer.bboxes[:, 2].max()),
392
+ int(buffer.bboxes[:, 3].max()),
393
+ )
394
+
395
+
396
+ def _buffer_layer_summary(buffer: PolygonBuffer, *, scale: _GdsScale | None = None) -> list[dict[str, object]]:
397
+ summary: dict[LayerSpec, dict[str, object]] = {}
398
+ for record in _records_from_buffer(buffer):
399
+ item = summary.setdefault(
400
+ record.layer,
401
+ {
402
+ "layer": record.layer.layer,
403
+ "datatype": record.layer.datatype,
404
+ "polygon_count": 0,
405
+ "twice_area_dbu2": 0,
406
+ "bbox_dbu": None,
407
+ },
408
+ )
409
+ item["polygon_count"] = int(item["polygon_count"]) + 1
410
+ item["twice_area_dbu2"] = int(item["twice_area_dbu2"]) + record.twice_area
411
+ item["bbox_dbu"] = list(_bbox_union((tuple(item["bbox_dbu"]) if item["bbox_dbu"] is not None else None, record.bbox)))
412
+ out = []
413
+ for layer in sorted(summary):
414
+ item = dict(summary[layer])
415
+ item["area_dbu2"] = format_twice_area(int(item.pop("twice_area_dbu2")))
416
+ if scale is not None:
417
+ factor = scale.dbu_to_user * scale.dbu_to_user
418
+ item["area_user2"] = float((Decimal(int(summary[layer]["twice_area_dbu2"])) / Decimal(2)) * factor)
419
+ out.append(item)
420
+ return out
421
+
422
+
423
+ def _bbox_union(bboxes: Iterable[BBox | None]) -> BBox:
424
+ clean = tuple(bbox for bbox in bboxes if bbox is not None)
425
+ if not clean:
426
+ raise ValueError("bbox union requires at least one bbox")
427
+ return (
428
+ min(bbox[0] for bbox in clean),
429
+ min(bbox[1] for bbox in clean),
430
+ max(bbox[2] for bbox in clean),
431
+ max(bbox[3] for bbox in clean),
432
+ )
433
+
434
+
435
+ def _buffer_signature(buffer: PolygonBuffer) -> str:
436
+ records = []
437
+ for record in _records_from_buffer(buffer):
438
+ records.append((record.layer.layer, record.layer.datatype, record.points))
439
+ payload = repr(tuple(sorted(records))).encode("utf-8")
440
+ return hashlib.sha256(payload).hexdigest()
441
+
442
+
443
+ def _transform_matrices(transforms: tuple[str, ...], *, allow_mirror: bool) -> tuple[tuple[str, tuple[int, int, int, int]], ...]:
444
+ normalized = {item.strip().lower() for item in transforms}
445
+ if not normalized or TRANSFORM_TRANSLATE in normalized:
446
+ matrices = [("translate", (1, 0, 0, 1))]
447
+ else:
448
+ matrices = []
449
+ if TRANSFORM_ROT90 in normalized:
450
+ matrices.extend(
451
+ [
452
+ ("rot90", (0, -1, 1, 0)),
453
+ ("rot180", (-1, 0, 0, -1)),
454
+ ("rot270", (0, 1, -1, 0)),
455
+ ]
456
+ )
457
+ unknown = normalized - {TRANSFORM_TRANSLATE, TRANSFORM_ROT90}
458
+ if unknown:
459
+ raise ValueError(f"unknown template transform(s): {', '.join(sorted(unknown))}")
460
+ if allow_mirror:
461
+ base = tuple(matrices)
462
+ matrices.extend((f"{name}+mirror-x", (a, -b, c, -d)) for name, (a, b, c, d) in base)
463
+ dedup: dict[tuple[int, int, int, int], str] = {}
464
+ for name, matrix in matrices:
465
+ dedup.setdefault(matrix, name)
466
+ return tuple((name, matrix) for matrix, name in dedup.items())
467
+
468
+
469
+ def _transform_template_records(
470
+ records: tuple[_PolygonRecord, ...],
471
+ name: str,
472
+ matrix: tuple[int, int, int, int],
473
+ ) -> _TransformVariant:
474
+ transformed_records = []
475
+ a, b, c, d = matrix
476
+ for record in records:
477
+ points = normalize_polygon_points([(a * x + b * y, c * x + d * y) for x, y in record.points])
478
+ transformed_records.append(
479
+ _PolygonRecord(
480
+ index=record.index,
481
+ layer=record.layer,
482
+ points=points,
483
+ bbox=polygon_bbox(points),
484
+ twice_area=polygon_twice_area(points),
485
+ )
486
+ )
487
+ bbox = _bbox_union(tuple(record.bbox for record in transformed_records))
488
+ normalized = tuple(
489
+ _PolygonRecord(
490
+ index=record.index,
491
+ layer=record.layer,
492
+ points=tuple((x - bbox[0], y - bbox[1]) for x, y in record.points),
493
+ bbox=(record.bbox[0] - bbox[0], record.bbox[1] - bbox[1], record.bbox[2] - bbox[0], record.bbox[3] - bbox[1]),
494
+ twice_area=record.twice_area,
495
+ )
496
+ for record in transformed_records
497
+ )
498
+ variant_bbox = _bbox_union(tuple(record.bbox for record in normalized))
499
+ anchor = _select_anchor(normalized)
500
+ return _TransformVariant(name=name, records=normalized, bbox=variant_bbox, anchor=anchor)
501
+
502
+
503
+ def _select_anchor(records: tuple[_PolygonRecord, ...]) -> _PolygonRecord:
504
+ return min(records, key=lambda record: (-record.twice_area, record.layer, record.bbox, record.points))
505
+
506
+
507
+ def _target_anchor_index(records: tuple[_PolygonRecord, ...]) -> dict[tuple[object, ...], list[_PolygonRecord]]:
508
+ out: dict[tuple[object, ...], list[_PolygonRecord]] = {}
509
+ for record in records:
510
+ out.setdefault(_anchor_key(record), []).append(record)
511
+ return out
512
+
513
+
514
+ def _anchor_key(record: _PolygonRecord) -> tuple[object, ...]:
515
+ x0, y0, x1, y1 = record.bbox
516
+ normalized = tuple((x - x0, y - y0) for x, y in record.points)
517
+ return (
518
+ record.layer,
519
+ x1 - x0,
520
+ y1 - y0,
521
+ record.twice_area,
522
+ len(record.points),
523
+ _canonical_points(normalized),
524
+ )
525
+
526
+
527
+ def _canonical_points(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
528
+ if not points:
529
+ return points
530
+ forward = _min_cyclic_rotation(points)
531
+ reverse = _min_cyclic_rotation(tuple(reversed(points)))
532
+ return min(forward, reverse)
533
+
534
+
535
+ def _min_cyclic_rotation(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
536
+ return min(points[index:] + points[:index] for index in range(len(points)))
537
+
538
+
539
+ def _shift_records(records: tuple[_PolygonRecord, ...], origin: tuple[int, int]) -> tuple[_PolygonRecord, ...]:
540
+ dx, dy = origin
541
+ return tuple(
542
+ _PolygonRecord(
543
+ index=record.index,
544
+ layer=record.layer,
545
+ points=tuple((x + dx, y + dy) for x, y in record.points),
546
+ bbox=_shift_bbox(record.bbox, origin),
547
+ twice_area=record.twice_area,
548
+ )
549
+ for record in records
550
+ )
551
+
552
+
553
+ def _shift_bbox(bbox: BBox, origin: tuple[int, int]) -> BBox:
554
+ dx, dy = origin
555
+ return bbox[0] + dx, bbox[1] + dy, bbox[2] + dx, bbox[3] + dy
556
+
557
+
558
+ def _candidate_layer_summary(
559
+ template_buffer: PolygonBuffer,
560
+ target_buffer: PolygonBuffer,
561
+ exact: ExactOracleResult,
562
+ ) -> tuple[dict[str, object], ...]:
563
+ template = _raw_layer_metrics(template_buffer)
564
+ target = _raw_layer_metrics(target_buffer)
565
+ exact_by_layer = {layer.layer: layer for layer in exact.layers}
566
+ rows = []
567
+ for layer in sorted(set(template) | set(target) | set(exact_by_layer)):
568
+ template_metrics = template.get(layer, {"polygon_count": 0, "twice_area": 0})
569
+ target_metrics = target.get(layer, {"polygon_count": 0, "twice_area": 0})
570
+ diff = exact_by_layer.get(layer)
571
+ rows.append(
572
+ {
573
+ "layer": layer.layer,
574
+ "datatype": layer.datatype,
575
+ "template_polygon_count": template_metrics["polygon_count"],
576
+ "target_polygon_count": target_metrics["polygon_count"],
577
+ "template_area_dbu2": format_twice_area(int(template_metrics["twice_area"])),
578
+ "target_area_dbu2": format_twice_area(int(target_metrics["twice_area"])),
579
+ "template_only_area_dbu2": format_twice_area(diff.old_minus_new_twice_area if diff is not None else 0),
580
+ "target_only_area_dbu2": format_twice_area(diff.new_minus_old_twice_area if diff is not None else 0),
581
+ "xor_area_dbu2": format_twice_area(diff.xor_twice_area if diff is not None else 0),
582
+ "template_only_count": len(diff.old_minus_new) if diff is not None else 0,
583
+ "target_only_count": len(diff.new_minus_old) if diff is not None else 0,
584
+ }
585
+ )
586
+ return tuple(rows)
587
+
588
+
589
+ def _raw_layer_metrics(buffer: PolygonBuffer) -> dict[LayerSpec, dict[str, int]]:
590
+ metrics: dict[LayerSpec, dict[str, int]] = {}
591
+ for record in _records_from_buffer(buffer):
592
+ item = metrics.setdefault(record.layer, {"polygon_count": 0, "twice_area": 0})
593
+ item["polygon_count"] += 1
594
+ item["twice_area"] += record.twice_area
595
+ return metrics
596
+
597
+
598
+ def _find_result_payload(
599
+ *,
600
+ template_path: Path,
601
+ target_path: Path,
602
+ template_top: str,
603
+ target_top: str,
604
+ layers,
605
+ transforms: tuple[str, ...],
606
+ allow_mirror: bool,
607
+ template_buffer: PolygonBuffer,
608
+ target_buffer: PolygonBuffer,
609
+ candidates: tuple[_MatchCandidate, ...],
610
+ ) -> dict[str, object]:
611
+ return {
612
+ "mode": "gdsdiff-template-find-v1",
613
+ "template": {
614
+ "path": str(template_path),
615
+ "top": template_top,
616
+ "polygon_count": template_buffer.polygon_count,
617
+ "signature_sha256": _buffer_signature(template_buffer),
618
+ },
619
+ "target": {
620
+ "path": str(target_path),
621
+ "top": target_top,
622
+ "polygon_count": target_buffer.polygon_count,
623
+ },
624
+ "selection": {
625
+ "layers": _layers_json(layers),
626
+ "transforms": list(transforms),
627
+ "allow_mirror": allow_mirror,
628
+ },
629
+ "summary": {
630
+ "candidate_count": len(candidates),
631
+ "match_count": sum(1 for candidate in candidates if candidate.status == "identical"),
632
+ "difference_count": sum(1 for candidate in candidates if candidate.status != "identical"),
633
+ },
634
+ "candidates": [
635
+ {
636
+ "status": candidate.status,
637
+ "transform": candidate.transform,
638
+ "origin_dbu": list(candidate.origin_dbu),
639
+ "bbox_dbu": list(candidate.bbox_dbu),
640
+ "template_polygon_count": candidate.polygon_count,
641
+ "target_polygon_count": candidate.target_polygon_count,
642
+ "layers": list(candidate.layers),
643
+ }
644
+ for candidate in candidates
645
+ ],
646
+ }
647
+
648
+
649
+ def _render_matches_markdown(result: dict[str, object]) -> str:
650
+ summary = result["summary"]
651
+ lines = [
652
+ "# GDS Template Matches",
653
+ "",
654
+ f"- mode: `{result['mode']}`",
655
+ f"- template: `{result['template']['path']}` top `{result['template']['top']}`",
656
+ f"- target: `{result['target']['path']}` top `{result['target']['top']}`",
657
+ f"- candidate_count: `{summary['candidate_count']}`",
658
+ f"- match_count: `{summary['match_count']}`",
659
+ f"- difference_count: `{summary['difference_count']}`",
660
+ "",
661
+ "| status | transform | bbox_dbu | template_polygons | target_polygons |",
662
+ "|---|---|---|---:|---:|",
663
+ ]
664
+ for candidate in result["candidates"]:
665
+ lines.append(
666
+ "| {status} | {transform} | `{bbox}` | {template_count} | {target_count} |".format(
667
+ status=candidate["status"],
668
+ transform=candidate["transform"],
669
+ bbox=candidate["bbox_dbu"],
670
+ template_count=candidate["template_polygon_count"],
671
+ target_count=candidate["target_polygon_count"],
672
+ )
673
+ )
674
+ if result["candidates"]:
675
+ lines.extend(
676
+ [
677
+ "",
678
+ "## Per-Layer Candidate Details",
679
+ "",
680
+ ]
681
+ )
682
+ for index, candidate in enumerate(result["candidates"], start=1):
683
+ lines.extend(
684
+ [
685
+ f"### Candidate {index}",
686
+ "",
687
+ f"- status: `{candidate['status']}`",
688
+ f"- transform: `{candidate['transform']}`",
689
+ f"- bbox_dbu: `{candidate['bbox_dbu']}`",
690
+ "",
691
+ "| layer | template_count | target_count | template_area_dbu2 | target_area_dbu2 | xor_area_dbu2 |",
692
+ "|---|---:|---:|---:|---:|---:|",
693
+ ]
694
+ )
695
+ for layer in candidate["layers"]:
696
+ lines.append(
697
+ "| {layer}:{datatype} | {template_count} | {target_count} | {template_area} | {target_area} | {xor_area} |".format(
698
+ layer=layer["layer"],
699
+ datatype=layer["datatype"],
700
+ template_count=layer["template_polygon_count"],
701
+ target_count=layer["target_polygon_count"],
702
+ template_area=layer["template_area_dbu2"],
703
+ target_area=layer["target_area_dbu2"],
704
+ xor_area=layer["xor_area_dbu2"],
705
+ )
706
+ )
707
+ lines.append("")
708
+ lines.append("")
709
+ return "\n".join(lines)