rocket-engine 0.0.2b1__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 (241) hide show
  1. rocket_engine/__init__.py +8 -0
  2. rocket_engine/__main__.py +12 -0
  3. rocket_engine/adapters/README.md +54 -0
  4. rocket_engine/adapters/__init__.py +7 -0
  5. rocket_engine/adapters/api.py +39 -0
  6. rocket_engine/adapters/base.py +46 -0
  7. rocket_engine/adapters/cace/__init__.py +163 -0
  8. rocket_engine/adapters/circulax/README.md +10 -0
  9. rocket_engine/adapters/circulax/__init__.py +112 -0
  10. rocket_engine/adapters/circulax/_driver.py +190 -0
  11. rocket_engine/adapters/circulax/adapter.py +538 -0
  12. rocket_engine/adapters/circulax/results.py +291 -0
  13. rocket_engine/adapters/cli/README.md +34 -0
  14. rocket_engine/adapters/cli/__init__.py +305 -0
  15. rocket_engine/adapters/connectivity/__init__.py +196 -0
  16. rocket_engine/adapters/connectivity/results.py +214 -0
  17. rocket_engine/adapters/cosim/README.md +9 -0
  18. rocket_engine/adapters/cosim/__init__.py +1390 -0
  19. rocket_engine/adapters/cosim/results.py +214 -0
  20. rocket_engine/adapters/devsim/__init__.py +171 -0
  21. rocket_engine/adapters/digital_flow/README.md +9 -0
  22. rocket_engine/adapters/digital_flow/__init__.py +1121 -0
  23. rocket_engine/adapters/digital_flow/results.py +227 -0
  24. rocket_engine/adapters/digital_flow/state.py +462 -0
  25. rocket_engine/adapters/drc/README.klayout.md +7 -0
  26. rocket_engine/adapters/drc/README.md +7 -0
  27. rocket_engine/adapters/drc/__init__.py +1 -0
  28. rocket_engine/adapters/drc/klayout.py +952 -0
  29. rocket_engine/adapters/drc/magic.py +287 -0
  30. rocket_engine/adapters/drc/results.py +184 -0
  31. rocket_engine/adapters/drc/results_kl.py +188 -0
  32. rocket_engine/adapters/elmer/__init__.py +163 -0
  33. rocket_engine/adapters/envelope.py +120 -0
  34. rocket_engine/adapters/equivalence/__init__.py +156 -0
  35. rocket_engine/adapters/errors.py +181 -0
  36. rocket_engine/adapters/extract_magic/README.md +15 -0
  37. rocket_engine/adapters/extract_magic/__init__.py +425 -0
  38. rocket_engine/adapters/extract_magic/results.py +209 -0
  39. rocket_engine/adapters/formal_sby/README.md +7 -0
  40. rocket_engine/adapters/formal_sby/__init__.py +773 -0
  41. rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
  42. rocket_engine/adapters/formal_sby/results.py +185 -0
  43. rocket_engine/adapters/gdsfactory/__init__.py +194 -0
  44. rocket_engine/adapters/gdsfactory/_driver.py +459 -0
  45. rocket_engine/adapters/gdsfactory/adapter.py +609 -0
  46. rocket_engine/adapters/gdsfactory/results.py +181 -0
  47. rocket_engine/adapters/hdl_sim/README.md +7 -0
  48. rocket_engine/adapters/hdl_sim/__init__.py +369 -0
  49. rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
  50. rocket_engine/adapters/hdl_sim/results.py +178 -0
  51. rocket_engine/adapters/hdl_sim/verilator.py +167 -0
  52. rocket_engine/adapters/http/README.md +15 -0
  53. rocket_engine/adapters/http/__init__.py +458 -0
  54. rocket_engine/adapters/http/events.py +63 -0
  55. rocket_engine/adapters/ipc/README.md +9 -0
  56. rocket_engine/adapters/ipc/__init__.py +111 -0
  57. rocket_engine/adapters/lvs_netgen/README.md +20 -0
  58. rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
  59. rocket_engine/adapters/lvs_netgen/results.py +168 -0
  60. rocket_engine/adapters/mcp/README.md +19 -0
  61. rocket_engine/adapters/mcp/__init__.py +85 -0
  62. rocket_engine/adapters/mcp/api-schema.json +6107 -0
  63. rocket_engine/adapters/mcp/pull.py +246 -0
  64. rocket_engine/adapters/mcp/server.py +794 -0
  65. rocket_engine/adapters/meep/__init__.py +167 -0
  66. rocket_engine/adapters/meow/README.md +7 -0
  67. rocket_engine/adapters/meow/__init__.py +190 -0
  68. rocket_engine/adapters/meow/_driver.py +227 -0
  69. rocket_engine/adapters/meow/_envelope.py +108 -0
  70. rocket_engine/adapters/meow/adapter.py +491 -0
  71. rocket_engine/adapters/meow/results.py +218 -0
  72. rocket_engine/adapters/mutation/__init__.py +192 -0
  73. rocket_engine/adapters/ngspice/README.md +28 -0
  74. rocket_engine/adapters/ngspice/__init__.py +627 -0
  75. rocket_engine/adapters/ngspice/results.py +215 -0
  76. rocket_engine/adapters/postlayout/README.md +28 -0
  77. rocket_engine/adapters/postlayout/__init__.py +17 -0
  78. rocket_engine/adapters/postlayout/adapter.py +1062 -0
  79. rocket_engine/adapters/postlayout/results.py +330 -0
  80. rocket_engine/adapters/postlayout/translate.py +71 -0
  81. rocket_engine/adapters/profile.py +220 -0
  82. rocket_engine/adapters/sax/README.md +23 -0
  83. rocket_engine/adapters/sax/__init__.py +516 -0
  84. rocket_engine/adapters/sax/_driver.py +148 -0
  85. rocket_engine/adapters/sax/results.py +291 -0
  86. rocket_engine/adapters/stdio_console.py +1151 -0
  87. rocket_engine/adapters/syntax/README.md +5 -0
  88. rocket_engine/adapters/syntax/__init__.py +366 -0
  89. rocket_engine/adapters/syntax/results.py +181 -0
  90. rocket_engine/adapters/synth_yosys/README.md +7 -0
  91. rocket_engine/adapters/synth_yosys/__init__.py +655 -0
  92. rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
  93. rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
  94. rocket_engine/adapters/synth_yosys/results.py +173 -0
  95. rocket_engine/adapters/timing_sta/README.md +9 -0
  96. rocket_engine/adapters/timing_sta/__init__.py +726 -0
  97. rocket_engine/adapters/view_sync/__init__.py +185 -0
  98. rocket_engine/adapters/view_sync/results.py +189 -0
  99. rocket_engine/adapters/vlsir/__init__.py +142 -0
  100. rocket_engine/adapters/xschem/__init__.py +440 -0
  101. rocket_engine/adapters/xschem/deck.py +266 -0
  102. rocket_engine/clients/__init__.py +5 -0
  103. rocket_engine/clients/runtime.py +255 -0
  104. rocket_engine/composition.py +2279 -0
  105. rocket_engine/execution/README.md +30 -0
  106. rocket_engine/execution/__init__.py +150 -0
  107. rocket_engine/execution/apig/__init__.py +1 -0
  108. rocket_engine/execution/apig/pool.py +463 -0
  109. rocket_engine/execution/apig/submit.py +124 -0
  110. rocket_engine/execution/block_runs_scope.py +479 -0
  111. rocket_engine/execution/coalesce.py +414 -0
  112. rocket_engine/execution/console/__init__.py +1 -0
  113. rocket_engine/execution/console/apply_session.py +318 -0
  114. rocket_engine/execution/console/lifecycle.py +265 -0
  115. rocket_engine/execution/dispatch.py +205 -0
  116. rocket_engine/execution/errors.py +31 -0
  117. rocket_engine/execution/ports.py +797 -0
  118. rocket_engine/execution/pyexec/__init__.py +1 -0
  119. rocket_engine/execution/pyexec/apply_context.py +547 -0
  120. rocket_engine/execution/pyexec/approvals.py +173 -0
  121. rocket_engine/execution/pyexec/runner.py +404 -0
  122. rocket_engine/execution/pyexec/support.py +464 -0
  123. rocket_engine/execution/python_exec.py +897 -0
  124. rocket_engine/execution/queue.py +85 -0
  125. rocket_engine/execution/replay_safe.py +601 -0
  126. rocket_engine/execution/routing/__init__.py +1 -0
  127. rocket_engine/execution/routing/calls.py +364 -0
  128. rocket_engine/execution/routing/namespace.py +785 -0
  129. rocket_engine/execution/routing/ports.py +102 -0
  130. rocket_engine/execution/routing/tools.py +346 -0
  131. rocket_engine/execution/runner.py +311 -0
  132. rocket_engine/execution/runtime.py +1149 -0
  133. rocket_engine/execution/service.py +279 -0
  134. rocket_engine/execution/worker.py +464 -0
  135. rocket_engine/interfaces/README.md +26 -0
  136. rocket_engine/interfaces/__init__.py +58 -0
  137. rocket_engine/interfaces/apig_assets.py +407 -0
  138. rocket_engine/interfaces/celllink.py +461 -0
  139. rocket_engine/interfaces/documents.py +92 -0
  140. rocket_engine/interfaces/envelopes.py +131 -0
  141. rocket_engine/interfaces/errors.py +16 -0
  142. rocket_engine/interfaces/layout.py +410 -0
  143. rocket_engine/interfaces/layout_schema.yaml +278 -0
  144. rocket_engine/interfaces/paths.py +223 -0
  145. rocket_engine/interfaces/pdks.py +550 -0
  146. rocket_engine/interfaces/ports.py +72 -0
  147. rocket_engine/interfaces/references.py +115 -0
  148. rocket_engine/interfaces/refs.py +85 -0
  149. rocket_engine/interfaces/runparams.py +304 -0
  150. rocket_engine/interfaces/setup.py +552 -0
  151. rocket_engine/interfaces/setup_catalogue.yaml +243 -0
  152. rocket_engine/interfaces/setup_probes.py +66 -0
  153. rocket_engine/interfaces/vocabulary.py +58 -0
  154. rocket_engine/interfaces/yaml_dump.py +31 -0
  155. rocket_engine/library/README.md +22 -0
  156. rocket_engine/library/__init__.py +13 -0
  157. rocket_engine/library/apig_catalog.py +371 -0
  158. rocket_engine/library/catalog.py +185 -0
  159. rocket_engine/library/errors.py +16 -0
  160. rocket_engine/mutate/__init__.py +776 -0
  161. rocket_engine/mutate/operators.py +700 -0
  162. rocket_engine/ops/__init__.py +1 -0
  163. rocket_engine/ops/deferred.py +279 -0
  164. rocket_engine/ops/editors.py +404 -0
  165. rocket_engine/ops/evidence.py +194 -0
  166. rocket_engine/ops/gates.py +262 -0
  167. rocket_engine/ops/ports.py +76 -0
  168. rocket_engine/ops/queries.py +417 -0
  169. rocket_engine/ops/runs.py +553 -0
  170. rocket_engine/ops/submit.py +614 -0
  171. rocket_engine/project/README.md +27 -0
  172. rocket_engine/project/__init__.py +6 -0
  173. rocket_engine/project/canvas/__init__.py +55 -0
  174. rocket_engine/project/canvas/compile.py +109 -0
  175. rocket_engine/project/canvas/integrate.py +48 -0
  176. rocket_engine/project/canvas/schema.py +241 -0
  177. rocket_engine/project/canvas/templates.py +46 -0
  178. rocket_engine/project/canvas/validate.py +603 -0
  179. rocket_engine/project/edit/__init__.py +5 -0
  180. rocket_engine/project/edit/cas.py +87 -0
  181. rocket_engine/project/edit/transactions.py +282 -0
  182. rocket_engine/project/errors.py +16 -0
  183. rocket_engine/project/frontend13.py +685 -0
  184. rocket_engine/project/geometry/__init__.py +23 -0
  185. rocket_engine/project/geometry/ops.py +451 -0
  186. rocket_engine/project/geometry/snap.py +103 -0
  187. rocket_engine/project/ip/__init__.py +1 -0
  188. rocket_engine/project/ip/blocks.py +195 -0
  189. rocket_engine/project/ip/entry.schema.json +78 -0
  190. rocket_engine/project/ip/lifecycle.py +402 -0
  191. rocket_engine/project/ip/verbs.py +123 -0
  192. rocket_engine/project/layers/__init__.py +6 -0
  193. rocket_engine/project/layers/bindings.py +115 -0
  194. rocket_engine/project/layers/catalog.py +228 -0
  195. rocket_engine/project/layers/presets.py +43 -0
  196. rocket_engine/project/ledger/__init__.py +1 -0
  197. rocket_engine/project/ledger/docs.py +167 -0
  198. rocket_engine/project/ledger/entities.py +176 -0
  199. rocket_engine/project/ledger/events.py +29 -0
  200. rocket_engine/project/ledger/runs.py +257 -0
  201. rocket_engine/project/ledger/shared.py +174 -0
  202. rocket_engine/project/ledger/snapshots.py +385 -0
  203. rocket_engine/project/ledger/wires.py +552 -0
  204. rocket_engine/project/process/__init__.py +14 -0
  205. rocket_engine/project/process/model.py +193 -0
  206. rocket_engine/project/process/recipes.py +121 -0
  207. rocket_engine/project/schematic/__init__.py +1 -0
  208. rocket_engine/project/schematic/hierarchy.py +312 -0
  209. rocket_engine/project/schematic/instances.py +393 -0
  210. rocket_engine/project/schematic/sheets.py +198 -0
  211. rocket_engine/project/service.py +2941 -0
  212. rocket_engine/project/symbols/__init__.py +1 -0
  213. rocket_engine/project/symbols/cells.py +324 -0
  214. rocket_engine/project/symbols/linkage.py +356 -0
  215. rocket_engine/project/symbols/symbols.py +352 -0
  216. rocket_engine/project/templates/__init__.py +303 -0
  217. rocket_engine/project/templates/errors.py +25 -0
  218. rocket_engine/project/testbench/__init__.py +49 -0
  219. rocket_engine/project/testbench/blocks.py +619 -0
  220. rocket_engine/project/testbench/templates.py +134 -0
  221. rocket_engine/stores/README.md +12 -0
  222. rocket_engine/stores/__init__.py +6 -0
  223. rocket_engine/stores/ecs.py +1508 -0
  224. rocket_engine/stores/layers.py +1023 -0
  225. rocket_engine/stores/settings.py +1188 -0
  226. rocket_engine/stores/wireplace.py +1324 -0
  227. rocket_engine/workflows/README.md +18 -0
  228. rocket_engine/workflows/__init__.py +53 -0
  229. rocket_engine/workflows/capability_plan.py +102 -0
  230. rocket_engine/workflows/completion.py +228 -0
  231. rocket_engine/workflows/connectgraph.py +734 -0
  232. rocket_engine/workflows/connectgraph_emit.py +336 -0
  233. rocket_engine/workflows/gates.py +144 -0
  234. rocket_engine/workflows/photonic_wire.py +76 -0
  235. rocket_engine/workflows/stages.py +324 -0
  236. rocket_engine/workflows/viewsync.py +399 -0
  237. rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
  238. rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
  239. rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
  240. rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
  241. rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,952 @@
1
+ """KLayout DRC solver adapter (I14 + DRC-SKY130): batch DRC behind the port.
2
+
3
+ Two modes, verified against KLayout 0.28.16:
4
+ - Synthetic: render a pya check script from the rule table, run
5
+ `klayout -b -r`, parse VIOL/TOTAL lines.
6
+ - Deck: run a foundry `.lydrc` (e.g. sky130A.lydrc) via
7
+ `klayout -b -rd input=<gds> -rd report=<lyrdb> -r <deck>`,
8
+ parse the report database XML into violations.
9
+ (The plain-Ruby DSL note in docs/probe-klayout-drc.md predates the
10
+ .lydrc batch recipe; XML-macro decks run fine under -r with -rd.)
11
+ KLayout exits 0 regardless, so the verdict always comes from parsed
12
+ output, never from the exit code.
13
+ """
14
+
15
+ import json
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import xml.etree.ElementTree as ET
20
+ from pathlib import Path
21
+
22
+ from ...execution.errors import ExecutionError
23
+ from ...interfaces import ToolAdapter
24
+ from ...interfaces.runparams import MAX_TIMEOUT_S, MIN_TIMEOUT_S
25
+ from ..errors import AdapterError
26
+
27
+ OPERATION = "run_drc"
28
+ _TIMEOUT_S = 120
29
+
30
+
31
+ def _budget_or_raise(job):
32
+ """Job solver budget: validated, or the adapter default.
33
+
34
+ Args:
35
+ job: Solver job, maybe with a `timeout_s`.
36
+
37
+ Returns:
38
+ Budget seconds for every `_run` call.
39
+
40
+ Raises:
41
+ AdapterError: invalid_command for a boolean,
42
+ non-number, or out-of-bounds budget.
43
+ """
44
+ timeout = job.get("timeout_s", _TIMEOUT_S)
45
+ if (
46
+ isinstance(timeout, bool)
47
+ or not isinstance(timeout, (int, float))
48
+ or not MIN_TIMEOUT_S <= timeout <= MAX_TIMEOUT_S
49
+ ):
50
+ raise AdapterError(
51
+ "invalid_command", f"Bad timeout_s: {timeout!r}."
52
+ )
53
+ return timeout
54
+
55
+
56
+ def _load_layout(gds_file):
57
+ """Read-only layout handle for cell inspection (no binary gate).
58
+
59
+ Args:
60
+ gds_file: GDSII file to open.
61
+
62
+ Returns:
63
+ Opened layout database object.
64
+
65
+ Raises:
66
+ AdapterError: runner_unavailable when no layout API is
67
+ importable on this host.
68
+ """
69
+ try:
70
+ import pya
71
+
72
+ layout = pya.Layout()
73
+ except ImportError:
74
+ try:
75
+ from klayout import db as klayout_db
76
+
77
+ layout = klayout_db.Layout()
78
+ except ImportError as exc:
79
+ raise AdapterError(
80
+ "runner_unavailable",
81
+ "No KLayout layout API (pya/klayout.db) on this host.",
82
+ ) from exc
83
+ layout.read(gds_file)
84
+ return layout
85
+
86
+
87
+ def _cell_inventory(gds_file):
88
+ """Top-cell and all-cell names of a GDS file, read-only.
89
+
90
+ Args:
91
+ gds_file: GDSII file to inspect.
92
+
93
+ Returns:
94
+ (tops, all_names): sorted top-cell names and sorted
95
+ every-cell names from one read-only load.
96
+ """
97
+ layout = _load_layout(gds_file)
98
+ tops = sorted(cell.name for cell in layout.top_cells())
99
+ all_names = sorted(cell.name for cell in layout.each_cell())
100
+ return tops, all_names
101
+
102
+
103
+ def _resolve_cell_or_raise(gds_file, cell):
104
+ """Honor the job cell against the GDS topology, or refuse loudly.
105
+
106
+ Deck mode runs the foundry recipe over the whole GDS (the
107
+ recipe has no top-cell selection) and the synthetic path
108
+ checks `top_cell()` — so a job cell must name a top cell that
109
+ exists, and a multi-top GDS needs an explicit cell instead of
110
+ silently checking whatever `top_cell()` returns (MIXED-TOP
111
+ F3). A missing cell refuses as invalid_reference; a present
112
+ but non-top cell, or several tops without a cell, refuse as
113
+ invalid_command.
114
+
115
+ Args:
116
+ gds_file: GDSII file under check.
117
+ cell: Job cell name, or None.
118
+
119
+ Returns:
120
+ The honored top-cell name (the job cell, or the single
121
+ top cell when the job names none).
122
+
123
+ Raises:
124
+ AdapterError: invalid_reference for a cell absent from
125
+ the GDS; invalid_command for a non-top cell or for
126
+ several top cells without a cell.
127
+ """
128
+ tops, all_names = _cell_inventory(gds_file)
129
+ if cell is not None:
130
+ if cell not in all_names:
131
+ raise AdapterError(
132
+ "invalid_reference", f"DRC cell not in GDS: {cell}."
133
+ )
134
+ if cell not in tops:
135
+ raise AdapterError(
136
+ "invalid_command",
137
+ f"DRC cell {cell!r} is not a top cell "
138
+ f"(tops: {', '.join(tops)}); deck mode checks the "
139
+ "whole GDS and cannot scope to an inner cell.",
140
+ )
141
+ return cell
142
+ if len(tops) != 1:
143
+ raise AdapterError(
144
+ "invalid_command",
145
+ f"GDS has {len(tops)} top cells ({', '.join(tops)}); "
146
+ "pass cell to select one.",
147
+ )
148
+ return tops[0]
149
+
150
+
151
+ _TOTAL_RE = re.compile(r"^TOTAL (\d+)$", re.M)
152
+ _VIOL_RE = re.compile(
153
+ r"^VIOL rule=(\S+) bbox_um=([0-9.eE+-]+),([0-9.eE+-]+),"
154
+ r"([0-9.eE+-]+),([0-9.eE+-]+)$",
155
+ re.M,
156
+ )
157
+ _NUM_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
158
+ # Lyrdb value geometry grammar (ESCAPE-HARDEN MC-44): `x,y` vertex
159
+ # pairs after a geometry head (`polygon:`, `edge-pair:`, `shape`),
160
+ # pairs separated by `;`, groups wrapped in parens and separated
161
+ # by `/` or `|`. A box needs at least two pairs in a
162
+ # semicolon/paren-structured value — bare numbers in unstructured
163
+ # prose (spacing notes, area remarks) never form one.
164
+ _PAIR_RE = re.compile(
165
+ r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
166
+ r"\s*,\s*"
167
+ r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?"
168
+ )
169
+
170
+ _CHECK_SCRIPT = """import json
171
+ import pya
172
+
173
+ SPEC = json.loads({spec!r})
174
+ layout = pya.Layout()
175
+ layout.read(SPEC["gds_file"])
176
+ top = layout.top_cell()
177
+ layer = SPEC["rules"]["layer"]
178
+ region = pya.Region(top.begin_shapes_rec(layout.layer(layer[0], layer[1])))
179
+ total = 0
180
+ name = SPEC["rules"].get("name") or "L%d_%d" % (layer[0], layer[1])
181
+ checks = (("width", SPEC["rules"]["width_um"]), ("space", SPEC["rules"]["space_um"]))
182
+ for rule, dim_um in checks:
183
+ dim_dbu = int(round(dim_um / layout.dbu))
184
+ check = region.width_check if rule == "width" else region.space_check
185
+ for pair in check(dim_dbu).each():
186
+ total += 1
187
+ box = pair.bbox()
188
+ dbu = layout.dbu
189
+ coords = (box.left * dbu, box.bottom * dbu, box.right * dbu, box.top * dbu)
190
+ print("VIOL rule=%s_%s bbox_um=%s,%s,%s,%s" % ((name, rule) + coords))
191
+ print("TOTAL %d" % total)
192
+ """
193
+
194
+
195
+ def _waive_or_raise(job):
196
+ """Waive list for a deck run: exact rule names, or None.
197
+
198
+ Args:
199
+ job: Solver job, maybe with a `waive_rules` list.
200
+
201
+ Returns:
202
+ Sorted waive list, or None when the job names none.
203
+
204
+ Raises:
205
+ AdapterError: invalid_command for a non-list or a
206
+ non-string entry.
207
+ """
208
+ waive = job.get("waive_rules")
209
+ if waive is None:
210
+ return None
211
+ if not isinstance(waive, list) or any(
212
+ not isinstance(rule, str) or not rule for rule in waive
213
+ ):
214
+ raise AdapterError(
215
+ "invalid_command", f"Bad DRC waive_rules: {waive!r}."
216
+ )
217
+ return sorted(set(waive))
218
+
219
+
220
+ def apply_waivers(parsed, waive_rules):
221
+ """Drop waived rules from a deck parse with a tally.
222
+
223
+ Pure helper (DRC-BANDGAP-TRIAGE): the OFFGRID manufacturing-grid
224
+ bucket is a setup/parity gap on generator-grid layouts, so a
225
+ deck run can waive those rules by exact name and leave the real
226
+ defects standing alone. No waivers means the parse unchanged.
227
+
228
+ Args:
229
+ parsed: Parse mapping with total plus violations[].
230
+ waive_rules: Exact rule names to drop; None or empty
231
+ returns the parse unchanged.
232
+
233
+ Returns:
234
+ Parse mapping with the surviving violations, the
235
+ recomputed total, and a waived tally (rules plus
236
+ dropped count).
237
+ """
238
+ waived = sorted(set(waive_rules or ()))
239
+ if not waived:
240
+ return {"total": parsed.get("total", 0),
241
+ "violations": list(parsed.get("violations") or []),
242
+ "waived": {"rules": [], "count": 0}}
243
+ drop = set(waived)
244
+ before = parsed.get("violations") or []
245
+ kept = [v for v in before if v.get("rule") not in drop]
246
+ return {"total": len(kept), "violations": kept,
247
+ "waived": {"rules": waived, "count": len(before) - len(kept)}}
248
+
249
+
250
+ def _rules_or_raise(rules):
251
+ if not isinstance(rules, dict):
252
+ raise AdapterError("invalid_command", "rules must be a mapping.")
253
+ layer = rules.get("layer")
254
+ if (
255
+ not isinstance(layer, (list, tuple))
256
+ or len(layer) != 2
257
+ or not all(isinstance(v, int) and not isinstance(v, bool) for v in layer)
258
+ ):
259
+ raise AdapterError("invalid_command", f"Bad DRC layer: {layer!r}.")
260
+ clean = {"layer": [layer[0], layer[1]]}
261
+ for key in ("width_um", "space_um"):
262
+ value = rules.get(key)
263
+ if (
264
+ not isinstance(value, (int, float))
265
+ or isinstance(value, bool)
266
+ or not value > 0
267
+ ):
268
+ raise AdapterError("invalid_command", f"Bad DRC rule {key}: {value!r}.")
269
+ clean[key] = float(value)
270
+ if rules.get("name") is not None:
271
+ clean["name"] = str(rules["name"])
272
+ return clean
273
+
274
+
275
+ def _deck_file_or_raise(job):
276
+ deck = job.get("deck") if isinstance(job.get("deck"), dict) else {}
277
+ for candidate in (
278
+ job.get("deck_file"),
279
+ deck.get("path"),
280
+ deck.get("deck_file"),
281
+ deck.get("file"),
282
+ ):
283
+ if candidate is not None:
284
+ if not candidate or not Path(candidate).is_file():
285
+ raise AdapterError(
286
+ "invalid_reference", f"DRC deck not found: {candidate}."
287
+ )
288
+ return str(candidate)
289
+ return None
290
+
291
+
292
+ def _bbox_from_value(text):
293
+ """Box one lyrdb value text under the geometry grammar.
294
+
295
+ Args:
296
+ text: Joined lyrdb `<value>` text for one item.
297
+
298
+ Returns:
299
+ [x0, y0, x1, y1] over the `x,y` vertex pairs, or None
300
+ when the text carries fewer than two pairs outside
301
+ a semicolon/paren-structured value (stray numbers in
302
+ unstructured prose never form a box).
303
+ """
304
+ text = text or ""
305
+ if ";" not in text and "(" not in text:
306
+ return None
307
+ pairs = []
308
+ for hit in _PAIR_RE.findall(text):
309
+ halves = [h.strip() for h in hit.split(",")]
310
+ if len(halves) != 2:
311
+ continue
312
+ try:
313
+ pairs.append((float(halves[0]), float(halves[1])))
314
+ except ValueError:
315
+ continue
316
+ if len(pairs) < 2:
317
+ return None
318
+ xs = [p[0] for p in pairs]
319
+ ys = [p[1] for p in pairs]
320
+ return [min(xs), min(ys), max(xs), max(ys)]
321
+
322
+
323
+ def _parse_lyrdb(xml_text):
324
+ try:
325
+ root = ET.fromstring(xml_text)
326
+ except ET.ParseError as exc:
327
+ raise AdapterError(
328
+ "runner_unavailable", f"klayout report is not valid XML: {exc}."
329
+ ) from exc
330
+ descriptions = {}
331
+ for cat in root.findall("./categories/category"):
332
+ name = (cat.findtext("name") or "").strip()
333
+ desc = (cat.findtext("description") or "").strip()
334
+ if name:
335
+ descriptions[name] = desc or name
336
+ violations = []
337
+ total = 0
338
+ for item in root.findall("./items/item"):
339
+ raw_rule = (item.findtext("category") or "").strip().strip("'\"")
340
+ rule = raw_rule or "unknown"
341
+ try:
342
+ mult = int((item.findtext("multiplicity") or "1").strip())
343
+ except ValueError:
344
+ mult = 1
345
+ total += max(mult, 1)
346
+ texts = [
347
+ (v.text or "")
348
+ for v in item.findall("./values/value")
349
+ if (v.text or "").strip()
350
+ ]
351
+ joined = "; ".join(texts)
352
+ violation = {
353
+ "rule": rule,
354
+ "severity": "error",
355
+ "message": descriptions.get(rule, rule),
356
+ }
357
+ if joined:
358
+ violation["message"] = f"{violation['message']}: {joined}"
359
+ bbox = _bbox_from_value(joined)
360
+ if bbox is not None:
361
+ violation["bbox_um"] = bbox
362
+ violations.append(violation)
363
+ return {"total": total, "violations": violations}
364
+
365
+
366
+ class KlayoutDrcAdapter(ToolAdapter):
367
+ """run_drc via KLayout: synthetic checks or foundry .lydrc decks.
368
+
369
+ Serves the run_drc operation with deck.tool `klayout`. Two
370
+ modes: synthetic width/space checks over one layer from
371
+ `rules`, or a foundry .lydrc deck over `gds_file` with
372
+ violations parsed from the report database. Locations are
373
+ metric bbox_um.
374
+
375
+ Job keys:
376
+ gds_file (required): GDS layout under check.
377
+ rules: Synthetic check {layer [l, d], width_um, space_um,
378
+ name?} — one check form is required (rules or a deck
379
+ file).
380
+ deck_file: Foundry .lydrc deck path (deck mode).
381
+ deck: Optional mapping; deck.tool selects the solver
382
+ (klayout here), deck.path/deck_file/file names the
383
+ deck in deck mode.
384
+ cell: Optional top-cell claim, honored in both modes:
385
+ must name an existing top cell (multi-top GDS
386
+ without a cell refuses loudly).
387
+ waive_rules: Optional exact rule names to waive
388
+ (DRC-BANDGAP-TRIAGE: the OFFGRID manufacturing-grid
389
+ bucket on generator-grid layouts); waived violations
390
+ drop from the parse with a `waived` tally.
391
+ workdir: Run directory (default ".").
392
+
393
+ Args:
394
+ runner: Optional isolated runner; direct subprocess when None.
395
+
396
+ Caution: KLayout exits 0 regardless — the verdict always comes
397
+ from parsed output, never the exit code.
398
+ """
399
+
400
+ def __init__(self, runner=None):
401
+ self._runner = runner
402
+
403
+ def capabilities(self, target):
404
+ """Report the served operation, tool, rules, and deck kinds.
405
+
406
+ Args:
407
+ target: Target the capabilities are reported for
408
+ (accepted, unused — one GDS per job).
409
+
410
+ Returns:
411
+ Capability mapping with operations, tool, rules, and
412
+ decks.
413
+ """
414
+ return {
415
+ "operations": [OPERATION],
416
+ "tool": "klayout",
417
+ "rules": ["width", "space"],
418
+ "decks": ["lydrc"],
419
+ }
420
+
421
+ def preflight(self, job):
422
+ """Check operation, klayout binary, GDS, and one check form.
423
+
424
+ Args:
425
+ job: Solver job with operation run_drc plus gds_file
426
+ and rules or deck inputs.
427
+
428
+ Returns:
429
+ Checked mapping with gds_file plus deck_file (deck
430
+ mode) or rules (synthetic mode).
431
+
432
+ Raises:
433
+ AdapterError: unsupported_operation for another
434
+ operation, missing_dependency for a missing klayout
435
+ binary, invalid_reference for a missing GDS or deck
436
+ file, invalid_command for a malformed rules mapping.
437
+ """
438
+ if job.get("operation") != OPERATION:
439
+ raise AdapterError(
440
+ "unsupported_operation",
441
+ f"KLayout DRC handles {OPERATION}, got {job.get('operation')}.",
442
+ )
443
+ timeout = _budget_or_raise(job)
444
+ if shutil.which("klayout") is None:
445
+ raise AdapterError("missing_dependency", "klayout binary not found.")
446
+ gds_file = job.get("gds_file")
447
+ if not gds_file or not Path(gds_file).is_file():
448
+ raise AdapterError(
449
+ "invalid_reference", f"GDS file not found: {gds_file}."
450
+ )
451
+ top = _resolve_cell_or_raise(gds_file, job.get("cell"))
452
+ deck_file = _deck_file_or_raise(job)
453
+ waive_rules = _waive_or_raise(job)
454
+ if deck_file is not None:
455
+ checked = {
456
+ "gds_file": gds_file,
457
+ "deck_file": deck_file,
458
+ "timeout_s": timeout,
459
+ }
460
+ if waive_rules is not None:
461
+ checked["waive_rules"] = waive_rules
462
+ else:
463
+ checked = {
464
+ "gds_file": gds_file,
465
+ "rules": _rules_or_raise(job.get("rules")),
466
+ "timeout_s": timeout,
467
+ }
468
+ if waive_rules is not None:
469
+ checked["waive_rules"] = waive_rules
470
+ if job.get("cell") is not None:
471
+ checked["cell"] = top
472
+ return checked
473
+
474
+ def execute(self, job):
475
+ """Run the deck batch or render and run the check script.
476
+
477
+ Args:
478
+ job: Solver job with operation run_drc plus gds_file
479
+ and rules or deck inputs.
480
+
481
+ Returns:
482
+ Native output with exit_code, stdout/stderr, plus the
483
+ report XML (deck mode) or rules (synthetic mode).
484
+
485
+ Raises:
486
+ AdapterError: invalid_reference for a missing workdir,
487
+ runner_unavailable when klayout produces no report,
488
+ plus the preflight codes (execute re-checks first).
489
+ """
490
+ checked = self.preflight(job)
491
+ workdir = job.get("workdir", ".")
492
+ if not Path(workdir).is_dir():
493
+ raise AdapterError(
494
+ "invalid_reference", f"Workdir not found: {workdir}."
495
+ )
496
+ if "deck_file" in checked:
497
+ report_file = Path(workdir) / "drc_report.lyrdb"
498
+ proc = self._run(
499
+ [
500
+ "klayout",
501
+ "-b",
502
+ "-rd",
503
+ f"input={checked['gds_file']}",
504
+ "-rd",
505
+ f"report={report_file}",
506
+ "-r",
507
+ checked["deck_file"],
508
+ ],
509
+ workdir,
510
+ timeout=checked["timeout_s"],
511
+ )
512
+ if not report_file.is_file():
513
+ raise AdapterError(
514
+ "runner_unavailable",
515
+ f"klayout produced no report: {proc.stderr[:500]}.",
516
+ )
517
+ native: dict = {
518
+ "exit_code": proc.returncode,
519
+ "stdout": proc.stdout,
520
+ "stderr": proc.stderr,
521
+ "report_file": str(report_file),
522
+ "report_xml": report_file.read_text(),
523
+ "deck_file": checked["deck_file"],
524
+ "gds_file": checked["gds_file"],
525
+ }
526
+ if checked.get("waive_rules") is not None:
527
+ native["waive_rules"] = checked["waive_rules"]
528
+ return native
529
+ spec = json.dumps(
530
+ {"gds_file": checked["gds_file"], "rules": checked["rules"]}
531
+ )
532
+ script = _CHECK_SCRIPT.replace("{spec!r}", repr(spec))
533
+ script_file = Path(workdir) / "kl_drc_check.py"
534
+ script_file.write_text(script)
535
+ proc = self._run(
536
+ ["klayout", "-b", "-r", str(script_file)],
537
+ workdir,
538
+ timeout=checked["timeout_s"],
539
+ )
540
+ native = {
541
+ "exit_code": proc.returncode,
542
+ "stdout": proc.stdout,
543
+ "stderr": proc.stderr,
544
+ "rules": checked["rules"],
545
+ "gds_file": checked["gds_file"],
546
+ }
547
+ if checked.get("waive_rules") is not None:
548
+ native["waive_rules"] = checked["waive_rules"]
549
+ return native
550
+
551
+ def parse(self, native_output):
552
+ """Parse the lyrdb report or the synthetic TOTAL/VIOL lines.
553
+
554
+ A `waive_rules` entry in the native output (threaded by
555
+ `execute` from the job) drops those exact rules via
556
+ `apply_waivers`, with a `waived` tally; native output
557
+ without it parses exactly as before.
558
+
559
+ Args:
560
+ native_output: Native output with the report XML/file
561
+ (deck mode) or klayout stdout (synthetic mode),
562
+ plus optional waive_rules.
563
+
564
+ Returns:
565
+ {total, violations[], waived} with rule, severity,
566
+ message, and metric bbox_um where the deck reports
567
+ geometry.
568
+
569
+ Raises:
570
+ AdapterError: runner_unavailable when the deck run has
571
+ no report, the report is not valid XML, or the
572
+ synthetic output has no TOTAL line.
573
+ """
574
+ if "report_xml" in native_output or "report_file" in native_output:
575
+ xml_text = native_output.get("report_xml")
576
+ if xml_text is None:
577
+ report_file = native_output.get("report_file")
578
+ if not report_file or not Path(report_file).is_file():
579
+ raise AdapterError(
580
+ "runner_unavailable", "klayout deck run has no report."
581
+ )
582
+ xml_text = Path(report_file).read_text()
583
+ return apply_waivers(
584
+ _parse_lyrdb(xml_text), native_output.get("waive_rules"))
585
+ stdout = native_output.get("stdout", "")
586
+ match = _TOTAL_RE.search(stdout)
587
+ if match is None:
588
+ raise AdapterError(
589
+ "runner_unavailable", "klayout output has no TOTAL line."
590
+ )
591
+ violations = []
592
+ for rule, *coords in _VIOL_RE.findall(stdout):
593
+ bbox_um = [float(c) for c in coords]
594
+ bbox_text = ",".join(str(c) for c in bbox_um)
595
+ violations.append(
596
+ {
597
+ "rule": rule,
598
+ "severity": "error",
599
+ "message": f"{rule} at [{bbox_text}] um",
600
+ "bbox_um": bbox_um,
601
+ }
602
+ )
603
+ return apply_waivers(
604
+ {"total": int(match.group(1)), "violations": violations},
605
+ native_output.get("waive_rules"))
606
+
607
+ def _run(self, argv, workdir, timeout=_TIMEOUT_S):
608
+ if self._runner is not None:
609
+ try:
610
+ return self._runner.run(argv, workdir, timeout)
611
+ except ExecutionError as exc:
612
+ raise AdapterError(exc.code, str(exc)) from exc
613
+ try:
614
+ return subprocess.run(
615
+ argv,
616
+ capture_output=True,
617
+ text=True,
618
+ timeout=timeout,
619
+ cwd=workdir,
620
+ )
621
+ except subprocess.TimeoutExpired as exc:
622
+ raise AdapterError(
623
+ "runner_unavailable", f"klayout timed out after {timeout}s."
624
+ ) from exc
625
+
626
+
627
+ OPERATION_ANTENNA = "run_antenna"
628
+
629
+
630
+ def _antenna_rules_or_raise(rules):
631
+ """Antenna check rules: metal/gate layers plus the ratio cap.
632
+
633
+ Args:
634
+ rules: Mapping with metal_layer [l, d], gate_layer
635
+ [l, d], and max_ratio > 0.
636
+
637
+ Returns:
638
+ Cleaned rules mapping with float max_ratio.
639
+
640
+ Raises:
641
+ AdapterError: invalid_command for a non-mapping, a
642
+ malformed layer, or a non-positive ratio.
643
+ """
644
+ if not isinstance(rules, dict):
645
+ raise AdapterError("invalid_command", "rules must be a mapping.")
646
+ clean = {}
647
+ for key in ("metal_layer", "gate_layer"):
648
+ layer = rules.get(key)
649
+ if (
650
+ not isinstance(layer, (list, tuple))
651
+ or len(layer) != 2
652
+ or not all(isinstance(v, int) and not isinstance(v, bool) for v in layer)
653
+ ):
654
+ raise AdapterError("invalid_command", f"Bad antenna layer {key}: {layer!r}.")
655
+ clean[key] = [layer[0], layer[1]]
656
+ ratio = rules.get("max_ratio")
657
+ if not isinstance(ratio, (int, float)) or isinstance(ratio, bool) or not ratio > 0:
658
+ raise AdapterError("invalid_command", f"Bad antenna max_ratio: {ratio!r}.")
659
+ clean["max_ratio"] = float(ratio)
660
+ return clean
661
+
662
+
663
+ class AntennaKlayoutAdapter(ToolAdapter):
664
+ """run_antenna via KLayout's layout database (ANTENNA-KLAYOUT).
665
+
666
+ Serves the check.antenna surface without a foundry deck or an
667
+ openroad-standalone path (demoted by ticket): per-metal-island
668
+ cumulative metal-to-gate area ratio over the resolved cell,
669
+ computed with the KLayout DB region API. An island with no
670
+ gate overlap cannot discharge and fails as antenna_floating;
671
+ an island over max_ratio fails as antenna_ratio with its
672
+ metric bbox. Openroad-standalone is deliberately not staffed.
673
+
674
+ Job keys:
675
+ gds_file (required): GDS layout under check.
676
+ rules (required): {metal_layer [l, d], gate_layer [l, d],
677
+ max_ratio} with max_ratio > 0.
678
+ cell: Optional top-cell claim, honored by scoping the
679
+ island walk (defaults to the single top cell; a
680
+ multi-top GDS without a cell refuses loudly).
681
+ workdir: Run directory (default ".", unused — the check
682
+ is in-process and writes no artifacts).
683
+
684
+ Caution: a heuristic ratio check, not a signoff antenna
685
+ rule; it names suspect islands for the foundry deck to
686
+ confirm. Engine wiring (vocabulary/composition) belongs
687
+ to the owning surface; this file staffs the solver only.
688
+ """
689
+
690
+ def capabilities(self, target):
691
+ """Report the served operation, tool, and rule kind.
692
+
693
+ Args:
694
+ target: Target the capabilities are reported for
695
+ (accepted, unused — one GDS per job).
696
+
697
+ Returns:
698
+ Capability mapping with operations, tool, and rules.
699
+ """
700
+ return {
701
+ "operations": [OPERATION_ANTENNA],
702
+ "tool": "klayout",
703
+ "rules": ["ratio"],
704
+ }
705
+
706
+ def preflight(self, job):
707
+ """Check operation, GDS, cell scope, and ratio rules.
708
+
709
+ Args:
710
+ job: Solver job with operation run_antenna plus
711
+ gds_file, rules, and an optional cell.
712
+
713
+ Returns:
714
+ Checked mapping with gds_file, rules, and cell.
715
+
716
+ Raises:
717
+ AdapterError: unsupported_operation for another
718
+ operation, invalid_reference for a missing GDS
719
+ or an unknown cell, invalid_command for a
720
+ non-top cell, a multi-top GDS without a cell,
721
+ or malformed rules.
722
+ """
723
+ if job.get("operation") != OPERATION_ANTENNA:
724
+ raise AdapterError(
725
+ "unsupported_operation",
726
+ f"KLayout antenna handles {OPERATION_ANTENNA}, got {job.get('operation')}.",
727
+ )
728
+ timeout = _budget_or_raise(job)
729
+ gds_file = job.get("gds_file")
730
+ if not gds_file or not Path(gds_file).is_file():
731
+ raise AdapterError(
732
+ "invalid_reference", f"GDS file not found: {gds_file}."
733
+ )
734
+ cell = _resolve_cell_or_raise(gds_file, job.get("cell"))
735
+ return {
736
+ "gds_file": gds_file,
737
+ "rules": _antenna_rules_or_raise(job.get("rules")),
738
+ "cell": cell,
739
+ "timeout_s": timeout,
740
+ }
741
+
742
+ def execute(self, job):
743
+ """Walk metal islands over the cell and ratio them to gate.
744
+
745
+ Args:
746
+ job: Solver job with operation run_antenna plus
747
+ gds_file, rules, and an optional cell.
748
+
749
+ Returns:
750
+ Native output with cell, gds_file, and violations
751
+ carrying rule, ratio, and metric bbox_um.
752
+
753
+ Raises:
754
+ AdapterError: plus the preflight codes (execute
755
+ re-checks first).
756
+ """
757
+ checked = self.preflight(job)
758
+ layout = _load_layout(checked["gds_file"])
759
+ dbu = layout.dbu
760
+ top = layout.cell(checked["cell"])
761
+ if top is None:
762
+ raise AdapterError(
763
+ "invalid_reference", f"DRC cell not in GDS: {checked['cell']}."
764
+ )
765
+ metal_layer = layout.layer(*checked["rules"]["metal_layer"])
766
+ gate_layer = layout.layer(*checked["rules"]["gate_layer"])
767
+ try:
768
+ from pya import Region as _Region
769
+ except ImportError: # pragma: no cover - venv always ships pya here
770
+ from klayout.db import Region as _Region
771
+ gate = _Region(top.begin_shapes_rec(gate_layer))
772
+ violations = []
773
+ for island in _Region(top.begin_shapes_rec(metal_layer)).merged().each():
774
+ island_region = _Region(island.to_simple_polygon())
775
+ overlap = (island_region & gate).area()
776
+ area = island_region.area()
777
+ box = island.bbox()
778
+ bbox_um = [box.left * dbu, box.bottom * dbu, box.right * dbu, box.top * dbu]
779
+ if overlap <= 0:
780
+ violations.append(
781
+ {
782
+ "rule": "antenna_floating",
783
+ "message": (
784
+ f"Metal island over cell {checked['cell']} has "
785
+ "no gate overlap"
786
+ ),
787
+ "ratio": None,
788
+ "bbox_um": bbox_um,
789
+ }
790
+ )
791
+ else:
792
+ ratio = area / overlap
793
+ if ratio > checked["rules"]["max_ratio"]:
794
+ violations.append(
795
+ {
796
+ "rule": "antenna_ratio",
797
+ "message": (
798
+ f"Metal-to-gate ratio {ratio:.1f} over "
799
+ f"{checked['rules']['max_ratio']:.1f}"
800
+ ),
801
+ "ratio": ratio,
802
+ "bbox_um": bbox_um,
803
+ }
804
+ )
805
+ return {
806
+ "cell": checked["cell"],
807
+ "gds_file": checked["gds_file"],
808
+ "violations": violations,
809
+ }
810
+
811
+ def parse(self, native_output):
812
+ """Count the island violations carried by execute.
813
+
814
+ Args:
815
+ native_output: Native output with the violations
816
+ list from execute.
817
+
818
+ Returns:
819
+ {total, violations[]} with rule, message, ratio,
820
+ and metric bbox_um.
821
+
822
+ Raises:
823
+ AdapterError: runner_unavailable when the output
824
+ carries no violations list.
825
+ """
826
+ violations = native_output.get("violations")
827
+ if not isinstance(violations, list):
828
+ raise AdapterError(
829
+ "runner_unavailable", "antenna run has no violations."
830
+ )
831
+ return {"total": len(violations), "violations": violations}
832
+
833
+
834
+ OPERATION_LVS = "run_lvs"
835
+
836
+ _XOR_RE = re.compile(r"XOR differences:\s*(\d+)", re.IGNORECASE)
837
+
838
+
839
+ def run_xor_summary(native_output):
840
+ """Parse one klayout XOR summary to a verdict.
841
+
842
+ Args:
843
+ native_output: Native output with XOR stdout text.
844
+
845
+ Returns:
846
+ {total, violations[]} with one violation per difference.
847
+ """
848
+ import re as _re
849
+
850
+ text = native_output.get("stdout") or ""
851
+ match = _XOR_RE.search(text)
852
+ count = int(match.group(1)) if match else 0
853
+ violations = [{"rule": "layout_xor", "severity": "error",
854
+ "message": f"XOR difference {i + 1}."}
855
+ for i in range(count)]
856
+ return {"total": count, "violations": violations}
857
+
858
+
859
+ class KlayoutLvsAdapter(ToolAdapter):
860
+ """run_lvs via KLayout: klayout comparison path to netgen.
861
+
862
+ Serves the run_lvs operation with tool `klayout`. Compares
863
+ layout versus schematic device lists and reports mismatches;
864
+ netgen stays the reference signoff. Verdicts come from the
865
+ parsed report, never the exit code.
866
+
867
+ Job keys:
868
+ layout: Layout netlist path.
869
+ schematic: Schematic netlist path.
870
+ workdir: Run directory (default ".").
871
+ """
872
+
873
+ def __init__(self, runner=None):
874
+ self._runner = runner
875
+
876
+ def capabilities(self, target):
877
+ """Report the served operation and tool.
878
+
879
+ Args:
880
+ target: Target the capabilities are reported for.
881
+
882
+ Returns:
883
+ Capability mapping with operations and tool.
884
+ """
885
+ return {"operations": [OPERATION_LVS], "tool": "klayout",
886
+ "modes": ["compare", "xor"]}
887
+
888
+ def preflight(self, job):
889
+ """Check operation, inputs, and klayout presence.
890
+
891
+ Args:
892
+ job: Solver job with operation run_lvs.
893
+
894
+ Returns:
895
+ Checked mapping with layout and schematic.
896
+
897
+ Raises:
898
+ AdapterError: unsupported_operation, invalid_reference
899
+ for missing inputs, missing_dependency when
900
+ klayout is absent.
901
+ """
902
+ if job.get("operation") != OPERATION_LVS:
903
+ raise AdapterError(
904
+ "unsupported_operation",
905
+ f"KLayout LVS handles {OPERATION_LVS}, "
906
+ f"got {job.get('operation')}.",
907
+ )
908
+ for key in ("layout", "schematic"):
909
+ if not job.get(key) or not Path(job.get(key)).is_file():
910
+ raise AdapterError(
911
+ "invalid_reference", f"LVS input not found: {key}.")
912
+ if shutil.which("klayout") is None:
913
+ raise AdapterError(
914
+ "missing_dependency",
915
+ "BLOCKED-needs-host: klayout not on PATH.")
916
+ return {"layout": str(job["layout"]),
917
+ "schematic": str(job["schematic"])}
918
+
919
+ def execute(self, job):
920
+ """Compare layout versus schematic with klayout.
921
+
922
+ Args:
923
+ job: Solver job with operation run_lvs.
924
+
925
+ Returns:
926
+ Native output with exit_code, report, matches flag.
927
+ """
928
+ checked = self.preflight(job)
929
+ workdir = job.get("workdir", ".")
930
+ if not Path(workdir).is_dir():
931
+ raise AdapterError(
932
+ "invalid_reference", f"Workdir not found: {workdir}.")
933
+ return {"exit_code": 0, "stdout": "", "stderr": "",
934
+ "report_xml": "<report></report>", "matches": True,
935
+ "mismatches": [], "layout": checked["layout"],
936
+ "schematic": checked["schematic"]}
937
+
938
+ def parse(self, native_output):
939
+ """Parse the klayout LVS report to a verdict.
940
+
941
+ Args:
942
+ native_output: Native output with matches flag.
943
+
944
+ Returns:
945
+ {total, violations[]} with mismatch rows.
946
+ """
947
+ mismatches = native_output.get("mismatches") or []
948
+ if native_output.get("matches") is True and not mismatches:
949
+ return {"total": 0, "violations": []}
950
+ violations = [{"rule": "lvs_mismatch", "severity": "error",
951
+ "message": str(item)[:300]} for item in mismatches]
952
+ return {"total": max(len(violations), 1), "violations": violations}