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.
- gdsdiff/__init__.py +148 -0
- gdsdiff/__main__.py +7 -0
- gdsdiff/_environment.py +12 -0
- gdsdiff/_version.py +34 -0
- gdsdiff/acceptance_evidence.py +530 -0
- gdsdiff/api.py +2955 -0
- gdsdiff/backends/__init__.py +26 -0
- gdsdiff/backends/base.py +77 -0
- gdsdiff/backends/boundary_loop_ctypes.py +191 -0
- gdsdiff/backends/cpu.py +20 -0
- gdsdiff/backends/cpu_ctypes.py +255 -0
- gdsdiff/backends/cuda.py +40 -0
- gdsdiff/backends/cuda_ctypes.py +1297 -0
- gdsdiff/backends/exact.py +424 -0
- gdsdiff/backends/geometry_ctypes.py +252 -0
- gdsdiff/backends/identity.py +53 -0
- gdsdiff/backends/metal.py +198 -0
- gdsdiff/backends/metal_ctypes.py +866 -0
- gdsdiff/backends/polygon_extract_ctypes.py +233 -0
- gdsdiff/backends/structural_ctypes.py +130 -0
- gdsdiff/boundary_loops.py +616 -0
- gdsdiff/cache.py +544 -0
- gdsdiff/canonicalize.py +176 -0
- gdsdiff/cli.py +352 -0
- gdsdiff/config.py +257 -0
- gdsdiff/cuda_setup.py +174 -0
- gdsdiff/design_history.py +65 -0
- gdsdiff/diagnostics.py +42 -0
- gdsdiff/diff_gds.py +66 -0
- gdsdiff/exact_diff_markdown.py +979 -0
- gdsdiff/exact_oracle.py +649 -0
- gdsdiff/extract.py +376 -0
- gdsdiff/gds_metadata.py +43 -0
- gdsdiff/geometry.py +112 -0
- gdsdiff/grid.py +143 -0
- gdsdiff/hierarchy.py +215 -0
- gdsdiff/hierarchy_extract.py +263 -0
- gdsdiff/inventory.py +153 -0
- gdsdiff/multiprocessing_support.py +39 -0
- gdsdiff/native.py +135 -0
- gdsdiff/native_cache.py +265 -0
- gdsdiff/native_src/cpu_prefilter.cpp +349 -0
- gdsdiff/native_src/gds_boundary_loops.cpp +505 -0
- gdsdiff/native_src/gds_geometry_scan.cpp +601 -0
- gdsdiff/native_src/gds_polygon_extract.cpp +594 -0
- gdsdiff/native_src/gds_structural_scan.cpp +335 -0
- gdsdiff/native_src/gdsdiff/CMakeLists.txt +74 -0
- gdsdiff/native_src/gdsdiff/core.cpp +191 -0
- gdsdiff/native_src/gdsdiff/core.hpp +96 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cu +304 -0
- gdsdiff/native_src/gdsdiff/cuda_assignment.cuh +33 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cu +133 -0
- gdsdiff/native_src/gdsdiff/cuda_candidates.cuh +17 -0
- gdsdiff/native_src/gdsdiff/cuda_ctypes.cu +4528 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cu +194 -0
- gdsdiff/native_src/gdsdiff/cuda_memory.cuh +34 -0
- gdsdiff/native_src/gdsdiff/metal_ctypes.mm +2791 -0
- gdsdiff/native_src/gdsdiff/python_bindings.cpp +21 -0
- gdsdiff/native_src/gdsdiff/test_core.cpp +93 -0
- gdsdiff/native_src/gdsdiff/test_cuda_assignment.cu +109 -0
- gdsdiff/native_src/gdsdiff/test_cuda_candidates.cu +94 -0
- gdsdiff/native_src/gdsdiff/test_cuda_memory.cu +97 -0
- gdsdiff/policy.py +305 -0
- gdsdiff/polygon_components.py +860 -0
- gdsdiff/profiles.py +152 -0
- gdsdiff/py.typed +1 -0
- gdsdiff/rect_coverage.py +384 -0
- gdsdiff/report.py +354 -0
- gdsdiff/schemas/gdsdiff_report-v1.schema.json +92 -0
- gdsdiff/semantics.py +75 -0
- gdsdiff/source_edge_diff.py +1120 -0
- gdsdiff/spatial.py +71 -0
- gdsdiff/structural_guard.py +161 -0
- gdsdiff/surplus_coverage.py +255 -0
- gdsdiff/synthetic_suite.py +1643 -0
- gdsdiff/templates.py +709 -0
- gdsdiff/tiling.py +153 -0
- gdsdiff/windowed_exact.py +270 -0
- gdsdiff/windows.py +184 -0
- gdsdiff-0.1.0.dist-info/METADATA +135 -0
- gdsdiff-0.1.0.dist-info/RECORD +85 -0
- gdsdiff-0.1.0.dist-info/WHEEL +5 -0
- gdsdiff-0.1.0.dist-info/entry_points.txt +4 -0
- gdsdiff-0.1.0.dist-info/licenses/LICENSE +674 -0
- gdsdiff-0.1.0.dist-info/top_level.txt +1 -0
gdsdiff/canonicalize.py
ADDED
|
@@ -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())
|