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
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import struct
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ from ._environment import get_environment
11
+ from .geometry import PolygonBuffer
12
+ from .multiprocessing_support import fork_context
13
+
14
+ ENCODING_VERSION = 1
15
+ VALIDITY_VALID = 0
16
+ VALIDITY_CONSERVATIVE_RAW = 1
17
+ RECTANGLE_ENCODING_VERSION = 2
18
+ _WORKER_BUFFER = None
19
+ _WORKER_FORCE_HASH_COLLISION = False
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CanonicalRecord:
24
+ polygon_index: int
25
+ blob: bytes
26
+ digest128: bytes
27
+ validity_class: int
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class InternResult:
32
+ canonical_ids: np.ndarray
33
+ unique_blobs: tuple[bytes, ...]
34
+ records: tuple[CanonicalRecord, ...]
35
+ duplicate_occurrence_count: int
36
+ hash_collision_bucket_count: int
37
+
38
+
39
+ def canonicalize_polygon(points: np.ndarray, *, valid: bool = True) -> tuple[bytes, int]:
40
+ point_tuple = tuple((int(x), int(y)) for x, y in points)
41
+ if valid:
42
+ encoded_points = _canonical_valid_points(point_tuple)
43
+ validity = VALIDITY_VALID
44
+ else:
45
+ encoded_points = point_tuple
46
+ validity = VALIDITY_CONSERVATIVE_RAW
47
+ return _encode_points(encoded_points, validity), validity
48
+
49
+
50
+ def intern_polygons(buffer: PolygonBuffer, *, force_hash_collision: bool = False) -> InternResult:
51
+ records = _canonical_records(buffer, force_hash_collision=force_hash_collision)
52
+
53
+ unique_blobs = tuple(sorted({record.blob for record in records}))
54
+ id_by_blob = {blob: index for index, blob in enumerate(unique_blobs)}
55
+ canonical_ids = np.asarray([id_by_blob[record.blob] for record in records], dtype=np.uint64)
56
+
57
+ digest_to_blobs: dict[bytes, set[bytes]] = {}
58
+ for record in records:
59
+ digest_to_blobs.setdefault(record.digest128, set()).add(record.blob)
60
+ collision_buckets = sum(1 for blobs in digest_to_blobs.values() if len(blobs) > 1)
61
+
62
+ return InternResult(
63
+ canonical_ids=canonical_ids,
64
+ unique_blobs=unique_blobs,
65
+ records=tuple(records),
66
+ duplicate_occurrence_count=len(records) - len(unique_blobs),
67
+ hash_collision_bucket_count=collision_buckets,
68
+ )
69
+
70
+
71
+ def _canonical_records(buffer: PolygonBuffer, *, force_hash_collision: bool) -> list[CanonicalRecord]:
72
+ worker_count = _resolve_worker_count(buffer.polygon_count)
73
+ if worker_count <= 1:
74
+ return _canonical_records_range(buffer, 0, buffer.polygon_count, force_hash_collision)
75
+ chunk_count = max(worker_count, worker_count * 8)
76
+ chunk_size = max(1, (buffer.polygon_count + chunk_count - 1) // chunk_count)
77
+ chunks = tuple((start, min(start + chunk_size, buffer.polygon_count)) for start in range(0, buffer.polygon_count, chunk_size))
78
+ context = fork_context()
79
+ if context is None:
80
+ return _canonical_records_range(buffer, 0, buffer.polygon_count, force_hash_collision)
81
+ global _WORKER_BUFFER, _WORKER_FORCE_HASH_COLLISION
82
+ _WORKER_BUFFER = buffer
83
+ _WORKER_FORCE_HASH_COLLISION = force_hash_collision
84
+ try:
85
+ try:
86
+ with context.Pool(processes=worker_count) as pool:
87
+ chunk_records = pool.map(_canonical_records_worker, chunks, chunksize=1)
88
+ except OSError:
89
+ return _canonical_records_range(buffer, 0, buffer.polygon_count, force_hash_collision)
90
+ finally:
91
+ _WORKER_BUFFER = None
92
+ _WORKER_FORCE_HASH_COLLISION = False
93
+ records = [record for chunk in chunk_records for record in chunk]
94
+ records.sort(key=lambda record: record.polygon_index)
95
+ return records
96
+
97
+
98
+ def _resolve_worker_count(polygon_count: int) -> int:
99
+ if polygon_count < _parallel_min_polygons():
100
+ return 1
101
+ return max(1, os.cpu_count() or 1)
102
+
103
+
104
+ def _parallel_min_polygons() -> int:
105
+ try:
106
+ value = int(
107
+ get_environment(
108
+ "GDSDIFF_PARALLEL_MIN_POLYGONS",
109
+ "GDS_GPU_COMPARE_PARALLEL_MIN_POLYGONS",
110
+ "",
111
+ )
112
+ or ""
113
+ )
114
+ except ValueError:
115
+ return 30_000
116
+ return value if value > 0 else 30_000
117
+
118
+
119
+ def _canonical_records_worker(chunk: tuple[int, int]) -> list[CanonicalRecord]:
120
+ if _WORKER_BUFFER is None:
121
+ raise RuntimeError("canonicalization worker globals are not initialized")
122
+ start, stop = chunk
123
+ return _canonical_records_range(_WORKER_BUFFER, start, stop, _WORKER_FORCE_HASH_COLLISION)
124
+
125
+
126
+ def _canonical_records_range(
127
+ buffer: PolygonBuffer,
128
+ start: int,
129
+ stop: int,
130
+ force_hash_collision: bool,
131
+ ) -> list[CanonicalRecord]:
132
+ records: list[CanonicalRecord] = []
133
+ for index in range(start, stop):
134
+ valid = int(buffer.flags[index]) == 0
135
+ if valid and int(buffer.polygon_offsets[index + 1] - buffer.polygon_offsets[index]) == 4 and _is_axis_aligned_rectangle(
136
+ buffer.polygon_vertices(index)
137
+ ):
138
+ blob = _encode_rectangle_bbox(buffer.bboxes[index])
139
+ validity = VALIDITY_VALID
140
+ else:
141
+ blob, validity = canonicalize_polygon(buffer.polygon_vertices(index), valid=valid)
142
+ digest = b"\0" * 16 if force_hash_collision else hashlib.blake2b(blob, digest_size=16).digest()
143
+ records.append(CanonicalRecord(index, blob, digest, validity))
144
+ return records
145
+
146
+
147
+ def _is_axis_aligned_rectangle(points: np.ndarray) -> bool:
148
+ if points.shape != (4, 2):
149
+ return False
150
+ return len({int(value) for value in points[:, 0]}) == 2 and len({int(value) for value in points[:, 1]}) == 2
151
+
152
+
153
+ def _encode_rectangle_bbox(bbox: np.ndarray) -> bytes:
154
+ x0, y0, x1, y1 = (int(value) for value in bbox)
155
+ return struct.pack(">IIqqqq", RECTANGLE_ENCODING_VERSION, VALIDITY_VALID, x0, y0, x1, y1)
156
+
157
+
158
+ def _canonical_valid_points(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
159
+ if not points:
160
+ return points
161
+ forward = _min_cyclic_rotation(points)
162
+ reverse = _min_cyclic_rotation(tuple(reversed(points)))
163
+ return min(forward, reverse)
164
+
165
+
166
+ def _min_cyclic_rotation(points: tuple[tuple[int, int], ...]) -> tuple[tuple[int, int], ...]:
167
+ rotations = [points[index:] + points[:index] for index in range(len(points))]
168
+ return min(rotations)
169
+
170
+
171
+ def _encode_points(points: tuple[tuple[int, int], ...], validity: int) -> bytes:
172
+ out = bytearray()
173
+ out.extend(struct.pack(">III", ENCODING_VERSION, validity, len(points)))
174
+ for x, y in points:
175
+ out.extend(struct.pack(">qq", x, y))
176
+ return bytes(out)
gdsdiff/cli.py ADDED
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from ._version import __version__
8
+ from .api import compare_gds_files
9
+ from .config import CompareConfig, parse_allowed_region, parse_allowed_whole_layer
10
+ from .semantics import Backend, EvidenceEngine, FailurePolicy
11
+ from .templates import extract_template, find_template
12
+
13
+
14
+ def build_parser(
15
+ *,
16
+ prog: str = "gdsdiff",
17
+ description: str | None = None,
18
+ default_backend: Backend = Backend.AUTO,
19
+ ) -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ prog=prog,
22
+ description=description or "Compare two GDS files with exact CPU verification.",
23
+ )
24
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
25
+ parser.add_argument("old_gds", type=Path)
26
+ parser.add_argument("new_gds", type=Path)
27
+ parser.add_argument("--top")
28
+ parser.add_argument("--old-top")
29
+ parser.add_argument("--new-top")
30
+ parser.add_argument("--layers", default="all", help="Layer selector such as '1:0,2:0' or 'all'.")
31
+ parser.add_argument("--tile-size", type=int, default=1024)
32
+ parser.add_argument("--backend", choices=[item.value for item in Backend], default=default_backend.value)
33
+ parser.add_argument(
34
+ "--evidence-engine",
35
+ choices=[item.value for item in EvidenceEngine],
36
+ default=EvidenceEngine.AUTO.value,
37
+ help=(
38
+ "Evidence generator to use. For gdsdiff, auto selects gpu-polygon for available GPU backends "
39
+ "and otherwise selects the CPU gdstk oracle."
40
+ ),
41
+ )
42
+ parser.add_argument(
43
+ "--output-dir",
44
+ type=Path,
45
+ help=(
46
+ "Write the standard artifact set into this directory: report.json, exact_diff.md, "
47
+ "and the automatically generated diff_vertices.jsonl sidecar."
48
+ ),
49
+ )
50
+ parser.add_argument("--failure-policy", choices=[item.value for item in FailurePolicy], default=FailurePolicy.FAIL_ON_UNEXPECTED.value)
51
+ parser.add_argument("--allow-region", action="append", default=[], metavar="L:D,DIRECTION,X0,Y0,X1,Y1[,HALO]")
52
+ parser.add_argument("--allow-whole-layer", action="append", default=[], metavar="L:D,DIRECTION")
53
+ parser.add_argument("--json-report", type=Path)
54
+ parser.add_argument("--diff-gds", type=Path)
55
+ parser.add_argument("--markdown", type=Path)
56
+ parser.add_argument("--exact-diff-markdown", type=Path)
57
+ parser.add_argument("--exact", action="store_true", help="Run full exact oracle instead of CPU tile prefilter.")
58
+ parser.add_argument(
59
+ "--verify-exact-after-fast",
60
+ action="store_true",
61
+ help="For eligible accelerated fast verdicts, run full exact verification afterward and attach polygon-level evidence.",
62
+ )
63
+ parser.add_argument(
64
+ "--audit-cpu-oracle",
65
+ action="store_true",
66
+ help="After a GPU evidence path, run the CPU gdstk oracle as audit evidence when supported.",
67
+ )
68
+ parser.add_argument(
69
+ "--exact-component-backend",
70
+ choices=(
71
+ "none",
72
+ "gdstk-component",
73
+ "rect",
74
+ "rect-fallback",
75
+ "cuda-rect",
76
+ "cuda-rect-fallback",
77
+ "metal-rect",
78
+ "metal-rect-fallback",
79
+ ),
80
+ default="none",
81
+ help="Optional component executor for full exact evidence. 'none' preserves the default gdstk oracle.",
82
+ )
83
+ parser.add_argument(
84
+ "--force-geometry-prefilter",
85
+ action="store_true",
86
+ help="Disable accelerated byte/hierarchy shortcuts and run the extraction plus tile-prefilter geometry path.",
87
+ )
88
+ parser.add_argument(
89
+ "--structural-guard",
90
+ action="store_true",
91
+ help="Prefer the native cold structural count guard before other fast paths.",
92
+ )
93
+ parser.add_argument(
94
+ "--native-geometry-guard",
95
+ action="store_true",
96
+ help="Prefer the custom native GDS geometry summary guard before other fast paths.",
97
+ )
98
+ parser.add_argument(
99
+ "--geometry-cache-dir",
100
+ type=Path,
101
+ help="Optional cache directory for extracted geometry, canonical IDs, and spatial indexes.",
102
+ )
103
+ parser.add_argument("--require-gpu", action="store_true", help="Fail instead of falling back to CPU when no GPU backend is available.")
104
+ parser.add_argument("--quiet", action="store_true")
105
+ return parser
106
+
107
+
108
+ def main(argv: list[str] | None = None) -> int:
109
+ return _main(
110
+ argv,
111
+ prog="gdsdiff",
112
+ description="General exact GDS diff tool with automatic GPU acceleration and CPU fallback.",
113
+ default_backend=Backend.AUTO,
114
+ general_output=True,
115
+ )
116
+
117
+
118
+ def _main(
119
+ argv: list[str] | None,
120
+ *,
121
+ prog: str,
122
+ description: str,
123
+ default_backend: Backend,
124
+ general_output: bool,
125
+ ) -> int:
126
+ argv = list(sys.argv[1:] if argv is None else argv)
127
+ if argv and argv[0] == "extract-template":
128
+ return _main_extract_template(argv[1:], prog=f"{prog} extract-template")
129
+ if argv and argv[0] == "find-template":
130
+ return _main_find_template(argv[1:], prog=f"{prog} find-template")
131
+
132
+ parser = build_parser(prog=prog, description=description, default_backend=default_backend)
133
+ args = parser.parse_args(argv)
134
+ try:
135
+ _validate_input_paths(args.old_gds, args.new_gds)
136
+ except ValueError as exc:
137
+ if general_output and not args.quiet:
138
+ print("status=unsupported-input evidence_status=unsupported-input differences_found=false")
139
+ print(f"{prog}: {exc}", file=sys.stderr)
140
+ return 2
141
+ try:
142
+ json_report_path, diff_gds_path, markdown_path, exact_diff_markdown_path = _resolve_artifact_paths(args)
143
+ evidence_engine = _resolve_evidence_engine(
144
+ args.evidence_engine,
145
+ args.backend,
146
+ require_gpu=args.require_gpu,
147
+ neutral_fallback=general_output,
148
+ )
149
+ allowed_regions = tuple(parse_allowed_region(value) for value in args.allow_region) + tuple(
150
+ parse_allowed_whole_layer(value) for value in args.allow_whole_layer
151
+ )
152
+ config = CompareConfig(
153
+ old_path=args.old_gds,
154
+ new_path=args.new_gds,
155
+ top=args.top,
156
+ old_top=args.old_top,
157
+ new_top=args.new_top,
158
+ layers=args.layers,
159
+ backend=args.backend,
160
+ evidence_engine=evidence_engine,
161
+ tile_size=args.tile_size,
162
+ allowed_regions=allowed_regions,
163
+ failure_policy=args.failure_policy,
164
+ geometry_cache_dir=args.geometry_cache_dir,
165
+ require_gpu=args.require_gpu,
166
+ )
167
+ result = compare_gds_files(
168
+ config,
169
+ json_report_path=json_report_path,
170
+ diff_gds_path=diff_gds_path,
171
+ markdown_path=markdown_path,
172
+ exact_diff_markdown_path=exact_diff_markdown_path,
173
+ exact=args.exact,
174
+ verify_exact_after_fast=args.verify_exact_after_fast,
175
+ audit_cpu_oracle=args.audit_cpu_oracle,
176
+ use_fast_shortcuts=not args.force_geometry_prefilter,
177
+ use_structural_guard=args.structural_guard,
178
+ use_native_geometry_guard=args.native_geometry_guard,
179
+ exact_component_backend=args.exact_component_backend,
180
+ command=(prog, *argv),
181
+ )
182
+ except Exception as exc:
183
+ print(f"{prog}: {exc}", file=sys.stderr)
184
+ return 2
185
+
186
+ if not args.quiet:
187
+ report = result.report.to_json_dict()
188
+ print(
189
+ "status={status} evidence_status={evidence_status} differences_found={differences_found}".format(
190
+ status=report["status"],
191
+ evidence_status=report["evidence_status"],
192
+ differences_found=str(report["differences_found"]).lower(),
193
+ )
194
+ )
195
+ if report.get("coverage_changed") is not None or report.get("inventory_changed") is not None:
196
+ print(
197
+ "coverage_changed={coverage_changed} inventory_changed={inventory_changed}".format(
198
+ coverage_changed=str(report.get("coverage_changed")).lower(),
199
+ inventory_changed=str(report.get("inventory_changed")).lower(),
200
+ )
201
+ )
202
+ if result.artifacts:
203
+ for name, path in sorted(result.artifacts.items()):
204
+ print(f"{name}={path}")
205
+ return result.exit_code
206
+
207
+
208
+ def build_extract_template_parser(*, prog: str = "gdsdiff extract-template") -> argparse.ArgumentParser:
209
+ parser = argparse.ArgumentParser(prog=prog, description="Extract an inspectable normalized template GDS from a source GDS.")
210
+ parser.add_argument("source_gds", type=Path)
211
+ parser.add_argument("--top", dest="top_name", help="Source top cell. Defaults to the GDS top cell when unambiguous.")
212
+ parser.add_argument("--layers", default="all", help="Layer selector such as '1:0,2:0' or 'all'.")
213
+ parser.add_argument("--bbox", required=True, type=_parse_user_bbox, metavar="X0,Y0,X1,Y1", help="Selection bbox in GDS user units.")
214
+ parser.add_argument("--output-gds", required=True, type=Path, help="Standalone template GDS path.")
215
+ parser.add_argument("--metadata", type=Path, help="JSON metadata sidecar. Defaults to OUTPUT_GDS with .json suffix.")
216
+ parser.add_argument("--template-top", default="TEMPLATE", help="Top cell name for the output template GDS.")
217
+ parser.add_argument("--quiet", action="store_true")
218
+ return parser
219
+
220
+
221
+ def build_find_template_parser(*, prog: str = "gdsdiff find-template") -> argparse.ArgumentParser:
222
+ parser = argparse.ArgumentParser(prog=prog, description="Find exact normalized template matches in a target GDS.")
223
+ parser.add_argument("template_gds", type=Path)
224
+ parser.add_argument("target_gds", type=Path)
225
+ parser.add_argument("--template-top", default="TEMPLATE", help="Template top cell name.")
226
+ parser.add_argument("--target-top", "--top", dest="target_top", help="Target top cell. Defaults to the GDS top cell when unambiguous.")
227
+ parser.add_argument("--layers", default="all", help="Layer selector such as '1:0,2:0' or 'all'.")
228
+ parser.add_argument("--output-dir", required=True, type=Path, help="Directory for matches.json and matches.md.")
229
+ parser.add_argument(
230
+ "--transforms",
231
+ default="translate",
232
+ type=_parse_transform_list,
233
+ help="Comma-separated transform set. Supported values: translate,rot90.",
234
+ )
235
+ parser.add_argument("--allow-mirror", action="store_true", help="Also search mirrored variants of the selected transforms.")
236
+ parser.add_argument("--quiet", action="store_true")
237
+ return parser
238
+
239
+
240
+ def _main_extract_template(argv: list[str], *, prog: str) -> int:
241
+ parser = build_extract_template_parser(prog=prog)
242
+ args = parser.parse_args(argv)
243
+ try:
244
+ metadata_path = args.metadata or args.output_gds.with_suffix(".json")
245
+ result = extract_template(
246
+ args.source_gds,
247
+ output_gds=args.output_gds,
248
+ metadata_path=metadata_path,
249
+ top_name=args.top_name,
250
+ layers=args.layers,
251
+ bbox_user=args.bbox,
252
+ template_top=args.template_top,
253
+ )
254
+ except Exception as exc:
255
+ print(f"{prog}: {exc}", file=sys.stderr)
256
+ return 2
257
+
258
+ if not args.quiet:
259
+ template = result.metadata["template"]
260
+ print(f"template_gds={result.output_gds}")
261
+ print(f"metadata={result.metadata_path}")
262
+ print(f"polygon_count={template['polygon_count']}")
263
+ print(f"signature_sha256={template['signature_sha256']}")
264
+ return 0
265
+
266
+
267
+ def _main_find_template(argv: list[str], *, prog: str) -> int:
268
+ parser = build_find_template_parser(prog=prog)
269
+ args = parser.parse_args(argv)
270
+ try:
271
+ result = find_template(
272
+ args.template_gds,
273
+ args.target_gds,
274
+ output_dir=args.output_dir,
275
+ template_top=args.template_top,
276
+ target_top=args.target_top,
277
+ layers=args.layers,
278
+ transforms=args.transforms,
279
+ allow_mirror=args.allow_mirror,
280
+ )
281
+ except Exception as exc:
282
+ print(f"{prog}: {exc}", file=sys.stderr)
283
+ return 2
284
+
285
+ if not args.quiet:
286
+ summary = result.result["summary"]
287
+ print(f"matches_json={result.matches_json}")
288
+ print(f"matches_markdown={result.matches_markdown}")
289
+ print(f"candidate_count={summary['candidate_count']}")
290
+ print(f"match_count={summary['match_count']}")
291
+ print(f"difference_count={summary['difference_count']}")
292
+ return 0
293
+
294
+
295
+ def _resolve_artifact_paths(args: argparse.Namespace) -> tuple[Path | None, Path | None, Path | None, Path | None]:
296
+ json_report_path = args.json_report
297
+ diff_gds_path = args.diff_gds
298
+ markdown_path = args.markdown
299
+ exact_diff_markdown_path = args.exact_diff_markdown
300
+ if args.output_dir is not None:
301
+ output_dir = args.output_dir
302
+ output_dir.mkdir(parents=True, exist_ok=True)
303
+ json_report_path = json_report_path or output_dir / "report.json"
304
+ exact_diff_markdown_path = exact_diff_markdown_path or output_dir / "exact_diff.md"
305
+ return json_report_path, diff_gds_path, markdown_path, exact_diff_markdown_path
306
+
307
+
308
+ def _validate_input_paths(*paths: Path) -> None:
309
+ for path in paths:
310
+ if not path.is_file():
311
+ raise ValueError(f"input GDS file does not exist or is not a regular file: {path}")
312
+
313
+
314
+ def _parse_user_bbox(value: str) -> tuple[float, float, float, float]:
315
+ parts = [part.strip() for part in value.split(",")]
316
+ if len(parts) != 4:
317
+ raise argparse.ArgumentTypeError("bbox must have form X0,Y0,X1,Y1")
318
+ try:
319
+ x0, y0, x1, y1 = (float(part) for part in parts)
320
+ except ValueError as exc:
321
+ raise argparse.ArgumentTypeError("bbox coordinates must be numeric") from exc
322
+ if x0 >= x1 or y0 >= y1:
323
+ raise argparse.ArgumentTypeError("bbox must satisfy X0 < X1 and Y0 < Y1")
324
+ return x0, y0, x1, y1
325
+
326
+
327
+ def _parse_transform_list(value: str) -> tuple[str, ...]:
328
+ transforms = tuple(part.strip().lower() for part in value.split(",") if part.strip())
329
+ if not transforms:
330
+ raise argparse.ArgumentTypeError("at least one transform is required")
331
+ unknown = set(transforms) - {"translate", "rot90"}
332
+ if unknown:
333
+ raise argparse.ArgumentTypeError(f"unknown transform(s): {', '.join(sorted(unknown))}")
334
+ return transforms
335
+
336
+
337
+ def _resolve_evidence_engine(
338
+ evidence_engine: str,
339
+ backend: str,
340
+ *,
341
+ require_gpu: bool = False,
342
+ neutral_fallback: bool = False,
343
+ ) -> str:
344
+ if EvidenceEngine.coerce(evidence_engine) != EvidenceEngine.AUTO:
345
+ return evidence_engine
346
+ if not neutral_fallback:
347
+ return evidence_engine
348
+ return EvidenceEngine.GPU_POLYGON.value
349
+
350
+
351
+ if __name__ == "__main__":
352
+ raise SystemExit(main())