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,1062 @@
1
+ """Post-layout SPICE adapter: run_post_layout behind the ToolAdapter port.
2
+
3
+ Protocol, mirroring the run_spice adapter: stage a corner deck into the
4
+ workdir — preamble (.param/.option/.temp/.lib) + extracted netlist plus
5
+ testbench bodies (bare `.end` lines stripped, else ngspice stops before
6
+ .control) + generated .control (save all, analyses, let/print meas_*,
7
+ optional wrdata) — plus a .spiceinit (ngbehavior=hsa, required to read
8
+ sky130 libs), then run `ngspice -b -o postlayout.log postlayout_deck.spice`
9
+ under a timeout and parse `meas_* = value` lines. Verdicts always come
10
+ from parsed output, never the exit code: bound misses are FAIL, a sim
11
+ that yields no values synthesizes sim_fail violations, and only a
12
+ missing binary raises missing_dependency.
13
+
14
+ Two post-layout additions ride on top. Provenance is enforced: the job
15
+ carries the extraction record (extraction_run_id plus the netlist
16
+ sha256), and a missing record or bytes that no longer match the record
17
+ refuse loudly — a schematic passed as extracted without a fresh record
18
+ never sims. And every parsed measurement joins a pre/post comparison
19
+ row against the schematic run referenced by schematic_run_id (run_id
20
+ accepted as an alias), whose values arrive either explicitly in the job
21
+ or through the runs resolver; deltas are evidence only and never gate
22
+ the verdict.
23
+
24
+ PDK models resolve through the PDK store (resolve_deck): a wrong
25
+ revision, an unknown model relpath, or a tampered model file fails
26
+ loudly in preflight — the adapter never silently sims against
27
+ substitute models.
28
+
29
+ Tool resolution: an explicit job `tool_dir` containing ngspice wins,
30
+ else PATH. Where the tools live on disk is B10 scope.
31
+ """
32
+
33
+ import hashlib
34
+ import math
35
+ import re
36
+ import shutil
37
+ import subprocess
38
+ from pathlib import Path
39
+
40
+ try:
41
+ from ...execution.errors import ExecutionError
42
+ except ImportError:
43
+ from rocket_engine.execution.errors import ExecutionError
44
+ try:
45
+ from ...execution.runner import tool_roots
46
+ except ImportError:
47
+ from rocket_engine.execution.runner import tool_roots
48
+ try:
49
+ from ...interfaces import ToolAdapter
50
+ except ImportError:
51
+ from rocket_engine.interfaces import ToolAdapter
52
+ try:
53
+ from ...interfaces.pdks import resolve_deck
54
+ except ImportError:
55
+ from rocket_engine.interfaces.pdks import resolve_deck
56
+ try:
57
+ from ..errors import AdapterError
58
+ except ImportError:
59
+ from rocket_engine.adapters.errors import AdapterError
60
+
61
+ OPERATION = "run_post_layout"
62
+ _TIMEOUT_S = 120
63
+ _DECK_NAME = "postlayout_deck.spice"
64
+ _LOG_NAME = "postlayout.log"
65
+ _SPICEINIT_NAME = ".spiceinit"
66
+ _WAVEFORMS_NAME = "postlayout_waveforms.dat"
67
+ _MODEL_REL = "libs.tech/combined/sky130.lib.spice"
68
+ _WAVEFORM_MAX_ROWS = 100000
69
+
70
+ # Mirrors the run_spice adapter MEAS_RE (leading whitespace tolerated).
71
+ _MEAS_RE = re.compile(
72
+ r"^\s*meas_([A-Za-z0-9_]+)\s*=\s*"
73
+ r"(-?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)",
74
+ re.M,
75
+ )
76
+ _NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
77
+ _CORNER_RE = re.compile(r"^[A-Za-z0-9_]+$")
78
+ _PARAM_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
79
+ _SHA_RE = re.compile(r"^[0-9a-fA-F]{64}$")
80
+ _ERROR_RE = re.compile(r"error", re.IGNORECASE)
81
+
82
+ _SPICEINIT = """* rocket_engine ngspice init (mirrors sky130-bandgap sim/spiceinit).
83
+ set ngbehavior=hsa ; HSPICE-ish compatibility, required to read PDK libs
84
+ set skywaterpdk ; skip some checks for faster lib loading
85
+ set ng_nomodcheck ; do not check model parameters
86
+ option noinit ; do not dump operating point data into the log
87
+ option klu ; KLU direct linear solver
88
+ """
89
+
90
+
91
+ def _is_number(value):
92
+ return (
93
+ isinstance(value, (int, float))
94
+ and not isinstance(value, bool)
95
+ and math.isfinite(value)
96
+ )
97
+
98
+
99
+ def _single_line(value):
100
+ return (
101
+ isinstance(value, str)
102
+ and value.strip()
103
+ and "\n" not in value
104
+ and "\r" not in value
105
+ )
106
+
107
+
108
+ def _check_measurements(measurements):
109
+ if not isinstance(measurements, list) or not measurements:
110
+ raise AdapterError(
111
+ "invalid_command", "run_post_layout needs at least one measurement."
112
+ )
113
+ clean = []
114
+ for item in measurements:
115
+ if not isinstance(item, dict):
116
+ raise AdapterError(
117
+ "invalid_command", f"Bad measurement {item!r} (not a mapping)."
118
+ )
119
+ name = item.get("name")
120
+ expr = item.get("expr")
121
+ if not isinstance(name, str) or not _NAME_RE.match(name):
122
+ raise AdapterError(
123
+ "invalid_command", f"Bad measurement name: {name!r}."
124
+ )
125
+ if not _single_line(expr):
126
+ raise AdapterError(
127
+ "invalid_command",
128
+ f"Measurement {name!r} needs a one-line expr.",
129
+ )
130
+ bounds = {
131
+ key: item[key]
132
+ for key in ("min", "max")
133
+ if item.get(key) is not None
134
+ }
135
+ if not bounds or not all(_is_number(v) for v in bounds.values()):
136
+ raise AdapterError(
137
+ "invalid_command",
138
+ f"Measurement {name!r} needs numeric min and/or max.",
139
+ )
140
+ if (
141
+ "min" in bounds
142
+ and "max" in bounds
143
+ and bounds["min"] > bounds["max"]
144
+ ):
145
+ raise AdapterError(
146
+ "invalid_command",
147
+ f"Measurement {name!r} has min above max.",
148
+ )
149
+ clean.append(
150
+ {
151
+ "name": name,
152
+ "expr": expr.strip(),
153
+ "unit": item.get("unit", ""),
154
+ "min": bounds.get("min"),
155
+ "max": bounds.get("max"),
156
+ }
157
+ )
158
+ return clean
159
+
160
+
161
+ def _check_waveforms(waveforms):
162
+ if waveforms is None:
163
+ return []
164
+ if not isinstance(waveforms, list):
165
+ raise AdapterError(
166
+ "invalid_command", f"Waveforms must be a list: {waveforms!r}."
167
+ )
168
+ clean = []
169
+ for item in waveforms:
170
+ if not isinstance(item, dict):
171
+ raise AdapterError(
172
+ "invalid_command", f"Bad waveform {item!r} (not a mapping)."
173
+ )
174
+ name = item.get("name")
175
+ expr = item.get("expr")
176
+ if not isinstance(name, str) or not _NAME_RE.match(name):
177
+ raise AdapterError(
178
+ "invalid_command", f"Bad waveform name: {name!r}."
179
+ )
180
+ if not _single_line(expr) or expr.strip().startswith("."):
181
+ raise AdapterError(
182
+ "invalid_command",
183
+ f"Waveform {name!r} needs a one-line vector expr.",
184
+ )
185
+ clean.append({"name": name, "expr": expr.strip()})
186
+ return clean
187
+
188
+
189
+ def _check_control_lines(values, kind):
190
+ clean = []
191
+ for value in values or []:
192
+ if not _single_line(value) or value.strip().startswith("."):
193
+ raise AdapterError(
194
+ "invalid_command", f"Bad {kind} line: {value!r}."
195
+ )
196
+ clean.append(value.strip())
197
+ return clean
198
+
199
+
200
+ def _check_params(params):
201
+ clean = {}
202
+ for key, value in (params or {}).items():
203
+ if not isinstance(key, str) or not _PARAM_KEY_RE.match(key):
204
+ raise AdapterError("invalid_command", f"Bad .param name: {key!r}.")
205
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
206
+ raise AdapterError(
207
+ "invalid_command", f"Bad .param value for {key!r}: {value!r}."
208
+ )
209
+ text = str(value)
210
+ if not text or any(char.isspace() for char in text) or '"' in text:
211
+ raise AdapterError(
212
+ "invalid_command", f"Bad .param value for {key!r}: {value!r}."
213
+ )
214
+ clean[key] = text
215
+ return clean
216
+
217
+
218
+ def _strip_end_lines(text):
219
+ return "\n".join(
220
+ line for line in text.splitlines() if line.strip().lower() != ".end"
221
+ )
222
+
223
+
224
+ def _read_source(path):
225
+ try:
226
+ return Path(path).read_text(encoding="utf-8")
227
+ except OSError as exc:
228
+ raise AdapterError(
229
+ "invalid_reference", f"Netlist unreadable: {path} ({exc})."
230
+ ) from exc
231
+
232
+
233
+ def _sha256_file(path):
234
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
235
+
236
+
237
+ def _check_provenance(record, extracted):
238
+ if not isinstance(record, dict):
239
+ raise AdapterError(
240
+ "invalid_command",
241
+ "run_post_layout needs extraction_provenance "
242
+ "{extraction_run_id, netlist_sha256, ...}; refusing to sim "
243
+ "a schematic as extracted.",
244
+ )
245
+ run_id = record.get("extraction_run_id")
246
+ if not _single_line(run_id):
247
+ raise AdapterError(
248
+ "invalid_command",
249
+ "extraction_provenance needs a one-line extraction_run_id.",
250
+ )
251
+ digest = record.get("netlist_sha256")
252
+ if not isinstance(digest, str) or _SHA_RE.match(digest) is None:
253
+ raise AdapterError(
254
+ "invalid_command",
255
+ "extraction_provenance needs netlist_sha256 as 64 hex chars.",
256
+ )
257
+ if _sha256_file(extracted).lower() != digest.lower():
258
+ raise AdapterError(
259
+ "invalid_reference",
260
+ f"Stale extraction record for {extracted}: netlist bytes differ "
261
+ f"from extraction {run_id} (re-extract, then rerun).",
262
+ )
263
+ spice_file = record.get("spice_file")
264
+ if spice_file is not None:
265
+ if Path(spice_file).name != Path(extracted).name:
266
+ raise AdapterError(
267
+ "invalid_reference",
268
+ f"extraction_provenance names {spice_file}, got {extracted}.",
269
+ )
270
+ clean = {
271
+ "extraction_run_id": run_id.strip(),
272
+ "netlist_sha256": digest.lower(),
273
+ }
274
+ for key in ("cell", "tech"):
275
+ if record.get(key) is not None:
276
+ if not _single_line(record[key]):
277
+ raise AdapterError(
278
+ "invalid_command",
279
+ f"extraction_provenance needs a one-line {key}.",
280
+ )
281
+ clean[key] = record[key].strip()
282
+ if spice_file is not None:
283
+ clean["spice_file"] = str(spice_file)
284
+ if record.get("device_count") is not None:
285
+ count = record["device_count"]
286
+ if (
287
+ not isinstance(count, int)
288
+ or isinstance(count, bool)
289
+ or count < 0
290
+ ):
291
+ raise AdapterError(
292
+ "invalid_command",
293
+ f"Bad device_count: {count!r} (want an int >= 0).",
294
+ )
295
+ clean["device_count"] = count
296
+ return clean
297
+
298
+
299
+ def _clean_value_list(items, what, code):
300
+ if not isinstance(items, list) or not items:
301
+ raise AdapterError(code, f"{what} has no measurements.")
302
+ values = {}
303
+ for item in items:
304
+ if not isinstance(item, dict):
305
+ raise AdapterError(code, f"{what} entry is not a mapping: {item!r}.")
306
+ name = item.get("name")
307
+ value = item.get("value")
308
+ if not isinstance(name, str) or not name:
309
+ raise AdapterError(code, f"{what} entry needs a name: {item!r}.")
310
+ if not _is_number(value):
311
+ raise AdapterError(
312
+ code, f"{what} entry {name!r} needs a numeric value."
313
+ )
314
+ values[name] = {"value": float(value), "unit": item.get("unit", "")}
315
+ return values
316
+
317
+
318
+ def _normalize_runs_result(raw, run_id):
319
+ if isinstance(raw, list):
320
+ items = raw
321
+ elif isinstance(raw, dict) and isinstance(raw.get("measurements"), list):
322
+ items = raw["measurements"]
323
+ elif isinstance(raw, dict):
324
+ items = [
325
+ {
326
+ "name": name,
327
+ "value": value.get("value") if isinstance(value, dict) else value,
328
+ "unit": value.get("unit", "") if isinstance(value, dict) else "",
329
+ }
330
+ for name, value in raw.items()
331
+ ]
332
+ else:
333
+ raise AdapterError(
334
+ "invalid_reference",
335
+ f"Unknown schematic run: {run_id} (no measurements).",
336
+ )
337
+ return _clean_value_list(items, f"Schematic run {run_id}", "invalid_reference")
338
+
339
+
340
+ def _parse_waveforms_file(path, spec):
341
+ try:
342
+ text = Path(path).read_text(encoding="utf-8")
343
+ except OSError:
344
+ return []
345
+ columns = [[] for _ in range(len(spec) + 1)]
346
+ for line in text.splitlines():
347
+ parts = line.split()
348
+ if not parts:
349
+ continue
350
+ try:
351
+ numbers = [float(part) for part in parts]
352
+ except ValueError:
353
+ continue
354
+ if len(numbers) != len(spec) + 1:
355
+ continue
356
+ for index, number in enumerate(numbers):
357
+ columns[index].append(number)
358
+ if not columns[0]:
359
+ return []
360
+ downsampled = False
361
+ if len(columns[0]) > _WAVEFORM_MAX_ROWS:
362
+ step = -(-len(columns[0]) // _WAVEFORM_MAX_ROWS)
363
+ columns = [column[::step] for column in columns]
364
+ downsampled = True
365
+ scale, rest = columns[0], columns[1:]
366
+ return [
367
+ {
368
+ "name": item["name"],
369
+ "expr": item["expr"],
370
+ "samples": samples,
371
+ "scale": list(scale),
372
+ "count": len(scale),
373
+ "downsampled": downsampled,
374
+ }
375
+ for item, samples in zip(spec, rest)
376
+ ]
377
+
378
+
379
+ def _format_cell(value):
380
+ if value is None:
381
+ return "-"
382
+ if isinstance(value, bool):
383
+ return str(value)
384
+ if isinstance(value, (int, float)):
385
+ return f"{value:.6g}"
386
+ return str(value)
387
+
388
+
389
+ def build_provenance(spice_file, extraction_run_id, cell=None, tech=None,
390
+ device_count=None):
391
+ """Build an extraction record hashing the netlist on disk.
392
+
393
+ The extraction side calls this when the .spice artifact lands, so
394
+ the post-layout side can verify freshness instead of trusting a
395
+ hand-written hash.
396
+
397
+ Args:
398
+ spice_file: Extracted netlist path the record pins.
399
+ extraction_run_id: run_extraction run id that produced it.
400
+ cell: Extracted cell name, when known.
401
+ tech: Extraction tech name, when known.
402
+ device_count: Extracted device count, when known.
403
+
404
+ Returns:
405
+ Provenance record with extraction_run_id, netlist_sha256,
406
+ spice_file, and the known extras.
407
+
408
+ Raises:
409
+ AdapterError: invalid_reference for a missing netlist,
410
+ invalid_command for a bad run id, cell, tech, or count.
411
+ """
412
+ if not _single_line(extraction_run_id):
413
+ raise AdapterError(
414
+ "invalid_command",
415
+ "build_provenance needs a one-line extraction_run_id.",
416
+ )
417
+ if not Path(spice_file).is_file():
418
+ raise AdapterError(
419
+ "invalid_reference", f"Extracted netlist not found: {spice_file}."
420
+ )
421
+ record = {
422
+ "extraction_run_id": extraction_run_id.strip(),
423
+ "netlist_sha256": _sha256_file(spice_file),
424
+ "spice_file": str(spice_file),
425
+ }
426
+ for key, value in (("cell", cell), ("tech", tech)):
427
+ if value is not None:
428
+ if not _single_line(value):
429
+ raise AdapterError(
430
+ "invalid_command",
431
+ f"build_provenance needs a one-line {key}.",
432
+ )
433
+ record[key] = value.strip()
434
+ if device_count is not None:
435
+ if (
436
+ not isinstance(device_count, int)
437
+ or isinstance(device_count, bool)
438
+ or device_count < 0
439
+ ):
440
+ raise AdapterError(
441
+ "invalid_command",
442
+ f"Bad device_count: {device_count!r} (want an int >= 0).",
443
+ )
444
+ record["device_count"] = device_count
445
+ return record
446
+
447
+
448
+ def build_deck(title, corner, temp_c, params, options, model_lib, bodies,
449
+ analyses, measurements, waveforms):
450
+ """Render the post-layout deck (preamble + bodies + .control).
451
+
452
+ Args:
453
+ title: Deck title line (without the `*`).
454
+ corner: Model corner named on the .lib line.
455
+ temp_c: Temperature in C for the .temp line.
456
+ params: Cleaned .param name/value mapping.
457
+ options: Cleaned .option lines.
458
+ model_lib: Store model library path, or None for model-free.
459
+ bodies: Netlist body texts (.end lines already stripped).
460
+ analyses: .control analysis lines (op when empty).
461
+ measurements: Cleaned measurement items (let/print pairs).
462
+ waveforms: Cleaned waveform items (one wrdata line).
463
+
464
+ Returns:
465
+ Rendered post-layout deck text.
466
+ """
467
+ head = [
468
+ f"* {title}",
469
+ f"* corner: {corner} temp: {temp_c}C "
470
+ "(generated by rocket_engine postlayout adapter)",
471
+ ]
472
+ for key, value in params.items():
473
+ head.append(f".param {key}={value}")
474
+ for opt in options:
475
+ head.append(f".option {opt}")
476
+ head.append(f".temp {temp_c}")
477
+ if model_lib is not None:
478
+ head.append(f'.lib "{model_lib}" {corner}')
479
+ control = [".control", "save all"]
480
+ control.extend(analyses or ["op"])
481
+ for item in measurements:
482
+ control.append(f"let meas_{item['name']} = {item['expr']}")
483
+ for item in measurements:
484
+ control.append(f"print meas_{item['name']}")
485
+ if waveforms:
486
+ exprs = " ".join(item["expr"] for item in waveforms)
487
+ control.append(f"wrdata {_WAVEFORMS_NAME} {exprs}")
488
+ control.extend(["quit", ".endc", ".end", ""])
489
+ return "\n".join(head + bodies + control)
490
+
491
+
492
+ def delta_table(comparison):
493
+ """Render pre/post comparison rows as an ASCII delta table.
494
+
495
+ Args:
496
+ comparison: Comparison rows with name, pre, post, delta,
497
+ rel, and status keys.
498
+
499
+ Returns:
500
+ Table text with one header line plus one line per row.
501
+ """
502
+ header = (
503
+ f"{'name':<14} {'pre':>12} {'post':>12} "
504
+ f"{'delta':>12} {'rel':>12} status"
505
+ )
506
+ if not comparison:
507
+ return header + "\n(no comparison rows)"
508
+ lines = [header]
509
+ for row in comparison:
510
+ lines.append(
511
+ f"{row.get('name', '?'):<14} "
512
+ f"{_format_cell(row.get('pre')):>12} "
513
+ f"{_format_cell(row.get('post')):>12} "
514
+ f"{_format_cell(row.get('delta')):>12} "
515
+ f"{_format_cell(row.get('rel')):>12} "
516
+ f"{row.get('status', '?')}"
517
+ )
518
+ return "\n".join(lines)
519
+
520
+
521
+ class PostLayoutAdapter(ToolAdapter):
522
+ """run_post_layout via ngspice: extracted sims with pre/post deltas.
523
+
524
+ Serves the run_post_layout operation with deck.tool `ngspice`
525
+ (the only tool). Sims the extracted netlist plus testbench
526
+ through ngspice in batch, evaluates post-layout measurements
527
+ against min/max bounds, dumps requested waveforms via wrdata,
528
+ and joins every measurement with its schematic value into a
529
+ pre/post comparison table; deltas are evidence only and never
530
+ gate the verdict.
531
+
532
+ Job keys:
533
+ extracted_netlist (required): Extracted netlist path simmed
534
+ as the first body (never a schematic without a record).
535
+ extraction_provenance (required): {extraction_run_id,
536
+ netlist_sha256, cell?, tech?, spice_file?, device_count?}
537
+ pinning the extraction; stale records refuse loudly.
538
+ measurements (required): [{name, expr, unit?, min?, max?}]
539
+ with numeric min and/or max each (bounds judge POST).
540
+ schematic_run_id (required): Schematic run_spice run id the
541
+ comparison reads pre values for (run_id is an alias).
542
+ schematic_measurements: Explicit pre values [{name, value,
543
+ unit?}] winning over the runs resolver.
544
+ testbench: Testbench wrapper path staged after the netlist.
545
+ waveforms: [{name, expr}] vector exprs dumped via wrdata.
546
+ analyses: .control analysis lines (default op).
547
+ options: .option lines (default [wnflag=1]).
548
+ params: .param name/value mapping.
549
+ corner: Model corner (default tt).
550
+ temp_c: Temperature in C (default 27).
551
+ title: Deck title line.
552
+ pdk: {id, revision, model_rel?, engine_root?} pin for PDK
553
+ models via the store; omitted sims model-free.
554
+ timeout_s: Sim budget in seconds (default 120).
555
+ tool_dir: Directory holding ngspice; PATH otherwise.
556
+ deck: Optional mapping; deck.tool must be ngspice here.
557
+ workdir: Run directory (default ".").
558
+
559
+ Args:
560
+ runner: Optional isolated runner; direct subprocess when None.
561
+ runs: Optional schematic resolver run_id -> measurements
562
+ (list, {measurements: [...]}, or {name: value}); used
563
+ only when the job carries no schematic_measurements.
564
+
565
+ Caution: Verdicts always come from parsed output, never the exit
566
+ code — a missing or stale extraction record refuses instead
567
+ of simming a schematic as extracted, and PDK models resolve
568
+ through the store, so a wrong revision or tampered model
569
+ fails loudly instead of simming against substitutes.
570
+ """
571
+
572
+ def __init__(self, runner=None, runs=None):
573
+ self._runner = runner
574
+ self._runs = runs
575
+
576
+ def capabilities(self, target):
577
+ """Report the served operation and tool name.
578
+
579
+ Args:
580
+ target: Target the capabilities are reported for
581
+ (accepted, unused — one netlist per job).
582
+
583
+ Returns:
584
+ Capability mapping with operations and tool.
585
+ """
586
+ return {"operations": [OPERATION], "tool": "ngspice"}
587
+
588
+ def preflight(self, job):
589
+ """Check operation, inputs, provenance, schematic ref, and binary.
590
+
591
+ Cheap deterministic checks first, provenance and schematic
592
+ resolution next (a stale record or unknown schematic run
593
+ must fail before any sim), PDK resolution after that, tool
594
+ resolution last.
595
+
596
+ Args:
597
+ job: Solver job with operation run_post_layout plus the
598
+ extracted netlist, provenance, measurements, and
599
+ schematic reference inputs.
600
+
601
+ Returns:
602
+ Checked mapping with the resolved ngspice path,
603
+ extracted netlist, testbench, measurements,
604
+ waveforms, analyses, options, params, corner,
605
+ temp_c, title, model_lib, schematic values,
606
+ provenance, and timeout_s.
607
+
608
+ Raises:
609
+ AdapterError: unsupported_operation for another
610
+ operation, invalid_command for bad inputs (sources
611
+ alias, timeout, measurements, waveforms, analyses,
612
+ options, params, corner, temp_c, title, missing or
613
+ malformed provenance, missing or conflicting
614
+ schematic ref, schematic gap), invalid_reference
615
+ for a missing netlist or testbench, a stale or
616
+ foreign provenance record, or an unresolvable
617
+ schematic run, store codes (unknown_pdk, pdk_drift,
618
+ ...) for a bad PDK pin, missing_dependency for an
619
+ unresolvable ngspice.
620
+ """
621
+ # Cheap deterministic checks first: operation, shape,
622
+ # timeout, inputs. Provenance and schematic resolution next
623
+ # (both must fail before any sim). PDK resolution after that
624
+ # (wrong rev must fail before any sim). Tool resolution last,
625
+ # since it depends on the machine.
626
+ if job.get("operation") != OPERATION:
627
+ raise AdapterError(
628
+ "unsupported_operation",
629
+ f"postlayout handles {OPERATION}, got {job.get('operation')}.",
630
+ )
631
+ if job.get("sources") is not None:
632
+ raise AdapterError(
633
+ "invalid_command",
634
+ "run_post_layout takes extracted_netlist, not sources.",
635
+ )
636
+ timeout = job.get("timeout_s", _TIMEOUT_S)
637
+ if (
638
+ not isinstance(timeout, (int, float))
639
+ or isinstance(timeout, bool)
640
+ or not timeout > 0
641
+ ):
642
+ raise AdapterError("invalid_command", f"Bad timeout_s: {timeout!r}.")
643
+ extracted = job.get("extracted_netlist")
644
+ if not extracted or not Path(extracted).is_file():
645
+ raise AdapterError(
646
+ "invalid_reference", f"Extracted netlist not found: {extracted}."
647
+ )
648
+ testbench = job.get("testbench")
649
+ if testbench is not None and not Path(testbench).is_file():
650
+ raise AdapterError(
651
+ "invalid_reference", f"Testbench not found: {testbench}."
652
+ )
653
+ measurements = _check_measurements(job.get("measurements"))
654
+ waveforms = _check_waveforms(job.get("waveforms"))
655
+ analyses = _check_control_lines(job.get("analyses"), "analyses")
656
+ options = _check_control_lines(
657
+ job.get("options", ["wnflag=1"]), "options"
658
+ )
659
+ params = _check_params(job.get("params"))
660
+ corner = job.get("corner", "tt")
661
+ if not isinstance(corner, str) or not _CORNER_RE.match(corner):
662
+ raise AdapterError("invalid_command", f"Bad corner: {corner!r}.")
663
+ temp_c = job.get("temp_c", 27)
664
+ if not _is_number(temp_c):
665
+ raise AdapterError("invalid_command", f"Bad temp_c: {temp_c!r}.")
666
+ title = job.get("title", "rocket_engine run_post_layout")
667
+ if not _single_line(title):
668
+ raise AdapterError("invalid_command", f"Bad title: {title!r}.")
669
+ provenance = _check_provenance(
670
+ job.get("extraction_provenance"), extracted
671
+ )
672
+ schematic = self._schematic_values(job, measurements)
673
+ model_lib = self._resolve_model(job.get("pdk"))
674
+ checked = {
675
+ "ngspice": self._resolve(job),
676
+ "extracted_netlist": str(extracted),
677
+ "testbench": str(testbench) if testbench is not None else None,
678
+ "measurements": measurements,
679
+ "waveforms": waveforms,
680
+ "analyses": analyses,
681
+ "options": options,
682
+ "params": params,
683
+ "corner": corner,
684
+ "temp_c": temp_c,
685
+ "title": title.strip(),
686
+ "model_lib": model_lib,
687
+ "schematic": schematic,
688
+ "provenance": provenance,
689
+ "timeout_s": timeout,
690
+ }
691
+ if self._runner is not None:
692
+ try:
693
+ self._runner.preflight()
694
+ except ExecutionError as exc:
695
+ raise AdapterError(exc.code, str(exc)) from exc
696
+ return checked
697
+
698
+ def execute(self, job):
699
+ """Stage the post-layout deck plus .spiceinit, then run ngspice.
700
+
701
+ Args:
702
+ job: Solver job with operation run_post_layout plus the
703
+ extracted netlist, provenance, measurements, and
704
+ schematic reference inputs.
705
+
706
+ Returns:
707
+ Native output with exit_code, stdout/stderr, log, deck,
708
+ extracted netlist, testbench, measurements, the
709
+ schematic sidecar, provenance, and the waveforms
710
+ spec plus file.
711
+
712
+ Raises:
713
+ AdapterError: invalid_reference for a missing workdir
714
+ or unreadable netlist, runner_unavailable on a sim
715
+ timeout, plus the preflight codes (execute
716
+ re-checks first).
717
+ """
718
+ checked = self.preflight(job)
719
+ workdir = job.get("workdir", ".")
720
+ if not Path(workdir).is_dir():
721
+ raise AdapterError(
722
+ "invalid_reference", f"Workdir not found: {workdir}."
723
+ )
724
+ workdir_path = Path(workdir)
725
+ bodies = [_strip_end_lines(_read_source(checked["extracted_netlist"]))]
726
+ if checked["testbench"] is not None:
727
+ bodies.append(_strip_end_lines(_read_source(checked["testbench"])))
728
+ deck = build_deck(
729
+ checked["title"],
730
+ checked["corner"],
731
+ checked["temp_c"],
732
+ checked["params"],
733
+ checked["options"],
734
+ checked["model_lib"],
735
+ bodies,
736
+ checked["analyses"],
737
+ checked["measurements"],
738
+ checked["waveforms"],
739
+ )
740
+ (workdir_path / _DECK_NAME).write_text(deck, encoding="utf-8")
741
+ (workdir_path / _SPICEINIT_NAME).write_text(_SPICEINIT, encoding="utf-8")
742
+ roots = tool_roots([checked["ngspice"]])
743
+ proc = self._run(
744
+ [checked["ngspice"], "-b", "-o", _LOG_NAME, _DECK_NAME],
745
+ workdir,
746
+ checked["timeout_s"],
747
+ roots,
748
+ )
749
+ log_path = workdir_path / _LOG_NAME
750
+ log_text = log_path.read_text(encoding="utf-8", errors="replace") if (
751
+ log_path.is_file()
752
+ ) else ""
753
+ return {
754
+ "exit_code": proc.returncode,
755
+ "stdout": proc.stdout,
756
+ "stderr": proc.stderr,
757
+ "log": log_text,
758
+ "deck": deck,
759
+ "extracted_netlist": checked["extracted_netlist"],
760
+ "testbench": checked["testbench"],
761
+ "measurements": checked["measurements"],
762
+ "schematic": checked["schematic"],
763
+ "provenance": checked["provenance"],
764
+ "waveforms_spec": checked["waveforms"],
765
+ "waveforms_file": str(workdir_path / _WAVEFORMS_NAME),
766
+ }
767
+
768
+ def parse(self, native_output):
769
+ """Parse meas_* values, join the pre/post table, read waveforms.
770
+
771
+ Bound misses are FAIL (margin always engine-computed, never
772
+ left to the caller); expected measurements with no value
773
+ synthesize sim_fail violations quoting ngspice's error text.
774
+ The comparison joins each post value with its schematic pre
775
+ value (delta = post - pre); deltas never gate the verdict.
776
+ Waveforms parse from the wrdata file when present and read
777
+ as empty otherwise — auxiliary evidence, never a failure.
778
+
779
+ Args:
780
+ native_output: Native output with stdout/stderr/log, the
781
+ extracted netlist, the expected measurements, the
782
+ schematic sidecar, provenance, and the waveforms
783
+ spec plus file.
784
+
785
+ Returns:
786
+ {total, violations[], measurements[], comparison[],
787
+ waveforms[], provenance, source} with value, unit,
788
+ target, margin, and status per measurement plus
789
+ pre, post, delta, and rel per comparison row;
790
+ source names the channel read (`log` for the -o
791
+ file, `stdio_fallback` else).
792
+
793
+ Raises:
794
+ AdapterError: runner_unavailable when ngspice produced
795
+ no output at all or the native carries no
796
+ schematic sidecar.
797
+ """
798
+ # MIGRATE-STDIO-FILE tier 1: the `-o postlayout.log` file
799
+ # is the parse channel; stdio survives only as a loud
800
+ # fallback for natives recorded before the file existed.
801
+ log = native_output.get("log") or ""
802
+ if log.strip():
803
+ combined, source = log, "log"
804
+ else:
805
+ stdio = "\n".join(
806
+ [native_output.get("stdout") or "",
807
+ native_output.get("stderr") or ""]
808
+ )
809
+ if not stdio.strip():
810
+ raise AdapterError(
811
+ "runner_unavailable", "ngspice produced no output at all."
812
+ )
813
+ combined, source = stdio, "stdio_fallback"
814
+ values = {}
815
+ for match in _MEAS_RE.finditer(combined):
816
+ try:
817
+ values[match.group(1)] = float(match.group(2))
818
+ except ValueError:
819
+ continue
820
+ expected = native_output.get("measurements") or []
821
+ waveforms = _parse_waveforms_file(
822
+ native_output.get("waveforms_file") or "",
823
+ native_output.get("waveforms_spec") or [],
824
+ )
825
+ provenance = self._provenance_echo(native_output)
826
+ missing = [item["name"] for item in expected if item["name"] not in values]
827
+ if missing:
828
+ errors = [
829
+ line.strip()
830
+ for line in combined.splitlines()
831
+ if _ERROR_RE.search(line)
832
+ ]
833
+ detail = (
834
+ f" ngspice said: {errors[0]}"
835
+ if errors
836
+ else " (no meas lines and no ngspice error text)"
837
+ )
838
+ first = native_output.get("extracted_netlist") or ""
839
+ violations = [
840
+ {
841
+ "rule": "sim_fail",
842
+ "severity": "error",
843
+ "message": (
844
+ f"ngspice produced no value for {name}.{detail}"
845
+ ),
846
+ "file": first,
847
+ }
848
+ for name in missing
849
+ ]
850
+ return {
851
+ "total": len(violations),
852
+ "violations": violations,
853
+ "comparison": [],
854
+ "waveforms": waveforms,
855
+ "provenance": provenance,
856
+ "source": source,
857
+ }
858
+ schematic = native_output.get("schematic") or {}
859
+ pre_values = schematic.get("values") or {}
860
+ if not schematic.get("run_id") or not pre_values:
861
+ raise AdapterError(
862
+ "runner_unavailable",
863
+ "post-layout native has no schematic sidecar.",
864
+ )
865
+ first = native_output.get("extracted_netlist") or ""
866
+ violations = []
867
+ measurements = []
868
+ comparison = []
869
+ for item in expected:
870
+ actual = values[item["name"]]
871
+ bounds = {
872
+ key: item[key]
873
+ for key in ("min", "max")
874
+ if item.get(key) is not None
875
+ }
876
+ margins = {}
877
+ if "min" in bounds:
878
+ margins["min"] = actual - bounds["min"]
879
+ if "max" in bounds:
880
+ margins["max"] = bounds["max"] - actual
881
+ failed = [
882
+ f"{kind} {bounds[kind]}"
883
+ for kind, margin in margins.items()
884
+ if margin < 0
885
+ ]
886
+ status = "fail" if failed else "pass"
887
+ margin = min(margins.values())
888
+ unit = item.get("unit") or ""
889
+ measurements.append(
890
+ {
891
+ "name": item["name"],
892
+ "value": actual,
893
+ "unit": unit,
894
+ "target": dict(bounds),
895
+ "margin": margin,
896
+ "status": status,
897
+ }
898
+ )
899
+ if failed:
900
+ unit_suffix = f" {unit}" if unit else ""
901
+ violations.append(
902
+ {
903
+ "rule": "measurement",
904
+ "severity": "error",
905
+ "message": (
906
+ f"{item['name']} = {actual:.6g}{unit_suffix} "
907
+ f"misses {', '.join(failed)}{unit_suffix} "
908
+ f"(margin {margin:.6g}{unit_suffix})."
909
+ ),
910
+ "file": first,
911
+ }
912
+ )
913
+ pre = pre_values[item["name"]]["value"]
914
+ delta = actual - pre
915
+ comparison.append(
916
+ {
917
+ "name": item["name"],
918
+ "unit": unit,
919
+ "pre": pre,
920
+ "post": actual,
921
+ "delta": delta,
922
+ "rel": delta / pre if pre != 0 else None,
923
+ "status": status,
924
+ }
925
+ )
926
+ return {
927
+ "total": len(violations),
928
+ "violations": violations,
929
+ "measurements": measurements,
930
+ "comparison": comparison,
931
+ "waveforms": waveforms,
932
+ "provenance": provenance,
933
+ "source": source,
934
+ }
935
+
936
+ def _schematic_values(self, job, measurements):
937
+ schem_id = job.get("schematic_run_id")
938
+ alias = job.get("run_id")
939
+ if schem_id is not None and alias is not None and schem_id != alias:
940
+ raise AdapterError(
941
+ "invalid_command",
942
+ "schematic_run_id and run_id disagree "
943
+ f"({schem_id!r} vs {alias!r}).",
944
+ )
945
+ run_id = schem_id if schem_id is not None else alias
946
+ if not isinstance(run_id, str) or not run_id:
947
+ raise AdapterError(
948
+ "invalid_command",
949
+ "run_post_layout needs schematic_run_id (the schematic "
950
+ "run_spice run id the comparison reads pre values for).",
951
+ )
952
+ explicit = job.get("schematic_measurements")
953
+ if explicit is not None:
954
+ values = _clean_value_list(
955
+ explicit, "schematic_measurements", "invalid_command"
956
+ )
957
+ elif self._runs is not None:
958
+ try:
959
+ raw = self._runs(run_id)
960
+ except AdapterError:
961
+ raise
962
+ except (KeyError, LookupError):
963
+ raise AdapterError(
964
+ "invalid_reference",
965
+ f"Unknown schematic run: {run_id} (no measurements).",
966
+ ) from None
967
+ values = _normalize_runs_result(raw, run_id)
968
+ else:
969
+ raise AdapterError(
970
+ "invalid_reference",
971
+ f"Unknown schematic run: {run_id} (pass "
972
+ "schematic_measurements or a runs resolver).",
973
+ )
974
+ for item in measurements:
975
+ if item["name"] not in values:
976
+ raise AdapterError(
977
+ "invalid_command",
978
+ f"Schematic run {run_id} has no value for "
979
+ f"measurement {item['name']!r}.",
980
+ )
981
+ return {"run_id": run_id, "values": values}
982
+
983
+ @staticmethod
984
+ def _provenance_echo(native_output):
985
+ record = native_output.get("provenance") or {}
986
+ schematic = native_output.get("schematic") or {}
987
+ echo = {
988
+ "extraction_run_id": record.get("extraction_run_id"),
989
+ "netlist_sha256": record.get("netlist_sha256"),
990
+ "schematic_run_id": schematic.get("run_id"),
991
+ }
992
+ for key in ("cell", "tech", "spice_file", "device_count"):
993
+ if record.get(key) is not None:
994
+ echo[key] = record[key]
995
+ return echo
996
+
997
+ @staticmethod
998
+ def _resolve_model(pdk):
999
+ if pdk is None:
1000
+ return None
1001
+ if not isinstance(pdk, dict):
1002
+ raise AdapterError(
1003
+ "invalid_command", f"PDK pin must be a mapping: {pdk!r}."
1004
+ )
1005
+ pdk_id = pdk.get("id")
1006
+ revision = pdk.get("revision")
1007
+ if not pdk_id or not revision:
1008
+ raise AdapterError(
1009
+ "invalid_command",
1010
+ f"PDK pin needs id and revision: {pdk!r}.",
1011
+ )
1012
+ rel = pdk.get("model_rel", _MODEL_REL)
1013
+ try:
1014
+ hit = resolve_deck(
1015
+ pdk_id, revision, rel, engine_root=pdk.get("engine_root")
1016
+ )
1017
+ except Exception as exc:
1018
+ # PdkError codes (unknown_pdk, pdk_drift, ...) ride
1019
+ # through unchanged so a model mismatch fails loudly
1020
+ # with its store meaning, never as a generic error.
1021
+ raise AdapterError(
1022
+ getattr(exc, "code", "unknown_pdk"), str(exc)
1023
+ ) from exc
1024
+ return hit["path"]
1025
+
1026
+ @staticmethod
1027
+ def _resolve(job):
1028
+ tool_dir = job.get("tool_dir")
1029
+ if tool_dir:
1030
+ candidate = Path(tool_dir) / "ngspice"
1031
+ if candidate.is_file():
1032
+ return str(candidate)
1033
+ raise AdapterError(
1034
+ "missing_dependency", f"ngspice not found in {tool_dir}."
1035
+ )
1036
+ found = shutil.which("ngspice")
1037
+ if found is None:
1038
+ raise AdapterError(
1039
+ "missing_dependency",
1040
+ "ngspice not on PATH (pass tool_dir or integrate via B10).",
1041
+ )
1042
+ return found
1043
+
1044
+ def _run(self, argv, workdir, timeout_s, extra_binds=()):
1045
+ if self._runner is not None:
1046
+ try:
1047
+ return self._runner.run(argv, workdir, timeout_s, extra_binds)
1048
+ except ExecutionError as exc:
1049
+ raise AdapterError(exc.code, str(exc)) from exc
1050
+ try:
1051
+ return subprocess.run(
1052
+ argv,
1053
+ capture_output=True,
1054
+ text=True,
1055
+ timeout=timeout_s,
1056
+ cwd=workdir,
1057
+ )
1058
+ except subprocess.TimeoutExpired as exc:
1059
+ raise AdapterError(
1060
+ "runner_unavailable",
1061
+ f"post-layout sim timed out after {timeout_s}s.",
1062
+ ) from exc