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,1390 @@
1
+ """Coupled mixed-signal solver adapter: run_cosim behind the ToolAdapter port.
2
+
3
+ Protocol, mirroring the designer's sim/top_tb.py acceptance plus the
4
+ run_spice/run_hdl_test adapters: stage a transient deck into the
5
+ workdir — preamble (.param/.option/.temp/.lib) + control-free
6
+ netlist bodies + generated .control (single-threaded tran plus a
7
+ wrdata of the sampled node) — run `ngspice -b -o` under a timeout,
8
+ sample the waveform mid-period into bits, stream the bits live into
9
+ the digital half's Verilog bench as cosim_bits.txt, run that under
10
+ icarus via HdlSimAdapter, and judge the JOINT verdict: the SNDR
11
+ gate plus the RTL ones-density/counts/golden-model checks all PASS.
12
+
13
+ Verdicts always come from parsed output, never the exit code: bound
14
+ misses are FAIL with rule measurement (the halves' shared
15
+ quantitative rule, so seam mutations keep their declared rules),
16
+ digital native violations pass through with their own rules, and a
17
+ sim that yields no bits synthesizes a sim_fail violation quoting
18
+ ngspice's error text. Only a missing binary raises
19
+ missing_dependency and only a hung sim raises runner_unavailable.
20
+
21
+ PDK models resolve through the PDK store (resolve_deck): a wrong
22
+ revision, an unknown model relpath, or a tampered model file fails
23
+ loudly in preflight — the adapter never silently sims against
24
+ substitute models. Model-free decks (the tier-1 behavioral tier)
25
+ omit the pdk pin and record pdk None in the run record.
26
+
27
+ Tool resolution: an explicit job `tool_dir` containing ngspice,
28
+ iverilog, and vvp wins, else PATH. Where the tools live on disk is
29
+ B10 scope.
30
+ """
31
+
32
+ # TODO: This whole thing might not even be needed. a COSIM workflow just happens by running a full digital workflow, generating gds, using magic ext2spice given a PDK and other vodoo magic scripts to get a spice netlist, then run LVS. the end goal of an analog/digital cosim is to check the PEX model vs the schematic model to see if they match, that is the end goal.
33
+ import bisect
34
+ import hashlib
35
+ import math
36
+ import re
37
+ import shutil
38
+ import subprocess
39
+ from pathlib import Path
40
+
41
+ from ...execution.errors import ExecutionError
42
+ from ...execution.runner import tool_roots
43
+ from ...interfaces import ToolAdapter
44
+ from ...interfaces.pdks import pdk_ingredient, resolve_deck
45
+ from ..errors import AdapterError
46
+ from ..hdl_sim import HdlSimAdapter
47
+
48
+ OPERATION = "run_cosim"
49
+ TOOL = "cosim"
50
+ VECTOR_NAME = "cosim_bits.txt"
51
+
52
+ _ANALOG_TIMEOUT_S = 300
53
+ _DIGITAL_TIMEOUT_S = 120
54
+ _DECK_NAME = "cosim_deck.spice"
55
+ _LOG_NAME = "cosim_ngspice.log"
56
+ _CSV_NAME = "cosim_out.csv"
57
+ _SPICEINIT_NAME = ".spiceinit"
58
+ _MODEL_REL = "libs.tech/combined/sky130.lib.spice"
59
+
60
+ _GATE_DB = 36.0
61
+ _ONES_MIN = 0.2
62
+ _ONES_MAX = 0.8
63
+ _OSR_FAST = 25
64
+ _OSR_PREC = 250
65
+ _TSTEP_S = 0.5e-9
66
+ _NBITS_MIN = 64
67
+ _NBITS_MAX = 8192
68
+ _SIG_BIN_MIN = 4
69
+
70
+ # First tokens rejected in analog bodies (case-insensitive): the
71
+ # adapter generates the one .control section, so bodies carrying
72
+ # their own sim control are refused rather than merged.
73
+ _CONTROL_FIRST = frozenset(
74
+ {".tran", ".control", ".endc", "run", "write", "wrdata", "set"}
75
+ )
76
+ _MEAS_RE = re.compile(r"^\s*meas_([A-Za-z0-9_]+)\s*=")
77
+ _ERROR_RE = re.compile(r"error", re.IGNORECASE)
78
+ _NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
79
+ _CORNER_RE = re.compile(r"^[A-Za-z0-9_]+$")
80
+ _PARAM_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
81
+ _DEFINE_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(=[A-Za-z0-9_.+-]+)?")
82
+ _BITS_RE = re.compile(r"^COSIM_BITS\s+(\d+)\s+(\d+)\s*$")
83
+ _COUNT_RE = re.compile(r"^COSIM_COUNT_(FAST|PREC)\s+(\d+)\s*$")
84
+ _DATA_RE = re.compile(r"^COSIM_DATA_(FAST|PREC)\s+(-?\d+)\s*$")
85
+
86
+ # Mirrors the ngspice adapter's init (required to read sky130 libs).
87
+ _SPICEINIT = """* rocket_engine cosim init (mirrors the ngspice adapter).
88
+ set ngbehavior=hsa ; HSPICE-ish compatibility, required to read PDK libs
89
+ set skywaterpdk ; skip some checks for faster lib loading
90
+ set ng_nomodcheck ; do not check model parameters
91
+ option noinit ; do not dump operating point data into the log
92
+ option klu ; KLU direct linear solver
93
+ """
94
+
95
+
96
+ def _is_number(value):
97
+ return (
98
+ isinstance(value, (int, float))
99
+ and not isinstance(value, bool)
100
+ and math.isfinite(value)
101
+ )
102
+
103
+
104
+ def _is_int(value):
105
+ return isinstance(value, int) and not isinstance(value, bool)
106
+
107
+
108
+ def _single_line(value):
109
+ return (
110
+ isinstance(value, str)
111
+ and value.strip()
112
+ and "\n" not in value
113
+ and "\r" not in value
114
+ )
115
+
116
+
117
+ def _is_pow2(value):
118
+ return _is_int(value) and value > 0 and (value & (value - 1)) == 0
119
+
120
+
121
+ def _check_params(params):
122
+ clean = {}
123
+ for key, value in (params or {}).items():
124
+ if not isinstance(key, str) or not _PARAM_KEY_RE.match(key):
125
+ raise AdapterError("invalid_command", f"Bad .param name: {key!r}.")
126
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
127
+ raise AdapterError(
128
+ "invalid_command", f"Bad .param value for {key!r}: {value!r}."
129
+ )
130
+ text = str(value)
131
+ if not text or any(char.isspace() for char in text) or '"' in text:
132
+ raise AdapterError(
133
+ "invalid_command", f"Bad .param value for {key!r}: {value!r}."
134
+ )
135
+ clean[key] = text
136
+ return clean
137
+
138
+
139
+ def _check_options(options):
140
+ clean = []
141
+ for value in options or ["wnflag=1"]:
142
+ if not _single_line(value) or value.strip().startswith("."):
143
+ raise AdapterError(
144
+ "invalid_command", f"Bad options line: {value!r}."
145
+ )
146
+ clean.append(value.strip())
147
+ return clean
148
+
149
+
150
+ def _strip_end_lines(text):
151
+ return "\n".join(
152
+ line for line in text.splitlines() if line.strip().lower() != ".end"
153
+ )
154
+
155
+
156
+ def _body_control_line(line):
157
+ bare = line.strip().lower()
158
+ if not bare or bare.startswith("*") or bare.startswith(";"):
159
+ return False
160
+ first = bare.split()[0]
161
+ return first in _CONTROL_FIRST or first.startswith("plot")
162
+
163
+
164
+ def _read_source(path):
165
+ try:
166
+ return Path(path).read_text(encoding="utf-8")
167
+ except OSError as exc:
168
+ raise AdapterError(
169
+ "invalid_reference", f"Netlist unreadable: {path} ({exc})."
170
+ ) from exc
171
+
172
+
173
+ def _fft(values):
174
+ """In-place radix-2 DIT FFT; returns the complex spectrum."""
175
+ data = [complex(v) for v in values]
176
+ size = len(data)
177
+ rank = 0
178
+ for index in range(1, size):
179
+ bit = size >> 1
180
+ while rank & bit:
181
+ rank ^= bit
182
+ bit >>= 1
183
+ rank ^= bit
184
+ if index < rank:
185
+ data[index], data[rank] = data[rank], data[index]
186
+ length = 2
187
+ while length <= size:
188
+ angle = -2 * math.pi / length
189
+ step = complex(math.cos(angle), math.sin(angle))
190
+ for start in range(0, size, length):
191
+ twiddle = 1 + 0j
192
+ half = length // 2
193
+ for pair in range(half):
194
+ up = data[start + pair]
195
+ down = data[start + pair + half] * twiddle
196
+ data[start + pair] = up + down
197
+ data[start + pair + half] = up - down
198
+ twiddle *= step
199
+ length <<= 1
200
+ return data
201
+
202
+
203
+ def rfft_power(samples):
204
+ """Power spectrum bins 0..n/2 of real samples via radix-2 FFT.
205
+
206
+ Args:
207
+ samples: Real-valued sample sequence, power-of-two length.
208
+
209
+ Returns:
210
+ Power magnitudes for bins 0 through n/2 inclusive.
211
+
212
+ Raises:
213
+ ValueError: Empty input, non-power-of-two length, or a
214
+ non-finite sample.
215
+ """
216
+ values = list(samples)
217
+ if not values or not _is_pow2(len(values)):
218
+ raise ValueError(
219
+ f"rfft_power needs power-of-two samples, got {len(values)}."
220
+ )
221
+ try:
222
+ magnitudes = [float(v) for v in values]
223
+ except (TypeError, ValueError) as exc:
224
+ raise ValueError(f"rfft_power needs real samples ({exc}).") from exc
225
+ if not all(math.isfinite(v) for v in magnitudes):
226
+ raise ValueError("rfft_power needs finite samples.")
227
+ spectrum = _fft(magnitudes)
228
+ half = len(spectrum) // 2
229
+ return [c.real * c.real + c.imag * c.imag for c in spectrum[:half + 1]]
230
+
231
+
232
+ def sndr_bits(bits, osr, sig_bin):
233
+ """SNDR in dB with the designer's sim/snr.py estimator arithmetic.
234
+
235
+ Replicates the vendor estimator exactly, including its guard
236
+ quirk: the signal sums spec bins sig-1..sig+1 while the inband
237
+ zeroing covers inband indices sig-3..sig+3, i.e. spec bins
238
+ sig-2..sig+4. Deviates in exactly one place: where numpy yields
239
+ nan with a RuntimeWarning (no signal or no noise power), this
240
+ returns -inf deterministically.
241
+
242
+ Args:
243
+ bits: Bitstream samples as +-1.0 floats, power-of-two count.
244
+ osr: Oversampling ratio defining the band edge n/(2*osr).
245
+ sig_bin: Input tone FFT bin, 4 <= sig_bin <= band-4.
246
+
247
+ Returns:
248
+ SNDR in dB, or -inf when the ratio is non-positive.
249
+
250
+ Raises:
251
+ ValueError: Bad sample count, bad OSR, or a sig_bin whose
252
+ guards do not fit the band.
253
+ """
254
+ values = list(bits)
255
+ if not values or not _is_pow2(len(values)):
256
+ raise ValueError(f"sndr_bits needs power-of-two bits, got {len(values)}.")
257
+ if not _is_int(osr) or osr < 1:
258
+ raise ValueError(f"sndr_bits needs a positive int osr, got {osr!r}.")
259
+ band = len(values) // (2 * osr)
260
+ if (
261
+ not _is_int(sig_bin)
262
+ or sig_bin < _SIG_BIN_MIN
263
+ or sig_bin > band - _SIG_BIN_MIN
264
+ ):
265
+ raise ValueError(
266
+ f"sndr_bits needs 4 <= sig_bin <= band-4 (band {band}), "
267
+ f"got {sig_bin!r}."
268
+ )
269
+ try:
270
+ magnitudes = [float(v) for v in values]
271
+ except (TypeError, ValueError) as exc:
272
+ raise ValueError(f"sndr_bits needs real samples ({exc}).") from exc
273
+ spec = rfft_power(magnitudes)
274
+ p_sig = sum(spec[sig_bin - 1:sig_bin + 2])
275
+ inband = list(spec[1:band + 1])
276
+ for index in range(sig_bin - 3, sig_bin + 4):
277
+ inband[index] = 0.0
278
+ noise = sum(inband)
279
+ if p_sig <= 0 or noise <= 0 or not math.isfinite(p_sig + noise):
280
+ return float("-inf")
281
+ return 10 * math.log10(p_sig / noise)
282
+
283
+
284
+ def _interp(grid_t, grid_v, instant):
285
+ """Linear interpolation with np.interp edge clamping."""
286
+ if instant <= grid_t[0]:
287
+ return grid_v[0]
288
+ if instant >= grid_t[-1]:
289
+ return grid_v[-1]
290
+ slot = bisect.bisect_right(grid_t, instant)
291
+ lo_t, hi_t = grid_t[slot - 1], grid_t[slot]
292
+ if hi_t == lo_t:
293
+ return grid_v[slot]
294
+ frac = (instant - lo_t) / (hi_t - lo_t)
295
+ return grid_v[slot - 1] + frac * (grid_v[slot] - grid_v[slot - 1])
296
+
297
+
298
+ def sample_bits(times, values, nsettle, nbits, period_s, threshold_v):
299
+ """Sample a waveform mid-period into bits, top_tb.py convention.
300
+
301
+ Sample instants are (nsettle + k + 0.5) * period_s for
302
+ k in 0..nbits-1, linearly interpolated with np.interp edge
303
+ clamping; instants at or above the threshold read one.
304
+
305
+ Args:
306
+ times: Ascending grid times in seconds.
307
+ values: Waveform values on the grid.
308
+ nsettle: Discarded startup cycles before the window.
309
+ nbits: Window length in bits.
310
+ period_s: Clock period in seconds.
311
+ threshold_v: Decision threshold in volts.
312
+
313
+ Returns:
314
+ (bits01, bits_pm) pair: 0/1 ints for the RTL vector plus
315
+ +-1.0 floats for the SNDR estimator.
316
+
317
+ Raises:
318
+ ValueError: Empty or ragged grid, non-ascending times, or a
319
+ bad window or period.
320
+ """
321
+ grid_t = list(times)
322
+ grid_v = list(values)
323
+ if (
324
+ not grid_t
325
+ or len(grid_t) != len(grid_v)
326
+ or any(b < a for a, b in zip(grid_t, grid_t[1:]))
327
+ ):
328
+ raise ValueError("sample_bits needs a non-empty ascending grid.")
329
+ if not _is_int(nsettle) or nsettle < 0:
330
+ raise ValueError(f"sample_bits needs nsettle >= 0, got {nsettle!r}.")
331
+ if not _is_int(nbits) or nbits < 1:
332
+ raise ValueError(f"sample_bits needs nbits >= 1, got {nbits!r}.")
333
+ if not _is_number(period_s) or period_s <= 0:
334
+ raise ValueError(f"sample_bits needs period_s > 0, got {period_s!r}.")
335
+ if not _is_number(threshold_v):
336
+ raise ValueError(
337
+ f"sample_bits needs a numeric threshold, got {threshold_v!r}."
338
+ )
339
+ bits01 = []
340
+ bits_pm = []
341
+ for k in range(nbits):
342
+ instant = (nsettle + k + 0.5) * period_s
343
+ level = _interp(grid_t, grid_v, instant)
344
+ one = level >= threshold_v
345
+ bits01.append(1 if one else 0)
346
+ bits_pm.append(1.0 if one else -1.0)
347
+ return bits01, bits_pm
348
+
349
+
350
+ def settled_stats(times, values, nsettle, period_s):
351
+ """Mean/min/max over the settled window (t > nsettle * period).
352
+
353
+ Args:
354
+ times: Grid times in seconds.
355
+ values: Waveform values on the grid.
356
+ nsettle: Discarded startup cycles before the window.
357
+ period_s: Clock period in seconds.
358
+
359
+ Returns:
360
+ (mean, min, max) over samples past the settle point.
361
+
362
+ Raises:
363
+ ValueError: Empty grid or no settled samples.
364
+ """
365
+ grid_t = list(times)
366
+ grid_v = list(values)
367
+ settled = [v for t, v in zip(grid_t, grid_v) if t > nsettle * period_s]
368
+ if not settled:
369
+ raise ValueError("settled_stats found no settled samples.")
370
+ return sum(settled) / len(settled), min(settled), max(settled)
371
+
372
+
373
+ def _sinc3_model(bits, osr):
374
+ """Golden model with the RTL's exact sampling convention (copied
375
+ verbatim from the vendored cocotb TB test_sdm_rx.py)."""
376
+ i1 = i2 = i3 = 0
377
+ z1 = z2 = z3 = 0
378
+ cnt = 0
379
+ out = []
380
+ for b in bits:
381
+ d = 1 if b else -1
382
+ i3_old, c1 = i3, i3 - z1
383
+ c2 = c1 - z2
384
+ c3 = c2 - z3
385
+ i1, i2, i3 = i1 + d, i2 + i1, i3 + i2
386
+ if cnt == osr - 1:
387
+ cnt = 0
388
+ z1, z2, z3 = i3_old, c1, c2
389
+ out.append(_sign32(c3))
390
+ else:
391
+ cnt += 1
392
+ return out
393
+
394
+
395
+ def _sign32(v):
396
+ v &= 0xFFFFFFFF
397
+ return v - (1 << 32) if v & 0x80000000 else v
398
+
399
+
400
+ def build_cosim_deck(title, corner, temp_c, params, options, model_lib,
401
+ bodies, tran_line, sample_node, int_node):
402
+ """Render the coupled deck (preamble + bodies + sampling .control).
403
+
404
+ Args:
405
+ title: Deck title line (without the `*`).
406
+ corner: Model corner named on the .lib line.
407
+ temp_c: Temperature in C for the .temp line.
408
+ params: Cleaned .param name/value mapping.
409
+ options: Cleaned .option lines.
410
+ model_lib: Store model library path, or None for model-free.
411
+ bodies: Netlist body texts (.end lines already stripped).
412
+ tran_line: .control tran line, e.g. `tran 0.5n 42.24u`.
413
+ sample_node: Bitstream node sampled mid-period into bits.
414
+ int_node: Integrator health node, or None to skip it.
415
+
416
+ Returns:
417
+ Rendered deck text with the single .control section.
418
+ """
419
+ head = [
420
+ f"* {title}",
421
+ f"* corner: {corner} temp: {temp_c}C "
422
+ "(generated by rocket_engine cosim adapter)",
423
+ ]
424
+ for key, value in params.items():
425
+ head.append(f".param {key}={value}")
426
+ for opt in options:
427
+ head.append(f".option {opt}")
428
+ head.append(f".temp {temp_c}")
429
+ if model_lib is not None:
430
+ head.append(f'.lib "{model_lib}" {corner}')
431
+ wrdata = f"wrdata {_CSV_NAME} v({sample_node})"
432
+ if int_node is not None:
433
+ wrdata += f" v({int_node})"
434
+ control = [".control", "set num_threads=1", tran_line, wrdata,
435
+ "quit", ".endc", ".end", ""]
436
+ return "\n".join(head + bodies + control)
437
+
438
+
439
+ def _check_node(node):
440
+ return (
441
+ _single_line(node)
442
+ and not any(char.isspace() for char in node)
443
+ )
444
+
445
+
446
+ def _check_loop(analog):
447
+ if not isinstance(analog, dict):
448
+ raise AdapterError(
449
+ "invalid_command", f"run_cosim needs an analog mapping: {analog!r}."
450
+ )
451
+ clock_hz = analog.get("clock_hz")
452
+ if not _is_number(clock_hz) or clock_hz <= 0:
453
+ raise AdapterError(
454
+ "invalid_command", f"Bad analog clock_hz: {clock_hz!r}."
455
+ )
456
+ nbits = analog.get("nbits")
457
+ if not _is_pow2(nbits) or not _NBITS_MIN <= nbits <= _NBITS_MAX:
458
+ raise AdapterError(
459
+ "invalid_command",
460
+ f"Bad analog nbits (power of two "
461
+ f"{_NBITS_MIN}..{_NBITS_MAX}): {nbits!r}.",
462
+ )
463
+ nsettle = analog.get("nsettle")
464
+ if not _is_int(nsettle) or nsettle < 0:
465
+ raise AdapterError(
466
+ "invalid_command", f"Bad analog nsettle: {nsettle!r}."
467
+ )
468
+ osr_fast = analog.get("osr_fast", _OSR_FAST)
469
+ osr_prec = analog.get("osr_prec", _OSR_PREC)
470
+ for name, value in (("osr_fast", osr_fast), ("osr_prec", osr_prec)):
471
+ if not _is_int(value) or value < 1:
472
+ raise AdapterError(
473
+ "invalid_command", f"Bad analog {name}: {value!r}."
474
+ )
475
+ if nbits < osr_fast or nbits < osr_prec:
476
+ raise AdapterError(
477
+ "invalid_command",
478
+ f"Bad analog nbits (must cover a strobe of osr_fast "
479
+ f"{osr_fast} and osr_prec {osr_prec}): {nbits!r}.",
480
+ )
481
+ band = nbits // (2 * osr_fast)
482
+ sig_bin = analog.get("sig_bin")
483
+ if (
484
+ not _is_int(sig_bin)
485
+ or sig_bin < _SIG_BIN_MIN
486
+ or sig_bin > band - _SIG_BIN_MIN
487
+ ):
488
+ raise AdapterError(
489
+ "invalid_command",
490
+ f"Bad analog sig_bin (4 <= bin <= band-4, band {band}): "
491
+ f"{sig_bin!r}.",
492
+ )
493
+ sample_node = analog.get("sample_node")
494
+ if not _check_node(sample_node):
495
+ raise AdapterError(
496
+ "invalid_command", f"Bad analog sample_node: {sample_node!r}."
497
+ )
498
+ threshold = analog.get("sample_threshold_v")
499
+ if not _is_number(threshold):
500
+ raise AdapterError(
501
+ "invalid_command",
502
+ f"Bad analog sample_threshold_v: {threshold!r}.",
503
+ )
504
+ int_node = analog.get("int_node")
505
+ if int_node is not None and not _check_node(int_node):
506
+ raise AdapterError(
507
+ "invalid_command", f"Bad analog int_node: {int_node!r}."
508
+ )
509
+ tstep_s = analog.get("tstep_s", _TSTEP_S)
510
+ if not _is_number(tstep_s) or tstep_s <= 0:
511
+ raise AdapterError(
512
+ "invalid_command", f"Bad analog tstep_s: {tstep_s!r}."
513
+ )
514
+ uic = analog.get("uic", False)
515
+ if not isinstance(uic, bool):
516
+ raise AdapterError(
517
+ "invalid_command", f"Bad analog uic (bool): {uic!r}."
518
+ )
519
+ gate_db = analog.get("gate_db", _GATE_DB)
520
+ if not _is_number(gate_db) or gate_db <= 0:
521
+ raise AdapterError(
522
+ "invalid_command", f"Bad analog gate_db: {gate_db!r}."
523
+ )
524
+ ones_min = analog.get("ones_min", _ONES_MIN)
525
+ ones_max = analog.get("ones_max", _ONES_MAX)
526
+ if not (
527
+ _is_number(ones_min)
528
+ and _is_number(ones_max)
529
+ and 0 < ones_min < ones_max < 1
530
+ ):
531
+ raise AdapterError(
532
+ "invalid_command",
533
+ f"Bad analog ones bounds: [{ones_min!r}, {ones_max!r}].",
534
+ )
535
+ corner = analog.get("corner", "tt")
536
+ if not isinstance(corner, str) or not _CORNER_RE.match(corner):
537
+ raise AdapterError("invalid_command", f"Bad corner: {corner!r}.")
538
+ temp_c = analog.get("temp_c", 27)
539
+ if not _is_number(temp_c):
540
+ raise AdapterError("invalid_command", f"Bad temp_c: {temp_c!r}.")
541
+ title = analog.get("title", "rocket_engine run_cosim")
542
+ if not _single_line(title):
543
+ raise AdapterError("invalid_command", f"Bad title: {title!r}.")
544
+ return {
545
+ "clock_hz": clock_hz,
546
+ "nbits": nbits,
547
+ "nsettle": nsettle,
548
+ "osr_fast": osr_fast,
549
+ "osr_prec": osr_prec,
550
+ "sig_bin": sig_bin,
551
+ "sample_node": sample_node.strip(),
552
+ "sample_threshold_v": threshold,
553
+ "int_node": int_node.strip() if int_node is not None else None,
554
+ "tstep_s": tstep_s,
555
+ "tstop_s": (nbits + nsettle) / clock_hz,
556
+ "uic": uic,
557
+ "gate_db": gate_db,
558
+ "ones_min": ones_min,
559
+ "ones_max": ones_max,
560
+ "corner": corner,
561
+ "temp_c": temp_c,
562
+ "title": title.strip(),
563
+ "params": _check_params(analog.get("params")),
564
+ "options": _check_options(analog.get("options")),
565
+ }
566
+
567
+
568
+ def _parse_wrdata(text, want_int):
569
+ """Grid columns from ngspice wrdata text: (t, sample[, int])."""
570
+ rows = []
571
+ for line in text.splitlines():
572
+ cells = line.split()
573
+ if not cells:
574
+ continue
575
+ try:
576
+ rows.append([float(cell) for cell in cells])
577
+ except ValueError:
578
+ return None, "non-numeric wrdata row"
579
+ if len(rows) < 2 or any(len(row) < 2 for row in rows):
580
+ return None, "short wrdata table"
581
+ if want_int and any(len(row) < 4 for row in rows):
582
+ return None, "wrdata misses the int column"
583
+ if any(b < a for a, b in zip([r[0] for r in rows], [r[0] for r in rows][1:])):
584
+ return None, "non-ascending wrdata times"
585
+ times = [row[0] for row in rows]
586
+ sample = [row[1] for row in rows]
587
+ integ = [row[3] for row in rows] if want_int else None
588
+ return (times, sample, integ), None
589
+
590
+
591
+ class CosimAdapter(ToolAdapter):
592
+ """run_cosim via ngspice plus icarus: coupled mixed-signal verdicts.
593
+
594
+ Serves the run_cosim operation with deck.tool `cosim` (the only
595
+ tool). Runs a transient on the analog half, samples the bitstream
596
+ node mid-period, streams the live bits into the digital half's
597
+ bench, and judges the joint verdict (SNDR gate plus RTL
598
+ ones-density/counts/golden-model checks).
599
+
600
+ Job keys:
601
+ analog_sources (required): Control-free SPICE body paths.
602
+ digital_sources (required): Verilog source paths, bench
603
+ included; the bench reads cosim_bits.txt from its cwd.
604
+ analog (required): Loop mapping with clock_hz, nbits
605
+ (power of two 64..8192), nsettle, sig_bin (4..band-4),
606
+ sample_node, sample_threshold_v, and optional int_node,
607
+ tstep_s, uic, gate_db, ones_min/ones_max, osr_fast/osr_prec,
608
+ corner, temp_c, title, params, options, pdk.
609
+ defines: Extra Verilog `define list; COSIM_NBITS is added.
610
+ timeout_s: Per-half budget in seconds (default 300).
611
+ analog_timeout_s: Analog budget override.
612
+ digital_timeout_s: Digital budget override (default 120).
613
+ tool_dir: Directory holding ngspice/iverilog/vvp; PATH
614
+ otherwise.
615
+ deck: Optional mapping; deck.tool must be cosim here.
616
+ workdir: Run directory (default "."); analog/ and digital/
617
+ subdirs are created beneath it.
618
+
619
+ Args:
620
+ runner: Optional isolated runner; direct subprocess when None.
621
+
622
+ Caution: The verdict always comes from parsed output, never the
623
+ exit code — and a tran that ends before the window closes
624
+ fails loudly (sim_fail) instead of judging clamped tail
625
+ bits.
626
+ """
627
+
628
+ def __init__(self, runner=None):
629
+ self._runner = runner
630
+
631
+ def capabilities(self, target):
632
+ """Report the served operation plus the coupled tools.
633
+
634
+ Args:
635
+ target: Target the capabilities are reported for
636
+ (accepted, unused — one loop per job).
637
+
638
+ Returns:
639
+ Capability mapping with operations, tool, analog, and
640
+ digital.
641
+ """
642
+ return {
643
+ "operations": [OPERATION],
644
+ "tool": TOOL,
645
+ "analog": "ngspice",
646
+ "digital": "icarus",
647
+ }
648
+
649
+ def preflight(self, job):
650
+ """Check operation, halves, loop config, PDK pin, and tools.
651
+
652
+ Cheap deterministic checks first, PDK resolution next (a
653
+ wrong rev must fail before any sim), tool resolution last.
654
+
655
+ Args:
656
+ job: Solver job with operation run_cosim plus analog
657
+ and digital halves and the loop config.
658
+
659
+ Returns:
660
+ Checked mapping with resolved tool paths, sources,
661
+ defines, timeouts, loop config, deck shape, and the
662
+ model library path (None when model-free).
663
+
664
+ Raises:
665
+ AdapterError: unsupported_operation for another
666
+ operation, invalid_command for a bad loop config,
667
+ deck shape, timeout, or defines, or for a body
668
+ carrying sim control, invalid_reference for missing
669
+ sources, store codes (unknown_pdk, pdk_drift, ...)
670
+ for a bad PDK pin, missing_dependency for
671
+ unresolvable tools.
672
+ """
673
+ if job.get("operation") != OPERATION:
674
+ raise AdapterError(
675
+ "unsupported_operation",
676
+ f"cosim handles {OPERATION}, got {job.get('operation')}.",
677
+ )
678
+ timeout = job.get("timeout_s", _ANALOG_TIMEOUT_S)
679
+ analog_timeout = job.get("analog_timeout_s", timeout)
680
+ digital_timeout = job.get("digital_timeout_s", _DIGITAL_TIMEOUT_S)
681
+ for name, value in (("timeout_s", timeout),
682
+ ("analog_timeout_s", analog_timeout),
683
+ ("digital_timeout_s", digital_timeout)):
684
+ if (
685
+ not isinstance(value, (int, float))
686
+ or isinstance(value, bool)
687
+ or not value > 0
688
+ ):
689
+ raise AdapterError(
690
+ "invalid_command", f"Bad {name}: {value!r}."
691
+ )
692
+ analog_sources = job.get("analog_sources") or []
693
+ missing = [s for s in analog_sources if not Path(s).is_file()]
694
+ if not analog_sources or missing:
695
+ raise AdapterError(
696
+ "invalid_reference",
697
+ f"Analog sources not found: {missing or analog_sources}.",
698
+ )
699
+ digital_sources = job.get("digital_sources") or []
700
+ missing = [s for s in digital_sources if not Path(s).is_file()]
701
+ if not digital_sources or missing:
702
+ raise AdapterError(
703
+ "invalid_reference",
704
+ f"Digital sources not found: {missing or digital_sources}.",
705
+ )
706
+ for path in analog_sources:
707
+ for line in _read_source(path).splitlines():
708
+ if _body_control_line(line):
709
+ raise AdapterError(
710
+ "invalid_command",
711
+ f"Analog body carries sim control: {path}: "
712
+ f"{line.strip()[:60]}.",
713
+ )
714
+ defines = job.get("defines") or []
715
+ if not isinstance(defines, list) or any(
716
+ not isinstance(d, str) or not _DEFINE_RE.fullmatch(d) for d in defines
717
+ ):
718
+ raise AdapterError("invalid_command", f"Bad defines: {defines!r}.")
719
+ loop = _check_loop(job.get("analog"))
720
+ model_lib = self._resolve_model(loop, job)
721
+ checked = {
722
+ "ngspice": self._resolve(job, "ngspice"),
723
+ "iverilog": self._resolve(job, "iverilog"),
724
+ "vvp": self._resolve(job, "vvp"),
725
+ "analog_sources": list(analog_sources),
726
+ "digital_sources": list(digital_sources),
727
+ "defines": list(defines),
728
+ "timeout_s": timeout,
729
+ "analog_timeout_s": analog_timeout,
730
+ "digital_timeout_s": digital_timeout,
731
+ "model_lib": model_lib,
732
+ }
733
+ checked.update(loop)
734
+ if self._runner is not None:
735
+ try:
736
+ self._runner.preflight()
737
+ except ExecutionError as exc:
738
+ raise AdapterError(exc.code, str(exc)) from exc
739
+ return checked
740
+
741
+ def execute(self, job):
742
+ """Run the coupled sim: tran, sample, stream, RTL, joint native.
743
+
744
+ Stages the sampling deck, runs ngspice, samples mid-period
745
+ into bits, writes the live vector, and runs the digital half
746
+ through HdlSimAdapter. A valueless analog sim returns its
747
+ block with no digital half; the verdict still comes from
748
+ parse, never the exit code alone.
749
+
750
+ Args:
751
+ job: Solver job with operation run_cosim plus analog
752
+ and digital halves and the loop config.
753
+
754
+ Returns:
755
+ Native output with exit_code, the analog block, the
756
+ digital native (None when the analog half yielded
757
+ no bits), sampled bits, SNDR, ones-density,
758
+ integrator stats, loop echoes, and gates.
759
+
760
+ Raises:
761
+ AdapterError: invalid_reference for a missing workdir,
762
+ runner_unavailable on a sim timeout, plus the
763
+ preflight codes (execute re-checks first).
764
+ """
765
+ checked = self.preflight(job)
766
+ workdir = job.get("workdir", ".")
767
+ if not Path(workdir).is_dir():
768
+ raise AdapterError(
769
+ "invalid_reference", f"Workdir not found: {workdir}."
770
+ )
771
+ workdir_path = Path(workdir)
772
+ analog_dir = workdir_path / "analog"
773
+ digital_dir = workdir_path / "digital"
774
+ analog_dir.mkdir(parents=True, exist_ok=True)
775
+ digital_dir.mkdir(parents=True, exist_ok=True)
776
+ # Remove any previous run's output FIRST: an aborted sim must
777
+ # never be judged on stale data from the prior invocation.
778
+ stale = analog_dir / _CSV_NAME
779
+ if stale.exists():
780
+ stale.unlink()
781
+ bodies = [
782
+ _strip_end_lines(_read_source(path))
783
+ for path in checked["analog_sources"]
784
+ ]
785
+ period_s = 1 / checked["clock_hz"]
786
+ tran_line = f"tran {checked['tstep_s']:.6g} {checked['tstop_s']:.6g}"
787
+ if checked["uic"]:
788
+ tran_line += " uic"
789
+ deck = build_cosim_deck(
790
+ checked["title"],
791
+ checked["corner"],
792
+ checked["temp_c"],
793
+ checked["params"],
794
+ checked["options"],
795
+ checked["model_lib"],
796
+ bodies,
797
+ tran_line,
798
+ checked["sample_node"],
799
+ checked["int_node"],
800
+ )
801
+ (analog_dir / _DECK_NAME).write_text(deck, encoding="utf-8")
802
+ (analog_dir / _SPICEINIT_NAME).write_text(_SPICEINIT, encoding="utf-8")
803
+ roots = tool_roots([checked["ngspice"]])
804
+ proc = self._run(
805
+ [checked["ngspice"], "-b", "-o", _LOG_NAME, _DECK_NAME],
806
+ str(analog_dir),
807
+ checked["analog_timeout_s"],
808
+ roots,
809
+ )
810
+ log_path = analog_dir / _LOG_NAME
811
+ log_text = log_path.read_text(encoding="utf-8", errors="replace") if (
812
+ log_path.is_file()
813
+ ) else ""
814
+ analog = {
815
+ "exit_code": proc.returncode,
816
+ "stdout": proc.stdout,
817
+ "stderr": proc.stderr,
818
+ "log": log_text,
819
+ "deck": deck,
820
+ "sources": checked["analog_sources"],
821
+ "csv_rows": 0,
822
+ "error": None,
823
+ }
824
+ bits01, detail = self._sample_analog(checked, analog_dir, analog)
825
+ if detail is not None or bits01 is None:
826
+ analog["error"] = detail or "no bits sampled"
827
+ return self._native(checked, analog, None, None, proc.returncode or 1)
828
+ bits_pm = [1.0 if b else -1.0 for b in bits01]
829
+ ones = sum(bits01) / len(bits01)
830
+ sndr_db = sndr_bits(bits_pm, checked["osr_fast"], checked["sig_bin"])
831
+ (digital_dir / VECTOR_NAME).write_text(
832
+ "\n".join(str(b) for b in bits01) + "\n", encoding="utf-8"
833
+ )
834
+ hdl_job = {
835
+ "operation": "run_hdl_test",
836
+ "sources": checked["digital_sources"],
837
+ "workdir": str(digital_dir),
838
+ "timeout_s": checked["digital_timeout_s"],
839
+ "defines": checked["defines"] + [f"COSIM_NBITS={checked['nbits']}"],
840
+ }
841
+ if job.get("tool_dir"):
842
+ hdl_job["tool_dir"] = job["tool_dir"]
843
+ digital = HdlSimAdapter(runner=self._runner).execute(hdl_job)
844
+ native = self._native(checked, analog, digital, bits01,
845
+ digital["exit_code"])
846
+ native["ones_density"] = ones
847
+ native["sndr_db"] = sndr_db
848
+ return native
849
+
850
+ def parse(self, native_output):
851
+ """Judge the joint verdict from the coupled native.
852
+
853
+ Analog gates (SNDR, ones-density) always evaluate;
854
+ digital native violations pass through with their own
855
+ rules; the Python-side handoff/count/golden checks
856
+ evaluate only while the bench reports clean, so a $fatal
857
+ stands alone instead of collecting pile-on violations.
858
+
859
+ Args:
860
+ native_output: Native output with the analog block,
861
+ the digital native, sampled bits, SNDR, loop
862
+ echoes, and gates.
863
+
864
+ Returns:
865
+ {total, violations[], measurements[], source} with
866
+ rule measurement for quantitative misses, digital
867
+ native rules passed through, sim_fail for
868
+ valueless halves, and the COSIM_* channel read
869
+ (`log` or `stdio_fallback`).
870
+
871
+ Raises:
872
+ AdapterError: runner_unavailable when sampled bits
873
+ exist but the digital block is missing.
874
+ """
875
+ return _parse_joint(native_output)
876
+
877
+ def _sample_analog(self, checked, analog_dir, analog):
878
+ csv_path = analog_dir / _CSV_NAME
879
+ if not csv_path.is_file():
880
+ return None, self._no_value_detail(
881
+ analog, checked["sample_node"], "no waveform file")
882
+ grid, problem = _parse_wrdata(
883
+ csv_path.read_text(encoding="utf-8", errors="replace"),
884
+ checked["int_node"] is not None,
885
+ )
886
+ if grid is None:
887
+ return None, self._no_value_detail(
888
+ analog, checked["sample_node"], problem)
889
+ times, sample, integ = grid
890
+ analog["csv_rows"] = len(times)
891
+ period_s = 1 / checked["clock_hz"]
892
+ last_instant = (checked["nsettle"] + checked["nbits"] - 0.5) * period_s
893
+ if times[-1] < last_instant:
894
+ return None, (
895
+ f"tran ended at {times[-1]:.6g} s before the window "
896
+ f"closed at {last_instant:.6g} s."
897
+ )
898
+ bits01, _ = sample_bits(
899
+ times, sample, checked["nsettle"], checked["nbits"],
900
+ period_s, checked["sample_threshold_v"],
901
+ )
902
+ if integ is not None:
903
+ mean, low, high = settled_stats(
904
+ times, integ, checked["nsettle"], period_s)
905
+ analog["int_mean_v"] = mean
906
+ analog["int_min_v"] = low
907
+ analog["int_max_v"] = high
908
+ return bits01, None
909
+
910
+ @staticmethod
911
+ def _no_value_detail(analog, node, problem):
912
+ combined = "\n".join(
913
+ [analog.get("stdout") or "", analog.get("stderr") or "",
914
+ analog.get("log") or ""]
915
+ )
916
+ errors = [line.strip() for line in combined.splitlines()
917
+ if _ERROR_RE.search(line)]
918
+ said = f" ngspice said: {errors[0]}" if errors else ""
919
+ return f"ngspice produced no waveform for v({node}) ({problem}).{said}"
920
+
921
+ @staticmethod
922
+ def _native(checked, analog, digital, bits01, exit_code):
923
+ return {
924
+ "exit_code": exit_code,
925
+ "analog": analog,
926
+ "digital": digital,
927
+ "bits": bits01,
928
+ "ones_density": None,
929
+ "sndr_db": None,
930
+ "int_mean_v": analog.get("int_mean_v"),
931
+ "int_min_v": analog.get("int_min_v"),
932
+ "int_max_v": analog.get("int_max_v"),
933
+ "nbits": checked["nbits"],
934
+ "sig_bin": checked["sig_bin"],
935
+ "osr_fast": checked["osr_fast"],
936
+ "osr_prec": checked["osr_prec"],
937
+ "gates": {
938
+ "gate_db": checked["gate_db"],
939
+ "ones_min": checked["ones_min"],
940
+ "ones_max": checked["ones_max"],
941
+ },
942
+ "vector_name": VECTOR_NAME,
943
+ }
944
+
945
+ @staticmethod
946
+ def _resolve_model(loop, job):
947
+ pdk = loop_analog_pdk(job)
948
+ if pdk is None:
949
+ return None
950
+ if not isinstance(pdk, dict):
951
+ raise AdapterError(
952
+ "invalid_command", f"PDK pin must be a mapping: {pdk!r}."
953
+ )
954
+ pdk_id = pdk.get("id")
955
+ revision = pdk.get("revision")
956
+ if not pdk_id or not revision:
957
+ raise AdapterError(
958
+ "invalid_command",
959
+ f"PDK pin needs id and revision: {pdk!r}.",
960
+ )
961
+ rel = pdk.get("model_rel", _MODEL_REL)
962
+ try:
963
+ hit = resolve_deck(
964
+ pdk_id, revision, rel, engine_root=pdk.get("engine_root")
965
+ )
966
+ except Exception as exc:
967
+ # PdkError codes (unknown_pdk, pdk_drift, ...) ride
968
+ # through unchanged so a model mismatch fails loudly
969
+ # with its store meaning, never as a generic error.
970
+ raise AdapterError(
971
+ getattr(exc, "code", "unknown_pdk"), str(exc)
972
+ ) from exc
973
+ return hit["path"]
974
+
975
+ @staticmethod
976
+ def _resolve(job, binary):
977
+ tool_dir = job.get("tool_dir")
978
+ if tool_dir:
979
+ candidate = Path(tool_dir) / binary
980
+ if candidate.is_file():
981
+ return str(candidate)
982
+ raise AdapterError(
983
+ "missing_dependency", f"{binary} not found in {tool_dir}."
984
+ )
985
+ found = shutil.which(binary)
986
+ if found is None:
987
+ raise AdapterError(
988
+ "missing_dependency",
989
+ f"{binary} not on PATH (pass tool_dir or integrate via B10).",
990
+ )
991
+ return found
992
+
993
+ def _run(self, argv, workdir, timeout_s, extra_binds=()):
994
+ if self._runner is not None:
995
+ try:
996
+ return self._runner.run(argv, workdir, timeout_s, extra_binds)
997
+ except ExecutionError as exc:
998
+ raise AdapterError(exc.code, str(exc)) from exc
999
+ try:
1000
+ return subprocess.run(
1001
+ argv,
1002
+ capture_output=True,
1003
+ text=True,
1004
+ timeout=timeout_s,
1005
+ cwd=workdir,
1006
+ )
1007
+ except subprocess.TimeoutExpired as exc:
1008
+ raise AdapterError(
1009
+ "runner_unavailable",
1010
+ f"cosim analog sim timed out after {timeout_s}s.",
1011
+ ) from exc
1012
+
1013
+
1014
+ def loop_analog_pdk(job):
1015
+ """Read the analog PDK pin from a run_cosim job mapping.
1016
+
1017
+ Args:
1018
+ job: Solver job mapping carrying the analog config.
1019
+
1020
+ Returns:
1021
+ PDK pin mapping, or None when the deck is model-free.
1022
+ """
1023
+ analog = job.get("analog") or {}
1024
+ return analog.get("pdk")
1025
+
1026
+
1027
+ def _measurement_violation(first_source, name, actual, failed, margin, unit):
1028
+ unit_suffix = f" {unit}" if unit else ""
1029
+ return {
1030
+ "rule": "measurement",
1031
+ "severity": "error",
1032
+ "message": (
1033
+ f"{name} = {actual:.6g}{unit_suffix} "
1034
+ f"misses {', '.join(failed)}{unit_suffix} "
1035
+ f"(margin {margin:.6g}{unit_suffix})."
1036
+ ),
1037
+ "file": first_source,
1038
+ }
1039
+
1040
+
1041
+ def _parse_cosim_reports(text):
1042
+ """COSIM_* bench reports: (bits, counts, data) mappings or None."""
1043
+ bits = counts = data = None
1044
+ for line in text.splitlines():
1045
+ match = _BITS_RE.match(line.strip())
1046
+ if match:
1047
+ bits = {"total": int(match.group(1)), "ones": int(match.group(2))}
1048
+ continue
1049
+ match = _COUNT_RE.match(line.strip())
1050
+ if match:
1051
+ counts = counts or {}
1052
+ counts[match.group(1).lower()] = int(match.group(2))
1053
+ continue
1054
+ match = _DATA_RE.match(line.strip())
1055
+ if match:
1056
+ data = data or {}
1057
+ data[match.group(1).lower()] = int(match.group(2))
1058
+ if bits is None or counts is None or data is None:
1059
+ return None
1060
+ if set(counts) != {"fast", "prec"} or set(data) != {"fast", "prec"}:
1061
+ return None
1062
+ return bits, counts, data
1063
+
1064
+
1065
+ def _parse_joint(native_output):
1066
+ """Joint analog/digital verdict (parse delegates here)."""
1067
+ analog = native_output.get("analog") or {}
1068
+ sources = analog.get("sources") or []
1069
+ first_source = sources[0] if sources else ""
1070
+ bits = native_output.get("bits")
1071
+ if not bits:
1072
+ detail = analog.get("error") or "no bits sampled"
1073
+ violations = [
1074
+ {
1075
+ "rule": "sim_fail",
1076
+ "severity": "error",
1077
+ "message": f"cosim analog half yielded no bits ({detail}).",
1078
+ "file": first_source,
1079
+ }
1080
+ ]
1081
+ analog_log = (analog.get("log") or "").strip()
1082
+ return {
1083
+ "total": 1,
1084
+ "violations": violations,
1085
+ "measurements": [],
1086
+ "source": "log" if analog_log else "stdio_fallback",
1087
+ }
1088
+ gates = native_output.get("gates") or {}
1089
+ gate_db = gates.get("gate_db", _GATE_DB)
1090
+ ones_min = gates.get("ones_min", _ONES_MIN)
1091
+ ones_max = gates.get("ones_max", _ONES_MAX)
1092
+ violations = []
1093
+ measurements = []
1094
+ sndr_db = native_output.get("sndr_db")
1095
+ margin = sndr_db - gate_db
1096
+ status = "fail" if margin < 0 else "pass"
1097
+ measurements.append(
1098
+ {
1099
+ "name": "sndr_fast_db",
1100
+ "value": sndr_db,
1101
+ "unit": "dB",
1102
+ "target": {"min": gate_db},
1103
+ "margin": margin,
1104
+ "status": status,
1105
+ }
1106
+ )
1107
+ if status == "fail":
1108
+ violations.append(
1109
+ _measurement_violation(
1110
+ first_source, "sndr_fast_db", sndr_db,
1111
+ [f"min {gate_db}"], margin, "dB")
1112
+ )
1113
+ ones = native_output.get("ones_density")
1114
+ margins = {"min": ones - ones_min, "max": ones_max - ones}
1115
+ failed = [f"{kind} {bound}" for kind, bound, edge in
1116
+ (("min", ones_min, margins["min"]),
1117
+ ("max", ones_max, margins["max"])) if edge < 0]
1118
+ margin = min(margins.values())
1119
+ status = "fail" if failed else "pass"
1120
+ measurements.append(
1121
+ {
1122
+ "name": "ones_density",
1123
+ "value": ones,
1124
+ "unit": "",
1125
+ "target": {"min": ones_min, "max": ones_max},
1126
+ "margin": margin,
1127
+ "status": status,
1128
+ }
1129
+ )
1130
+ if status == "fail":
1131
+ violations.append(
1132
+ _measurement_violation(
1133
+ first_source, "ones_density", ones, failed, margin, "")
1134
+ )
1135
+ digital = native_output.get("digital")
1136
+ if digital is None:
1137
+ raise AdapterError(
1138
+ "runner_unavailable", "Native output has no digital block."
1139
+ )
1140
+ fres = HdlSimAdapter().parse(digital)
1141
+ violations.extend(fres["violations"])
1142
+ run_block = digital.get("run") or {}
1143
+ # MIGRATE-STDIO-FILE: the `vvp -l` log (written by the shared
1144
+ # HdlSimAdapter.execute digital half) is the reports channel;
1145
+ # stdio survives only as a loud fallback for older natives.
1146
+ run_log = run_block.get("log") or ""
1147
+ if run_log.strip():
1148
+ text, report_source = run_log, "log"
1149
+ else:
1150
+ text = run_block.get("stdout", "") + "\n" + run_block.get("stderr", "")
1151
+ report_source = "stdio_fallback"
1152
+ if any(v.get("severity") == "error" for v in fres["violations"]):
1153
+ # The bench already failed: its reports are untrustworthy, so
1154
+ # the Python-side handoff/count/golden checks stay silent and
1155
+ # the native rule stands alone (no pile-on after $fatal).
1156
+ return {
1157
+ "total": len(violations),
1158
+ "violations": violations,
1159
+ "measurements": measurements,
1160
+ "source": report_source,
1161
+ }
1162
+ reports = _parse_cosim_reports(text)
1163
+ if reports is None:
1164
+ violations.append(
1165
+ {
1166
+ "rule": "sim_fail",
1167
+ "severity": "error",
1168
+ "message": "cosim bench reported no COSIM_* lines.",
1169
+ "file": first_source,
1170
+ }
1171
+ )
1172
+ return {
1173
+ "total": len(violations),
1174
+ "violations": violations,
1175
+ "measurements": measurements,
1176
+ "source": report_source,
1177
+ }
1178
+ report_bits, counts, data = reports
1179
+ nbits = native_output.get("nbits", len(bits))
1180
+ analog_ones = sum(bits)
1181
+ if report_bits["total"] != nbits or report_bits["ones"] != analog_ones:
1182
+ if report_bits["total"] != nbits:
1183
+ violations.append(
1184
+ _measurement_violation(
1185
+ first_source, "rtl_total", report_bits["total"],
1186
+ [f"streamed {nbits}"],
1187
+ report_bits["total"] - nbits, "")
1188
+ )
1189
+ if report_bits["ones"] != analog_ones:
1190
+ violations.append(
1191
+ _measurement_violation(
1192
+ first_source, "rtl_ones", report_bits["ones"],
1193
+ [f"analog {analog_ones}"],
1194
+ -(abs(report_bits["ones"] - analog_ones)), "")
1195
+ )
1196
+ osr_fast = native_output.get("osr_fast", _OSR_FAST)
1197
+ osr_prec = native_output.get("osr_prec", _OSR_PREC)
1198
+ for name, want in (("count_fast", nbits // osr_fast),
1199
+ ("count_prec", nbits // osr_prec)):
1200
+ got = counts["fast" if name == "count_fast" else "prec"]
1201
+ if got != want:
1202
+ violations.append(
1203
+ _measurement_violation(
1204
+ first_source, name, got, [f"strobes {want}"],
1205
+ -(abs(got - want)), "")
1206
+ )
1207
+ for name, osr, key in (("data_fast", osr_fast, "fast"),
1208
+ ("data_prec", osr_prec, "prec")):
1209
+ model = _sinc3_model(bits, osr)[-1]
1210
+ got = data[key]
1211
+ margin = -abs(got - model)
1212
+ status = "fail" if got != model else "pass"
1213
+ measurements.append(
1214
+ {
1215
+ "name": name,
1216
+ "value": got,
1217
+ "unit": "",
1218
+ "target": {"min": model, "max": model},
1219
+ "margin": margin,
1220
+ "status": status,
1221
+ }
1222
+ )
1223
+ if status == "fail":
1224
+ violations.append(
1225
+ _measurement_violation(
1226
+ first_source, name, got, [f"golden {model}"],
1227
+ margin, "")
1228
+ )
1229
+ return {
1230
+ "total": len(violations),
1231
+ "violations": violations,
1232
+ "measurements": measurements,
1233
+ "source": report_source,
1234
+ }
1235
+
1236
+
1237
+ def _sha256_text(text):
1238
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
1239
+
1240
+
1241
+ def _sha256_file(path):
1242
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
1243
+
1244
+
1245
+ def _probe_versions():
1246
+ versions = {}
1247
+ for binary in ("ngspice", "iverilog", "vvp"):
1248
+ found = shutil.which(binary)
1249
+ versions[binary] = found or "absent"
1250
+ if found:
1251
+ flag = "--version" if binary == "ngspice" else "-V"
1252
+ try:
1253
+ proc = subprocess.run(
1254
+ [binary, flag], capture_output=True, text=True, timeout=30
1255
+ )
1256
+ versions[binary] = (proc.stdout + proc.stderr).splitlines()[0][:120]
1257
+ except Exception:
1258
+ pass
1259
+ return versions
1260
+
1261
+
1262
+ def build_run_record(case, design, job, native, parsed, mutation=None,
1263
+ versions=None, extra=None, engine_root=None):
1264
+ """Build the I28 run record with ingredient closure for both halves.
1265
+
1266
+ Pins every regenerability ingredient: per-half source bytes,
1267
+ deck bytes, the streamed vector bytes, sampled bits, loop
1268
+ config, solver versions, and the PDK closure (verified fresh —
1269
+ drift fails loudly instead of recording a stale hash).
1270
+
1271
+ Args:
1272
+ case: Case name for the record.
1273
+ design: Design pair name, e.g. sd_top+sdm_rx.
1274
+ job: run_cosim job mapping the run executed.
1275
+ native: Native output mapping from execute.
1276
+ parsed: Parsed result mapping from parse.
1277
+ mutation: Mutation evidence mapping, or None for good.
1278
+ versions: Solver version mapping; probed live when omitted.
1279
+ extra: Extra ingredient mappings merged in (fixture pin).
1280
+ engine_root: Explicit PDK store root; resolved when omitted.
1281
+
1282
+ Returns:
1283
+ Run record mapping with case, design, engine, ingredients,
1284
+ and results sections.
1285
+
1286
+ Raises:
1287
+ OSError: A recorded source file is unreadable.
1288
+ PdkError: unknown_pdk for a missing entry, pdk_drift when
1289
+ the store changed since registration.
1290
+ """
1291
+ analog_cfg = job.get("analog") or {}
1292
+ analog = native.get("analog") or {}
1293
+ pdk_pin = analog_cfg.get("pdk")
1294
+ if pdk_pin is None:
1295
+ pdk = None
1296
+ else:
1297
+ pdk = pdk_ingredient(
1298
+ pdk_pin["id"], pdk_pin["revision"],
1299
+ engine_root=pdk_pin.get("engine_root", engine_root),
1300
+ )
1301
+ analog_sources = [
1302
+ {"name": Path(path).name, "sha256": _sha256_file(path)}
1303
+ for path in checked_job_sources(job, "analog_sources")
1304
+ ]
1305
+ digital_sources = [
1306
+ {"name": Path(path).name, "sha256": _sha256_file(path)}
1307
+ for path in checked_job_sources(job, "digital_sources")
1308
+ ]
1309
+ bits = native.get("bits") or []
1310
+ bits_text = "\n".join(str(b) for b in bits) + "\n" if bits else ""
1311
+ versions = dict(versions) if versions is not None else _probe_versions()
1312
+ verdict = "FAIL" if any(v.get("severity") == "error"
1313
+ for v in parsed.get("violations", [])) else "PASS"
1314
+ ingredients = {
1315
+ "analog": {
1316
+ "solver": "ngspice",
1317
+ "tool_version": versions.get("ngspice"),
1318
+ "sources": sorted(analog_sources, key=lambda s: s["name"]),
1319
+ "deck_sha256": _sha256_text(analog.get("deck") or ""),
1320
+ "pdk": pdk,
1321
+ },
1322
+ "digital": {
1323
+ "solver": "icarus",
1324
+ "tool_versions": {
1325
+ "iverilog": versions.get("iverilog"),
1326
+ "vvp": versions.get("vvp"),
1327
+ },
1328
+ "sources": sorted(digital_sources, key=lambda s: s["name"]),
1329
+ "defines": list(job.get("defines") or []),
1330
+ },
1331
+ "config": {
1332
+ "clock_hz": analog_cfg.get("clock_hz"),
1333
+ "nbits": analog_cfg.get("nbits"),
1334
+ "nsettle": analog_cfg.get("nsettle"),
1335
+ "sig_bin": analog_cfg.get("sig_bin"),
1336
+ "osr_fast": analog_cfg.get("osr_fast", _OSR_FAST),
1337
+ "osr_prec": analog_cfg.get("osr_prec", _OSR_PREC),
1338
+ "sample_node": analog_cfg.get("sample_node"),
1339
+ "sample_threshold_v": analog_cfg.get("sample_threshold_v"),
1340
+ "int_node": analog_cfg.get("int_node"),
1341
+ "tstep_s": analog_cfg.get("tstep_s", _TSTEP_S),
1342
+ "uic": analog_cfg.get("uic", False),
1343
+ "gate_db": analog_cfg.get("gate_db", _GATE_DB),
1344
+ "ones_min": analog_cfg.get("ones_min", _ONES_MIN),
1345
+ "ones_max": analog_cfg.get("ones_max", _ONES_MAX),
1346
+ "corner": analog_cfg.get("corner", "tt"),
1347
+ "temp_c": analog_cfg.get("temp_c", 27),
1348
+ "params": dict(analog_cfg.get("params") or {}),
1349
+ "options": list(analog_cfg.get("options") or ["wnflag=1"]),
1350
+ "timeout_s": job.get("timeout_s", _ANALOG_TIMEOUT_S),
1351
+ },
1352
+ "vector": {
1353
+ "name": native.get("vector_name", VECTOR_NAME),
1354
+ "sha256": _sha256_text(bits_text) if bits_text else None,
1355
+ },
1356
+ "sampled_bits": {
1357
+ "nbits": len(bits),
1358
+ "ones": sum(bits),
1359
+ "sha256": _sha256_text(bits_text) if bits_text else None,
1360
+ },
1361
+ "mutation": mutation,
1362
+ }
1363
+ for key, value in (extra or {}).items():
1364
+ ingredients[key] = value
1365
+ return {
1366
+ "case": case,
1367
+ "design": design,
1368
+ "engine": {"operation": OPERATION},
1369
+ "ingredients": ingredients,
1370
+ "results": {
1371
+ "verdict": verdict,
1372
+ "total": parsed.get("total", 0),
1373
+ "rules": sorted({v.get("rule") for v in
1374
+ parsed.get("violations", [])}),
1375
+ "measurements": parsed.get("measurements", []),
1376
+ },
1377
+ }
1378
+
1379
+
1380
+ def checked_job_sources(job, key):
1381
+ """Source paths echoed from a run_cosim job mapping.
1382
+
1383
+ Args:
1384
+ job: Solver job mapping carrying the source lists.
1385
+ key: Source list key (analog_sources/digital_sources).
1386
+
1387
+ Returns:
1388
+ Source path list (empty when the key is absent).
1389
+ """
1390
+ return list(job.get(key) or [])