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,1643 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import sys
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Callable, Iterable, Sequence
11
+
12
+ import gdstk
13
+
14
+ from .api import compare_gds_files
15
+ from .config import CompareConfig, LayerSpec
16
+ from .exact_oracle import run_full_exact_oracle
17
+ from .extract import extract_gds_polygons
18
+ from .native import discover_native_capabilities
19
+
20
+ LayerTuple = tuple[int, int]
21
+ BBox = tuple[float, float, float, float]
22
+
23
+ _UNIT = 1e-6
24
+ _PRECISION = 1e-9
25
+ _DBU_PER_UM = int(round(_UNIT / _PRECISION))
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ScaleProfile:
30
+ name: str
31
+ sparse_grid_n: int
32
+ mixed_count: int
33
+ hollow_grid_n: int
34
+ hierarchy_cols: int
35
+ hierarchy_rows: int
36
+ reference_cols: int
37
+ reference_rows: int
38
+
39
+
40
+ SCALE_PROFILES: dict[str, ScaleProfile] = {
41
+ "smoke": ScaleProfile(
42
+ name="smoke",
43
+ sparse_grid_n=6,
44
+ mixed_count=24,
45
+ hollow_grid_n=4,
46
+ hierarchy_cols=4,
47
+ hierarchy_rows=3,
48
+ reference_cols=5,
49
+ reference_rows=4,
50
+ ),
51
+ "benchmark": ScaleProfile(
52
+ name="benchmark",
53
+ sparse_grid_n=110,
54
+ mixed_count=900,
55
+ hollow_grid_n=28,
56
+ hierarchy_cols=60,
57
+ hierarchy_rows=24,
58
+ reference_cols=80,
59
+ reference_rows=80,
60
+ ),
61
+ "stress": ScaleProfile(
62
+ name="stress",
63
+ sparse_grid_n=300,
64
+ mixed_count=5000,
65
+ hollow_grid_n=80,
66
+ hierarchy_cols=180,
67
+ hierarchy_rows=80,
68
+ reference_cols=250,
69
+ reference_rows=200,
70
+ ),
71
+ }
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class SyntheticEdit:
76
+ operation_id: str
77
+ shape_id: str
78
+ operation: str
79
+ expected_direction: str
80
+ before_layer: LayerTuple | None = None
81
+ after_layer: LayerTuple | None = None
82
+ before_bbox_um: BBox | None = None
83
+ after_bbox_um: BBox | None = None
84
+ notes: str = ""
85
+
86
+ @property
87
+ def coverage_changed(self) -> bool:
88
+ return self.expected_direction != "inventory_only"
89
+
90
+ def changed_layers(self) -> tuple[LayerTuple, ...]:
91
+ if not self.coverage_changed:
92
+ return ()
93
+ layers: list[LayerTuple] = []
94
+ if self.before_layer is not None:
95
+ layers.append(self.before_layer)
96
+ if self.after_layer is not None and self.after_layer not in layers:
97
+ layers.append(self.after_layer)
98
+ return tuple(layers)
99
+
100
+ def to_json(self) -> dict[str, object]:
101
+ return {
102
+ "operation_id": self.operation_id,
103
+ "shape_id": self.shape_id,
104
+ "operation": self.operation,
105
+ "expected_direction": self.expected_direction,
106
+ "before_layer": list(self.before_layer) if self.before_layer is not None else None,
107
+ "after_layer": list(self.after_layer) if self.after_layer is not None else None,
108
+ "before_bbox_um": list(self.before_bbox_um) if self.before_bbox_um is not None else None,
109
+ "after_bbox_um": list(self.after_bbox_um) if self.after_bbox_um is not None else None,
110
+ "notes": self.notes,
111
+ }
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class SyntheticCaseBuild:
116
+ before: gdstk.Library
117
+ after: gdstk.Library
118
+ edits: tuple[SyntheticEdit, ...]
119
+ equivalent_expected: bool = False
120
+ notes: str = ""
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class SyntheticCaseDefinition:
125
+ name: str
126
+ title: str
127
+ builder: Callable[[ScaleProfile], SyntheticCaseBuild]
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class SyntheticCaseRecord:
132
+ name: str
133
+ title: str
134
+ scale: str
135
+ before_path: Path
136
+ after_path: Path
137
+ manifest_path: Path
138
+ changes_path: Path
139
+ before_polygon_count: int
140
+ after_polygon_count: int
141
+ before_vertex_count: int
142
+ after_vertex_count: int
143
+ expected_changed_layers: tuple[LayerTuple, ...]
144
+ equivalent_expected: bool
145
+ edits: tuple[SyntheticEdit, ...]
146
+
147
+ def to_json(self) -> dict[str, object]:
148
+ return {
149
+ "name": self.name,
150
+ "title": self.title,
151
+ "scale": self.scale,
152
+ "paths": {
153
+ "before_gds": str(self.before_path),
154
+ "after_gds": str(self.after_path),
155
+ "manifest": str(self.manifest_path),
156
+ "changes": str(self.changes_path),
157
+ },
158
+ "counts": {
159
+ "before_polygons": self.before_polygon_count,
160
+ "after_polygons": self.after_polygon_count,
161
+ "before_vertices": self.before_vertex_count,
162
+ "after_vertices": self.after_vertex_count,
163
+ },
164
+ "expected_changed_layers": [list(layer) for layer in self.expected_changed_layers],
165
+ "equivalent_expected": self.equivalent_expected,
166
+ "edits": [edit.to_json() for edit in self.edits],
167
+ }
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class SyntheticBenchmarkRun:
172
+ case_name: str
173
+ scale: str
174
+ mode: str
175
+ status: str
176
+ exit_code: int
177
+ wall_s: float
178
+ total_s: float | None
179
+ changed_layers: tuple[LayerTuple, ...]
180
+ audit_match: bool | None
181
+ json_report_path: Path
182
+ exact_diff_markdown_path: Path
183
+ stdout_path: Path
184
+ stderr_path: Path
185
+ command_path: Path
186
+ error: str | None = None
187
+
188
+ def to_json(self) -> dict[str, object]:
189
+ return {
190
+ "case_name": self.case_name,
191
+ "scale": self.scale,
192
+ "mode": self.mode,
193
+ "status": self.status,
194
+ "exit_code": self.exit_code,
195
+ "wall_s": self.wall_s,
196
+ "total_s": self.total_s,
197
+ "changed_layers": [list(layer) for layer in self.changed_layers],
198
+ "audit_match": self.audit_match,
199
+ "artifacts": {
200
+ "json_report": str(self.json_report_path),
201
+ "exact_diff_markdown": str(self.exact_diff_markdown_path),
202
+ "stdout": str(self.stdout_path),
203
+ "stderr": str(self.stderr_path),
204
+ "command": str(self.command_path),
205
+ },
206
+ "error": self.error,
207
+ }
208
+
209
+
210
+ def build_synthetic_suite(
211
+ output_dir: str | Path,
212
+ *,
213
+ scale: str = "benchmark",
214
+ case_names: Iterable[str] | None = None,
215
+ ) -> tuple[SyntheticCaseRecord, ...]:
216
+ profile = _profile(scale)
217
+ selected = set(case_names) if case_names is not None else None
218
+ output_dir = Path(output_dir)
219
+ output_dir.mkdir(parents=True, exist_ok=True)
220
+ records: list[SyntheticCaseRecord] = []
221
+ for definition in SYNTHETIC_CASES:
222
+ if selected is not None and definition.name not in selected:
223
+ continue
224
+ records.append(_write_case(output_dir, definition, profile))
225
+ suite_manifest = {
226
+ "suite": "gdsdiff-synthetic-readiness-corpus",
227
+ "scale": profile.name,
228
+ "case_count": len(records),
229
+ "cases": [record.to_json() for record in records],
230
+ }
231
+ (output_dir / "suite_manifest.json").write_text(_json_dumps(suite_manifest), encoding="utf-8")
232
+ return tuple(records)
233
+
234
+
235
+ def benchmark_synthetic_records(
236
+ records: Sequence[SyntheticCaseRecord],
237
+ *,
238
+ run_cpu: bool = True,
239
+ run_metal: bool = True,
240
+ run_metal_audit: bool = True,
241
+ stress_cpu_case_limit: int = 2,
242
+ ) -> tuple[SyntheticBenchmarkRun, ...]:
243
+ rows: list[SyntheticBenchmarkRun] = []
244
+ stress_cpu_count = 0
245
+ for record in records:
246
+ allow_cpu = run_cpu
247
+ allow_metal_audit = run_metal_audit
248
+ if record.scale == "stress":
249
+ allow_cpu = run_cpu and stress_cpu_count < stress_cpu_case_limit
250
+ allow_metal_audit = run_metal_audit and stress_cpu_count < stress_cpu_case_limit
251
+ if allow_cpu:
252
+ stress_cpu_count += 1
253
+ if allow_cpu:
254
+ rows.append(_benchmark_one(record, mode="cpu"))
255
+ if run_metal:
256
+ rows.append(_benchmark_one(record, mode="metal"))
257
+ if run_metal and allow_metal_audit:
258
+ rows.append(_benchmark_one(record, mode="metal_audit"))
259
+ return tuple(rows)
260
+
261
+
262
+ def write_benchmark_summary(
263
+ output_dir: str | Path,
264
+ records: Sequence[SyntheticCaseRecord],
265
+ runs: Sequence[SyntheticBenchmarkRun],
266
+ ) -> Path:
267
+ output_dir = Path(output_dir)
268
+ output_dir.mkdir(parents=True, exist_ok=True)
269
+ run_json_path = output_dir / "benchmark_runs.json"
270
+ run_json_path.write_text(_json_dumps([run.to_json() for run in runs]), encoding="utf-8")
271
+ summary_path = output_dir / "benchmark_summary.md"
272
+ summary_path.write_text(_benchmark_markdown(records, runs, run_json_path), encoding="utf-8")
273
+ return summary_path
274
+
275
+
276
+ def _write_case(output_dir: Path, definition: SyntheticCaseDefinition, profile: ScaleProfile) -> SyntheticCaseRecord:
277
+ case_dir = output_dir / definition.name
278
+ case_dir.mkdir(parents=True, exist_ok=True)
279
+ built = definition.builder(profile)
280
+ before_path = case_dir / "before.gds"
281
+ after_path = case_dir / "after.gds"
282
+ built.before.write_gds(before_path)
283
+ built.after.write_gds(after_path)
284
+ before_stats = _library_stats(built.before)
285
+ after_stats = _library_stats(built.after)
286
+ expected_changed_layers = _expected_changed_layers(built.edits)
287
+ manifest_path = case_dir / "manifest.json"
288
+ changes_path = case_dir / "changes.md"
289
+ record = SyntheticCaseRecord(
290
+ name=definition.name,
291
+ title=definition.title,
292
+ scale=profile.name,
293
+ before_path=before_path,
294
+ after_path=after_path,
295
+ manifest_path=manifest_path,
296
+ changes_path=changes_path,
297
+ before_polygon_count=before_stats["polygon_count"],
298
+ after_polygon_count=after_stats["polygon_count"],
299
+ before_vertex_count=before_stats["vertex_count"],
300
+ after_vertex_count=after_stats["vertex_count"],
301
+ expected_changed_layers=expected_changed_layers,
302
+ equivalent_expected=built.equivalent_expected,
303
+ edits=built.edits,
304
+ )
305
+ manifest = record.to_json() | {
306
+ "unit_m": _UNIT,
307
+ "precision_m": _PRECISION,
308
+ "dbu_per_um": _DBU_PER_UM,
309
+ "notes": built.notes,
310
+ }
311
+ manifest_path.write_text(_json_dumps(manifest), encoding="utf-8")
312
+ changes_path.write_text(_changes_markdown(record, built.notes), encoding="utf-8")
313
+ return record
314
+
315
+
316
+ def _benchmark_one(record: SyntheticCaseRecord, *, mode: str) -> SyntheticBenchmarkRun:
317
+ case_dir = record.before_path.parent
318
+ artifact_dir = case_dir / "gdsdiff" / mode
319
+ artifact_dir.mkdir(parents=True, exist_ok=True)
320
+ json_report_path = artifact_dir / "report.json"
321
+ exact_diff_markdown_path = artifact_dir / "exact_diff.md"
322
+ stdout_path = artifact_dir / "stdout.txt"
323
+ stderr_path = artifact_dir / "stderr.txt"
324
+ command_path = artifact_dir / "command.json"
325
+ if mode == "cpu":
326
+ config = CompareConfig(
327
+ record.before_path,
328
+ record.after_path,
329
+ backend="cpu",
330
+ evidence_engine="gdstk-oracle",
331
+ )
332
+ audit = False
333
+ elif mode == "metal":
334
+ config = CompareConfig(
335
+ record.before_path,
336
+ record.after_path,
337
+ backend="metal",
338
+ evidence_engine="gpu-polygon",
339
+ )
340
+ audit = False
341
+ elif mode == "metal_audit":
342
+ config = CompareConfig(
343
+ record.before_path,
344
+ record.after_path,
345
+ backend="metal",
346
+ evidence_engine="gpu-polygon",
347
+ )
348
+ audit = True
349
+ else:
350
+ raise ValueError(f"unknown benchmark mode: {mode!r}")
351
+
352
+ command_payload = {
353
+ "mode": mode,
354
+ "config": config.to_json_dict(),
355
+ "audit_cpu_oracle": audit,
356
+ "json_report_path": str(json_report_path),
357
+ "exact_diff_markdown_path": str(exact_diff_markdown_path),
358
+ "synthetic_case": record.name,
359
+ "synthetic_scale": record.scale,
360
+ }
361
+ start = time.perf_counter()
362
+ try:
363
+ result = compare_gds_files(
364
+ config,
365
+ json_report_path=json_report_path,
366
+ exact_diff_markdown_path=exact_diff_markdown_path,
367
+ audit_cpu_oracle=audit,
368
+ command=("gdsdiff.synthetic_suite", "benchmark", record.scale, record.name, mode),
369
+ )
370
+ wall_s = time.perf_counter() - start
371
+ payload = result.report.to_json_dict()
372
+ stdout_path.write_text(
373
+ "\n".join(
374
+ [
375
+ f"status={payload.get('status')}",
376
+ f"evidence_status={payload.get('evidence_status')}",
377
+ f"differences_found={payload.get('differences_found')}",
378
+ ]
379
+ )
380
+ + "\n",
381
+ encoding="utf-8",
382
+ )
383
+ stderr_path.write_text("\n".join(str(item) for item in payload.get("warnings", ())) + "\n", encoding="utf-8")
384
+ row = SyntheticBenchmarkRun(
385
+ case_name=record.name,
386
+ scale=record.scale,
387
+ mode=mode,
388
+ status=str(payload.get("status")),
389
+ exit_code=result.exit_code,
390
+ wall_s=wall_s,
391
+ total_s=_float_or_none(payload.get("timings", {}).get("total_s") if isinstance(payload.get("timings"), dict) else None),
392
+ changed_layers=_changed_layers_from_report(payload),
393
+ audit_match=_audit_match_from_report(payload),
394
+ json_report_path=json_report_path,
395
+ exact_diff_markdown_path=exact_diff_markdown_path,
396
+ stdout_path=stdout_path,
397
+ stderr_path=stderr_path,
398
+ command_path=command_path,
399
+ )
400
+ except Exception as exc:
401
+ wall_s = time.perf_counter() - start
402
+ stdout_path.write_text("", encoding="utf-8")
403
+ stderr_path.write_text(f"{type(exc).__name__}: {exc}\n", encoding="utf-8")
404
+ row = SyntheticBenchmarkRun(
405
+ case_name=record.name,
406
+ scale=record.scale,
407
+ mode=mode,
408
+ status="error",
409
+ exit_code=2,
410
+ wall_s=wall_s,
411
+ total_s=None,
412
+ changed_layers=(),
413
+ audit_match=None,
414
+ json_report_path=json_report_path,
415
+ exact_diff_markdown_path=exact_diff_markdown_path,
416
+ stdout_path=stdout_path,
417
+ stderr_path=stderr_path,
418
+ command_path=command_path,
419
+ error=f"{type(exc).__name__}: {exc}",
420
+ )
421
+ command_path.write_text(_json_dumps(command_payload | {"result": row.to_json()}), encoding="utf-8")
422
+ return row
423
+
424
+
425
+ def _benchmark_markdown(
426
+ records: Sequence[SyntheticCaseRecord],
427
+ runs: Sequence[SyntheticBenchmarkRun],
428
+ run_json_path: Path,
429
+ ) -> str:
430
+ by_case_mode = {(run.scale, run.case_name, run.mode): run for run in runs}
431
+ lines = [
432
+ "# GDSDiff Synthetic Readiness Benchmark",
433
+ "",
434
+ f"- Host capabilities: `{json.dumps(discover_native_capabilities().to_json(), sort_keys=True)}`",
435
+ f"- Run JSON: `{run_json_path}`",
436
+ "",
437
+ "| Scale | Case | Before polys | After polys | Changed layers | CPU wall s | Metal wall s | Speedup | Metal audit | Artifacts |",
438
+ "|---|---:|---:|---:|---|---:|---:|---:|---|---|",
439
+ ]
440
+ for record in records:
441
+ cpu = by_case_mode.get((record.scale, record.name, "cpu"))
442
+ metal = by_case_mode.get((record.scale, record.name, "metal"))
443
+ metal_audit = by_case_mode.get((record.scale, record.name, "metal_audit"))
444
+ cpu_wall = cpu.wall_s if cpu is not None and cpu.error is None else None
445
+ metal_wall = metal.wall_s if metal is not None and metal.error is None else None
446
+ speedup = (cpu_wall / metal_wall) if cpu_wall and metal_wall and metal_wall > 0 else None
447
+ layers = _format_layers(record.expected_changed_layers)
448
+ audit = (
449
+ "match"
450
+ if metal_audit is not None and metal_audit.audit_match is True
451
+ else "mismatch"
452
+ if metal_audit is not None and metal_audit.audit_match is False
453
+ else "not-run"
454
+ if metal_audit is None
455
+ else metal_audit.status
456
+ )
457
+ artifact_bits = []
458
+ if cpu is not None:
459
+ artifact_bits.append(f"cpu `{cpu.json_report_path}`")
460
+ if metal is not None:
461
+ artifact_bits.append(f"metal `{metal.json_report_path}`")
462
+ if metal_audit is not None:
463
+ artifact_bits.append(f"audit `{metal_audit.json_report_path}`")
464
+ lines.append(
465
+ "| {scale} | `{case}` | {before} | {after} | {layers} | {cpu_wall} | {metal_wall} | {speedup} | {audit} | {artifacts} |".format(
466
+ scale=record.scale,
467
+ case=record.name,
468
+ before=record.before_polygon_count,
469
+ after=record.after_polygon_count,
470
+ layers=layers or "none",
471
+ cpu_wall=_format_seconds(cpu_wall),
472
+ metal_wall=_format_seconds(metal_wall),
473
+ speedup=f"{speedup:.2f}x" if speedup is not None else "n/a",
474
+ audit=audit,
475
+ artifacts="<br>".join(artifact_bits) if artifact_bits else "none",
476
+ )
477
+ )
478
+ lines.append("")
479
+ return "\n".join(lines)
480
+
481
+
482
+ def _changes_markdown(record: SyntheticCaseRecord, notes: str) -> str:
483
+ lines = [
484
+ f"# {record.title}",
485
+ "",
486
+ f"- Case: `{record.name}`",
487
+ f"- Scale: `{record.scale}`",
488
+ f"- Before GDS: `{record.before_path}`",
489
+ f"- After GDS: `{record.after_path}`",
490
+ f"- Expected equivalent coverage: `{str(record.equivalent_expected).lower()}`",
491
+ f"- Expected changed layers: `{_format_layers(record.expected_changed_layers) or 'none'}`",
492
+ f"- Before polygons/vertices: `{record.before_polygon_count}` / `{record.before_vertex_count}`",
493
+ f"- After polygons/vertices: `{record.after_polygon_count}` / `{record.after_vertex_count}`",
494
+ ]
495
+ if notes:
496
+ lines.extend(["", "## Notes", "", notes])
497
+ lines.extend(["", "## Intended Changes", ""])
498
+ for edit in record.edits:
499
+ lines.extend(
500
+ [
501
+ f"### {edit.operation_id}",
502
+ "",
503
+ f"- shape_id: `{edit.shape_id}`",
504
+ f"- operation: `{edit.operation}`",
505
+ f"- expected_direction: `{edit.expected_direction}`",
506
+ f"- before_layer: `{_format_layer(edit.before_layer) if edit.before_layer else 'none'}`",
507
+ f"- after_layer: `{_format_layer(edit.after_layer) if edit.after_layer else 'none'}`",
508
+ f"- before_bbox_um: `{edit.before_bbox_um}`",
509
+ f"- after_bbox_um: `{edit.after_bbox_um}`",
510
+ f"- notes: {edit.notes}",
511
+ "",
512
+ ]
513
+ )
514
+ return "\n".join(lines)
515
+
516
+
517
+ def _case_rect_add_remove_shift(_profile: ScaleProfile) -> SyntheticCaseBuild:
518
+ layer = (1, 0)
519
+ old, old_top = _library()
520
+ new, new_top = _library()
521
+ stable = _rect(0, 0, 10, 10, layer)
522
+ old_removed = _rect(20, 0, 30, 10, layer)
523
+ old_shift = _rect(40, 0, 50, 10, layer)
524
+ new_shift = _rect(43, 0, 53, 10, layer)
525
+ added = _rect(60, 0, 70, 10, layer)
526
+ _add(old_top, stable, old_removed, old_shift)
527
+ _add(new_top, _rect(0, 0, 10, 10, layer), new_shift, added)
528
+ edits = (
529
+ SyntheticEdit("rect_remove_001", "removed_rect", "remove", "coverage_before_only", before_layer=layer, before_bbox_um=_bbox(old_removed)),
530
+ SyntheticEdit("rect_shift_001", "shifted_rect", "shift", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_shift), after_bbox_um=_bbox(new_shift)),
531
+ SyntheticEdit("rect_add_001", "added_rect", "add", "coverage_after_only", after_layer=layer, after_bbox_um=_bbox(added)),
532
+ )
533
+ return SyntheticCaseBuild(old, new, edits, notes="Simple axis-aligned add, remove, and shift operations.")
534
+
535
+
536
+ def _case_overlap_rect_unions(_profile: ScaleProfile) -> SyntheticCaseBuild:
537
+ layer = (2, 0)
538
+ old, old_top = _library()
539
+ new, new_top = _library()
540
+ old_a = _rect(0, 0, 20, 20, layer)
541
+ old_b = _rect(10, 0, 30, 20, layer)
542
+ new_a = _rect(0, 0, 20, 20, layer)
543
+ new_b = _rect(15, 0, 35, 20, layer)
544
+ new_c = _rect(5, 10, 25, 30, layer)
545
+ _add(old_top, old_a, old_b)
546
+ _add(new_top, new_a, new_b, new_c)
547
+ edits = (
548
+ SyntheticEdit("overlap_shift_001", "overlap_rect_b", "shift_overlapping_rect", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_b), after_bbox_um=_bbox(new_b), notes="The shifted rectangle overlaps a stable neighbor."),
549
+ SyntheticEdit("overlap_add_001", "overlap_rect_c", "add_overlapping_rect", "coverage_after_only", after_layer=layer, after_bbox_um=_bbox(new_c)),
550
+ )
551
+ return SyntheticCaseBuild(old, new, edits)
552
+
553
+
554
+ def _case_circle_breakpoints(_profile: ScaleProfile) -> SyntheticCaseBuild:
555
+ layer = (3, 0)
556
+ old, old_top = _library()
557
+ new, new_top = _library()
558
+ old_circle = _ellipse_polygon(0, 0, 10, 10, 32, layer)
559
+ new_circle = _ellipse_polygon(0, 0, 10, 10, 48, layer)
560
+ old_ellipse = _ellipse_polygon(35, 0, 13, 8, 40, layer)
561
+ new_ellipse = _ellipse_polygon(35, 0, 10.5, 8, 40, layer)
562
+ _add(old_top, old_circle, old_ellipse)
563
+ _add(new_top, new_circle, new_ellipse)
564
+ edits = (
565
+ SyntheticEdit("circle_breakpoint_001", "circle_same_radius", "change_curve_breakpoints", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_circle), after_bbox_um=_bbox(new_circle), notes="Same radius with different polygonal approximation."),
566
+ SyntheticEdit("ellipse_thin_001", "ellipse_thinned", "thin_curve", "coverage_before_only", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_ellipse), after_bbox_um=_bbox(new_ellipse)),
567
+ )
568
+ return SyntheticCaseBuild(old, new, edits)
569
+
570
+
571
+ def _case_star_concave(_profile: ScaleProfile) -> SyntheticCaseBuild:
572
+ layer = (4, 0)
573
+ old, old_top = _library()
574
+ new, new_top = _library()
575
+ old_star = _star_polygon(0, 0, 12, 5, 5, 0.0, layer)
576
+ new_star = _star_polygon(0, 0, 12, 5, 5, 0.2, layer)
577
+ old_removed = _star_polygon(35, 0, 10, 4, 7, 0.1, layer)
578
+ new_added = _star_polygon(70, 0, 11, 4, 6, -0.3, layer)
579
+ _add(old_top, old_star, old_removed)
580
+ _add(new_top, new_star, new_added)
581
+ edits = (
582
+ SyntheticEdit("star_rotate_001", "five_point_star", "rotate_concave_polygon", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_star), after_bbox_um=_bbox(new_star)),
583
+ SyntheticEdit("star_remove_001", "seven_point_star", "remove_concave_polygon", "coverage_before_only", before_layer=layer, before_bbox_um=_bbox(old_removed)),
584
+ SyntheticEdit("star_add_001", "six_point_star", "add_concave_polygon", "coverage_after_only", after_layer=layer, after_bbox_um=_bbox(new_added)),
585
+ )
586
+ return SyntheticCaseBuild(old, new, edits)
587
+
588
+
589
+ def _case_hollow_rect_circle(_profile: ScaleProfile) -> SyntheticCaseBuild:
590
+ layer = (5, 0)
591
+ old, old_top = _library()
592
+ new, new_top = _library()
593
+ old_ring = _rect_ring(0, 0, 34, 34, 9, 9, 25, 25, layer)
594
+ new_ring = _rect_ring(0, 0, 34, 34, 7, 7, 27, 27, layer)
595
+ old_donut = _ellipse_ring(70, 17, 16, 16, 7, 7, 48, layer)
596
+ new_donut = _ellipse_ring(70, 17, 16, 16, 9, 5, 64, layer)
597
+ _add(old_top, *old_ring, *old_donut)
598
+ _add(new_top, *new_ring, *new_donut)
599
+ edits = (
600
+ SyntheticEdit("hollow_rect_001", "rectangular_hole", "resize_inner_hole", "coverage_before_only", before_layer=layer, after_layer=layer, before_bbox_um=(7, 7, 27, 27), after_bbox_um=(7, 7, 27, 27)),
601
+ SyntheticEdit("hollow_circle_001", "circular_hole", "resize_and_refracture_inner_hole", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=(63, 10, 77, 24), after_bbox_um=(61, 12, 79, 22)),
602
+ )
603
+ return SyntheticCaseBuild(old, new, edits)
604
+
605
+
606
+ def _case_nested_holes_bridge(_profile: ScaleProfile) -> SyntheticCaseBuild:
607
+ layer = (6, 0)
608
+ old, old_top = _library()
609
+ new, new_top = _library()
610
+ outer = _rect(0, 0, 90, 42, layer)
611
+ holes_old = [_rect(10, 10, 22, 30, layer), _rect(38, 10, 50, 30, layer), _rect(66, 10, 78, 30, layer)]
612
+ holes_new = [_rect(10, 10, 22, 30, layer), _rect(66, 10, 78, 30, layer), _rect(42, 0, 46, 42, layer)]
613
+ old_polys = _boolean(outer, holes_old, "not", layer)
614
+ new_polys = _boolean(_rect(0, 0, 90, 42, layer), holes_new, "not", layer)
615
+ _add(old_top, *old_polys)
616
+ _add(new_top, *new_polys)
617
+ edits = (
618
+ SyntheticEdit("nested_fill_001", "middle_hole", "fill_inner_hole", "coverage_after_only", before_layer=layer, after_layer=layer, before_bbox_um=(38, 10, 50, 30), after_bbox_um=(38, 10, 50, 30)),
619
+ SyntheticEdit("nested_bridge_cut_001", "vertical_slot", "add_bridge_cut", "coverage_before_only", before_layer=layer, after_layer=layer, before_bbox_um=(42, 0, 46, 42), after_bbox_um=(42, 0, 46, 42)),
620
+ )
621
+ return SyntheticCaseBuild(old, new, edits)
622
+
623
+
624
+ def _case_thin_slivers_near_collinear(_profile: ScaleProfile) -> SyntheticCaseBuild:
625
+ layer = (7, 0)
626
+ old, old_top = _library()
627
+ new, new_top = _library()
628
+ old_sliver = _rect(0, 0, 100, 0.2, layer)
629
+ new_sliver = _rect(0, 0, 80, 0.2, layer)
630
+ old_poly = gdstk.Polygon([(0, 10), (50, 10.001), (100, 10), (100, 12), (0, 12)], layer=layer[0], datatype=layer[1])
631
+ new_poly = gdstk.Polygon([(0, 10), (40, 10.001), (100, 10.15), (100, 12), (0, 12)], layer=layer[0], datatype=layer[1])
632
+ added = _rect(120, 0, 160, 0.15, layer)
633
+ _add(old_top, old_sliver, old_poly)
634
+ _add(new_top, new_sliver, new_poly, added)
635
+ edits = (
636
+ SyntheticEdit("sliver_shorten_001", "thin_rectangle", "shorten_thin_polygon", "coverage_before_only", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_sliver), after_bbox_um=_bbox(new_sliver)),
637
+ SyntheticEdit("sliver_collinear_001", "near_collinear_polygon", "perturb_near_collinear_vertex", "coverage_before_only", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_poly), after_bbox_um=_bbox(new_poly)),
638
+ SyntheticEdit("sliver_add_001", "added_sliver", "add_thin_polygon", "coverage_after_only", after_layer=layer, after_bbox_um=_bbox(added)),
639
+ )
640
+ return SyntheticCaseBuild(old, new, edits)
641
+
642
+
643
+ def _case_layer_datatype_moves(_profile: ScaleProfile) -> SyntheticCaseBuild:
644
+ old_layer_a = (8, 0)
645
+ old_layer_b = (8, 1)
646
+ new_layer_a = (9, 0)
647
+ new_layer_b = (8, 2)
648
+ old, old_top = _library()
649
+ new, new_top = _library()
650
+ rect_a_old = _rect(0, 0, 12, 12, old_layer_a)
651
+ rect_b_old = _rect(30, 0, 42, 12, old_layer_b)
652
+ rect_a_new = _rect(0, 0, 12, 12, new_layer_a)
653
+ rect_b_new = _rect(30, 0, 42, 12, new_layer_b)
654
+ _add(old_top, rect_a_old, rect_b_old)
655
+ _add(new_top, rect_a_new, rect_b_new)
656
+ edits = (
657
+ SyntheticEdit("layer_move_001", "layer_moved_rect", "move_layer", "coverage_both", before_layer=old_layer_a, after_layer=new_layer_a, before_bbox_um=_bbox(rect_a_old), after_bbox_um=_bbox(rect_a_new)),
658
+ SyntheticEdit("datatype_move_001", "datatype_moved_rect", "move_datatype", "coverage_both", before_layer=old_layer_b, after_layer=new_layer_b, before_bbox_um=_bbox(rect_b_old), after_bbox_um=_bbox(rect_b_new)),
659
+ )
660
+ return SyntheticCaseBuild(old, new, edits)
661
+
662
+
663
+ def _case_same_coverage_refracturing(_profile: ScaleProfile) -> SyntheticCaseBuild:
664
+ layer = (10, 0)
665
+ old, old_top = _library()
666
+ new, new_top = _library()
667
+ old_rect = _rect(0, 0, 40, 20, layer)
668
+ left = _rect(0, 0, 20, 20, layer)
669
+ right = _rect(20, 0, 40, 20, layer)
670
+ old_top.add(old_rect)
671
+ _add(new_top, left, right)
672
+ edits = (
673
+ SyntheticEdit("refracture_001", "split_rectangle", "split_equivalent_coverage", "inventory_only", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_rect), after_bbox_um=_bbox(old_rect), notes="Coverage is intentionally identical; polygon inventory changes only."),
674
+ )
675
+ return SyntheticCaseBuild(old, new, edits, equivalent_expected=True)
676
+
677
+
678
+ def _case_touching_edges_points(_profile: ScaleProfile) -> SyntheticCaseBuild:
679
+ layer = (11, 0)
680
+ old, old_top = _library()
681
+ new, new_top = _library()
682
+ old_left = _rect(0, 0, 10, 10, layer)
683
+ old_right = _rect(10, 0, 20, 10, layer)
684
+ old_corner = _rect(30, 0, 40, 10, layer)
685
+ new_left = _rect(0, 0, 10, 10, layer)
686
+ new_right = _rect(10.2, 0, 20.2, 10, layer)
687
+ new_corner = _rect(39.8, 9.8, 49.8, 19.8, layer)
688
+ _add(old_top, old_left, old_right, old_corner)
689
+ _add(new_top, new_left, new_right, new_corner)
690
+ edits = (
691
+ SyntheticEdit("touch_gap_001", "edge_touching_pair", "open_small_gap", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=(10, 0, 20, 10), after_bbox_um=(10.2, 0, 20.2, 10)),
692
+ SyntheticEdit("touch_point_overlap_001", "corner_touching_square", "move_to_tiny_overlap", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_corner), after_bbox_um=_bbox(new_corner)),
693
+ )
694
+ return SyntheticCaseBuild(old, new, edits)
695
+
696
+
697
+ def _case_cell_references_arrays(profile: ScaleProfile) -> SyntheticCaseBuild:
698
+ layer = (12, 0)
699
+ old = gdstk.Library(unit=_UNIT, precision=_PRECISION)
700
+ old_child = old.new_cell("leaf")
701
+ old_child.add(_rect(0, 0, 8, 8, layer))
702
+ old_top = old.new_cell("top")
703
+ old_top.add(gdstk.Reference(old_child, (0, 0), columns=profile.hierarchy_cols, rows=profile.hierarchy_rows, spacing=(16, 16)))
704
+
705
+ new = gdstk.Library(unit=_UNIT, precision=_PRECISION)
706
+ new_child = new.new_cell("leaf")
707
+ new_child.add(_rect(0, 0, 8, 8, layer))
708
+ new_shifted = new.new_cell("leaf_shifted")
709
+ new_shifted.add(_rect(1.5, 0, 9.5, 8, layer))
710
+ new_top = new.new_cell("top")
711
+ new_top.add(gdstk.Reference(new_child, (0, 0), columns=max(1, profile.hierarchy_cols - 1), rows=profile.hierarchy_rows, spacing=(16, 16)))
712
+ new_top.add(gdstk.Reference(new_shifted, ((profile.hierarchy_cols - 1) * 16, 0), rows=profile.hierarchy_rows, spacing=(16, 16)))
713
+ target_y1 = (profile.hierarchy_rows - 1) * 16 + 8
714
+ old_bbox = ((profile.hierarchy_cols - 1) * 16, 0, (profile.hierarchy_cols - 1) * 16 + 8, target_y1)
715
+ new_bbox = ((profile.hierarchy_cols - 1) * 16 + 1.5, 0, (profile.hierarchy_cols - 1) * 16 + 9.5, target_y1)
716
+ edits = (
717
+ SyntheticEdit("hierarchy_array_shift_001", "last_array_column", "shift_referenced_array_column", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=old_bbox, after_bbox_um=new_bbox, notes="Only the final array column uses a shifted leaf cell."),
718
+ )
719
+ return SyntheticCaseBuild(old, new, edits)
720
+
721
+
722
+ def _case_sparse_large_rect_grid(profile: ScaleProfile) -> SyntheticCaseBuild:
723
+ layer = (13, 0)
724
+ old, old_top = _library()
725
+ new, new_top = _library()
726
+ n = profile.sparse_grid_n
727
+ removed = {(1, 1), (n // 2, n // 2)}
728
+ shifted = {(n - 2, n - 2)}
729
+ added_positions = [(n + 2, 0), (n + 3, 1)]
730
+ for ix in range(n):
731
+ for iy in range(n):
732
+ poly = _grid_rect(ix, iy, layer)
733
+ old_top.add(poly)
734
+ if (ix, iy) in removed:
735
+ continue
736
+ if (ix, iy) in shifted:
737
+ new_top.add(_grid_rect(ix, iy, layer, dx=0.7))
738
+ else:
739
+ new_top.add(_grid_rect(ix, iy, layer))
740
+ for pos in added_positions:
741
+ new_top.add(_grid_rect(pos[0], pos[1], layer))
742
+ edits = (
743
+ SyntheticEdit("sparse_remove_001", "grid_rect_1_1", "remove_grid_rect", "coverage_before_only", before_layer=layer, before_bbox_um=_grid_bbox(1, 1)),
744
+ SyntheticEdit("sparse_remove_002", "grid_rect_mid", "remove_grid_rect", "coverage_before_only", before_layer=layer, before_bbox_um=_grid_bbox(n // 2, n // 2)),
745
+ SyntheticEdit("sparse_shift_001", "grid_rect_last", "shift_grid_rect", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_grid_bbox(n - 2, n - 2), after_bbox_um=_grid_bbox(n - 2, n - 2, dx=0.7)),
746
+ SyntheticEdit("sparse_add_001", "grid_rect_extra_0", "add_grid_rect", "coverage_after_only", after_layer=layer, after_bbox_um=_grid_bbox(n + 2, 0)),
747
+ SyntheticEdit("sparse_add_002", "grid_rect_extra_1", "add_grid_rect", "coverage_after_only", after_layer=layer, after_bbox_um=_grid_bbox(n + 3, 1)),
748
+ )
749
+ return SyntheticCaseBuild(old, new, edits)
750
+
751
+
752
+ def _case_mixed_complex_polygons(profile: ScaleProfile) -> SyntheticCaseBuild:
753
+ layer = (14, 0)
754
+ old, old_top = _library()
755
+ new, new_top = _library()
756
+ count = profile.mixed_count
757
+ changed = {3, count // 3, (2 * count) // 3}
758
+ removed_index = count // 2
759
+ for index in range(count):
760
+ x = (index % 40) * 18.0
761
+ y = (index // 40) * 18.0
762
+ kind = index % 3
763
+ old_poly = _mixed_shape(kind, x, y, index, layer, changed=False)
764
+ old_top.add(old_poly)
765
+ if index == removed_index:
766
+ continue
767
+ new_top.add(_mixed_shape(kind, x, y, index, layer, changed=index in changed))
768
+ added = _star_polygon(-35, -35, 8, 3, 6, 0.2, layer)
769
+ new_top.add(added)
770
+ edits = tuple(
771
+ SyntheticEdit(
772
+ f"mixed_change_{slot:03d}",
773
+ f"mixed_shape_{slot}",
774
+ "modify_complex_polygon",
775
+ "coverage_both",
776
+ before_layer=layer,
777
+ after_layer=layer,
778
+ before_bbox_um=_bbox(_mixed_shape(slot % 3, (slot % 40) * 18.0, (slot // 40) * 18.0, slot, layer, changed=False)),
779
+ after_bbox_um=_bbox(_mixed_shape(slot % 3, (slot % 40) * 18.0, (slot // 40) * 18.0, slot, layer, changed=True)),
780
+ )
781
+ for slot in sorted(changed)
782
+ ) + (
783
+ SyntheticEdit("mixed_remove_001", f"mixed_shape_{removed_index}", "remove_complex_polygon", "coverage_before_only", before_layer=layer, before_bbox_um=_bbox(_mixed_shape(removed_index % 3, (removed_index % 40) * 18.0, (removed_index // 40) * 18.0, removed_index, layer, changed=False))),
784
+ SyntheticEdit("mixed_add_001", "mixed_added_star", "add_complex_polygon", "coverage_after_only", after_layer=layer, after_bbox_um=_bbox(added)),
785
+ )
786
+ return SyntheticCaseBuild(old, new, edits)
787
+
788
+
789
+ def _case_hollow_grid_many(profile: ScaleProfile) -> SyntheticCaseBuild:
790
+ layer = (15, 0)
791
+ old, old_top = _library()
792
+ new, new_top = _library()
793
+ n = profile.hollow_grid_n
794
+ changed = (n // 2, n // 2)
795
+ removed = (max(0, n - 2), 1)
796
+ for ix in range(n):
797
+ for iy in range(n):
798
+ x = ix * 18.0
799
+ y = iy * 18.0
800
+ old_ring = _rect_ring(x, y, x + 12, y + 12, x + 4, y + 4, x + 8, y + 8, layer)
801
+ for poly in old_ring:
802
+ old_top.add(poly)
803
+ if (ix, iy) == removed:
804
+ continue
805
+ if (ix, iy) == changed:
806
+ new_ring = _rect_ring(x, y, x + 12, y + 12, x + 3, y + 3, x + 9, y + 9, layer)
807
+ else:
808
+ new_ring = _rect_ring(x, y, x + 12, y + 12, x + 4, y + 4, x + 8, y + 8, layer)
809
+ for poly in new_ring:
810
+ new_top.add(poly)
811
+ edits = (
812
+ SyntheticEdit("hollow_grid_resize_001", "central_ring_hole", "resize_grid_ring_hole", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=(changed[0] * 18 + 4, changed[1] * 18 + 4, changed[0] * 18 + 8, changed[1] * 18 + 8), after_bbox_um=(changed[0] * 18 + 3, changed[1] * 18 + 3, changed[0] * 18 + 9, changed[1] * 18 + 9)),
813
+ SyntheticEdit("hollow_grid_remove_001", "removed_ring", "remove_hollow_ring", "coverage_before_only", before_layer=layer, before_bbox_um=(removed[0] * 18, removed[1] * 18, removed[0] * 18 + 12, removed[1] * 18 + 12)),
814
+ )
815
+ return SyntheticCaseBuild(old, new, edits)
816
+
817
+
818
+ def _case_multi_layer_mixed_edits(_profile: ScaleProfile) -> SyntheticCaseBuild:
819
+ layers = ((16, 0), (16, 1), (17, 0), (18, 2))
820
+ old, old_top = _library()
821
+ new, new_top = _library()
822
+ edits: list[SyntheticEdit] = []
823
+ for idx, layer in enumerate(layers):
824
+ for j in range(8):
825
+ x = idx * 80 + j * 8
826
+ rect = _rect(x, 0, x + 5, 5, layer)
827
+ old_top.add(rect)
828
+ if idx == 0 and j == 2:
829
+ edits.append(SyntheticEdit("multi_remove_001", "layer16_removed", "remove_layer_shape", "coverage_before_only", before_layer=layer, before_bbox_um=_bbox(rect)))
830
+ continue
831
+ if idx == 1 and j == 3:
832
+ moved_layer = (19, 0)
833
+ new_rect = _rect(x, 0, x + 5, 5, moved_layer)
834
+ new_top.add(new_rect)
835
+ edits.append(SyntheticEdit("multi_layer_move_001", "datatype_shape_moved_layer", "move_to_new_layer", "coverage_both", before_layer=layer, after_layer=moved_layer, before_bbox_um=_bbox(rect), after_bbox_um=_bbox(new_rect)))
836
+ continue
837
+ if idx == 2 and j == 4:
838
+ new_rect = _rect(x + 1, 0, x + 6, 5, layer)
839
+ new_top.add(new_rect)
840
+ edits.append(SyntheticEdit("multi_shift_001", "layer17_shifted", "shift_layer_shape", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(rect), after_bbox_um=_bbox(new_rect)))
841
+ continue
842
+ new_top.add(_rect(x, 0, x + 5, 5, layer))
843
+ added = _ellipse_polygon(360, 0, 5, 5, 24, layers[-1])
844
+ new_top.add(added)
845
+ edits.append(SyntheticEdit("multi_add_001", "layer18_added_circle", "add_layer_shape", "coverage_after_only", after_layer=layers[-1], after_bbox_um=_bbox(added)))
846
+ return SyntheticCaseBuild(old, new, tuple(edits))
847
+
848
+
849
+ def _case_block_like_hierarchy_stress(profile: ScaleProfile) -> SyntheticCaseBuild:
850
+ layer = (20, 0)
851
+ old = gdstk.Library(unit=_UNIT, precision=_PRECISION)
852
+ old_block = old.new_cell("block")
853
+ _add(old_block, _rect(0, 0, 8, 8, layer), _rect(10, 0, 18, 8, layer), _star_polygon(9, 16, 5, 2, 5, 0.0, layer))
854
+ old_top = old.new_cell("top")
855
+
856
+ new = gdstk.Library(unit=_UNIT, precision=_PRECISION)
857
+ new_block = new.new_cell("block")
858
+ _add(new_block, _rect(0, 0, 8, 8, layer), _rect(10, 0, 18, 8, layer), _star_polygon(9, 16, 5, 2, 5, 0.0, layer))
859
+ new_block_modified = new.new_cell("block_modified")
860
+ _add(new_block_modified, _rect(0, 0, 8, 8, layer), _rect(11.5, 0, 19.5, 8, layer), _star_polygon(9, 16, 5, 2, 5, 0.25, layer))
861
+ new_top = new.new_cell("top")
862
+
863
+ cols = profile.reference_cols
864
+ rows = profile.reference_rows
865
+ modified = {(cols // 2, rows // 2), (max(0, cols - 3), max(0, rows - 2))}
866
+ for ix in range(cols):
867
+ for iy in range(rows):
868
+ origin = (ix * 28, iy * 28)
869
+ old_top.add(gdstk.Reference(old_block, origin))
870
+ new_top.add(gdstk.Reference(new_block_modified if (ix, iy) in modified else new_block, origin))
871
+ edits = tuple(
872
+ SyntheticEdit(
873
+ f"block_modify_{idx:03d}",
874
+ f"block_ref_{ix}_{iy}",
875
+ "modify_referenced_block",
876
+ "coverage_both",
877
+ before_layer=layer,
878
+ after_layer=layer,
879
+ before_bbox_um=(ix * 28, iy * 28, ix * 28 + 20, iy * 28 + 22),
880
+ after_bbox_um=(ix * 28, iy * 28, ix * 28 + 20, iy * 28 + 22),
881
+ notes="One referenced block instance uses shifted metal and a rotated star.",
882
+ )
883
+ for idx, (ix, iy) in enumerate(sorted(modified), start=1)
884
+ )
885
+ return SyntheticCaseBuild(old, new, edits)
886
+
887
+
888
+ def _case_adversarial_nested_rings_islands(_profile: ScaleProfile) -> SyntheticCaseBuild:
889
+ layer = (21, 0)
890
+ old, old_top = _library()
891
+ new, new_top = _library()
892
+ old_outer = _rect(0, 0, 90, 70, layer)
893
+ old_holes = (
894
+ _rect(10, 10, 25, 25, layer),
895
+ _rect(35, 10, 50, 25, layer),
896
+ _rect(20, 40, 70, 58, layer),
897
+ )
898
+ new_holes = (
899
+ _rect(10, 10, 25, 25, layer),
900
+ _rect(32, 7, 54, 28, layer),
901
+ _rect(20, 40, 70, 58, layer),
902
+ )
903
+ island = _rect(41, 15, 46, 20, layer)
904
+ inner_ring_old = _rect_ring(30, 42, 60, 56, 38, 46, 52, 52, layer)
905
+ inner_ring_new = _rect_ring(30, 42, 60, 56, 40, 48, 50, 51, layer)
906
+ _add(old_top, *_boolean(old_outer, old_holes, "not", layer), *inner_ring_old)
907
+ _add(new_top, *_boolean(_rect(0, 0, 90, 70, layer), new_holes, "not", layer), island, *inner_ring_new)
908
+ edits = (
909
+ SyntheticEdit(
910
+ "adv_nested_hole_expand_001",
911
+ "expanded_rect_hole",
912
+ "expand_inner_hole_inside_outer_ring",
913
+ "coverage_before_only",
914
+ before_layer=layer,
915
+ after_layer=layer,
916
+ before_bbox_um=(32, 7, 54, 28),
917
+ after_bbox_um=(32, 7, 54, 28),
918
+ notes="The hole is enlarged, creating before-only ring fragments fully contained by the outer polygon.",
919
+ ),
920
+ SyntheticEdit(
921
+ "adv_nested_island_add_001",
922
+ "island_inside_hole",
923
+ "add_island_inside_hole",
924
+ "coverage_after_only",
925
+ after_layer=layer,
926
+ after_bbox_um=_bbox(island),
927
+ notes="A disconnected island is added inside a void.",
928
+ ),
929
+ SyntheticEdit(
930
+ "adv_nested_inner_ring_fill_001",
931
+ "nested_ring_hole",
932
+ "shrink_nested_ring_hole",
933
+ "coverage_after_only",
934
+ before_layer=layer,
935
+ after_layer=layer,
936
+ before_bbox_um=(38, 46, 52, 52),
937
+ after_bbox_um=(38, 46, 52, 52),
938
+ notes="The inner ring hole shrinks, producing after-only coverage inside nested topology.",
939
+ ),
940
+ )
941
+ return SyntheticCaseBuild(old, new, edits, notes="Nested ring, multi-hole, and island-in-hole topology.")
942
+
943
+
944
+ def _case_adversarial_hole_touching_boundary(_profile: ScaleProfile) -> SyntheticCaseBuild:
945
+ layer = (22, 0)
946
+ old, old_top = _library()
947
+ new, new_top = _library()
948
+ old_outer = _rect(0, 0, 60, 42, layer)
949
+ old_hole = _rect(12, 10, 28, 30, layer)
950
+ new_edge_cut = _rect(0, 10, 28, 30, layer)
951
+ old_diamond = _diamond_polygon(46, 21, 7, layer)
952
+ new_diamond = _diamond_polygon(53, 21, 7, layer)
953
+ _add(old_top, *_boolean(old_outer, (old_hole, old_diamond), "not", layer))
954
+ _add(new_top, *_boolean(_rect(0, 0, 60, 42, layer), (new_edge_cut, new_diamond), "not", layer))
955
+ edits = (
956
+ SyntheticEdit(
957
+ "adv_hole_edge_touch_001",
958
+ "rect_hole_to_edge_notch",
959
+ "grow_hole_until_it_touches_outer_edge",
960
+ "coverage_before_only",
961
+ before_layer=layer,
962
+ after_layer=layer,
963
+ before_bbox_um=(0, 10, 12, 30),
964
+ after_bbox_um=(0, 10, 12, 30),
965
+ notes="A true hole becomes a notch connected to the outer boundary.",
966
+ ),
967
+ SyntheticEdit(
968
+ "adv_hole_point_touch_001",
969
+ "diamond_hole_to_boundary_point",
970
+ "move_hole_until_one_vertex_touches_boundary",
971
+ "coverage_both",
972
+ before_layer=layer,
973
+ after_layer=layer,
974
+ before_bbox_um=(39, 14, 53, 28),
975
+ after_bbox_um=(46, 14, 60, 28),
976
+ notes="The moved diamond cutout has a point on the outer boundary.",
977
+ ),
978
+ )
979
+ return SyntheticCaseBuild(old, new, edits)
980
+
981
+
982
+ def _case_adversarial_containment_no_edge_crossing(_profile: ScaleProfile) -> SyntheticCaseBuild:
983
+ layer = (23, 0)
984
+ old, old_top = _library()
985
+ new, new_top = _library()
986
+ small_old = _rect(0, 0, 10, 10, layer)
987
+ grown_new = _rect(-5, -5, 15, 15, layer)
988
+ large_old = _rect(35, 0, 75, 35, layer)
989
+ shrunk_new = _rect(48, 9, 62, 24, layer)
990
+ _add(old_top, small_old, large_old)
991
+ _add(new_top, grown_new, shrunk_new)
992
+ edits = (
993
+ SyntheticEdit(
994
+ "adv_containment_grow_001",
995
+ "small_rect_grown_around_old",
996
+ "grow_polygon_fully_containing_before_polygon",
997
+ "coverage_after_only",
998
+ before_layer=layer,
999
+ after_layer=layer,
1000
+ before_bbox_um=_bbox(small_old),
1001
+ after_bbox_um=_bbox(grown_new),
1002
+ notes="The before polygon is fully contained by the after polygon, with no boundary crossing.",
1003
+ ),
1004
+ SyntheticEdit(
1005
+ "adv_containment_shrink_001",
1006
+ "large_rect_shrunk_inside_old",
1007
+ "shrink_polygon_fully_inside_before_polygon",
1008
+ "coverage_before_only",
1009
+ before_layer=layer,
1010
+ after_layer=layer,
1011
+ before_bbox_um=_bbox(large_old),
1012
+ after_bbox_um=_bbox(shrunk_new),
1013
+ notes="The after polygon is fully contained by the before polygon.",
1014
+ ),
1015
+ )
1016
+ return SyntheticCaseBuild(old, new, edits)
1017
+
1018
+
1019
+ def _case_adversarial_reversed_winding_equivalent(_profile: ScaleProfile) -> SyntheticCaseBuild:
1020
+ layer = (24, 0)
1021
+ old, old_top = _library()
1022
+ new, new_top = _library()
1023
+ points = ((0, 0), (34, 0), (34, 18), (18, 18), (18, 30), (0, 30))
1024
+ old_poly = gdstk.Polygon(points, layer=layer[0], datatype=layer[1])
1025
+ new_poly = gdstk.Polygon(tuple(reversed(points)), layer=layer[0], datatype=layer[1])
1026
+ old_top.add(old_poly)
1027
+ new_top.add(new_poly)
1028
+ edits = (
1029
+ SyntheticEdit(
1030
+ "adv_winding_reverse_001",
1031
+ "same_polygon_reversed_winding",
1032
+ "reverse_vertex_winding_without_changing_coverage",
1033
+ "inventory_only",
1034
+ before_layer=layer,
1035
+ after_layer=layer,
1036
+ before_bbox_um=_bbox(old_poly),
1037
+ after_bbox_um=_bbox(new_poly),
1038
+ notes="Coverage is identical; only source vertex order changes.",
1039
+ ),
1040
+ )
1041
+ return SyntheticCaseBuild(old, new, edits, equivalent_expected=True)
1042
+
1043
+
1044
+ def _case_adversarial_duplicate_polygons_equivalent(_profile: ScaleProfile) -> SyntheticCaseBuild:
1045
+ layer = (25, 0)
1046
+ old, old_top = _library()
1047
+ new, new_top = _library()
1048
+ rect = _rect(0, 0, 30, 20, layer)
1049
+ old_top.add(rect)
1050
+ old_top.add(_rect(0, 0, 30, 20, layer))
1051
+ _add(new_top, _rect(0, 0, 15, 20, layer), _rect(15, 0, 30, 20, layer))
1052
+ edits = (
1053
+ SyntheticEdit(
1054
+ "adv_duplicate_refracture_001",
1055
+ "duplicate_rect_to_split_rect",
1056
+ "remove_duplicate_and_refracture_without_changing_coverage",
1057
+ "inventory_only",
1058
+ before_layer=layer,
1059
+ after_layer=layer,
1060
+ before_bbox_um=_bbox(rect),
1061
+ after_bbox_um=_bbox(rect),
1062
+ notes="Duplicate coincident source polygons and split target polygons describe identical coverage.",
1063
+ ),
1064
+ )
1065
+ return SyntheticCaseBuild(old, new, edits, equivalent_expected=True)
1066
+
1067
+
1068
+ def _case_adversarial_partial_collinear_overlap(_profile: ScaleProfile) -> SyntheticCaseBuild:
1069
+ layer = (26, 0)
1070
+ old, old_top = _library()
1071
+ new, new_top = _library()
1072
+ base_old = _rect(0, 0, 60, 5, layer)
1073
+ tab_old = _rect(12, 5, 42, 18, layer)
1074
+ tab_new = _rect(12, 5.001, 42, 18.001, layer)
1075
+ _add(old_top, base_old, tab_old, _rect(72, 0, 92, 20, layer))
1076
+ _add(new_top, _rect(0, 0, 60, 5, layer), tab_new, _rect(72, 0, 92, 20, layer))
1077
+ edits = (
1078
+ SyntheticEdit(
1079
+ "adv_collinear_gap_001",
1080
+ "tab_with_partially_collinear_edge",
1081
+ "open_one_dbu_gap_along_partially_collinear_edge",
1082
+ "coverage_both",
1083
+ before_layer=layer,
1084
+ after_layer=layer,
1085
+ before_bbox_um=_bbox(tab_old),
1086
+ after_bbox_um=_bbox(tab_new),
1087
+ notes="A tab sharing part of a base rectangle edge moves by one DBU.",
1088
+ ),
1089
+ )
1090
+ return SyntheticCaseBuild(old, new, edits)
1091
+
1092
+
1093
+ def _case_adversarial_one_dbu_touch_tjunction(_profile: ScaleProfile) -> SyntheticCaseBuild:
1094
+ layer = (27, 0)
1095
+ old, old_top = _library()
1096
+ new, new_top = _library()
1097
+ corner_a = _rect(0, 0, 10, 10, layer)
1098
+ corner_b_old = _rect(10, 10, 20, 20, layer)
1099
+ corner_b_new = _rect(10.001, 10.001, 20.001, 20.001, layer)
1100
+ tee_stem_old = _rect(42, 0, 46, 18, layer)
1101
+ tee_bar = _rect(32, 18, 56, 24, layer)
1102
+ tee_stem_new = _rect(42, 0, 46.001, 18, layer)
1103
+ _add(old_top, corner_a, corner_b_old, tee_stem_old, tee_bar)
1104
+ _add(new_top, _rect(0, 0, 10, 10, layer), corner_b_new, tee_stem_new, _rect(32, 18, 56, 24, layer))
1105
+ edits = (
1106
+ SyntheticEdit(
1107
+ "adv_point_touch_gap_001",
1108
+ "corner_touch_pair",
1109
+ "open_one_dbu_gap_from_point_touch",
1110
+ "coverage_both",
1111
+ before_layer=layer,
1112
+ after_layer=layer,
1113
+ before_bbox_um=_bbox(corner_b_old),
1114
+ after_bbox_um=_bbox(corner_b_new),
1115
+ ),
1116
+ SyntheticEdit(
1117
+ "adv_tjunction_overlap_001",
1118
+ "t_junction_stem",
1119
+ "extend_t_junction_stem_by_one_dbu",
1120
+ "coverage_after_only",
1121
+ before_layer=layer,
1122
+ after_layer=layer,
1123
+ before_bbox_um=_bbox(tee_stem_old),
1124
+ after_bbox_um=(46, 0, 46.001, 18),
1125
+ ),
1126
+ )
1127
+ return SyntheticCaseBuild(old, new, edits)
1128
+
1129
+
1130
+ def _case_adversarial_one_dbu_sliver_grid(profile: ScaleProfile) -> SyntheticCaseBuild:
1131
+ layer = (28, 0)
1132
+ old, old_top = _library()
1133
+ new, new_top = _library()
1134
+ n = max(6, min(profile.sparse_grid_n, 180))
1135
+ removed = {1, n // 2}
1136
+ extended = n - 2
1137
+ for index in range(n):
1138
+ y = index * 0.01
1139
+ old_sliver = _rect(0, y, 20, y + 0.001, layer)
1140
+ old_top.add(old_sliver)
1141
+ if index in removed:
1142
+ continue
1143
+ if index == extended:
1144
+ new_top.add(_rect(0, y, 22, y + 0.001, layer))
1145
+ else:
1146
+ new_top.add(_rect(0, y, 20, y + 0.001, layer))
1147
+ edits = (
1148
+ SyntheticEdit("adv_sliver_remove_001", "one_dbu_sliver_1", "remove_one_dbu_sliver", "coverage_before_only", before_layer=layer, before_bbox_um=(0, 0.01, 20, 0.011)),
1149
+ SyntheticEdit("adv_sliver_remove_002", "one_dbu_sliver_mid", "remove_one_dbu_sliver", "coverage_before_only", before_layer=layer, before_bbox_um=(0, (n // 2) * 0.01, 20, (n // 2) * 0.01 + 0.001)),
1150
+ SyntheticEdit("adv_sliver_extend_001", "one_dbu_sliver_extended", "extend_one_dbu_sliver", "coverage_after_only", before_layer=layer, after_layer=layer, before_bbox_um=(20, extended * 0.01, 22, extended * 0.01 + 0.001), after_bbox_um=(20, extended * 0.01, 22, extended * 0.01 + 0.001)),
1151
+ )
1152
+ return SyntheticCaseBuild(old, new, edits, notes=f"Scale-aware one-DBU sliver set with {n} source slivers.")
1153
+
1154
+
1155
+ def _case_adversarial_large_coordinates_tiny_delta(_profile: ScaleProfile) -> SyntheticCaseBuild:
1156
+ layer = (29, 0)
1157
+ old, old_top = _library()
1158
+ new, new_top = _library()
1159
+ base_x = 1_000_000.0
1160
+ base_y = -900_000.0
1161
+ old_rect = _rect(base_x, base_y, base_x + 25, base_y + 12, layer)
1162
+ new_rect = _rect(base_x + 0.001, base_y, base_x + 25.001, base_y + 12, layer)
1163
+ old_far = _rect(-1_000_000.0, 900_000.0, -999_980.0, 900_020.0, layer)
1164
+ new_far = _rect(-1_000_000.0, 900_000.001, -999_980.0, 900_020.001, layer)
1165
+ _add(old_top, old_rect, old_far)
1166
+ _add(new_top, new_rect, new_far)
1167
+ edits = (
1168
+ SyntheticEdit("adv_large_coord_shift_001", "large_positive_coordinate_rect", "shift_large_coordinate_rect_by_one_dbu", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_rect), after_bbox_um=_bbox(new_rect)),
1169
+ SyntheticEdit("adv_large_coord_shift_002", "large_negative_coordinate_rect", "shift_large_negative_coordinate_rect_by_one_dbu", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_far), after_bbox_um=_bbox(new_far)),
1170
+ )
1171
+ return SyntheticCaseBuild(old, new, edits)
1172
+
1173
+
1174
+ def _case_adversarial_snap_rounding_equivalent(_profile: ScaleProfile) -> SyntheticCaseBuild:
1175
+ layer = (30, 0)
1176
+ old, old_top = _library()
1177
+ new, new_top = _library()
1178
+ old_rect = _rect(0, 0, 10, 10, layer)
1179
+ new_rect = _rect(0.0004, 0.0004, 10.0004, 10.0004, layer)
1180
+ old_top.add(old_rect)
1181
+ new_top.add(new_rect)
1182
+ edits = (
1183
+ SyntheticEdit(
1184
+ "adv_snap_rounding_001",
1185
+ "sub_dbu_shifted_rect",
1186
+ "shift_below_gds_grid_resolution",
1187
+ "inventory_only",
1188
+ before_layer=layer,
1189
+ after_layer=layer,
1190
+ before_bbox_um=_bbox(old_rect),
1191
+ after_bbox_um=_bbox(new_rect),
1192
+ notes="The geometric source coordinates differ, but GDS quantization snaps both to the same DBU grid.",
1193
+ ),
1194
+ )
1195
+ return SyntheticCaseBuild(old, new, edits, equivalent_expected=True)
1196
+
1197
+
1198
+ def _case_adversarial_curve_single_vertex_bump(_profile: ScaleProfile) -> SyntheticCaseBuild:
1199
+ layer = (31, 0)
1200
+ old, old_top = _library()
1201
+ new, new_top = _library()
1202
+ old_curve = _ellipse_polygon(0, 0, 16, 12, 48, layer)
1203
+ points = [(float(x), float(y)) for x, y in old_curve.points]
1204
+ x, y = points[7]
1205
+ points[7] = (x + 1.25, y - 0.75)
1206
+ new_curve = gdstk.Polygon(points, layer=layer[0], datatype=layer[1])
1207
+ _add(old_top, old_curve, _ellipse_polygon(45, 0, 10, 10, 64, layer))
1208
+ _add(new_top, new_curve, _ellipse_polygon(45, 0, 10, 10, 64, layer))
1209
+ edits = (
1210
+ SyntheticEdit(
1211
+ "adv_curve_vertex_bump_001",
1212
+ "single_vertex_bumped_ellipse",
1213
+ "move_one_curve_breakpoint",
1214
+ "coverage_both",
1215
+ before_layer=layer,
1216
+ after_layer=layer,
1217
+ before_bbox_um=_bbox(old_curve),
1218
+ after_bbox_um=_bbox(new_curve),
1219
+ ),
1220
+ )
1221
+ return SyntheticCaseBuild(old, new, edits)
1222
+
1223
+
1224
+ def _case_adversarial_dense_sparse_curve_overlap(_profile: ScaleProfile) -> SyntheticCaseBuild:
1225
+ layer = (32, 0)
1226
+ old, old_top = _library()
1227
+ new, new_top = _library()
1228
+ dense = _ellipse_polygon(0, 0, 20, 20, 128, layer)
1229
+ sparse = _ellipse_polygon(0, 0, 20, 20, 20, layer, angle=math.pi / 20)
1230
+ old_overlap = _ellipse_polygon(42, 0, 16, 10, 96, layer)
1231
+ new_overlap = _ellipse_polygon(42.001, 0, 16, 10, 24, layer)
1232
+ _add(old_top, dense, old_overlap)
1233
+ _add(new_top, sparse, new_overlap)
1234
+ edits = (
1235
+ SyntheticEdit("adv_curve_refracture_001", "dense_to_sparse_circle", "replace_dense_curve_with_sparse_curve", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(dense), after_bbox_um=_bbox(sparse)),
1236
+ SyntheticEdit("adv_curve_overlap_001", "overlapping_curve_changed_breakpoints", "shift_and_refracture_overlapping_curve", "coverage_both", before_layer=layer, after_layer=layer, before_bbox_um=_bbox(old_overlap), after_bbox_um=_bbox(new_overlap)),
1237
+ )
1238
+ return SyntheticCaseBuild(old, new, edits)
1239
+
1240
+
1241
+ def _case_adversarial_hierarchy_transforms(profile: ScaleProfile) -> SyntheticCaseBuild:
1242
+ layer = (33, 0)
1243
+ old = gdstk.Library(unit=_UNIT, precision=_PRECISION)
1244
+ old_leaf = old.new_cell("leaf")
1245
+ _add(old_leaf, _rect(0, 0, 8, 4, layer), _star_polygon(4, 10, 4, 1.5, 5, 0.0, layer))
1246
+ old_top = old.new_cell("top")
1247
+ old_top.add(gdstk.Reference(old_leaf, (0, 0), columns=max(2, profile.hierarchy_cols // 2), rows=2, spacing=(20, 24)))
1248
+ old_top.add(gdstk.Reference(old_leaf, (120, 0), rotation=math.pi / 2, magnification=2.0))
1249
+ old_top.add(gdstk.Reference(old_leaf, (170, 40), rotation=math.pi / 4, magnification=1.5, x_reflection=True))
1250
+
1251
+ new = gdstk.Library(unit=_UNIT, precision=_PRECISION)
1252
+ new_leaf = new.new_cell("leaf")
1253
+ _add(new_leaf, _rect(0, 0, 8, 4, layer), _star_polygon(4, 10, 4, 1.5, 5, 0.0, layer))
1254
+ new_leaf_shifted = new.new_cell("leaf_shifted")
1255
+ _add(new_leaf_shifted, _rect(1, 0, 9, 4, layer), _star_polygon(4, 10, 4, 1.5, 5, 0.2, layer))
1256
+ new_top = new.new_cell("top")
1257
+ new_top.add(gdstk.Reference(new_leaf, (0, 0), columns=max(2, profile.hierarchy_cols // 2), rows=2, spacing=(20, 24)))
1258
+ new_top.add(gdstk.Reference(new_leaf_shifted, (120, 0), rotation=math.pi / 2, magnification=2.0))
1259
+ new_top.add(gdstk.Reference(new_leaf, (170, 40), rotation=math.pi / 4, magnification=1.5, x_reflection=True))
1260
+ edits = (
1261
+ SyntheticEdit(
1262
+ "adv_hierarchy_transform_001",
1263
+ "rotated_magnified_reference",
1264
+ "modify_child_used_by_rotated_magnified_reference",
1265
+ "coverage_both",
1266
+ before_layer=layer,
1267
+ after_layer=layer,
1268
+ before_bbox_um=(108, -2, 124, 24),
1269
+ after_bbox_um=(108, -2, 124, 24),
1270
+ notes="Only a rotated and magnified reference changes; stable array and reflected reference remain unchanged.",
1271
+ ),
1272
+ )
1273
+ return SyntheticCaseBuild(old, new, edits)
1274
+
1275
+
1276
+ def _case_adversarial_array_pitch_one_dbu(profile: ScaleProfile) -> SyntheticCaseBuild:
1277
+ layer = (34, 0)
1278
+ old = gdstk.Library(unit=_UNIT, precision=_PRECISION)
1279
+ old_leaf = old.new_cell("leaf")
1280
+ old_leaf.add(_rect(0, 0, 4, 4, layer))
1281
+ old_top = old.new_cell("top")
1282
+ rows = max(3, min(profile.hierarchy_rows, 20))
1283
+ cols = max(3, min(profile.hierarchy_cols, 20))
1284
+ old_top.add(gdstk.Reference(old_leaf, (0, 0), columns=cols, rows=rows, spacing=(8, 8)))
1285
+
1286
+ new = gdstk.Library(unit=_UNIT, precision=_PRECISION)
1287
+ new_leaf = new.new_cell("leaf")
1288
+ new_leaf.add(_rect(0, 0, 4, 4, layer))
1289
+ new_top = new.new_cell("top")
1290
+ new_top.add(gdstk.Reference(new_leaf, (0, 0), columns=cols, rows=max(1, rows - 1), spacing=(8, 8)))
1291
+ new_top.add(gdstk.Reference(new_leaf, (0, (rows - 1) * 8 + 0.001), columns=cols, rows=1, spacing=(8, 8)))
1292
+ y0 = (rows - 1) * 8
1293
+ edits = (
1294
+ SyntheticEdit(
1295
+ "adv_array_pitch_001",
1296
+ "last_array_row",
1297
+ "shift_array_row_by_one_dbu",
1298
+ "coverage_both",
1299
+ before_layer=layer,
1300
+ after_layer=layer,
1301
+ before_bbox_um=(0, y0, (cols - 1) * 8 + 4, y0 + 4),
1302
+ after_bbox_um=(0, y0 + 0.001, (cols - 1) * 8 + 4, y0 + 4.001),
1303
+ ),
1304
+ )
1305
+ return SyntheticCaseBuild(old, new, edits)
1306
+
1307
+
1308
+ BASE_SYNTHETIC_CASES: tuple[SyntheticCaseDefinition, ...] = (
1309
+ SyntheticCaseDefinition("rect_add_remove_shift", "Rectangle Add Remove Shift", _case_rect_add_remove_shift),
1310
+ SyntheticCaseDefinition("overlap_rect_unions", "Overlapping Rectangle Unions", _case_overlap_rect_unions),
1311
+ SyntheticCaseDefinition("circle_breakpoints", "Circle And Ellipse Breakpoints", _case_circle_breakpoints),
1312
+ SyntheticCaseDefinition("star_concave", "Concave Star Polygons", _case_star_concave),
1313
+ SyntheticCaseDefinition("hollow_rect_circle", "Hollow Rectangle And Donut", _case_hollow_rect_circle),
1314
+ SyntheticCaseDefinition("nested_holes_bridge", "Nested Holes And Bridge Cuts", _case_nested_holes_bridge),
1315
+ SyntheticCaseDefinition("thin_slivers_near_collinear", "Thin Slivers And Near-Collinear Vertices", _case_thin_slivers_near_collinear),
1316
+ SyntheticCaseDefinition("layer_datatype_moves", "Layer And Datatype Moves", _case_layer_datatype_moves),
1317
+ SyntheticCaseDefinition("same_coverage_refracturing", "Same-Coverage Refracturing", _case_same_coverage_refracturing),
1318
+ SyntheticCaseDefinition("touching_edges_points", "Touching Edges And Points", _case_touching_edges_points),
1319
+ SyntheticCaseDefinition("cell_references_arrays", "Cell References And Arrays", _case_cell_references_arrays),
1320
+ SyntheticCaseDefinition("sparse_large_rect_grid", "Sparse Large Rectangle Grid", _case_sparse_large_rect_grid),
1321
+ SyntheticCaseDefinition("mixed_complex_polygons", "Mixed Complex Polygon Set", _case_mixed_complex_polygons),
1322
+ SyntheticCaseDefinition("hollow_grid_many", "Many Hollow Grid Rings", _case_hollow_grid_many),
1323
+ SyntheticCaseDefinition("multi_layer_mixed_edits", "Multi-Layer Mixed Edits", _case_multi_layer_mixed_edits),
1324
+ SyntheticCaseDefinition("block_like_hierarchy_stress", "Block-Like Hierarchy Stress", _case_block_like_hierarchy_stress),
1325
+ )
1326
+
1327
+
1328
+ ADVERSARIAL_SYNTHETIC_CASES: tuple[SyntheticCaseDefinition, ...] = (
1329
+ SyntheticCaseDefinition("adversarial_nested_rings_islands", "Adversarial Nested Rings And Islands", _case_adversarial_nested_rings_islands),
1330
+ SyntheticCaseDefinition("adversarial_hole_touching_boundary", "Adversarial Hole Touching Boundary", _case_adversarial_hole_touching_boundary),
1331
+ SyntheticCaseDefinition("adversarial_containment_no_edge_crossing", "Adversarial Containment Without Edge Crossing", _case_adversarial_containment_no_edge_crossing),
1332
+ SyntheticCaseDefinition("adversarial_reversed_winding_equivalent", "Adversarial Reversed Winding Equivalent Coverage", _case_adversarial_reversed_winding_equivalent),
1333
+ SyntheticCaseDefinition("adversarial_duplicate_polygons_equivalent", "Adversarial Duplicate Polygons Equivalent Coverage", _case_adversarial_duplicate_polygons_equivalent),
1334
+ SyntheticCaseDefinition("adversarial_partial_collinear_overlap", "Adversarial Partial Collinear Overlap", _case_adversarial_partial_collinear_overlap),
1335
+ SyntheticCaseDefinition("adversarial_one_dbu_touch_tjunction", "Adversarial One-DBU Touch And T-Junction", _case_adversarial_one_dbu_touch_tjunction),
1336
+ SyntheticCaseDefinition("adversarial_one_dbu_sliver_grid", "Adversarial One-DBU Sliver Grid", _case_adversarial_one_dbu_sliver_grid),
1337
+ SyntheticCaseDefinition("adversarial_large_coordinates_tiny_delta", "Adversarial Large Coordinates Tiny Delta", _case_adversarial_large_coordinates_tiny_delta),
1338
+ SyntheticCaseDefinition("adversarial_snap_rounding_equivalent", "Adversarial Snap Rounding Equivalent Coverage", _case_adversarial_snap_rounding_equivalent),
1339
+ SyntheticCaseDefinition("adversarial_curve_single_vertex_bump", "Adversarial Curve Single Vertex Bump", _case_adversarial_curve_single_vertex_bump),
1340
+ SyntheticCaseDefinition("adversarial_dense_sparse_curve_overlap", "Adversarial Dense Sparse Curve Overlap", _case_adversarial_dense_sparse_curve_overlap),
1341
+ SyntheticCaseDefinition("adversarial_hierarchy_transforms", "Adversarial Hierarchy Transforms", _case_adversarial_hierarchy_transforms),
1342
+ SyntheticCaseDefinition("adversarial_array_pitch_one_dbu", "Adversarial Array Pitch One DBU", _case_adversarial_array_pitch_one_dbu),
1343
+ )
1344
+
1345
+
1346
+ SYNTHETIC_CASES: tuple[SyntheticCaseDefinition, ...] = BASE_SYNTHETIC_CASES + ADVERSARIAL_SYNTHETIC_CASES
1347
+
1348
+
1349
+ def _library() -> tuple[gdstk.Library, gdstk.Cell]:
1350
+ lib = gdstk.Library(unit=_UNIT, precision=_PRECISION)
1351
+ return lib, lib.new_cell("top")
1352
+
1353
+
1354
+ def _profile(scale: str) -> ScaleProfile:
1355
+ try:
1356
+ return SCALE_PROFILES[scale]
1357
+ except KeyError as exc:
1358
+ raise ValueError(f"unknown synthetic suite scale {scale!r}; expected one of {', '.join(SCALE_PROFILES)}") from exc
1359
+
1360
+
1361
+ def _rect(x0: float, y0: float, x1: float, y1: float, layer: LayerTuple) -> gdstk.Polygon:
1362
+ return gdstk.rectangle((x0, y0), (x1, y1), layer=layer[0], datatype=layer[1])
1363
+
1364
+
1365
+ def _grid_rect(ix: int, iy: int, layer: LayerTuple, *, dx: float = 0.0, dy: float = 0.0) -> gdstk.Polygon:
1366
+ x0, y0, x1, y1 = _grid_bbox(ix, iy, dx=dx, dy=dy)
1367
+ return _rect(x0, y0, x1, y1, layer)
1368
+
1369
+
1370
+ def _grid_bbox(ix: int, iy: int, *, dx: float = 0.0, dy: float = 0.0) -> BBox:
1371
+ x = ix * 6.0 + dx
1372
+ y = iy * 6.0 + dy
1373
+ return (x, y, x + 3.0, y + 3.0)
1374
+
1375
+
1376
+ def _ellipse_polygon(
1377
+ cx: float,
1378
+ cy: float,
1379
+ rx: float,
1380
+ ry: float,
1381
+ point_count: int,
1382
+ layer: LayerTuple,
1383
+ *,
1384
+ angle: float = 0.0,
1385
+ ) -> gdstk.Polygon:
1386
+ points = []
1387
+ cos_a = math.cos(angle)
1388
+ sin_a = math.sin(angle)
1389
+ for index in range(point_count):
1390
+ theta = 2 * math.pi * index / point_count
1391
+ x = rx * math.cos(theta)
1392
+ y = ry * math.sin(theta)
1393
+ points.append((cx + cos_a * x - sin_a * y, cy + sin_a * x + cos_a * y))
1394
+ return gdstk.Polygon(points, layer=layer[0], datatype=layer[1])
1395
+
1396
+
1397
+ def _star_polygon(
1398
+ cx: float,
1399
+ cy: float,
1400
+ outer_radius: float,
1401
+ inner_radius: float,
1402
+ point_count: int,
1403
+ angle: float,
1404
+ layer: LayerTuple,
1405
+ ) -> gdstk.Polygon:
1406
+ points = []
1407
+ for index in range(point_count * 2):
1408
+ radius = outer_radius if index % 2 == 0 else inner_radius
1409
+ theta = angle + math.pi * index / point_count
1410
+ points.append((cx + radius * math.cos(theta), cy + radius * math.sin(theta)))
1411
+ return gdstk.Polygon(points, layer=layer[0], datatype=layer[1])
1412
+
1413
+
1414
+ def _diamond_polygon(cx: float, cy: float, radius: float, layer: LayerTuple) -> gdstk.Polygon:
1415
+ return gdstk.Polygon(
1416
+ ((cx, cy + radius), (cx + radius, cy), (cx, cy - radius), (cx - radius, cy)),
1417
+ layer=layer[0],
1418
+ datatype=layer[1],
1419
+ )
1420
+
1421
+
1422
+ def _rect_ring(
1423
+ ox0: float,
1424
+ oy0: float,
1425
+ ox1: float,
1426
+ oy1: float,
1427
+ ix0: float,
1428
+ iy0: float,
1429
+ ix1: float,
1430
+ iy1: float,
1431
+ layer: LayerTuple,
1432
+ ) -> tuple[gdstk.Polygon, ...]:
1433
+ return _boolean(_rect(ox0, oy0, ox1, oy1, layer), [_rect(ix0, iy0, ix1, iy1, layer)], "not", layer)
1434
+
1435
+
1436
+ def _ellipse_ring(
1437
+ cx: float,
1438
+ cy: float,
1439
+ outer_rx: float,
1440
+ outer_ry: float,
1441
+ inner_rx: float,
1442
+ inner_ry: float,
1443
+ point_count: int,
1444
+ layer: LayerTuple,
1445
+ ) -> tuple[gdstk.Polygon, ...]:
1446
+ outer = _ellipse_polygon(cx, cy, outer_rx, outer_ry, point_count, layer)
1447
+ inner = _ellipse_polygon(cx, cy, inner_rx, inner_ry, point_count, layer)
1448
+ return _boolean(outer, [inner], "not", layer)
1449
+
1450
+
1451
+ def _boolean(
1452
+ subject: gdstk.Polygon | Sequence[gdstk.Polygon],
1453
+ clip: Sequence[gdstk.Polygon],
1454
+ operation: str,
1455
+ layer: LayerTuple,
1456
+ ) -> tuple[gdstk.Polygon, ...]:
1457
+ result = gdstk.boolean(subject, clip, operation, precision=1e-3, layer=layer[0], datatype=layer[1])
1458
+ return tuple(result or ())
1459
+
1460
+
1461
+ def _mixed_shape(kind: int, x: float, y: float, index: int, layer: LayerTuple, *, changed: bool) -> gdstk.Polygon:
1462
+ if kind == 0:
1463
+ delta = 0.7 if changed else 0.0
1464
+ return _rect(x + delta, y, x + 8 + delta, y + 5, layer)
1465
+ if kind == 1:
1466
+ radius = 5.5 if changed else 5.0
1467
+ return _ellipse_polygon(x + 5, y + 5, radius, 3.5, 20 + (index % 5), layer, angle=0.1 * (index % 7))
1468
+ angle = 0.4 if changed else 0.0
1469
+ return _star_polygon(x + 5, y + 5, 5.5, 2.0, 5 + (index % 3), angle, layer)
1470
+
1471
+
1472
+ def _add(cell: gdstk.Cell, *items: gdstk.Polygon | gdstk.Reference) -> None:
1473
+ for item in items:
1474
+ cell.add(item)
1475
+
1476
+
1477
+ def _bbox(poly: gdstk.Polygon) -> BBox:
1478
+ points = poly.points
1479
+ xs = [float(point[0]) for point in points]
1480
+ ys = [float(point[1]) for point in points]
1481
+ return (min(xs), min(ys), max(xs), max(ys))
1482
+
1483
+
1484
+ def _bbox_dbu(bbox_um: BBox) -> tuple[int, int, int, int]:
1485
+ return tuple(int(round(value * _DBU_PER_UM)) for value in bbox_um) # type: ignore[return-value]
1486
+
1487
+
1488
+ def _bboxes_intersect(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool:
1489
+ return a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]
1490
+
1491
+
1492
+ def _library_stats(lib: gdstk.Library) -> dict[str, int]:
1493
+ top = _top_cell(lib)
1494
+ polygons = top.get_polygons(apply_repetitions=True, include_paths=True)
1495
+ return {
1496
+ "polygon_count": len(polygons),
1497
+ "vertex_count": sum(len(polygon.points) for polygon in polygons),
1498
+ }
1499
+
1500
+
1501
+ def _top_cell(lib: gdstk.Library) -> gdstk.Cell:
1502
+ for cell in lib.cells:
1503
+ if cell.name == "top":
1504
+ return cell
1505
+ tops = lib.top_level()
1506
+ if len(tops) != 1:
1507
+ raise ValueError(f"synthetic library expected one top cell, found {len(tops)}")
1508
+ return tops[0]
1509
+
1510
+
1511
+ def _expected_changed_layers(edits: Sequence[SyntheticEdit]) -> tuple[LayerTuple, ...]:
1512
+ layers = {layer for edit in edits for layer in edit.changed_layers()}
1513
+ return tuple(sorted(layers))
1514
+
1515
+
1516
+ def _changed_layers_from_report(payload: dict[str, object]) -> tuple[LayerTuple, ...]:
1517
+ layers = []
1518
+ for item in payload.get("layers", ()):
1519
+ if not isinstance(item, dict):
1520
+ continue
1521
+ if str(item.get("xor_area_dbu2", "0")) == "0" and str(item.get("old_minus_new_area_dbu2", "0")) == "0" and str(item.get("new_minus_old_area_dbu2", "0")) == "0":
1522
+ continue
1523
+ layers.append((int(item["layer"]), int(item["datatype"])))
1524
+ return tuple(sorted(layers))
1525
+
1526
+
1527
+ def _audit_match_from_report(payload: dict[str, object]) -> bool | None:
1528
+ exact_fallback = payload.get("exact_fallback")
1529
+ if not isinstance(exact_fallback, dict):
1530
+ return None
1531
+ audit = exact_fallback.get("cpu_oracle_audit")
1532
+ if not isinstance(audit, dict):
1533
+ return None
1534
+ match = audit.get("match")
1535
+ return bool(match) if match is not None else None
1536
+
1537
+
1538
+ def _float_or_none(value: object) -> float | None:
1539
+ if value is None:
1540
+ return None
1541
+ try:
1542
+ return float(value)
1543
+ except (TypeError, ValueError):
1544
+ return None
1545
+
1546
+
1547
+ def _format_layer(layer: LayerTuple) -> str:
1548
+ return f"{layer[0]}:{layer[1]}"
1549
+
1550
+
1551
+ def _format_layers(layers: Iterable[LayerTuple]) -> str:
1552
+ return ",".join(_format_layer(layer) for layer in layers)
1553
+
1554
+
1555
+ def _format_seconds(value: float | None) -> str:
1556
+ return "n/a" if value is None else f"{value:.4f}"
1557
+
1558
+
1559
+ def _json_dumps(payload: object) -> str:
1560
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
1561
+
1562
+
1563
+ def validate_synthetic_case_with_cpu_oracle(record: SyntheticCaseRecord) -> dict[str, object]:
1564
+ old = extract_gds_polygons(record.before_path).buffer
1565
+ new = extract_gds_polygons(record.after_path).buffer
1566
+ result = run_full_exact_oracle(old, new)
1567
+ by_layer = {layer.layer: layer for layer in result.layers}
1568
+ changed_layers = tuple(sorted(layer for layer, diff in by_layer.items() if diff.xor_twice_area))
1569
+ expected_layers = tuple(LayerSpec(*layer) for layer in record.expected_changed_layers)
1570
+ missing_layers = tuple(layer for layer in expected_layers if layer not in changed_layers)
1571
+ missing_windows: list[dict[str, object]] = []
1572
+ for edit in record.edits:
1573
+ if not edit.coverage_changed:
1574
+ continue
1575
+ for layer_key, bbox_um, side in _validation_sides(edit):
1576
+ if layer_key is None or bbox_um is None:
1577
+ continue
1578
+ diff = by_layer.get(LayerSpec(*layer_key))
1579
+ if diff is None:
1580
+ missing_windows.append({"operation_id": edit.operation_id, "layer": layer_key, "side": side, "reason": "layer missing"})
1581
+ continue
1582
+ fragments = diff.old_minus_new if side == "before" else diff.new_minus_old
1583
+ bbox_dbu = _bbox_dbu(bbox_um)
1584
+ if fragments and not any(_bboxes_intersect(fragment.bbox, bbox_dbu) for fragment in fragments):
1585
+ missing_windows.append({"operation_id": edit.operation_id, "layer": layer_key, "side": side, "bbox_dbu": bbox_dbu})
1586
+ return {
1587
+ "equivalent": result.equivalent,
1588
+ "expected_equivalent": record.equivalent_expected,
1589
+ "changed_layers": [[layer.layer, layer.datatype] for layer in changed_layers],
1590
+ "expected_changed_layers": [list(layer) for layer in record.expected_changed_layers],
1591
+ "missing_layers": [[layer.layer, layer.datatype] for layer in missing_layers],
1592
+ "missing_windows": missing_windows,
1593
+ }
1594
+
1595
+
1596
+ def _validation_sides(edit: SyntheticEdit) -> tuple[tuple[LayerTuple | None, BBox | None, str], ...]:
1597
+ if edit.expected_direction == "coverage_before_only":
1598
+ return ((edit.before_layer, edit.before_bbox_um, "before"),)
1599
+ if edit.expected_direction == "coverage_after_only":
1600
+ return ((edit.after_layer, edit.after_bbox_um, "after"),)
1601
+ if edit.expected_direction == "coverage_both":
1602
+ return (
1603
+ (edit.before_layer, edit.before_bbox_um, "before"),
1604
+ (edit.after_layer, edit.after_bbox_um, "after"),
1605
+ )
1606
+ return ()
1607
+
1608
+
1609
+ def main(argv: Sequence[str] | None = None) -> int:
1610
+ parser = argparse.ArgumentParser(description="Generate and optionally benchmark synthetic gdsdiff readiness GDS pairs.")
1611
+ parser.add_argument("output_dir", type=Path)
1612
+ parser.add_argument("--scale", action="append", choices=tuple(SCALE_PROFILES), help="Scale profile to generate. May be repeated.")
1613
+ parser.add_argument("--case", action="append", dest="cases", help="Generate only the named case. May be repeated.")
1614
+ parser.add_argument("--benchmark", action="store_true", help="Run CPU and Metal gdsdiff comparisons after generation.")
1615
+ parser.add_argument("--no-cpu", action="store_true", help="Skip CPU oracle benchmark runs.")
1616
+ parser.add_argument("--no-metal", action="store_true", help="Skip Metal benchmark runs.")
1617
+ parser.add_argument("--no-metal-audit", action="store_true", help="Skip Metal runs with CPU audit.")
1618
+ parser.add_argument("--stress-cpu-case-limit", type=int, default=2)
1619
+ args = parser.parse_args(argv)
1620
+
1621
+ scales = tuple(args.scale or ("benchmark",))
1622
+ all_records: list[SyntheticCaseRecord] = []
1623
+ for scale in scales:
1624
+ scale_dir = args.output_dir / scale if len(scales) > 1 else args.output_dir
1625
+ all_records.extend(build_synthetic_suite(scale_dir, scale=scale, case_names=args.cases))
1626
+
1627
+ if args.benchmark:
1628
+ runs = benchmark_synthetic_records(
1629
+ all_records,
1630
+ run_cpu=not args.no_cpu,
1631
+ run_metal=not args.no_metal,
1632
+ run_metal_audit=not args.no_metal_audit,
1633
+ stress_cpu_case_limit=args.stress_cpu_case_limit,
1634
+ )
1635
+ summary = write_benchmark_summary(args.output_dir, all_records, runs)
1636
+ print(f"benchmark_summary={summary}")
1637
+ for record in all_records:
1638
+ print(f"{record.scale}/{record.name}: {record.before_path} {record.after_path}")
1639
+ return 0
1640
+
1641
+
1642
+ if __name__ == "__main__":
1643
+ raise SystemExit(main(sys.argv[1:]))