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,627 @@
1
+ """Ngspice SPICE solver adapter: run_spice behind the ToolAdapter port.
2
+
3
+ Corner-deck protocol (staged into the workdir): preamble
4
+ (.param/.option/.temp/.lib) + netlist bodies (bare `.end` lines
5
+ stripped, else ngspice stops before .control) + generated
6
+ .control (save all, analyses, let/print meas_*) — plus a
7
+ .spiceinit (neutral base lines plus the PDK profile extras),
8
+ then run `ngspice -b -o ngspice.log ngspice_deck.spice` under a
9
+ timeout and parse `meas_* = value` lines. Verdicts always come
10
+ from parsed output, never the exit code: bound misses are FAIL,
11
+ a sim that yields no values synthesizes sim_fail violations, and
12
+ only a missing binary raises missing_dependency.
13
+
14
+ Simulator needs resolve through PDK profile records
15
+ (rocket_engine.adapters.profile, data under docs/pdk/<lane>/):
16
+ model relpaths and .spiceinit lines come from the lane profile,
17
+ never from adapter special-cases. PDK models resolve through the
18
+ PDK store (resolve_deck): a wrong revision, an unknown model
19
+ relpath, or a tampered model file fails loudly in preflight —
20
+ the adapter never silently sims against substitute models.
21
+
22
+ Tool resolution: an explicit job `tool_dir` containing ngspice
23
+ wins, else PATH. Where the tools live on disk is B10 scope.
24
+ """
25
+
26
+ import math
27
+ import re
28
+ import shutil
29
+ import subprocess
30
+ from pathlib import Path
31
+
32
+ from ...execution.errors import ExecutionError
33
+ from ...execution.runner import tool_roots
34
+ from ...interfaces import ToolAdapter
35
+ from ...interfaces.pdks import resolve_deck
36
+ from ..errors import AdapterError
37
+ from ..profile import default_model_rel, render_spiceinit, spiceinit_for
38
+
39
+ OPERATION = "run_spice"
40
+ _TIMEOUT_S = 120
41
+ _DECK_NAME = "ngspice_deck.spice"
42
+ _LOG_NAME = "ngspice.log"
43
+ _WAVEFORM_PREFIX = "waveforms-"
44
+ _SWEEPS = ("ac", "dc", "tran", "noise")
45
+ _SPICEINIT_NAME = ".spiceinit"
46
+
47
+ # Mirrors corner-run.py MEAS_RE (leading whitespace tolerated).
48
+ _MEAS_RE = re.compile(
49
+ r"^\s*meas_([A-Za-z0-9_]+)\s*=\s*"
50
+ r"(-?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)",
51
+ re.M,
52
+ )
53
+ _NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
54
+ _CORNER_RE = re.compile(r"^[A-Za-z0-9_]+$")
55
+ _PARAM_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
56
+ _ERROR_RE = re.compile(r"error", re.IGNORECASE)
57
+
58
+ #: .spiceinit staging honors the PDK profile: neutral base lines plus
59
+ #: the lane extras (rocket_engine.adapters.profile). Model-free runs
60
+ #: (no pdk pin) stage the base only.
61
+
62
+
63
+ def _is_number(value):
64
+ return (
65
+ isinstance(value, (int, float))
66
+ and not isinstance(value, bool)
67
+ and math.isfinite(value)
68
+ )
69
+
70
+
71
+ def _single_line(value):
72
+ return (
73
+ isinstance(value, str)
74
+ and value.strip()
75
+ and "\n" not in value
76
+ and "\r" not in value
77
+ )
78
+
79
+
80
+ def _check_measurements(measurements):
81
+ if not isinstance(measurements, list) or not measurements:
82
+ raise AdapterError(
83
+ "invalid_command", "run_spice needs at least one measurement."
84
+ )
85
+ clean = []
86
+ for item in measurements:
87
+ if not isinstance(item, dict):
88
+ raise AdapterError(
89
+ "invalid_command", f"Bad measurement {item!r} (not a mapping)."
90
+ )
91
+ name = item.get("name")
92
+ expr = item.get("expr")
93
+ if not isinstance(name, str) or not _NAME_RE.match(name):
94
+ raise AdapterError(
95
+ "invalid_command", f"Bad measurement name: {name!r}."
96
+ )
97
+ if not _single_line(expr):
98
+ raise AdapterError(
99
+ "invalid_command",
100
+ f"Measurement {name!r} needs a one-line expr.",
101
+ )
102
+ bounds = {
103
+ key: item[key]
104
+ for key in ("min", "max")
105
+ if item.get(key) is not None
106
+ }
107
+ if not bounds or not all(_is_number(v) for v in bounds.values()):
108
+ raise AdapterError(
109
+ "invalid_command",
110
+ f"Measurement {name!r} needs numeric min and/or max.",
111
+ )
112
+ if (
113
+ "min" in bounds
114
+ and "max" in bounds
115
+ and bounds["min"] > bounds["max"]
116
+ ):
117
+ raise AdapterError(
118
+ "invalid_command",
119
+ f"Measurement {name!r} has min above max.",
120
+ )
121
+ clean.append(
122
+ {
123
+ "name": name,
124
+ "expr": expr.strip(),
125
+ "unit": item.get("unit", ""),
126
+ "min": bounds.get("min"),
127
+ "max": bounds.get("max"),
128
+ }
129
+ )
130
+ return clean
131
+
132
+
133
+ def _check_control_lines(values, kind):
134
+ clean = []
135
+ for value in values or []:
136
+ if not _single_line(value) or value.strip().startswith("."):
137
+ raise AdapterError(
138
+ "invalid_command", f"Bad {kind} line: {value!r}."
139
+ )
140
+ clean.append(value.strip())
141
+ return clean
142
+
143
+
144
+ def _check_params(params):
145
+ clean = {}
146
+ for key, value in (params or {}).items():
147
+ if not isinstance(key, str) or not _PARAM_KEY_RE.match(key):
148
+ raise AdapterError("invalid_command", f"Bad .param name: {key!r}.")
149
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
150
+ raise AdapterError(
151
+ "invalid_command", f"Bad .param value for {key!r}: {value!r}."
152
+ )
153
+ text = str(value)
154
+ if not text or any(char.isspace() for char in text) or '"' in text:
155
+ raise AdapterError(
156
+ "invalid_command", f"Bad .param value for {key!r}: {value!r}."
157
+ )
158
+ clean[key] = text
159
+ return clean
160
+
161
+
162
+ def _strip_end_lines(text):
163
+ return "\n".join(
164
+ line for line in text.splitlines() if line.strip().lower() != ".end"
165
+ )
166
+
167
+
168
+ def _read_source(path):
169
+ try:
170
+ return Path(path).read_text(encoding="utf-8")
171
+ except OSError as exc:
172
+ raise AdapterError(
173
+ "invalid_reference", f"Netlist unreadable: {path} ({exc})."
174
+ ) from exc
175
+
176
+
177
+ def build_deck(title, corner, temp_c, params, options, model_lib, bodies,
178
+ analyses, measurements):
179
+ """Render the corner deck (preamble + bodies + .control).
180
+
181
+ Args:
182
+ title: Deck title line (without the `*`).
183
+ corner: Model corner named on the .lib line.
184
+ temp_c: Temperature in C for the .temp line.
185
+ params: Cleaned .param name/value mapping.
186
+ options: Cleaned .option lines.
187
+ model_lib: Store model library path, or None for model-free.
188
+ bodies: Netlist body texts (.end lines already stripped).
189
+ analyses: .control analysis lines (op when empty).
190
+ measurements: Cleaned measurement items (let/print pairs).
191
+
192
+ Returns:
193
+ Rendered corner deck text.
194
+ """
195
+ head = [
196
+ f"* {title}",
197
+ f"* corner: {corner} temp: {temp_c}C "
198
+ "(generated by rocket_engine ngspice adapter)",
199
+ ]
200
+ for key, value in params.items():
201
+ head.append(f".param {key}={value}")
202
+ for opt in options:
203
+ head.append(f".option {opt}")
204
+ head.append(f".temp {temp_c}")
205
+ if model_lib is not None:
206
+ head.append(f'.lib "{model_lib}" {corner}')
207
+ control = [".control", "save all", "set filetype=ascii"]
208
+ # Each sweep's vectors go to an ASCII rawfile right after it runs: the
209
+ # dataset behind the verdict, for clients to plot.
210
+ sweeps = 0
211
+ for line in analyses or ["op"]:
212
+ control.append(line)
213
+ kind = line.split()[0].lower() if line.split() else ""
214
+ if kind in _SWEEPS:
215
+ sweeps += 1
216
+ control.append(f"write {_WAVEFORM_PREFIX}{sweeps}-{kind}.raw")
217
+ for item in measurements:
218
+ control.append(f"let meas_{item['name']} = {item['expr']}")
219
+ for item in measurements:
220
+ control.append(f"print meas_{item['name']}")
221
+ control.extend(["quit", ".endc", ".end", ""])
222
+ return "\n".join(head + bodies + control)
223
+
224
+
225
+ class NgspiceAdapter(ToolAdapter):
226
+ """run_spice via ngspice: corner sims with measured bounds.
227
+
228
+ Serves the run_spice operation with deck.tool `ngspice` (the
229
+ only tool). Stages a corner deck (preamble + netlist bodies +
230
+ generated .control), runs it in batch, and parses meas_* values
231
+ against min/max bounds; bound misses are FAIL, valueless sims
232
+ synthesize sim_fail violations.
233
+
234
+ Job keys:
235
+ sources (required): Netlist source paths staged as bodies.
236
+ measurements (required): [{name, expr, unit?, min?, max?}]
237
+ with numeric min and/or max each.
238
+ analyses: .control analysis lines (default op).
239
+ options: .option lines (default [wnflag=1]).
240
+ params: .param name/value mapping.
241
+ corner: Model corner (default tt).
242
+ temp_c: Temperature in C (default 27).
243
+ title: Deck title line.
244
+ pdk: {id, revision, model_rel?, engine_root?} pin for PDK
245
+ models via the store; omitted sims model-free.
246
+ timeout_s: Sim budget in seconds (default 120).
247
+ tool_dir: Directory holding ngspice; PATH otherwise.
248
+ deck: Optional mapping; deck.tool must be ngspice here.
249
+ workdir: Run directory (default ".").
250
+
251
+ Args:
252
+ runner: Optional isolated runner; direct subprocess when None.
253
+
254
+ Caution: Verdicts always come from parsed output, never the exit
255
+ code — and PDK models resolve through the store, so a wrong
256
+ revision or tampered model fails loudly instead of simming
257
+ against substitutes.
258
+ """
259
+
260
+ def __init__(self, runner=None):
261
+ self._runner = runner
262
+
263
+ def capabilities(self, target):
264
+ """Report the served operation and tool name.
265
+
266
+ Args:
267
+ target: Target the capabilities are reported for
268
+ (accepted, unused — one corner per job).
269
+
270
+ Returns:
271
+ Capability mapping with operations and tool.
272
+ """
273
+ return {"operations": [OPERATION], "tool": "ngspice"}
274
+
275
+ def preflight(self, job):
276
+ """Check operation, inputs, deck shape, PDK pin, and binary.
277
+
278
+ Cheap deterministic checks first, PDK resolution next (a
279
+ wrong rev must fail before any sim), tool resolution last.
280
+
281
+ Args:
282
+ job: Solver job with operation run_spice plus sources,
283
+ measurements, analyses, and deck inputs.
284
+
285
+ Returns:
286
+ Checked mapping with the resolved ngspice path,
287
+ sources, measurements, analyses, options, params,
288
+ corner, temp_c, title, model_lib, and timeout_s.
289
+
290
+ Raises:
291
+ AdapterError: unsupported_operation for another
292
+ operation, invalid_command for bad inputs (timeout,
293
+ measurements, analyses, options, params, corner,
294
+ temp_c, title), invalid_reference for missing
295
+ sources, store codes (unknown_pdk, pdk_drift, ...)
296
+ for a bad PDK pin, missing_dependency for an
297
+ unresolvable ngspice.
298
+ """
299
+ # Cheap deterministic checks first: operation, timeout,
300
+ # sources, measurements, deck shape. PDK resolution next
301
+ # (wrong rev must fail before any sim). Tool resolution
302
+ # last, since it depends on the machine.
303
+ if job.get("operation") != OPERATION:
304
+ raise AdapterError(
305
+ "unsupported_operation",
306
+ f"ngspice handles {OPERATION}, got {job.get('operation')}.",
307
+ )
308
+ timeout = job.get("timeout_s", _TIMEOUT_S)
309
+ if (
310
+ not isinstance(timeout, (int, float))
311
+ or isinstance(timeout, bool)
312
+ or not timeout > 0
313
+ ):
314
+ raise AdapterError("invalid_command", f"Bad timeout_s: {timeout!r}.")
315
+ sources = job.get("sources") or []
316
+ missing = [s for s in sources if not Path(s).is_file()]
317
+ if not sources or missing:
318
+ raise AdapterError(
319
+ "invalid_reference", f"Sources not found: {missing or sources}."
320
+ )
321
+ measurements = _check_measurements(job.get("measurements"))
322
+ analyses = _check_control_lines(job.get("analyses"), "analyses")
323
+ options = _check_control_lines(
324
+ job.get("options", ["wnflag=1"]), "options"
325
+ )
326
+ params = _check_params(job.get("params"))
327
+ corner = job.get("corner", "tt")
328
+ if not isinstance(corner, str) or not _CORNER_RE.match(corner):
329
+ raise AdapterError("invalid_command", f"Bad corner: {corner!r}.")
330
+ temp_c = job.get("temp_c", 27)
331
+ if not _is_number(temp_c):
332
+ raise AdapterError("invalid_command", f"Bad temp_c: {temp_c!r}.")
333
+ title = job.get("title", "rocket_engine run_spice")
334
+ if not _single_line(title):
335
+ raise AdapterError("invalid_command", f"Bad title: {title!r}.")
336
+ model_lib = self._resolve_model(job.get("pdk"))
337
+ checked = {
338
+ "ngspice": self._resolve(job),
339
+ "sources": list(sources),
340
+ "measurements": measurements,
341
+ "analyses": analyses,
342
+ "options": options,
343
+ "params": params,
344
+ "corner": corner,
345
+ "temp_c": temp_c,
346
+ "title": title.strip(),
347
+ "model_lib": model_lib,
348
+ "timeout_s": timeout,
349
+ }
350
+ if self._runner is not None:
351
+ try:
352
+ self._runner.preflight()
353
+ except ExecutionError as exc:
354
+ raise AdapterError(exc.code, str(exc)) from exc
355
+ return checked
356
+
357
+ def execute(self, job):
358
+ """Stage the corner deck plus .spiceinit, then run ngspice.
359
+
360
+ Args:
361
+ job: Solver job with operation run_spice plus sources,
362
+ measurements, analyses, and deck inputs.
363
+
364
+ Returns:
365
+ Native output with exit_code, stdout/stderr, log, deck,
366
+ sources, and measurements.
367
+
368
+ Raises:
369
+ AdapterError: invalid_reference for a missing workdir
370
+ or unreadable netlist, runner_unavailable on a sim
371
+ timeout, plus the preflight codes (execute
372
+ re-checks first).
373
+ """
374
+ checked = self.preflight(job)
375
+ workdir = job.get("workdir", ".")
376
+ if not Path(workdir).is_dir():
377
+ raise AdapterError(
378
+ "invalid_reference", f"Workdir not found: {workdir}."
379
+ )
380
+ workdir_path = Path(workdir)
381
+ bodies = [
382
+ _strip_end_lines(_read_source(path)) for path in checked["sources"]
383
+ ]
384
+ deck = build_deck(
385
+ checked["title"],
386
+ checked["corner"],
387
+ checked["temp_c"],
388
+ checked["params"],
389
+ checked["options"],
390
+ checked["model_lib"],
391
+ bodies,
392
+ checked["analyses"],
393
+ checked["measurements"],
394
+ )
395
+ # Rawfiles from an earlier run in this workdir must not pass as this run's data.
396
+ for stale in workdir_path.glob(f"{_WAVEFORM_PREFIX}*.raw"):
397
+ stale.unlink()
398
+ (workdir_path / _DECK_NAME).write_text(deck, encoding="utf-8")
399
+ pdk_pin = job.get("pdk")
400
+ pdk_id = pdk_pin.get("id") if isinstance(pdk_pin, dict) else None
401
+ spiceinit = render_spiceinit(spiceinit_for(pdk_id))
402
+ (workdir_path / _SPICEINIT_NAME).write_text(
403
+ spiceinit, encoding="utf-8"
404
+ )
405
+ roots = tool_roots([checked["ngspice"]])
406
+ proc = self._run(
407
+ [checked["ngspice"], "-b", "-o", _LOG_NAME, _DECK_NAME],
408
+ workdir,
409
+ checked["timeout_s"],
410
+ roots,
411
+ )
412
+ log_path = workdir_path / _LOG_NAME
413
+ log_text = log_path.read_text(encoding="utf-8", errors="replace") if (
414
+ log_path.is_file()
415
+ ) else ""
416
+ return {
417
+ "exit_code": proc.returncode,
418
+ "stdout": proc.stdout,
419
+ "stderr": proc.stderr,
420
+ "log": log_text,
421
+ "deck": deck,
422
+ "waveforms": [
423
+ {"analysis": path.stem.rsplit("-", 1)[-1], "path": str(path)}
424
+ for path in sorted(workdir_path.glob(f"{_WAVEFORM_PREFIX}*.raw"))
425
+ ],
426
+ "sources": checked["sources"],
427
+ "measurements": checked["measurements"],
428
+ }
429
+
430
+ def parse(self, native_output):
431
+ """Parse meas_* values against bounds with engine margins.
432
+
433
+ Bound misses are FAIL (margin always engine-computed, never
434
+ left to the caller); expected measurements with no value
435
+ synthesize sim_fail violations quoting ngspice's error text.
436
+
437
+ Args:
438
+ native_output: Native output with stdout/stderr/log,
439
+ sources, and the expected measurements.
440
+
441
+ Returns:
442
+ {total, violations[], measurements[], source} with
443
+ value, unit, target, margin, and status per
444
+ measurement; source names the channel read
445
+ (`log` for the -o file, `stdio_fallback` else).
446
+
447
+ Raises:
448
+ AdapterError: runner_unavailable when ngspice produced
449
+ no output at all.
450
+ """
451
+ sources = native_output.get("sources") or []
452
+ first_source = sources[0] if sources else ""
453
+ # MIGRATE-STDIO-FILE tier 1: the `-o ngspice.log` file is the
454
+ # parse channel; stdio survives only as a loud fallback for
455
+ # natives recorded before the file channel existed.
456
+ log = native_output.get("log") or ""
457
+ if log.strip():
458
+ combined, source = log, "log"
459
+ else:
460
+ stdio = "\n".join(
461
+ [native_output.get("stdout") or "",
462
+ native_output.get("stderr") or ""]
463
+ )
464
+ if not stdio.strip():
465
+ raise AdapterError(
466
+ "runner_unavailable", "ngspice produced no output at all."
467
+ )
468
+ combined, source = stdio, "stdio_fallback"
469
+ values = {}
470
+ for match in _MEAS_RE.finditer(combined):
471
+ try:
472
+ values[match.group(1)] = float(match.group(2))
473
+ except ValueError:
474
+ continue
475
+ expected = native_output.get("measurements") or []
476
+ missing = [item["name"] for item in expected if item["name"] not in values]
477
+ if missing:
478
+ errors = [
479
+ line.strip()
480
+ for line in combined.splitlines()
481
+ if _ERROR_RE.search(line)
482
+ ]
483
+ detail = (
484
+ f" ngspice said: {errors[0]}"
485
+ if errors
486
+ else " (no meas lines and no ngspice error text)"
487
+ )
488
+ violations = [
489
+ {
490
+ "rule": "sim_fail",
491
+ "severity": "error",
492
+ "message": (
493
+ f"ngspice produced no value for {name}.{detail}"
494
+ ),
495
+ "file": first_source,
496
+ }
497
+ for name in missing
498
+ ]
499
+ return {
500
+ "total": len(violations),
501
+ "violations": violations,
502
+ "source": source,
503
+ }
504
+ violations = []
505
+ measurements = []
506
+ for item in expected:
507
+ actual = values[item["name"]]
508
+ bounds = {
509
+ key: item[key]
510
+ for key in ("min", "max")
511
+ if item.get(key) is not None
512
+ }
513
+ margins = {}
514
+ if "min" in bounds:
515
+ margins["min"] = actual - bounds["min"]
516
+ if "max" in bounds:
517
+ margins["max"] = bounds["max"] - actual
518
+ failed = [
519
+ f"{kind} {bounds[kind]}"
520
+ for kind, margin in margins.items()
521
+ if margin < 0
522
+ ]
523
+ status = "fail" if failed else "pass"
524
+ margin = min(margins.values())
525
+ unit = item.get("unit") or ""
526
+ measurements.append(
527
+ {
528
+ "name": item["name"],
529
+ "value": actual,
530
+ "unit": unit,
531
+ "target": dict(bounds),
532
+ "margin": margin,
533
+ "status": status,
534
+ }
535
+ )
536
+ if failed:
537
+ unit_suffix = f" {unit}" if unit else ""
538
+ violations.append(
539
+ {
540
+ "rule": "measurement",
541
+ "severity": "error",
542
+ "message": (
543
+ f"{item['name']} = {actual:.6g}{unit_suffix} "
544
+ f"misses {', '.join(failed)}{unit_suffix} "
545
+ f"(margin {margin:.6g}{unit_suffix})."
546
+ ),
547
+ "file": first_source,
548
+ }
549
+ )
550
+ return {
551
+ "total": len(violations),
552
+ "violations": violations,
553
+ "measurements": measurements,
554
+ "source": source,
555
+ }
556
+
557
+ @staticmethod
558
+ def _resolve_model(pdk):
559
+ if pdk is None:
560
+ return None
561
+ if not isinstance(pdk, dict):
562
+ raise AdapterError(
563
+ "invalid_command", f"PDK pin must be a mapping: {pdk!r}."
564
+ )
565
+ pdk_id = pdk.get("id")
566
+ revision = pdk.get("revision")
567
+ if not pdk_id or not revision:
568
+ raise AdapterError(
569
+ "invalid_command",
570
+ f"PDK pin needs id and revision: {pdk!r}.",
571
+ )
572
+ rel = pdk.get("model_rel") or default_model_rel(pdk.get("id"))
573
+ if rel is None:
574
+ raise AdapterError(
575
+ "invalid_command",
576
+ f"PDK {pdk.get('id')!r} declares no sim profile: pass an "
577
+ "explicit model_rel (see docs/pdk/<lane>/profile.yaml).",
578
+ )
579
+ try:
580
+ hit = resolve_deck(
581
+ pdk_id, revision, rel, engine_root=pdk.get("engine_root")
582
+ )
583
+ except Exception as exc:
584
+ # PdkError codes (unknown_pdk, pdk_drift, ...) ride
585
+ # through unchanged so a model mismatch fails loudly
586
+ # with its store meaning, never as a generic error.
587
+ raise AdapterError(
588
+ getattr(exc, "code", "unknown_pdk"), str(exc)
589
+ ) from exc
590
+ return hit["path"]
591
+
592
+ @staticmethod
593
+ def _resolve(job):
594
+ tool_dir = job.get("tool_dir")
595
+ if tool_dir:
596
+ candidate = Path(tool_dir) / "ngspice"
597
+ if candidate.is_file():
598
+ return str(candidate)
599
+ raise AdapterError(
600
+ "missing_dependency", f"ngspice not found in {tool_dir}."
601
+ )
602
+ found = shutil.which("ngspice")
603
+ if found is None:
604
+ raise AdapterError(
605
+ "missing_dependency",
606
+ "ngspice not on PATH (pass tool_dir or integrate via B10).",
607
+ )
608
+ return found
609
+
610
+ def _run(self, argv, workdir, timeout_s, extra_binds=()):
611
+ if self._runner is not None:
612
+ try:
613
+ return self._runner.run(argv, workdir, timeout_s, extra_binds)
614
+ except ExecutionError as exc:
615
+ raise AdapterError(exc.code, str(exc)) from exc
616
+ try:
617
+ return subprocess.run(
618
+ argv,
619
+ capture_output=True,
620
+ text=True,
621
+ timeout=timeout_s,
622
+ cwd=workdir,
623
+ )
624
+ except subprocess.TimeoutExpired as exc:
625
+ raise AdapterError(
626
+ "runner_unavailable", f"spice sim timed out after {timeout_s}s."
627
+ ) from exc