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,889 @@
1
+ """Netgen LVS solver adapter (EXTRACT-SKY130): layout vs schematic.
2
+
3
+ Protocol, verified against netgen 1.5.316 + sky130A_setup.tcl: the
4
+ one-shot `netgen -batch lvs <layout> <schematic> <setup> <report>`
5
+ compares TOP-level circuits, so both sides are first flattened to
6
+ top level (leaf-cell normalization: the single `.subckt` wrapper is
7
+ dropped; a library file needs `cell` to select its block). Netgen
8
+ exits 0 on match and mismatch alike, so the verdict always comes
9
+ from parsed output, never the exit code. Batch runs with DISPLAY
10
+ scrubbed: netgen's Tk init fails against an unreachable display.
11
+ Because netgen matches by topology, a rename of a singly-connected
12
+ net escapes it (LVS-ESCAPE): the flattened net-name inventories are
13
+ compared too, and name-only-on-one-side nets fail as
14
+ `lvs_net_mismatch`.
15
+ """
16
+
17
+ import os
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from ...execution.errors import ExecutionError
24
+ from ...interfaces import ToolAdapter
25
+ from ...interfaces.runparams import MAX_TIMEOUT_S, MIN_TIMEOUT_S
26
+ from ..errors import AdapterError
27
+
28
+ OPERATION = "run_lvs"
29
+ _TIMEOUT_S = 120
30
+ _IDENTIFY_TIMEOUT_S = 10
31
+
32
+
33
+ def _budget_or_raise(job):
34
+ """Job solver budget: validated, or the adapter default.
35
+
36
+ Args:
37
+ job: Solver job, maybe with a `timeout_s`.
38
+
39
+ Returns:
40
+ Budget seconds for every `_run` call.
41
+
42
+ Raises:
43
+ AdapterError: invalid_command for a boolean,
44
+ non-number, or out-of-bounds budget.
45
+ """
46
+ timeout = job.get("timeout_s", _TIMEOUT_S)
47
+ if (
48
+ isinstance(timeout, bool)
49
+ or not isinstance(timeout, (int, float))
50
+ or not MIN_TIMEOUT_S <= timeout <= MAX_TIMEOUT_S
51
+ ):
52
+ raise AdapterError(
53
+ "invalid_command", f"Bad timeout_s: {timeout!r}."
54
+ )
55
+ return timeout
56
+
57
+ # Observed impostor markers (Oct 2026): the `netgen` apt package is
58
+ # the Schoeberl mesh generator — same name, different tool. Denylist
59
+ # only: NGSPICE-LIVE promotes this to a strict allowlist once the
60
+ # real LVS banner is verifiable against an installed binary.
61
+ _IMPOSTOR_RE = re.compile(
62
+ r"Schoeberl|Vienna University|RWTH Aachen|Johannes Kepler"
63
+ )
64
+
65
+ _SUBCKT_RE = re.compile(r"^\s*\.subckt\s+(\S+)", re.IGNORECASE)
66
+ _ENDS_RE = re.compile(r"^\s*\.ends\b", re.IGNORECASE)
67
+ _DEVICE_RE = re.compile(
68
+ r"^X(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(.*)$",
69
+ re.IGNORECASE,
70
+ )
71
+ _MATCH_RE = re.compile(r"(Circuits match uniquely|Netlists match uniquely)\.")
72
+ _MISMATCH_RES = (
73
+ re.compile(r"Netlists do not match\."),
74
+ re.compile(r"\*\*\* MISMATCH \*\*\*"),
75
+ )
76
+ _NET_RE = re.compile(r"Net:\s+(\S+)")
77
+ _INSTANCE_RE = re.compile(r"Instance:\s+(\S+)")
78
+
79
+
80
+ def slice_subckt(text, cell):
81
+ """Return the wrapped `.subckt cell ... .ends` block from a library.
82
+
83
+ Args:
84
+ text: Netlist library text to slice.
85
+ cell: Subcircuit name to select (case-insensitive).
86
+
87
+ Returns:
88
+ The wrapped .subckt block with its closing .ends.
89
+
90
+ Raises:
91
+ AdapterError: invalid_command when the cell is absent or
92
+ has no closing .ends.
93
+ """
94
+ lines = text.splitlines()
95
+ start = next(
96
+ (
97
+ i
98
+ for i, line in enumerate(lines)
99
+ if (m := _SUBCKT_RE.match(line)) and m.group(1).lower() == cell.lower()
100
+ ),
101
+ None,
102
+ )
103
+ if start is None:
104
+ raise AdapterError(
105
+ "invalid_command", f"No .subckt {cell} in the netlist."
106
+ )
107
+ end = next(
108
+ (i for i in range(start + 1, len(lines)) if _ENDS_RE.match(lines[i])),
109
+ None,
110
+ )
111
+ if end is None:
112
+ raise AdapterError(
113
+ "invalid_command", f".subckt {cell} has no closing .ends."
114
+ )
115
+ return "\n".join(lines[start : end + 1]) + "\n"
116
+
117
+
118
+ def flatten_spice(text, cell=None):
119
+ """Normalize a netlist to top level for netgen's one-shot lvs.
120
+
121
+ A bare netlist passes through; a single `.subckt` wrapper is
122
+ dropped; a library needs `cell` to select its block, else the
123
+ call refuses instead of guessing.
124
+
125
+ Args:
126
+ text: Netlist text to normalize.
127
+ cell: Subcircuit to select from a library, or None for a
128
+ bare or single-wrapper netlist.
129
+
130
+ Returns:
131
+ Top-level netlist text with .subckt/.ends lines dropped.
132
+
133
+ Raises:
134
+ AdapterError: invalid_command for an ambiguous multi-block
135
+ library without cell, or a missing/unclosed block.
136
+ """
137
+ if cell is not None:
138
+ text = slice_subckt(text, cell)
139
+ elif sum(1 for line in text.splitlines() if _SUBCKT_RE.match(line)) > 1:
140
+ raise AdapterError(
141
+ "invalid_command", "Ambiguous netlist: pass cell to select a block."
142
+ )
143
+ return "\n".join(
144
+ line
145
+ for line in text.splitlines()
146
+ if not _SUBCKT_RE.match(line) and not _ENDS_RE.match(line)
147
+ ) + "\n"
148
+
149
+
150
+ def _device_nets(tokens):
151
+ """Net names of one X/M device line's token body.
152
+
153
+ Args:
154
+ tokens: Bare tokens after the device name (nets, class,
155
+ then `k=v` params).
156
+
157
+ Returns:
158
+ Net names: every bare token except the trailing class.
159
+ """
160
+ params_at = next(
161
+ (i for i, token in enumerate(tokens) if "=" in token), len(tokens)
162
+ )
163
+ bare = tokens[:params_at]
164
+ if params_at < len(tokens):
165
+ bare = bare[:-1] if bare else []
166
+ return bare
167
+
168
+
169
+ def inventory_nets(text):
170
+ """Net names of the flattened netlist's X and M device lines.
171
+
172
+ Netgen's one-shot `lvs` matches by topology, so a rename of a
173
+ singly-connected net escapes it (LVS-ESCAPE). This inventory
174
+ keeps the dropped name evidence: comment, subckt/ends, empty,
175
+ and non-device lines never contribute; params (`k=v`) never
176
+ count as nets; the class token is the last bare token before
177
+ the params start. M-lines cover the klt/reference format both
178
+ bandgap sides use; X-lines cover ext2spice output.
179
+
180
+ Args:
181
+ text: Flattened netlist text to inventory.
182
+
183
+ Returns:
184
+ Set of net names on X and M device lines.
185
+ """
186
+ nets = set()
187
+ for line in text.splitlines():
188
+ stripped = line.strip()
189
+ if not stripped or stripped.startswith("*") or stripped.startswith("."):
190
+ continue
191
+ tokens = stripped.split()
192
+ if not tokens or not tokens[0][:1].upper() in ("X", "M"):
193
+ continue
194
+ nets.update(_device_nets(tokens[1:]))
195
+ return nets
196
+
197
+
198
+ def _split_subckts(text):
199
+ """Subckt blocks of a netlist in file order.
200
+
201
+ Args:
202
+ text: Netlist text, possibly hierarchical.
203
+
204
+ Returns:
205
+ List of (name, ports, body lines) per `.subckt` block;
206
+ lines outside any block are dropped.
207
+ """
208
+ blocks = []
209
+ current = None
210
+ for line in text.splitlines():
211
+ match = _SUBCKT_RE.match(line)
212
+ if match is not None:
213
+ parts = line.strip().split()
214
+ current = [parts[1], parts[2:], []]
215
+ blocks.append(current)
216
+ continue
217
+ if _ENDS_RE.match(line):
218
+ current = None
219
+ continue
220
+ if current is not None:
221
+ current[2].append(line)
222
+ return [(name, ports, body) for name, ports, body in blocks]
223
+
224
+
225
+ def _instance_class(line):
226
+ """Class token of an X instance line, or None.
227
+
228
+ Args:
229
+ line: One netlist line (already stripped).
230
+
231
+ Returns:
232
+ Last bare token before params start, or None when the
233
+ line is not an X instance line.
234
+ """
235
+ tokens = line.split()
236
+ if not tokens or not tokens[0].upper().startswith("X"):
237
+ return None
238
+ body = tokens[1:]
239
+ params_at = next(
240
+ (i for i, token in enumerate(body) if "=" in token), len(body)
241
+ )
242
+ bare = body[:params_at]
243
+ if params_at < len(body):
244
+ bare = bare[:-1] if bare else []
245
+ if not bare:
246
+ return None
247
+ return bare[-1]
248
+
249
+
250
+ def flatten_hierarchy(text, top=None):
251
+ """Inline hierarchical subckt instances to flat top level.
252
+
253
+ Magic ext2spice writes one `.subckt` per layout cell, but
254
+ netgen's one-shot LVS compares TOP-level circuits, so the
255
+ engine-extracted bandgap netlist needs inlining past the
256
+ single-wrapper strip of `flatten_spice`. Leaf instances whose
257
+ class names a known block expand with port nets mapped onto
258
+ the instance nets and private nets qualified as
259
+ `INST_net`; X/M device cards map the same way. Cards that are
260
+ neither X nor M device lines pass through unmapped
261
+ (documented boundary: engine-extracted leaves are FET-only).
262
+
263
+ Args:
264
+ text: Hierarchical netlist text to flatten.
265
+ top: Subckt to flatten from, or None to take the single
266
+ never-instantiated (root) block.
267
+
268
+ Returns:
269
+ Flat netlist text with no `.subckt`/`.ends` lines.
270
+ Texts without any block pass through unchanged.
271
+
272
+ Raises:
273
+ AdapterError: invalid_command for a missing top, or
274
+ for zero or several roots without top.
275
+ """
276
+ blocks = {
277
+ name.lower(): (name, ports, body)
278
+ for name, ports, body in _split_subckts(text)
279
+ }
280
+ if not blocks:
281
+ return text if text.endswith("\n") else text + "\n"
282
+ referenced = set()
283
+ for _, _, body in blocks.values():
284
+ for line in body:
285
+ cls = _instance_class(line.strip())
286
+ if cls is not None and cls.lower() in blocks:
287
+ referenced.add(cls.lower())
288
+ if top is not None:
289
+ root_key = top.lower()
290
+ if root_key not in blocks:
291
+ raise AdapterError(
292
+ "invalid_command", f"No .subckt {top} in the netlist."
293
+ )
294
+ else:
295
+ roots = [key for key in blocks if key not in referenced]
296
+ if len(roots) != 1:
297
+ raise AdapterError(
298
+ "invalid_command",
299
+ "Ambiguous hierarchy: pass top to select the root block.",
300
+ )
301
+ root_key = roots[0]
302
+
303
+ def emit(block_key, scope, prefix, out):
304
+ """Inline one block: ports map via scope, privates qualify."""
305
+ _, ports, body = blocks[block_key]
306
+ for line in body:
307
+ stripped = line.strip()
308
+ cls = _instance_class(stripped)
309
+ if cls is not None and cls.lower() in blocks:
310
+ tokens = stripped.split()
311
+ inst = tokens[0]
312
+ nets = _device_nets(tokens[1:])
313
+ _, sub_ports, _ = blocks[cls.lower()]
314
+ child_scope = {}
315
+ for port, net in zip(sub_ports, nets):
316
+ child_scope[port] = scope.get(net, net)
317
+ child_prefix = f"{prefix}_{inst}" if prefix else inst
318
+ emit(cls.lower(), child_scope, child_prefix, out)
319
+ continue
320
+ mapped = _map_device_line(stripped, scope, prefix)
321
+ if mapped is None:
322
+ out.append(line)
323
+ continue
324
+ if prefix:
325
+ head, _, rest = mapped.partition(" ")
326
+ mapped = f"{prefix}_{head} {rest}"
327
+ out.append(mapped)
328
+
329
+ _, root_ports, _ = blocks[root_key]
330
+ out = []
331
+ emit(root_key, {port: port for port in root_ports}, "", out)
332
+ return "\n".join(out) + "\n"
333
+
334
+
335
+ def _map_device_line(stripped, scope, prefix):
336
+ """One device line with block-local nets mapped to flat names.
337
+
338
+ Args:
339
+ stripped: One stripped netlist line.
340
+ scope: Local net to flat name mapping (ports bound).
341
+ prefix: Instance qualifier for private nets ("" at root).
342
+
343
+ Returns:
344
+ Remapped X/M device line, or None for other cards.
345
+ """
346
+ tokens = stripped.split()
347
+ if not tokens or tokens[0][:1].upper() not in ("X", "M"):
348
+ return None
349
+ body = tokens[1:]
350
+ params_at = next(
351
+ (i for i, token in enumerate(body) if "=" in token), len(body)
352
+ )
353
+ bare = body[:params_at]
354
+ if not bare:
355
+ return None
356
+ cls = bare[-1] if params_at < len(body) else None
357
+ nets = bare[:-1] if cls is not None else bare
358
+ flat_nets = [scope.get(net, f"{prefix}_{net}" if prefix else net) for net in nets]
359
+ tail = body[params_at:]
360
+ parts = [tokens[0]] + flat_nets
361
+ if cls is not None:
362
+ parts.append(cls)
363
+ parts.extend(tail)
364
+ return " ".join(parts)
365
+
366
+
367
+ def retype_by_bulk(text, n_bulk_nets, p_bulk_nets):
368
+ """Re-type sky130 X-FET lines by their bulk terminal.
369
+
370
+ Wave-1 bulk-grouping evidence (docs/probe-extract-gds.md):
371
+ magic's uniform-pfet typing is wrong where the bulk says
372
+ otherwise — 52 devices share one common (p) bulk mirroring the
373
+ klt pfet split, the rest bulk to VSS. The bulk net
374
+ adjudicates: a pfet-family line bulked to an n-family net
375
+ re-types to the nfet flavor with its suffix kept
376
+ (`pfet_01v8_hvt` -> `nfet_01v8_hvt`), and vice versa. Bulk
377
+ nets outside both families never re-type (conservative).
378
+
379
+ Args:
380
+ text: Flat netlist text to adjudicate.
381
+ n_bulk_nets: Net names treated as n-family bulk (VSS...).
382
+ p_bulk_nets: Net names treated as p-family bulk (VDD...).
383
+
384
+ Returns:
385
+ (New text, number of re-typed lines).
386
+ """
387
+ n_bulk = {net for net in n_bulk_nets}
388
+ p_bulk = {net for net in p_bulk_nets}
389
+ count = 0
390
+ out = []
391
+ for line in text.splitlines():
392
+ stripped = line.strip()
393
+ match = _DEVICE_RE.match(stripped)
394
+ if match is None:
395
+ out.append(line)
396
+ continue
397
+ name, drain, gate, source, bulk, cls, params = match.groups()
398
+ lowered = cls.lower()
399
+ if "pfet" in lowered and bulk in n_bulk:
400
+ cls = cls.replace("pfet", "nfet").replace("PFET", "NFET")
401
+ count += 1
402
+ elif "nfet" in lowered and bulk in p_bulk:
403
+ cls = cls.replace("nfet", "pfet").replace("NFET", "PFET")
404
+ count += 1
405
+ out.append(f"X{name} {drain} {gate} {source} {bulk} {cls}{params}")
406
+ return "\n".join(out) + "\n", count
407
+
408
+
409
+ def _inventory_violations(layout_nets, schematic_nets):
410
+ """Name-only-on-one-side nets as `lvs_net_mismatch` violations.
411
+
412
+ Args:
413
+ layout_nets: Net names on the layout side.
414
+ schematic_nets: Net names on the schematic side.
415
+
416
+ Returns:
417
+ Violations naming each asymmetric net with its side.
418
+ """
419
+ violations = []
420
+ for net in sorted(set(layout_nets) - set(schematic_nets)):
421
+ violations.append(
422
+ {
423
+ "rule": "lvs_net_mismatch",
424
+ "message": f"Net: {net} (only in layout)",
425
+ "net": net,
426
+ "severity": "error",
427
+ }
428
+ )
429
+ for net in sorted(set(schematic_nets) - set(layout_nets)):
430
+ violations.append(
431
+ {
432
+ "rule": "lvs_net_mismatch",
433
+ "message": f"Net: {net} (only in schematic)",
434
+ "net": net,
435
+ "severity": "error",
436
+ }
437
+ )
438
+ return violations
439
+
440
+
441
+ def netgen_env():
442
+ """Process env for netgen: DISPLAY scrubbed, the rest intact.
443
+
444
+ Returns:
445
+ Process environment mapping without DISPLAY (netgen's Tk
446
+ init fails against an unreachable display).
447
+ """
448
+ env = dict(os.environ)
449
+ env.pop("DISPLAY", None)
450
+ return env
451
+
452
+
453
+ def _identify_netgen(path):
454
+ """Banner check: None if usable, else the rejection cause."""
455
+ try:
456
+ proc = subprocess.run(
457
+ [path, "-help"],
458
+ stdin=subprocess.DEVNULL,
459
+ capture_output=True,
460
+ text=True,
461
+ timeout=_IDENTIFY_TIMEOUT_S,
462
+ )
463
+ except (OSError, subprocess.TimeoutExpired):
464
+ return (
465
+ f"netgen at {path} is unprovable: the identity probe "
466
+ "failed (see docs/tickets/NGSPICE-LIVE.md)."
467
+ )
468
+ banner = (proc.stdout or "") + (proc.stderr or "")
469
+ if _IMPOSTOR_RE.search(banner):
470
+ first = banner.strip().splitlines()[0] if banner.strip() else "empty banner"
471
+ return (
472
+ f"netgen at {path} is the Schoeberl mesh-generator "
473
+ f"impostor, not the LVS netgen ({first}); install "
474
+ "opencircuitdesign netgen (see docs/tickets/NGSPICE-LIVE.md)."
475
+ )
476
+ return None
477
+
478
+
479
+ def resolve_netgen():
480
+ """Path to a usable LVS netgen, or None.
481
+
482
+ PATH presence is NOT enough: the `netgen` apt package is the
483
+ Schoeberl mesh generator, a same-named different tool (caught
484
+ live Oct 2026). Denylist: only observed impostor markers
485
+ reject; an unknown banner admits — NGSPICE-LIVE promotes this
486
+ to a strict allowlist once the real binary is verifiable.
487
+
488
+ Returns:
489
+ Path to a usable LVS netgen, or None when missing or
490
+ rejected by the banner probe.
491
+ """
492
+ found = shutil.which("netgen")
493
+ if found is None:
494
+ return None
495
+ if _identify_netgen(found) is not None:
496
+ return None
497
+ return found
498
+
499
+
500
+ def _count_subckts(text):
501
+ """Number of `.subckt` blocks in netlist text.
502
+
503
+ Args:
504
+ text: Netlist text to scan.
505
+
506
+ Returns:
507
+ Count of `.subckt` header lines (case-insensitive).
508
+ """
509
+ return sum(1 for line in text.splitlines() if _SUBCKT_RE.match(line))
510
+
511
+
512
+ def _prepare_flat(text, cell):
513
+ """Flatten one netlist side to top level for the one-shot LVS.
514
+
515
+ A named cell slices a library; a single wrapper (or none)
516
+ strips as before; several blocks without a cell inline the
517
+ hierarchy from its root instead of refusing.
518
+
519
+ Args:
520
+ text: Netlist text to flatten.
521
+ cell: Subcircuit to select, or None.
522
+
523
+ Returns:
524
+ Flat top-level netlist text.
525
+ """
526
+ if cell is not None:
527
+ if _count_subckts(text) > 1:
528
+ return flatten_hierarchy(text, top=cell)
529
+ return flatten_spice(text, cell)
530
+ if _count_subckts(text) > 1:
531
+ return flatten_hierarchy(text)
532
+ return flatten_spice(text)
533
+
534
+
535
+ def _bulk_or_raise(job):
536
+ """Bulk-net families for p/n adjudication, or None.
537
+
538
+ Args:
539
+ job: Solver job, maybe with a `bulk_nets` mapping of
540
+ `n`/`p` net-name lists.
541
+
542
+ Returns:
543
+ (n_bulk, p_bulk) frozensets, or None when the job names
544
+ no families (retype skipped, old behavior kept).
545
+
546
+ Raises:
547
+ AdapterError: invalid_command for a malformed mapping.
548
+ """
549
+ bulk = job.get("bulk_nets")
550
+ if bulk is None:
551
+ return None
552
+ if not isinstance(bulk, dict):
553
+ raise AdapterError(
554
+ "invalid_command", f"Bad bulk_nets (not a mapping): {bulk!r}."
555
+ )
556
+ clean = {}
557
+ for family in ("n", "p"):
558
+ nets = bulk.get(family, [])
559
+ if not isinstance(nets, (list, tuple)) or not all(
560
+ isinstance(net, str) and net for net in nets
561
+ ):
562
+ raise AdapterError(
563
+ "invalid_command", f"Bad bulk_nets[{family!r}]: {nets!r}."
564
+ )
565
+ clean[family] = frozenset(nets)
566
+ return clean
567
+
568
+
569
+ def _setup_file_or_raise(job):
570
+ deck = job.get("deck") if isinstance(job.get("deck"), dict) else {}
571
+ for candidate in (
572
+ job.get("setup_file"),
573
+ deck.get("path"),
574
+ deck.get("setup_file"),
575
+ deck.get("setup"),
576
+ deck.get("file"),
577
+ ):
578
+ if candidate is not None:
579
+ if not candidate or not Path(candidate).is_file():
580
+ raise AdapterError(
581
+ "invalid_reference", f"Netgen setup not found: {candidate}."
582
+ )
583
+ return str(candidate)
584
+ raise AdapterError("invalid_command", "LVS needs a netgen setup file.")
585
+
586
+
587
+ def _parse_mismatches(report):
588
+ violations = []
589
+ net_section, _, device_section = report.partition("DEVICE mismatches:")
590
+ seen = set()
591
+ for name in _NET_RE.findall(net_section):
592
+ if name not in seen:
593
+ seen.add(name)
594
+ violations.append(
595
+ {
596
+ "rule": "lvs_net_mismatch",
597
+ "message": f"Net: {name}",
598
+ "net": name,
599
+ "severity": "error",
600
+ }
601
+ )
602
+ for _ in re.finditer(r"\(no matching net\)", net_section):
603
+ violations.append(
604
+ {
605
+ "rule": "lvs_net_mismatch",
606
+ "message": "(no matching net)",
607
+ "severity": "error",
608
+ }
609
+ )
610
+ seen = set()
611
+ for name in _INSTANCE_RE.findall(device_section):
612
+ if name not in seen:
613
+ seen.add(name)
614
+ violations.append(
615
+ {
616
+ "rule": "lvs_device_mismatch",
617
+ "message": f"Instance: {name}",
618
+ "instance": name,
619
+ "severity": "error",
620
+ }
621
+ )
622
+ for _ in re.finditer(r"\(no matching instance\)", device_section):
623
+ violations.append(
624
+ {
625
+ "rule": "lvs_device_mismatch",
626
+ "message": "(no matching instance)",
627
+ "severity": "error",
628
+ }
629
+ )
630
+ return violations
631
+
632
+
633
+ class NetgenLvsAdapter(ToolAdapter):
634
+ """run_lvs via Netgen: layout vs schematic, TOP-level compare.
635
+
636
+ Serves the run_lvs operation with deck.tool `netgen` (the only
637
+ tool). Both netlists flatten to top level first (a library file
638
+ needs `cell` to select its block), then the one-shot batch LVS
639
+ runs; the result is {match, total, violations[]} with net and
640
+ device mismatches.
641
+
642
+ Job keys:
643
+ layout_spice (required): Layout netlist path.
644
+ schematic_spice (required): Schematic netlist path.
645
+ setup_file (required): Netgen setup path — one setup form
646
+ is required (setup_file or deck.path/setup_file/setup/
647
+ file).
648
+ deck: Optional mapping naming the setup file (see above).
649
+ cell: .subckt block to select from library netlists.
650
+ bulk_nets: Optional {n: [...], p: [...]} bulk-net
651
+ families; X-FET lines bulked to the opposite family
652
+ re-type before compare (bandgap p/n adjudication).
653
+ workdir: Run directory (default ".").
654
+
655
+ Args:
656
+ runner: Optional isolated runner; direct subprocess when None.
657
+
658
+ Caution: Netgen exits 0 on match and mismatch alike — the
659
+ verdict always comes from parsed output, never the exit
660
+ code. PATH presence is not enough: same-named impostor
661
+ binaries are rejected by banner probe.
662
+ """
663
+
664
+ def __init__(self, runner=None):
665
+ self._runner = runner
666
+
667
+ def capabilities(self, target):
668
+ """Report the served operation and tool name.
669
+
670
+ Args:
671
+ target: Target the capabilities are reported for
672
+ (accepted, unused — one netlist pair per job).
673
+
674
+ Returns:
675
+ Capability mapping with operations and tool.
676
+ """
677
+ return {"operations": [OPERATION], "tool": "netgen"}
678
+
679
+ def preflight(self, job):
680
+ """Check operation, banner-proved binary, setup, and netlists.
681
+
682
+ Args:
683
+ job: Solver job with operation run_lvs plus netlist,
684
+ setup, and cell inputs.
685
+
686
+ Returns:
687
+ Checked mapping with setup_file, both netlist paths,
688
+ and cell when given.
689
+
690
+ Raises:
691
+ AdapterError: unsupported_operation for another
692
+ operation, missing_dependency for a missing netgen
693
+ or a banner-rejected impostor, invalid_reference
694
+ for missing netlist files, invalid_command when no
695
+ setup form is given.
696
+ """
697
+ if job.get("operation") != OPERATION:
698
+ raise AdapterError(
699
+ "unsupported_operation",
700
+ f"Netgen LVS handles {OPERATION}, got {job.get('operation')}.",
701
+ )
702
+ timeout = _budget_or_raise(job)
703
+ found = shutil.which("netgen")
704
+ if found is None:
705
+ raise AdapterError("missing_dependency", "netgen binary not found.")
706
+ cause = _identify_netgen(found)
707
+ if cause is not None:
708
+ raise AdapterError("missing_dependency", cause)
709
+ checked = {"setup_file": _setup_file_or_raise(job)}
710
+ for key in ("layout_spice", "schematic_spice"):
711
+ path = job.get(key)
712
+ if not path or not Path(path).is_file():
713
+ raise AdapterError(
714
+ "invalid_reference", f"Netlist file not found: {path}."
715
+ )
716
+ checked[key] = path
717
+ if job.get("cell") is not None:
718
+ checked["cell"] = str(job["cell"])
719
+ bulk = _bulk_or_raise(job)
720
+ if bulk is not None:
721
+ checked["bulk_nets"] = {
722
+ "n": sorted(bulk["n"]),
723
+ "p": sorted(bulk["p"]),
724
+ }
725
+ checked["timeout_s"] = timeout
726
+ return checked
727
+
728
+ def execute(self, job):
729
+ """Flatten both sides, then run the one-shot batch LVS.
730
+
731
+ Batch runs with DISPLAY scrubbed: netgen's Tk init fails
732
+ against an unreachable display. Hierarchical sides inline
733
+ from their root; a `bulk_nets` job key re-types uniform
734
+ ext2spice FETs by bulk before compare (bandgap leg).
735
+
736
+ Args:
737
+ job: Solver job with operation run_lvs plus netlist,
738
+ setup, cell, and bulk_nets inputs.
739
+
740
+ Returns:
741
+ Native output with exit_code, stdout/stderr, report
742
+ file/text, both netlist paths, and the per-side
743
+ retype counts.
744
+
745
+ Raises:
746
+ AdapterError: invalid_reference for a missing workdir,
747
+ invalid_command for an ambiguous library without
748
+ cell, runner_unavailable when netgen produces no
749
+ report, plus the preflight codes (execute
750
+ re-checks first).
751
+ """
752
+ checked = self.preflight(job)
753
+ workdir = job.get("workdir", ".")
754
+ if not Path(workdir).is_dir():
755
+ raise AdapterError(
756
+ "invalid_reference", f"Workdir not found: {workdir}."
757
+ )
758
+ cell = checked.get("cell")
759
+ bulk = checked.get("bulk_nets")
760
+ staged = {}
761
+ flats = {}
762
+ retyped = {}
763
+ for key, name in (
764
+ ("layout_spice", "layout_flat.spice"),
765
+ ("schematic_spice", "schematic_flat.spice"),
766
+ ):
767
+ flat = _prepare_flat(
768
+ Path(checked[key]).read_text(encoding="utf-8"), cell
769
+ )
770
+ count = 0
771
+ if bulk is not None:
772
+ flat, count = retype_by_bulk(flat, bulk["n"], bulk["p"])
773
+ staged[key] = Path(workdir) / name
774
+ staged[key].write_text(flat, encoding="utf-8")
775
+ flats[key] = flat
776
+ retyped[key] = count
777
+ report_file = Path(workdir) / "lvs_report.out"
778
+ proc = self._run(
779
+ [
780
+ "netgen",
781
+ "-batch",
782
+ "lvs",
783
+ str(staged["layout_spice"]),
784
+ str(staged["schematic_spice"]),
785
+ checked["setup_file"],
786
+ str(report_file),
787
+ ],
788
+ workdir,
789
+ timeout=checked["timeout_s"],
790
+ )
791
+ if not report_file.is_file():
792
+ raise AdapterError(
793
+ "runner_unavailable",
794
+ f"netgen produced no report: {proc.stderr[:500]}.",
795
+ )
796
+ return {
797
+ "exit_code": proc.returncode,
798
+ "stdout": proc.stdout,
799
+ "stderr": proc.stderr,
800
+ "report_file": str(report_file),
801
+ "report_text": report_file.read_text(encoding="utf-8"),
802
+ "layout_spice": checked["layout_spice"],
803
+ "schematic_spice": checked["schematic_spice"],
804
+ "layout_nets": sorted(inventory_nets(flats["layout_spice"])),
805
+ "schematic_nets": sorted(inventory_nets(flats["schematic_spice"])),
806
+ "layout_retyped": retyped["layout_spice"],
807
+ "schematic_retyped": retyped["schematic_spice"],
808
+ }
809
+
810
+ def parse(self, native_output):
811
+ """Parse the match line or the net/device mismatches.
812
+
813
+ A match returns zero violations — unless the flattened net
814
+ inventories disagree (a rename of a singly-connected net
815
+ is topologically invisible to netgen, LVS-ESCAPE), in which
816
+ case the name-only-on-one-side nets fail as
817
+ lvs_net_mismatch. Mismatches parse per net and instance
818
+ plus any inventory extras; anything without a verdict
819
+ raises instead of guessing.
820
+
821
+ Args:
822
+ native_output: Native output with the report text/file
823
+ or stdout fallback, plus optional layout_nets /
824
+ schematic_nets inventories from execute (absent in
825
+ legacy outputs, which parse exactly as before).
826
+
827
+ Returns:
828
+ {match, total, violations[]} with lvs_net_mismatch and
829
+ lvs_device_mismatch rules.
830
+
831
+ Raises:
832
+ AdapterError: runner_unavailable when the output has no
833
+ LVS verdict.
834
+ """
835
+ report = native_output.get("report_text")
836
+ if report is None:
837
+ report_file = native_output.get("report_file")
838
+ if report_file and Path(report_file).is_file():
839
+ report = Path(report_file).read_text(encoding="utf-8")
840
+ else:
841
+ report = native_output.get("stdout", "")
842
+ layout_nets = native_output.get("layout_nets")
843
+ schematic_nets = native_output.get("schematic_nets")
844
+ extra = []
845
+ if layout_nets is not None and schematic_nets is not None:
846
+ extra = _inventory_violations(layout_nets, schematic_nets)
847
+ if _MATCH_RE.search(report):
848
+ if extra:
849
+ return {"match": False, "total": len(extra), "violations": extra}
850
+ return {"match": True, "total": 0, "violations": []}
851
+ if any(rx.search(report) for rx in _MISMATCH_RES):
852
+ violations = _parse_mismatches(report)
853
+ seen = {violation["message"] for violation in violations}
854
+ for violation in extra:
855
+ if violation["message"] not in seen:
856
+ violations.append(violation)
857
+ seen.add(violation["message"])
858
+ return {
859
+ "match": False,
860
+ "total": len(violations),
861
+ "violations": violations,
862
+ }
863
+ raise AdapterError(
864
+ "runner_unavailable", "netgen output has no LVS verdict."
865
+ )
866
+
867
+ def _run(self, argv, workdir, timeout=_TIMEOUT_S):
868
+ if self._runner is not None:
869
+ try:
870
+ return self._runner.run(argv, workdir, timeout)
871
+ except ExecutionError as exc:
872
+ raise AdapterError(exc.code, str(exc)) from exc
873
+ if not Path(workdir).is_dir():
874
+ raise AdapterError(
875
+ "invalid_reference", f"Workdir not found: {workdir}."
876
+ )
877
+ try:
878
+ return subprocess.run(
879
+ argv,
880
+ capture_output=True,
881
+ text=True,
882
+ timeout=timeout,
883
+ cwd=workdir,
884
+ env=netgen_env(),
885
+ )
886
+ except subprocess.TimeoutExpired as exc:
887
+ raise AdapterError(
888
+ "runner_unavailable", f"netgen timed out after {timeout}s."
889
+ ) from exc