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/config.py ADDED
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from decimal import Decimal, InvalidOperation
5
+ from pathlib import Path
6
+ from typing import Iterable, Literal
7
+
8
+ from .semantics import Backend, DifferenceDirection, EvidenceEngine, FailurePolicy, PathPolicy
9
+
10
+ LayerSelector = Literal["all"] | tuple["LayerSpec", ...]
11
+
12
+
13
+ def _decimal_or_none(value) -> Decimal | None:
14
+ if value is None:
15
+ return None
16
+ if isinstance(value, Decimal):
17
+ result = value
18
+ else:
19
+ try:
20
+ result = Decimal(str(value))
21
+ except (InvalidOperation, ValueError) as exc:
22
+ raise ValueError(f"invalid decimal value: {value!r}") from exc
23
+ if not result.is_finite() or result <= 0:
24
+ raise ValueError(f"decimal value must be finite and positive: {value!r}")
25
+ return result
26
+
27
+
28
+ @dataclass(frozen=True, order=True)
29
+ class LayerSpec:
30
+ layer: int
31
+ datatype: int = 0
32
+
33
+ def __post_init__(self) -> None:
34
+ if not isinstance(self.layer, int) or not isinstance(self.datatype, int):
35
+ raise TypeError("layer and datatype must be integers")
36
+ if self.layer < 0 or self.datatype < 0:
37
+ raise ValueError("layer and datatype must be nonnegative")
38
+
39
+ @classmethod
40
+ def parse(cls, value: str | tuple[int, int] | "LayerSpec") -> "LayerSpec":
41
+ if isinstance(value, cls):
42
+ return value
43
+ if isinstance(value, tuple):
44
+ if len(value) != 2:
45
+ raise ValueError("layer tuple must be (layer, datatype)")
46
+ return cls(int(value[0]), int(value[1]))
47
+ parts = str(value).strip().split(":")
48
+ if len(parts) != 2 or not parts[0] or not parts[1]:
49
+ raise ValueError(f"layer spec must have form L:D, got {value!r}")
50
+ return cls(int(parts[0]), int(parts[1]))
51
+
52
+ def to_json(self) -> dict[str, int]:
53
+ return {"layer": self.layer, "datatype": self.datatype}
54
+
55
+
56
+ def parse_layer_selector(value: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec]) -> LayerSelector:
57
+ if value == "all":
58
+ return "all"
59
+ if isinstance(value, str):
60
+ parts = [part.strip() for part in value.split(",") if part.strip()]
61
+ if not parts:
62
+ raise ValueError("layer selector cannot be empty")
63
+ layers = tuple(sorted({LayerSpec.parse(part) for part in parts}))
64
+ elif isinstance(value, LayerSpec):
65
+ layers = (value,)
66
+ else:
67
+ layers = tuple(sorted({LayerSpec.parse(part) for part in value}))
68
+ if not layers:
69
+ raise ValueError("layer selector cannot be empty")
70
+ return layers
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class GridSpec:
75
+ meters_per_dbu: Decimal | str | float | None = None
76
+ source_old_precision_m: Decimal | str | float | None = None
77
+ source_new_precision_m: Decimal | str | float | None = None
78
+ selection_mode: str = "auto"
79
+ rounding_mode: str = "nearest-ties-away-from-zero"
80
+ max_coordinate: int | None = None
81
+ max_snap_error_dbu: Decimal | str | float | None = None
82
+
83
+ def __post_init__(self) -> None:
84
+ object.__setattr__(self, "meters_per_dbu", _decimal_or_none(self.meters_per_dbu))
85
+ object.__setattr__(self, "source_old_precision_m", _decimal_or_none(self.source_old_precision_m))
86
+ object.__setattr__(self, "source_new_precision_m", _decimal_or_none(self.source_new_precision_m))
87
+ object.__setattr__(self, "max_snap_error_dbu", _decimal_or_none(self.max_snap_error_dbu))
88
+ if self.rounding_mode != "nearest-ties-away-from-zero":
89
+ raise ValueError("unsupported rounding mode")
90
+ if self.max_coordinate is not None and self.max_coordinate <= 0:
91
+ raise ValueError("max_coordinate must be positive when set")
92
+
93
+ def to_json(self) -> dict[str, object]:
94
+ return {
95
+ "meters_per_dbu": str(self.meters_per_dbu) if self.meters_per_dbu is not None else None,
96
+ "source_old_precision_m": str(self.source_old_precision_m)
97
+ if self.source_old_precision_m is not None
98
+ else None,
99
+ "source_new_precision_m": str(self.source_new_precision_m)
100
+ if self.source_new_precision_m is not None
101
+ else None,
102
+ "selection_mode": self.selection_mode,
103
+ "rounding_mode": self.rounding_mode,
104
+ "max_coordinate": self.max_coordinate,
105
+ "max_snap_error_dbu": str(self.max_snap_error_dbu) if self.max_snap_error_dbu is not None else None,
106
+ }
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class AllowedRegion:
111
+ layers: tuple[LayerSpec, ...]
112
+ direction: DifferenceDirection | str = DifferenceDirection.BOTH
113
+ bbox: tuple[int, int, int, int] | None = None
114
+ whole_layer: bool = False
115
+ halo_dbu: int = 0
116
+ label: str = ""
117
+ reason: str = ""
118
+
119
+ def __post_init__(self) -> None:
120
+ layers = tuple(sorted({LayerSpec.parse(layer) for layer in self.layers}))
121
+ if not layers:
122
+ raise ValueError("allowed region must list at least one layer")
123
+ object.__setattr__(self, "layers", layers)
124
+ object.__setattr__(self, "direction", DifferenceDirection.coerce(self.direction))
125
+ if self.halo_dbu < 0:
126
+ raise ValueError("allowed region halo_dbu must be nonnegative")
127
+ if self.whole_layer:
128
+ if self.bbox is not None:
129
+ raise ValueError("whole-layer allowed region cannot also set bbox")
130
+ elif self.bbox is None:
131
+ raise ValueError("allowed region requires bbox unless whole_layer is true")
132
+ else:
133
+ x0, y0, x1, y1 = self.bbox
134
+ if x0 >= x1 or y0 >= y1:
135
+ raise ValueError("allowed region bbox must satisfy x0 < x1 and y0 < y1")
136
+
137
+ def to_json(self) -> dict[str, object]:
138
+ return {
139
+ "layers": [layer.to_json() for layer in self.layers],
140
+ "direction": self.direction.value,
141
+ "bbox": list(self.bbox) if self.bbox is not None else None,
142
+ "whole_layer": self.whole_layer,
143
+ "halo_dbu": self.halo_dbu,
144
+ "label": self.label,
145
+ "reason": self.reason,
146
+ }
147
+
148
+
149
+ def parse_allowed_region(value: str) -> AllowedRegion:
150
+ """Parse ``L:D,DIRECTION,X0,Y0,X1,Y1[,HALO]`` into an allowance."""
151
+
152
+ parts = [part.strip() for part in value.split(",")]
153
+ if len(parts) not in {6, 7}:
154
+ raise ValueError("allowed region must have form L:D,DIRECTION,X0,Y0,X1,Y1[,HALO]")
155
+ layer = LayerSpec.parse(parts[0])
156
+ direction = DifferenceDirection.coerce(parts[1])
157
+ x0, y0, x1, y1 = (int(part) for part in parts[2:6])
158
+ halo = int(parts[6]) if len(parts) == 7 else 0
159
+ return AllowedRegion(
160
+ layers=(layer,),
161
+ direction=direction,
162
+ bbox=(x0, y0, x1, y1),
163
+ halo_dbu=halo,
164
+ )
165
+
166
+
167
+ def parse_allowed_whole_layer(value: str) -> AllowedRegion:
168
+ """Parse ``L:D,DIRECTION`` into a whole-layer allowance."""
169
+
170
+ parts = [part.strip() for part in value.split(",")]
171
+ if len(parts) != 2:
172
+ raise ValueError("whole-layer allowance must have form L:D,DIRECTION")
173
+ return AllowedRegion(
174
+ layers=(LayerSpec.parse(parts[0]),),
175
+ direction=DifferenceDirection.coerce(parts[1]),
176
+ whole_layer=True,
177
+ )
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class CompareConfig:
182
+ old_path: Path | str
183
+ new_path: Path | str
184
+ top: str | None = None
185
+ old_top: str | None = None
186
+ new_top: str | None = None
187
+ require_top_name_match: bool = False
188
+ layers: str | LayerSpec | Iterable[str | tuple[int, int] | LayerSpec] = "all"
189
+ dbu: Decimal | str | float | None = None
190
+ precision: Decimal | str | float | None = None
191
+ path_policy: PathPolicy | str = PathPolicy.GDSTK
192
+ backend: Backend | str = Backend.AUTO
193
+ evidence_engine: EvidenceEngine | str = EvidenceEngine.AUTO
194
+ tile_size: int = 1024
195
+ allowed_regions: tuple[AllowedRegion, ...] = field(default_factory=tuple)
196
+ allow_empty_selected_layer: bool = False
197
+ strict: bool = True
198
+ require_gpu: bool = False
199
+ failure_policy: FailurePolicy | str = FailurePolicy.FAIL_ON_UNEXPECTED
200
+ memory_budget_bytes: int | None = None
201
+ geometry_cache_dir: Path | str | None = None
202
+
203
+ def __post_init__(self) -> None:
204
+ object.__setattr__(self, "old_path", Path(self.old_path))
205
+ object.__setattr__(self, "new_path", Path(self.new_path))
206
+ if self.geometry_cache_dir is not None:
207
+ object.__setattr__(self, "geometry_cache_dir", Path(self.geometry_cache_dir))
208
+ if self.top and (self.old_top or self.new_top):
209
+ raise ValueError("top cannot be combined with old_top or new_top")
210
+ if bool(self.old_top) != bool(self.new_top):
211
+ raise ValueError("old_top and new_top must be supplied together")
212
+ layers = parse_layer_selector(self.layers)
213
+ object.__setattr__(self, "layers", layers)
214
+ dbu = _decimal_or_none(self.dbu)
215
+ precision = _decimal_or_none(self.precision)
216
+ if dbu is not None and precision is not None and dbu != precision:
217
+ raise ValueError("explicit dbu and precision must match in MVP semantics")
218
+ object.__setattr__(self, "dbu", dbu)
219
+ object.__setattr__(self, "precision", precision)
220
+ object.__setattr__(self, "path_policy", PathPolicy.coerce(self.path_policy))
221
+ backend = Backend.coerce(self.backend)
222
+ if self.require_gpu and backend == Backend.CPU:
223
+ raise ValueError("require_gpu cannot be used with backend='cpu'")
224
+ object.__setattr__(self, "backend", backend)
225
+ object.__setattr__(self, "evidence_engine", EvidenceEngine.coerce(self.evidence_engine))
226
+ object.__setattr__(self, "failure_policy", FailurePolicy.coerce(self.failure_policy))
227
+ if self.tile_size <= 0:
228
+ raise ValueError("tile_size must be positive")
229
+ if self.memory_budget_bytes is not None and self.memory_budget_bytes <= 0:
230
+ raise ValueError("memory_budget_bytes must be positive when set")
231
+ object.__setattr__(self, "allowed_regions", tuple(self.allowed_regions))
232
+
233
+ def to_json_dict(self) -> dict[str, object]:
234
+ data = {
235
+ "old_path": str(self.old_path),
236
+ "new_path": str(self.new_path),
237
+ "top": self.top,
238
+ "old_top": self.old_top,
239
+ "new_top": self.new_top,
240
+ "require_top_name_match": self.require_top_name_match,
241
+ "layers": "all" if self.layers == "all" else [layer.to_json() for layer in self.layers],
242
+ "dbu": str(self.dbu) if self.dbu is not None else None,
243
+ "precision": str(self.precision) if self.precision is not None else None,
244
+ "path_policy": self.path_policy.value,
245
+ "backend": self.backend.value,
246
+ "evidence_engine": self.evidence_engine.value,
247
+ "tile_size": self.tile_size,
248
+ "allowed_regions": [region.to_json() for region in self.allowed_regions],
249
+ "allow_empty_selected_layer": self.allow_empty_selected_layer,
250
+ "strict": self.strict,
251
+ "require_gpu": self.require_gpu,
252
+ "failure_policy": self.failure_policy.value,
253
+ "memory_budget_bytes": self.memory_budget_bytes,
254
+ }
255
+ if self.geometry_cache_dir is not None:
256
+ data["geometry_cache_dir"] = str(self.geometry_cache_dir)
257
+ return data
gdsdiff/cuda_setup.py ADDED
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Sequence
12
+
13
+ from .native import nvcc_architecture_flags, resolve_nvcc_path
14
+ from .native_cache import native_cache_dir
15
+
16
+
17
+ class CudaPreparationError(RuntimeError):
18
+ """Raised when the optional CUDA backend cannot be prepared and verified."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CudaToolchainReport:
23
+ available: bool
24
+ platform: str
25
+ machine: str
26
+ nvcc_path: str | None
27
+ nvcc_version: str | None
28
+ host_compiler: str | None
29
+ driver_version: str | None
30
+ devices: tuple[str, ...]
31
+ architectures: tuple[str, ...]
32
+ compile_flags: tuple[str, ...]
33
+ cache_path: str
34
+ reason: str | None = None
35
+
36
+ def to_json(self) -> dict[str, object]:
37
+ return asdict(self)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CudaPreparationReport:
42
+ ready: bool
43
+ built: bool
44
+ reused: bool
45
+ verified: bool
46
+ library_path: str
47
+ toolchain: CudaToolchainReport
48
+
49
+ def to_json(self) -> dict[str, object]:
50
+ return asdict(self)
51
+
52
+
53
+ def inspect_cuda_toolchain() -> CudaToolchainReport:
54
+ """Inspect CUDA without importing, compiling, or loading the backend."""
55
+
56
+ nvcc = resolve_nvcc_path()
57
+ version = _command_output((nvcc, "--version")) if nvcc else None
58
+ smi = _command_output(
59
+ (
60
+ "nvidia-smi",
61
+ "--query-gpu=name,compute_cap,driver_version",
62
+ "--format=csv,noheader,nounits",
63
+ )
64
+ )
65
+ devices = tuple(line.strip() for line in (smi or "").splitlines() if line.strip())
66
+ driver = None
67
+ if devices:
68
+ parts = [part.strip() for part in devices[0].split(",")]
69
+ driver = parts[-1] if len(parts) >= 3 else None
70
+ architectures = tuple(
71
+ part.strip() for part in os.environ.get("GDSDIFF_CUDA_ARCHITECTURES", "").replace(";", ",").split(",") if part.strip()
72
+ )
73
+ available = nvcc is not None and bool(devices)
74
+ reason = None
75
+ if nvcc is None:
76
+ reason = "nvcc was not found in PATH, CUDA_HOME, /usr/local/cuda, or /opt/cuda"
77
+ elif not devices:
78
+ reason = "no NVIDIA GPU/driver was visible through nvidia-smi"
79
+ return CudaToolchainReport(
80
+ available=available,
81
+ platform=platform.system(),
82
+ machine=platform.machine(),
83
+ nvcc_path=nvcc,
84
+ nvcc_version=version,
85
+ host_compiler=_which("g++"),
86
+ driver_version=driver,
87
+ devices=devices,
88
+ architectures=architectures,
89
+ compile_flags=nvcc_architecture_flags(),
90
+ cache_path=str(native_cache_dir()),
91
+ reason=reason,
92
+ )
93
+
94
+
95
+ def prepare_cuda() -> CudaPreparationReport:
96
+ """Build the CUDA backend in the user cache and run a device smoke test."""
97
+
98
+ try:
99
+ toolchain = inspect_cuda_toolchain()
100
+ except (OSError, ValueError) as exc:
101
+ raise CudaPreparationError(str(exc)) from exc
102
+ if not toolchain.available:
103
+ raise CudaPreparationError(toolchain.reason or "CUDA toolchain is unavailable")
104
+ try:
105
+ from .backends.cuda_ctypes import compare_files_cuda, prepared_cuda_library_path
106
+
107
+ library = prepared_cuda_library_path()
108
+ existed = library.is_file()
109
+ with tempfile.TemporaryDirectory(prefix="gdsdiff-cuda-smoke-") as directory:
110
+ left = Path(directory) / "left.bin"
111
+ right = Path(directory) / "right.bin"
112
+ left.write_bytes(b"gdsdiff-cuda-smoke")
113
+ right.write_bytes(b"gdsdiff-cuda-smoke")
114
+ result = compare_files_cuda(left, right)
115
+ if not result.same:
116
+ raise CudaPreparationError("CUDA smoke comparison returned a mismatch")
117
+ except CudaPreparationError:
118
+ raise
119
+ except Exception as exc:
120
+ raise CudaPreparationError(str(exc)) from exc
121
+ if not library.is_file():
122
+ raise CudaPreparationError("CUDA preparation did not publish the expected native library")
123
+ built = not existed
124
+ return CudaPreparationReport(
125
+ ready=True,
126
+ built=built,
127
+ reused=not built,
128
+ verified=True,
129
+ library_path=str(library),
130
+ toolchain=toolchain,
131
+ )
132
+
133
+
134
+ def main(argv: Sequence[str] | None = None) -> int:
135
+ import argparse
136
+
137
+ parser = argparse.ArgumentParser(prog="gdsdiff-prepare-cuda", description="Build and verify the optional gdsdiff CUDA backend.")
138
+ parser.add_argument("--json", action="store_true", help="Emit a machine-readable report.")
139
+ args = parser.parse_args(argv)
140
+ try:
141
+ report = prepare_cuda()
142
+ except CudaPreparationError as exc:
143
+ if args.json:
144
+ print(json.dumps({"ready": False, "error": str(exc)}, indent=2, sort_keys=True))
145
+ else:
146
+ print(f"gdsdiff-prepare-cuda: {exc}", file=sys.stderr)
147
+ return 2
148
+ if args.json:
149
+ print(json.dumps(report.to_json(), indent=2, sort_keys=True))
150
+ else:
151
+ action = "built" if report.built else "reused"
152
+ print(f"CUDA backend ready ({action}): {report.library_path}")
153
+ return 0
154
+
155
+
156
+ def _command_output(command: tuple[str | None, ...]) -> str | None:
157
+ if not command or command[0] is None:
158
+ return None
159
+ try:
160
+ completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=10)
161
+ except (OSError, subprocess.SubprocessError):
162
+ return None
163
+ output = (completed.stdout or completed.stderr).strip()
164
+ return output if completed.returncode == 0 and output else None
165
+
166
+
167
+ def _which(command: str) -> str | None:
168
+ import shutil
169
+
170
+ return shutil.which(command)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ raise SystemExit(main())
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ from .exact_oracle import format_twice_area
8
+ from .policy import PolicyClassificationResult
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class DesignHistoryEntry:
13
+ title: str
14
+ policy_result: PolicyClassificationResult
15
+ diff_gds_path: str | Path | None = None
16
+ tests_run: tuple[str, ...] = field(default_factory=tuple)
17
+ notes: tuple[str, ...] = field(default_factory=tuple)
18
+ entry_date: date | None = None
19
+
20
+ def render_markdown(self) -> str:
21
+ entry_date = self.entry_date or date.today()
22
+ lines = [
23
+ f"### {entry_date.isoformat()} - {self.title}",
24
+ "",
25
+ f"- Status: {self.policy_result.status.value}.",
26
+ f"- Expected diff area dbu^2: {format_twice_area(self.policy_result.expected_twice_area)}.",
27
+ f"- Unexpected diff area dbu^2: {format_twice_area(self.policy_result.unexpected_twice_area)}.",
28
+ ]
29
+ if self.diff_gds_path is not None:
30
+ lines.append(f"- Diff GDS: `{self.diff_gds_path}`.")
31
+ if self.tests_run:
32
+ lines.append("- Tests run:")
33
+ lines.extend(f" - `{test}`" for test in self.tests_run)
34
+ if self.notes:
35
+ lines.append("- Notes:")
36
+ lines.extend(f" - {note}" for note in self.notes)
37
+ lines.append("- Layer summary:")
38
+ for layer in self.policy_result.layers:
39
+ lines.append(
40
+ " - "
41
+ f"{layer.layer.layer}:{layer.layer.datatype} expected="
42
+ f"{format_twice_area(layer.expected_twice_area)} unexpected="
43
+ f"{format_twice_area(layer.unexpected_twice_area)} components="
44
+ f"{len(layer.components)}"
45
+ )
46
+ return "\n".join(lines) + "\n"
47
+
48
+
49
+ def render_design_history_entry(
50
+ title: str,
51
+ policy_result: PolicyClassificationResult,
52
+ *,
53
+ diff_gds_path: str | Path | None = None,
54
+ tests_run: tuple[str, ...] = (),
55
+ notes: tuple[str, ...] = (),
56
+ entry_date: date | None = None,
57
+ ) -> str:
58
+ return DesignHistoryEntry(
59
+ title=title,
60
+ policy_result=policy_result,
61
+ diff_gds_path=diff_gds_path,
62
+ tests_run=tests_run,
63
+ notes=notes,
64
+ entry_date=entry_date,
65
+ ).render_markdown()
gdsdiff/diagnostics.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import platform
5
+ import sys
6
+ from typing import Any, Sequence
7
+
8
+ from .native import discover_native_capabilities
9
+ from .native_cache import ENV_NATIVE_CACHE_DIR, LEGACY_ENV_NATIVE_CACHE_DIR, native_cache_dir
10
+
11
+
12
+ def backend_diagnostics() -> dict[str, Any]:
13
+ """Return CPU-safe runtime and optional-backend diagnostics."""
14
+
15
+ return {
16
+ "python": {
17
+ "implementation": platform.python_implementation(),
18
+ "version": platform.python_version(),
19
+ },
20
+ "platform": {
21
+ "system": platform.system(),
22
+ "machine": platform.machine(),
23
+ },
24
+ "native_cache": {
25
+ "path": str(native_cache_dir()),
26
+ "environment": ENV_NATIVE_CACHE_DIR,
27
+ "legacy_environment": LEGACY_ENV_NATIVE_CACHE_DIR,
28
+ },
29
+ "capabilities": discover_native_capabilities().to_json(),
30
+ }
31
+
32
+
33
+ def main(argv: Sequence[str] | None = None) -> int:
34
+ if argv:
35
+ print("gdsdiff-diagnostics does not accept arguments", file=sys.stderr)
36
+ return 2
37
+ print(json.dumps(backend_diagnostics(), indent=2, sort_keys=True))
38
+ return 0
39
+
40
+
41
+ if __name__ == "__main__":
42
+ raise SystemExit(main())
gdsdiff/diff_gds.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import gdstk
7
+
8
+ from .policy import ClassifiedComponent, PolicyClassificationResult
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class DiffGdsWriteResult:
13
+ path: Path
14
+ component_count: int
15
+ category_cells: tuple[str, ...]
16
+
17
+ def to_json(self) -> dict[str, object]:
18
+ return {
19
+ "path": str(self.path),
20
+ "component_count": self.component_count,
21
+ "category_cells": list(self.category_cells),
22
+ }
23
+
24
+
25
+ def write_diff_gds(
26
+ policy_result: PolicyClassificationResult,
27
+ path: str | Path,
28
+ *,
29
+ unit: float = 1e-6,
30
+ precision: float = 1e-9,
31
+ top_cell_name: str = "GDS_GPU_COMPARE_DIFF",
32
+ ) -> DiffGdsWriteResult:
33
+ path = Path(path)
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ lib = gdstk.Library(unit=unit, precision=precision)
36
+ top = lib.new_cell(top_cell_name)
37
+ cells: dict[str, gdstk.Cell] = {}
38
+ component_count = 0
39
+ for component in _iter_components(policy_result):
40
+ cell_name = _category_cell_name(component)
41
+ cell = cells.get(cell_name)
42
+ if cell is None:
43
+ cell = lib.new_cell(cell_name)
44
+ cells[cell_name] = cell
45
+ top.add(gdstk.Reference(cell))
46
+ cell.add(
47
+ gdstk.Polygon(
48
+ component.points,
49
+ layer=component.layer.layer,
50
+ datatype=component.layer.datatype,
51
+ )
52
+ )
53
+ component_count += 1
54
+ lib.write_gds(path)
55
+ return DiffGdsWriteResult(path=path, component_count=component_count, category_cells=tuple(sorted(cells)))
56
+
57
+
58
+ def _iter_components(policy_result: PolicyClassificationResult) -> tuple[ClassifiedComponent, ...]:
59
+ components = []
60
+ for layer in policy_result.layers:
61
+ components.extend(layer.components)
62
+ return tuple(sorted(components, key=lambda component: (component.classification, component.direction.value, component.layer, component.bbox)))
63
+
64
+
65
+ def _category_cell_name(component: ClassifiedComponent) -> str:
66
+ return f"{component.classification}_{component.direction.value}".upper().replace("-", "_")