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,1121 @@
1
+ """LibreLane digital-flow solver adapter: run_digital_flow behind the ToolAdapter port.
2
+
3
+ Protocol (LibreLane 3.0.17 Classic, subprocess): stage the project RTL
4
+ into a design dir (`src/` plus a generated `digital_flow_config.json`
5
+ carrying DESIGN_NAME/VERILOG_FILES/CLOCK_PERIOD/CLOCK_PORT/PDK), run
6
+ `librelane <config> --run-tag <tag> --pdk <pdk> --overwrite`
7
+ (`--pdk-root`/`--scl` when the job names them) under the job timeout in
8
+ its own process group, then read `design/runs/<tag>/` step dirs for
9
+ per-stage machine files (state_out.json metrics plus state views,
10
+ metrics.csv, the signoff drc.klayout.json/lvs.netgen.json reports).
11
+ Every gate verdict comes from those machine files only: D11 passes on
12
+ the staged config plus the versioned binary, D12 on synthesis state
13
+ files, D22/D25/D26/D27 on merged step metrics (plus the signoff DRC
14
+ total when step metrics carry no DRC keys), D24 on a non-empty state
15
+ view GDS, the rest on per-step state files. Each gate otherwise fails
16
+ loudly naming the stage plus the report, or is force-bypassed via the
17
+ job `force` list (a warning, never silent). Report text and file names
18
+ ride as evidence pointers only and never decide; steps without machine
19
+ files fail loudly. A nonzero exit marks the first failing gate failed
20
+ and everything downstream not_run; a timeout SIGKILLs the whole tree
21
+ and reports digital_flow_timeout with whatever partial evidence
22
+ survived.
23
+
24
+ Tool resolution: an explicit job `tool_dir` wins, else PATH, and the
25
+ resolved binary must answer `librelane --version` with the pinned
26
+ 3.0.17 banner — ambient installs are kept and version-asserted, never
27
+ reinstalled. Where the tools live on disk is B10/PROV scope.
28
+ """
29
+
30
+ import json
31
+ import os
32
+ import re
33
+ import shutil
34
+ import signal
35
+ import subprocess
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ from ...execution.errors import ExecutionError
40
+ from ...execution.runner import tool_roots
41
+ from ...interfaces import ToolAdapter
42
+ from ..errors import AdapterError
43
+ from .state import (
44
+ drc_verdict,
45
+ equiv_verdict,
46
+ lvs_verdict,
47
+ missing_reason,
48
+ read_run_states,
49
+ signoff_drc_total,
50
+ signoff_lvs_report,
51
+ sta_verdict,
52
+ )
53
+
54
+ OPERATION = "run_digital_flow"
55
+ _TOOL = "librelane"
56
+ _FLOW = "Classic"
57
+ _PIN = "3.0.17"
58
+ _TIMEOUT_S = 3600
59
+ _VERSION_TIMEOUT_S = 30
60
+
61
+ _CONFIG_NAME = "digital_flow_config.json"
62
+ _LOG_NAME = "digital_flow.log"
63
+ _DESIGN_DIR = "design"
64
+ _SRC_DIR = "src"
65
+ _RUNS_DIR = "runs"
66
+
67
+ _MISSING_REMEDY = (
68
+ "install the pinned LibreLane 3.0.17 supported distribution (run "
69
+ "scripts/provision_toolchain.sh --install for the pinned AppImage per "
70
+ "the upstream install docs); inside a nix-shell env, `librelane "
71
+ "--version` must report 3.0.17 (see "
72
+ "src/interfaces/setup_catalogue.yaml id librelane)"
73
+ )
74
+
75
+ _TOP_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
76
+ _TAG_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
77
+ _PDK_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
78
+
79
+ # PRD D11-D27 projected onto LibreLane Classic step dirs: lowercase
80
+ # substrings matched against the step-dir name (first path part under
81
+ # runs/<tag>/). The table locates steps only — verdicts never come
82
+ # from it. Step IDs drift across LibreLane versions; the locator stays
83
+ # substring on purpose, and a gate whose steps wrote no machine files
84
+ # fails loudly (never skipped). The artifact tuples select evidence
85
+ # pointers (plus filename fallbacks behind state views), never
86
+ # verdicts.
87
+ _STAGES = (
88
+ {"id": "D11", "label": "Start the LibreLane flow", "steps": (), "artifacts": ()},
89
+ {"id": "D12", "label": "Synthesize to standard cells",
90
+ "steps": ("synthesis",), "artifacts": (".v", "stat")},
91
+ {"id": "D13", "label": "Pre-placement constraint and timing check",
92
+ "steps": ("sdc", "constraint", "staprepnr"), "artifacts": (".rpt", ".sdc", ".log")},
93
+ {"id": "D14", "label": "Floorplan", "steps": ("floorplan",),
94
+ "artifacts": (".odb", ".def")},
95
+ {"id": "D15", "label": "Power grid", "steps": ("tapendcap", "pdn", "powergrid"),
96
+ "artifacts": (".def", ".rpt")},
97
+ {"id": "D16", "label": "Placement", "steps": ("placement", "repairdesign"),
98
+ "artifacts": (".odb", ".def", ".rpt")},
99
+ {"id": "D17", "label": "Clock tree", "steps": ("cts", "clocktree"),
100
+ "artifacts": (".odb", ".rpt", ".log")},
101
+ {"id": "D18", "label": "Global routing", "steps": ("globalrout", "groute"),
102
+ "artifacts": (".guide", ".rpt")},
103
+ {"id": "D19", "label": "Detail routing",
104
+ "steps": ("detailedrout", "detailroute", "droute", "antenna"),
105
+ "artifacts": (".odb", ".def", ".rpt")},
106
+ {"id": "D20", "label": "Filler and density",
107
+ "steps": ("filler", "densityfill", "fill"), "artifacts": (".odb", ".def")},
108
+ {"id": "D21", "label": "RC extraction", "steps": ("rcx", "parasitic"),
109
+ "artifacts": (".spef",)},
110
+ {"id": "D22", "label": "Post-PNR STA", "steps": ("stapostpnr", "stamidpnr"),
111
+ "artifacts": (".rpt",)},
112
+ {"id": "D23", "label": "Power and IR drop", "steps": ("irdrop", "power", "redist"),
113
+ "artifacts": (".rpt",)},
114
+ {"id": "D24", "label": "Stream GDS/LEF views",
115
+ "steps": ("writegds", "writeviews", "streamout", "gds"),
116
+ "artifacts": (".gds", ".lef")},
117
+ {"id": "D25", "label": "Foundry-deck DRC", "steps": ("drc", "cvc"),
118
+ "artifacts": (".rpt", ".lyrdb")},
119
+ {"id": "D26", "label": "LVS vs reference", "steps": ("lvs", "netgen", "extract"),
120
+ "artifacts": (".out", ".rpt", ".spice")},
121
+ {"id": "D27", "label": "Equivalence and final gates",
122
+ "steps": ("eqy", "equiv", "signoff", "finish"), "artifacts": (".rpt", ".log")},
123
+ )
124
+ _STAGE_IDS = tuple(stage["id"] for stage in _STAGES)
125
+
126
+ _EVIDENCE_CAP = 25
127
+ _STREAM_KEEP_BYTES = 131072
128
+
129
+
130
+ def _scan_run(run_dir):
131
+ """Map step-dir names to their files under one run root.
132
+
133
+ Args:
134
+ run_dir: runs/<tag>/ dir path, or None when the flow never
135
+ created it.
136
+
137
+ Returns:
138
+ Step-dir name to sorted file-path list (empty when no run).
139
+ """
140
+ found = {}
141
+ if run_dir is None or not Path(run_dir).is_dir():
142
+ return found
143
+ root = Path(run_dir)
144
+ for path in sorted(root.rglob("*")):
145
+ if not path.is_file():
146
+ continue
147
+ try:
148
+ rel = path.relative_to(root)
149
+ except ValueError:
150
+ continue
151
+ if len(rel.parts) < 2:
152
+ continue
153
+ found.setdefault(rel.parts[0], []).append(path)
154
+ return found
155
+
156
+
157
+ def _match_steps(step_names, needles):
158
+ """Step-dir names containing any needle (case-insensitive).
159
+
160
+ Args:
161
+ step_names: Step-dir names from the run scan.
162
+ needles: Lowercase substrings to match.
163
+
164
+ Returns:
165
+ Sorted matching step-dir names.
166
+ """
167
+ return sorted(name for name in step_names if any(n in name.lower() for n in needles))
168
+
169
+
170
+ def _evidence_files(scan, step_names, artifacts):
171
+ """Artifact files under the matched step dirs, capped.
172
+
173
+ Args:
174
+ scan: Step-dir to file-list mapping from the run scan.
175
+ step_names: Matched step-dir names for one stage.
176
+ artifacts: Lowercase filename substrings for the stage.
177
+
178
+ Returns:
179
+ Sorted matching file paths, capped at _EVIDENCE_CAP.
180
+ """
181
+ out = []
182
+ for name in step_names:
183
+ for path in scan.get(name, []):
184
+ lowered = path.name.lower()
185
+ if any(a in lowered for a in artifacts):
186
+ out.append(path)
187
+ return sorted(out)[:_EVIDENCE_CAP]
188
+
189
+
190
+ def _tail_kept(text):
191
+ """Bound one captured stream to its tail with a marker.
192
+
193
+ Args:
194
+ text: Full captured stream text.
195
+
196
+ Returns:
197
+ Tail text, marked when truncated.
198
+ """
199
+ encoded = text.encode("utf-8", "replace")
200
+ if len(encoded) <= _STREAM_KEEP_BYTES:
201
+ return text
202
+ tail = encoded[-_STREAM_KEEP_BYTES:].decode("utf-8", "replace")
203
+ return f"...[truncated to the last {_STREAM_KEEP_BYTES} bytes]\n{tail}"
204
+
205
+
206
+ def _step_metrics(states, step_names):
207
+ """Merge machine metrics across one gate's matched step dirs.
208
+
209
+ Args:
210
+ states: Step-dir to {metrics, files, views} mapping from the
211
+ run states readout.
212
+ step_names: Matched step-dir names for one stage.
213
+
214
+ Returns:
215
+ (metrics, files): merged metric mapping plus sorted state
216
+ file paths.
217
+ """
218
+ metrics = {}
219
+ files = []
220
+ for name in step_names:
221
+ entry = states.get(name, {})
222
+ for key, value in entry.get("metrics", {}).items():
223
+ metrics.setdefault(key, value)
224
+ files.extend(entry.get("files", []))
225
+ return metrics, sorted(set(files))
226
+
227
+
228
+ def _step_views(states, step_names, suffixes):
229
+ """State view paths under one gate's matched step dirs, filtered.
230
+
231
+ Args:
232
+ states: Step-dir to {metrics, files, views} mapping from the
233
+ run states readout.
234
+ step_names: Matched step-dir names for one stage.
235
+ suffixes: Filename suffixes a view must end with (lowercase
236
+ compared, so pass tuples like (".gds", ".gds.gz")).
237
+
238
+ Returns:
239
+ Sorted view paths ending with one of the suffixes.
240
+ """
241
+ found = []
242
+ for name in step_names:
243
+ for view in states.get(name, {}).get("views", []):
244
+ if view.lower().endswith(suffixes):
245
+ found.append(view)
246
+ return sorted(set(found))
247
+
248
+
249
+ def _machine_gate(stage_id, kind, steps, metrics, files, run_dir):
250
+ """Judge one machine-gated stage from its merged metrics.
251
+
252
+ Args:
253
+ stage_id: Gate id (D22/D25/D26/D27).
254
+ kind: Metric kind (STA/DRC/LVS/equivalence).
255
+ steps: Matched step-dir names for the stage.
256
+ metrics: Merged machine metrics across those steps.
257
+ files: State file paths backing the metrics.
258
+ run_dir: Run dir label for missing-evidence reasons.
259
+
260
+ Returns:
261
+ (ok, reason, evidence, reports) for the gate.
262
+ """
263
+ if not files:
264
+ if not steps:
265
+ return (
266
+ False,
267
+ f"no {kind} machine-file metrics "
268
+ f"(state_out.json/metrics.csv) in {run_dir}; "
269
+ "keys present: none",
270
+ [],
271
+ [],
272
+ )
273
+ return (
274
+ False,
275
+ missing_reason(stage_id, kind, metrics),
276
+ [],
277
+ [],
278
+ )
279
+ if kind == "STA":
280
+ ok, detail = sta_verdict(metrics)
281
+ elif kind == "DRC":
282
+ ok, detail, _ = drc_verdict(metrics)
283
+ elif kind == "LVS":
284
+ ok, detail, _ = lvs_verdict(metrics)
285
+ else:
286
+ ok, detail, _ = equiv_verdict(metrics)
287
+ if ok is None:
288
+ return False, missing_reason(stage_id, kind, metrics), [], files
289
+ return bool(ok), detail, files, files
290
+
291
+
292
+ def _stage_gate(stage, scan, native, states):
293
+ """Judge one stage gate from machine files only (D11 excepted).
294
+
295
+ D11 passes on the staged config plus the versioned binary (no run
296
+ dirs needed); every other gate judges its matched steps' machine
297
+ files — D12 on synthesis state files, D22/D25/D26/D27 on merged
298
+ state_out.json / metrics.csv metrics (plus the signoff DRC total
299
+ when step metrics carry no DRC keys), D24 on a non-empty state
300
+ view GDS, the rest on per-step state files. Report text and file
301
+ names ride as evidence pointers only and never decide; steps
302
+ without machine files fail loudly.
303
+
304
+ Args:
305
+ stage: Stage row (id, label, steps, artifacts).
306
+ scan: Step-dir to file-list mapping from the run scan.
307
+ native: Native output with config, version, and run context.
308
+ states: Step-dir to {metrics, files, views} machine readout.
309
+
310
+ Returns:
311
+ (ok, reason, steps, evidence, reports) for the gate.
312
+ """
313
+ stage_id = stage["id"]
314
+ run_dir = native.get("run_dir") or "run dir"
315
+ if stage_id == "D11":
316
+ config = native.get("config")
317
+ version = native.get("librelane_version") or ""
318
+ if config is not None and Path(config).is_file() and version:
319
+ count = len(scan)
320
+ evidence = [config]
321
+ return True, f"flow {native.get('flow')}, {count} step dirs", [], evidence, evidence
322
+ return False, "no staged flow config", [], [], []
323
+ steps = _match_steps(scan, stage["steps"])
324
+ evidence = _evidence_files(scan, steps, stage["artifacts"])
325
+ reports = [str(p) for p in evidence]
326
+ if stage_id == "D12":
327
+ _, files = _step_metrics(states, steps)
328
+ if not files:
329
+ logs = sorted(
330
+ str(path)
331
+ for name in steps
332
+ for path in scan.get(name, [])
333
+ if path.suffix == ".log"
334
+ )
335
+ return (
336
+ False,
337
+ "no synthesis machine-file metrics "
338
+ f"(state_out.json/metrics.csv) in {run_dir}",
339
+ steps,
340
+ [],
341
+ logs,
342
+ )
343
+ return (
344
+ True,
345
+ f"synthesis complete ({len(files)} state files)",
346
+ steps,
347
+ evidence,
348
+ reports,
349
+ )
350
+ if stage_id == "D22":
351
+ metrics, files = _step_metrics(states, steps)
352
+ ok, reason, shown, shown_reports = _machine_gate(
353
+ stage_id, "STA", steps, metrics, files, run_dir
354
+ )
355
+ return ok, reason, steps, shown, shown_reports
356
+ if stage_id == "D24":
357
+ viewed = _step_views(states, steps, (".gds", ".gds.gz"))
358
+ gds = [Path(p) for p in viewed]
359
+ if not gds:
360
+ gds = [
361
+ p
362
+ for p in evidence
363
+ if p.name.lower().endswith((".gds", ".gds.gz"))
364
+ ]
365
+ if not gds:
366
+ return False, f"no GDS evidence in {run_dir}", steps, evidence, reports
367
+ sized = [p for p in gds if p.is_file() and p.stat().st_size > 0]
368
+ if not sized:
369
+ return False, "GDS artifact is empty", steps, evidence, [str(p) for p in gds]
370
+ return True, f"GDS {sized[0].name}", steps, evidence, [str(p) for p in sized]
371
+ if stage_id == "D25":
372
+ metrics, files = _step_metrics(states, steps)
373
+ _, _, total = drc_verdict(metrics)
374
+ if total is None:
375
+ signoff_total, signoff_path = signoff_drc_total(native.get("run_dir"))
376
+ if signoff_total is not None:
377
+ if signoff_total > 0:
378
+ return (
379
+ False,
380
+ f"{signoff_total} DRC violations (drc.klayout.json)",
381
+ steps,
382
+ [signoff_path],
383
+ [signoff_path],
384
+ )
385
+ return (
386
+ True,
387
+ "DRC total 0 (drc.klayout.json)",
388
+ steps,
389
+ [signoff_path],
390
+ [signoff_path],
391
+ )
392
+ ok, reason, shown, shown_reports = _machine_gate(
393
+ stage_id, "DRC", steps, metrics, files, run_dir
394
+ )
395
+ return ok, reason, steps, shown, shown_reports
396
+ if stage_id == "D26":
397
+ metrics, files = _step_metrics(states, steps)
398
+ ok, reason, shown, shown_reports = _machine_gate(
399
+ stage_id, "LVS", steps, metrics, files, run_dir
400
+ )
401
+ lvs_report = signoff_lvs_report(native.get("run_dir"))
402
+ if lvs_report is not None and lvs_report not in shown_reports:
403
+ shown_reports = [*shown_reports, lvs_report]
404
+ return ok, reason, steps, shown, shown_reports
405
+ if stage_id == "D27":
406
+ metrics, files = _step_metrics(states, steps)
407
+ ok, reason, shown, shown_reports = _machine_gate(
408
+ stage_id, "equivalence", steps, metrics, files, run_dir
409
+ )
410
+ return ok, reason, steps, shown, shown_reports
411
+ _, files = _step_metrics(states, steps)
412
+ if not files:
413
+ if not steps:
414
+ return (
415
+ False,
416
+ f"no {stage['label'].lower()} machine-file metrics "
417
+ f"(state_out.json/metrics.csv) in {run_dir}",
418
+ steps,
419
+ [],
420
+ [],
421
+ )
422
+ return (
423
+ False,
424
+ f"no {stage['label'].lower()} machine-file metrics "
425
+ f"(state_out.json/metrics.csv) in {', '.join(steps)}",
426
+ steps,
427
+ [],
428
+ [],
429
+ )
430
+ return (
431
+ True,
432
+ f"{stage['label'].lower()} complete ({len(files)} state files)",
433
+ steps,
434
+ evidence,
435
+ reports,
436
+ )
437
+
438
+
439
+ def _evaluate_stages(native):
440
+ """Judge every D11-D27 gate, applying force-bypass and exit rules.
441
+
442
+ A failed run marks the first failing gate failed and evidence-free
443
+ downstream gates not_run (no violation — they never ran); a clean
444
+ exit turns every missing gate into a failure. Forced failures
445
+ become warnings; force on a passing gate is inert. D27 additionally
446
+ fails as blocked while any earlier gate stays failed.
447
+
448
+ Args:
449
+ native: Native output with exit_code, run scan inputs, the
450
+ forced list, and flow context.
451
+
452
+ Returns:
453
+ (stages, violations) with per-stage status plus evidence and
454
+ gate/forced violations.
455
+ """
456
+ scan = _scan_run(native.get("run_dir"))
457
+ states = read_run_states(native.get("run_dir"))
458
+ forced = set(native.get("forced") or [])
459
+ exit_code = native.get("exit_code")
460
+ failed_run = exit_code is not None and exit_code != 0
461
+ judged = []
462
+ for stage in _STAGES:
463
+ ok, reason, steps, evidence, reports = _stage_gate(
464
+ stage, scan, native, states
465
+ )
466
+ judged.append(
467
+ {
468
+ "id": stage["id"],
469
+ "label": stage["label"],
470
+ "ok": ok,
471
+ "reason": reason,
472
+ "steps": steps,
473
+ "evidence": [str(p) for p in evidence],
474
+ "reports": [str(r) for r in reports],
475
+ }
476
+ )
477
+ blocked = [j["id"] for j in judged[:-1] if not j["ok"] and j["id"] not in forced]
478
+ if blocked:
479
+ final = judged[-1]
480
+ final["ok"] = False
481
+ final["reason"] = f"blocked by {', '.join(blocked)}"
482
+ if not final["reports"]:
483
+ final["reports"] = [native.get("run_dir") or native.get("design_dir", "")]
484
+ first_fail = next((i for i, j in enumerate(judged) if not j["ok"]), None)
485
+ stages = []
486
+ violations = []
487
+ for index, entry in enumerate(judged):
488
+ status = "pass" if entry["ok"] else "fail"
489
+ if failed_run and first_fail is not None and index > first_fail:
490
+ if not entry["steps"] and not entry["evidence"]:
491
+ status = "not_run"
492
+ if status == "fail" and entry["id"] in forced:
493
+ status = "forced"
494
+ stages.append(
495
+ {
496
+ "id": entry["id"],
497
+ "label": entry["label"],
498
+ "status": status,
499
+ "steps": entry["steps"],
500
+ "evidence": entry["evidence"],
501
+ "reports": entry["reports"],
502
+ "reason": entry["reason"],
503
+ }
504
+ )
505
+ if status == "fail":
506
+ report = entry["reports"][0] if entry["reports"] else native.get("run_dir")
507
+ message = f"{entry['id']} {entry['label']} failed: {entry['reason']}"
508
+ if report:
509
+ message += f" (see {report})"
510
+ violation = {
511
+ "rule": "digital_flow_gate",
512
+ "severity": "error",
513
+ "message": message,
514
+ }
515
+ if report:
516
+ violation["file"] = report
517
+ violations.append(violation)
518
+ elif status == "forced":
519
+ report = entry["reports"][0] if entry["reports"] else native.get("run_dir")
520
+ message = (
521
+ f"{entry['id']} {entry['label']} gate bypassed "
522
+ f"via force: {entry['reason']}"
523
+ )
524
+ if report:
525
+ message += f" (see {report})"
526
+ violation = {
527
+ "rule": "digital_flow_forced",
528
+ "severity": "warning",
529
+ "message": message,
530
+ }
531
+ if report:
532
+ violation["file"] = report
533
+ violations.append(violation)
534
+ if failed_run and not [v for v in violations if v["severity"] == "error"]:
535
+ violations.append(
536
+ {
537
+ "rule": "digital_flow_error",
538
+ "severity": "error",
539
+ "message": f"librelane exited {exit_code} with no failing gate",
540
+ }
541
+ )
542
+ return stages, violations
543
+
544
+
545
+ class LibrelaneFlowAdapter(ToolAdapter):
546
+ """run_digital_flow via LibreLane: RTL to verified GDS plus evidence.
547
+
548
+ Serves the run_digital_flow operation with deck.tool `librelane`
549
+ (the only tool). Stages the project RTL into a LibreLane design
550
+ dir, runs the Classic flow as a subprocess under the job timeout,
551
+ and judges the D11-D27 stage gates from the run's machine files
552
+ (state_out.json metrics plus state views, metrics.csv, signoff
553
+ JSON reports): report text never decides, failures name the stage
554
+ plus the report, and the job `force` list bypasses named gates as
555
+ warnings.
556
+
557
+ Job keys:
558
+ sources (required): Verilog source paths staged to design/.
559
+ top (required): Top module name (plain identifier).
560
+ clock_port: Clock port name (default clk; None omits it).
561
+ clock_period: Clock period in ns (default 10.0).
562
+ pdk: PDK name for --pdk (default sky130A).
563
+ pdk_root: PDK root dir for --pdk-root; omitted when None.
564
+ manual_pdk: Pass --manual-pdk (use PDK root as-is, no ciel
565
+ download; required for read-only provisioned PDKs).
566
+ scl: Standard-cell library for --scl; omitted when None.
567
+ flow: Flow name; only Classic is supported.
568
+ tag: Run tag for runs/<tag>/ (default engine).
569
+ force: Stage ids (D11-D27) whose gate failures bypass.
570
+ config: Extra config mapping merged into the flow config.
571
+ timeout_s: Flow budget in seconds (default 3600).
572
+ tool_dir: Directory holding librelane; PATH otherwise.
573
+ deck: Optional mapping; deck.tool must be librelane here.
574
+ workdir: Run directory (default ".").
575
+
576
+ Args:
577
+ runner: Optional isolated runner; direct subprocess when None.
578
+
579
+ Caution: Overwrites design/, the flow config, and digital_flow.log
580
+ in the workdir; long runs die by timeout_s with partial
581
+ evidence, never a hang — and a failed run never parses as
582
+ clean.
583
+ """
584
+
585
+ def __init__(self, runner=None):
586
+ self._runner = runner
587
+
588
+ def capabilities(self, target):
589
+ """Report the served operation, tool, flow, and stage gates.
590
+
591
+ Args:
592
+ target: Target the capabilities are reported for
593
+ (accepted, unused — one flow run per job).
594
+
595
+ Returns:
596
+ Capability mapping with operations, tool, flow, stages,
597
+ and force-bypass support.
598
+ """
599
+ return {
600
+ "operations": [OPERATION],
601
+ "tool": _TOOL,
602
+ "flow": _FLOW,
603
+ "stages": list(_STAGE_IDS),
604
+ "force": True,
605
+ }
606
+
607
+ def preflight(self, job):
608
+ """Check operation, deck, flow inputs, sources, and librelane.
609
+
610
+ Cheap deterministic checks first, tool resolution and its
611
+ version probe last; the resolved binary must report the
612
+ pinned 3.0.17 banner.
613
+
614
+ Args:
615
+ job: Solver job with operation run_digital_flow plus
616
+ sources, top, clock, PDK, tag, force, and config
617
+ inputs.
618
+
619
+ Returns:
620
+ Checked mapping with the resolved librelane path and
621
+ version plus the staged flow inputs.
622
+
623
+ Raises:
624
+ AdapterError: unsupported_operation for another
625
+ operation or a foreign deck.tool, invalid_command
626
+ for bad flow inputs, invalid_reference for missing
627
+ sources or pdk_root, missing_dependency for an
628
+ unresolvable or unpinned librelane.
629
+ """
630
+ if job.get("operation") != OPERATION:
631
+ raise AdapterError(
632
+ "unsupported_operation",
633
+ f"LibreLane flow handles {OPERATION}, got {job.get('operation')}.",
634
+ )
635
+ deck = job.get("deck")
636
+ if deck is not None:
637
+ if not isinstance(deck, dict):
638
+ raise AdapterError("invalid_command", f"Bad deck: {deck!r}.")
639
+ if deck.get("tool", _TOOL) != _TOOL:
640
+ raise AdapterError(
641
+ "unsupported_operation",
642
+ f"LibreLane flow handles deck.tool {_TOOL}, got {deck.get('tool')}.",
643
+ )
644
+ top = job.get("top")
645
+ if not isinstance(top, str) or not _TOP_RE.fullmatch(top):
646
+ raise AdapterError("invalid_command", f"Bad top: {top!r}.")
647
+ timeout = job.get("timeout_s", _TIMEOUT_S)
648
+ if (
649
+ not isinstance(timeout, (int, float))
650
+ or isinstance(timeout, bool)
651
+ or not timeout > 0
652
+ ):
653
+ raise AdapterError("invalid_command", f"Bad timeout_s: {timeout!r}.")
654
+ flow = job.get("flow", _FLOW)
655
+ if flow != _FLOW:
656
+ raise AdapterError(
657
+ "invalid_command", f"Bad flow: {flow!r} (only {_FLOW})."
658
+ )
659
+ tag = job.get("tag", "engine")
660
+ if not isinstance(tag, str) or not _TAG_RE.fullmatch(tag):
661
+ raise AdapterError("invalid_command", f"Bad tag: {tag!r}.")
662
+ pdk = job.get("pdk", "sky130A")
663
+ if not isinstance(pdk, str) or not _PDK_RE.fullmatch(pdk):
664
+ raise AdapterError("invalid_command", f"Bad pdk: {pdk!r}.")
665
+ period = job.get("clock_period", 10.0)
666
+ if (
667
+ not isinstance(period, (int, float))
668
+ or isinstance(period, bool)
669
+ or not period > 0
670
+ ):
671
+ raise AdapterError("invalid_command", f"Bad clock_period: {period!r}.")
672
+ port = job.get("clock_port", "clk")
673
+ if port is not None and (
674
+ not isinstance(port, str) or not _TOP_RE.fullmatch(port)
675
+ ):
676
+ raise AdapterError("invalid_command", f"Bad clock_port: {port!r}.")
677
+ sources = job.get("sources") or []
678
+ missing = [s for s in sources if not Path(s).is_file()]
679
+ if not sources or missing:
680
+ raise AdapterError(
681
+ "invalid_reference", f"Sources not found: {missing or sources}."
682
+ )
683
+ basenames = [Path(s).name for s in sources]
684
+ dupes = sorted({name for name in basenames if basenames.count(name) > 1})
685
+ if dupes:
686
+ raise AdapterError(
687
+ "invalid_command",
688
+ f"Duplicate source basenames collide in design/src/: {dupes}.",
689
+ )
690
+ force = job.get("force", [])
691
+ if (
692
+ not isinstance(force, list)
693
+ or any(not isinstance(f, str) or f not in _STAGE_IDS for f in force)
694
+ ):
695
+ raise AdapterError("invalid_command", f"Bad force: {force!r}.")
696
+ extra = job.get("config", {})
697
+ if not isinstance(extra, dict) or any(not isinstance(k, str) for k in extra):
698
+ raise AdapterError("invalid_command", f"Bad config: {extra!r}.")
699
+ try:
700
+ json.dumps(extra)
701
+ except (TypeError, ValueError) as exc:
702
+ raise AdapterError("invalid_command", f"Bad config: {exc}.") from exc
703
+ pdk_root = job.get("pdk_root")
704
+ if pdk_root is not None and not Path(pdk_root).is_dir():
705
+ raise AdapterError("invalid_reference", f"PDK root not found: {pdk_root}.")
706
+ scl = job.get("scl")
707
+ if scl is not None and (not isinstance(scl, str) or not scl.strip()):
708
+ raise AdapterError("invalid_command", f"Bad scl: {scl!r}.")
709
+ manual_pdk = job.get("manual_pdk", False)
710
+ if not isinstance(manual_pdk, bool):
711
+ raise AdapterError("invalid_command", f"Bad manual_pdk: {manual_pdk!r}.")
712
+ librelane = self._resolve(job)
713
+ checked = {
714
+ "librelane": librelane,
715
+ "librelane_version": self._version(librelane),
716
+ "sources": list(sources),
717
+ "top": top,
718
+ "clock_port": port,
719
+ "clock_period": period,
720
+ "pdk": pdk,
721
+ "pdk_root": pdk_root,
722
+ "manual_pdk": manual_pdk,
723
+ "scl": scl,
724
+ "flow": flow,
725
+ "tag": tag,
726
+ "force": list(force),
727
+ "config": dict(extra),
728
+ "timeout_s": timeout,
729
+ }
730
+ if self._runner is not None:
731
+ try:
732
+ self._runner.preflight()
733
+ except ExecutionError as exc:
734
+ raise AdapterError(exc.code, str(exc)) from exc
735
+ return checked
736
+
737
+ def execute(self, job):
738
+ """Stage the design, run the Classic flow bounded and cancellable.
739
+
740
+ The flow runs in its own process group under timeout_s; expiry
741
+ SIGKILLs the whole tree and reports a timeout native block
742
+ with partial evidence instead of hanging the engine.
743
+
744
+ Args:
745
+ job: Solver job with operation run_digital_flow plus
746
+ sources, top, clock, PDK, tag, force, and config
747
+ inputs.
748
+
749
+ Returns:
750
+ Native output with exit_code, bounded streams, the full
751
+ log path, run/config dirs, the forced list, and the
752
+ librelane version.
753
+
754
+ Raises:
755
+ AdapterError: invalid_reference for a missing workdir or
756
+ an unstageable design, plus the preflight codes
757
+ (execute re-checks first).
758
+ """
759
+ checked = self.preflight(job)
760
+ workdir = Path(job.get("workdir", "."))
761
+ if not workdir.is_dir():
762
+ raise AdapterError(
763
+ "invalid_reference", f"Workdir not found: {workdir}."
764
+ )
765
+ design = workdir / _DESIGN_DIR
766
+ try:
767
+ src_dir = design / _SRC_DIR
768
+ src_dir.mkdir(parents=True, exist_ok=True)
769
+ staged = []
770
+ for source in checked["sources"]:
771
+ dest = src_dir / Path(source).name
772
+ shutil.copy(source, dest)
773
+ staged.append(str(dest))
774
+ config = {
775
+ "DESIGN_NAME": checked["top"],
776
+ "VERILOG_FILES": staged,
777
+ "CLOCK_PERIOD": checked["clock_period"],
778
+ "PDK": checked["pdk"],
779
+ }
780
+ if checked["clock_port"] is not None:
781
+ config["CLOCK_PORT"] = checked["clock_port"]
782
+ config.update(checked["config"])
783
+ config_path = design / _CONFIG_NAME
784
+ config_path.write_text(json.dumps(config, indent=2) + "\n")
785
+ except OSError as exc:
786
+ raise AdapterError(
787
+ "invalid_reference", f"Cannot stage digital-flow design: {exc}."
788
+ ) from exc
789
+ argv = [
790
+ checked["librelane"],
791
+ str(config_path),
792
+ # 3.x renamed --tag to --run-tag (pinned 3.0.17 rejects
793
+ # --tag with "No such option").
794
+ "--run-tag",
795
+ checked["tag"],
796
+ "--pdk",
797
+ checked["pdk"],
798
+ "--overwrite",
799
+ ]
800
+ if checked["pdk_root"] is not None:
801
+ argv += ["--pdk-root", checked["pdk_root"]]
802
+ if checked.get("manual_pdk"):
803
+ # --manual-pdk: use the PDK root as-is (proven recipe in
804
+ # waveform-generator). No ciel download, no writes into the
805
+ # PDK tree, so read-only provisioned PDKs work.
806
+ argv += ["--manual-pdk"]
807
+ if checked["scl"] is not None:
808
+ argv += ["--scl", checked["scl"]]
809
+ binds = tool_roots([checked["librelane"]])
810
+ if checked["pdk_root"] is not None:
811
+ binds = list(binds) + [(checked["pdk_root"], checked["pdk_root"])]
812
+ timed_out, proc = self._run(argv, str(workdir), checked["timeout_s"], binds)
813
+ stdout = proc.stdout if proc is not None else ""
814
+ stderr = proc.stderr if proc is not None else ""
815
+ (workdir / _LOG_NAME).write_text(
816
+ f"$ {' '.join(argv)}\n{stdout}\n{stderr}", encoding="utf-8"
817
+ )
818
+ run_dir = design / _RUNS_DIR / checked["tag"]
819
+ return {
820
+ "exit_code": proc.returncode if proc is not None else None,
821
+ "stdout": _tail_kept(stdout or ""),
822
+ "stderr": _tail_kept(stderr or ""),
823
+ "log": _tail_kept(f"{stdout or ''}\n{stderr or ''}"),
824
+ "log_file": str(workdir / _LOG_NAME),
825
+ "timed_out": timed_out,
826
+ "workdir": str(workdir),
827
+ "design_dir": str(design),
828
+ "run_dir": str(run_dir) if run_dir.is_dir() else None,
829
+ "config": str(config_path),
830
+ "top": checked["top"],
831
+ "tag": checked["tag"],
832
+ "pdk": checked["pdk"],
833
+ "flow": checked["flow"],
834
+ "forced": list(checked["force"]),
835
+ "librelane_version": checked["librelane_version"],
836
+ "timeout_s": checked["timeout_s"],
837
+ }
838
+
839
+ def parse(self, native_output):
840
+ """Judge the D11-D27 gates from the run's machine files.
841
+
842
+ Timeouts report digital_flow_timeout plus the partial gates;
843
+ failed runs fail the first gate and mark evidence-free
844
+ downstream gates not_run; clean exits fail every stateless
845
+ stage. Forced failures warn instead of erroring, and a flow
846
+ failure always names its stage plus report.
847
+
848
+ Args:
849
+ native_output: Native output from execute with exit
850
+ state, run/config dirs, the forced list, and flow
851
+ context.
852
+
853
+ Returns:
854
+ {total, violations[], measurements{}} with the per-stage
855
+ table plus gds, netlist, spef, drc_total, lvs_match,
856
+ version, tag, run_dir, and timed_out evidence.
857
+
858
+ Raises:
859
+ AdapterError: runner_unavailable when the output has no
860
+ exit code and no timeout — unparseable never parses
861
+ as clean.
862
+ """
863
+ exit_code = native_output.get("exit_code")
864
+ timed_out = bool(native_output.get("timed_out"))
865
+ if exit_code is None and not timed_out:
866
+ raise AdapterError(
867
+ "runner_unavailable", "Native output has no exit code."
868
+ )
869
+ stages, violations = _evaluate_stages(native_output)
870
+ by_id = {stage["id"]: stage for stage in stages}
871
+ states = read_run_states(native_output.get("run_dir"))
872
+ gds = next(
873
+ (
874
+ path
875
+ for path in _step_views(
876
+ states, by_id.get("D24", {}).get("steps", []), (".gds", ".gds.gz")
877
+ )
878
+ + [
879
+ path
880
+ for path in by_id.get("D24", {}).get("reports", [])
881
+ if path.lower().endswith((".gds", ".gds.gz"))
882
+ ]
883
+ ),
884
+ None,
885
+ )
886
+ netlist = next(
887
+ (
888
+ path
889
+ for path in _step_views(
890
+ states, by_id.get("D12", {}).get("steps", []), (".v",)
891
+ )
892
+ + [
893
+ path
894
+ for path in by_id.get("D12", {}).get("evidence", [])
895
+ if path.lower().endswith(".v")
896
+ ]
897
+ ),
898
+ None,
899
+ )
900
+ spef = next(
901
+ (
902
+ path
903
+ for path in _step_views(
904
+ states, by_id.get("D21", {}).get("steps", []), (".spef",)
905
+ )
906
+ + [
907
+ path
908
+ for path in by_id.get("D21", {}).get("evidence", [])
909
+ if path.lower().endswith(".spef")
910
+ ]
911
+ ),
912
+ None,
913
+ )
914
+ drc_metrics, _ = _step_metrics(
915
+ states, by_id.get("D25", {}).get("steps", [])
916
+ )
917
+ _, _, drc_total = drc_verdict(drc_metrics)
918
+ if drc_total is None:
919
+ drc_total, _ = signoff_drc_total(native_output.get("run_dir"))
920
+ lvs_metrics, _ = _step_metrics(
921
+ states, by_id.get("D26", {}).get("steps", [])
922
+ )
923
+ _, _, lvs_match = lvs_verdict(lvs_metrics)
924
+ state_files = sorted(
925
+ {
926
+ path
927
+ for name in (
928
+ by_id.get("D22", {}).get("steps", [])
929
+ + by_id.get("D25", {}).get("steps", [])
930
+ + by_id.get("D26", {}).get("steps", [])
931
+ + by_id.get("D27", {}).get("steps", [])
932
+ )
933
+ for path in states.get(name, {}).get("files", [])
934
+ }
935
+ )
936
+ if timed_out:
937
+ timeout_s = native_output.get("timeout_s")
938
+ violations = [
939
+ {
940
+ "rule": "digital_flow_timeout",
941
+ "severity": "error",
942
+ "message": f"librelane timed out after {timeout_s}s.",
943
+ }
944
+ ] + violations
945
+ measurements = {
946
+ "flow": native_output.get("flow"),
947
+ "tag": native_output.get("tag"),
948
+ "top": native_output.get("top"),
949
+ "pdk": native_output.get("pdk"),
950
+ "librelane_version": native_output.get("librelane_version"),
951
+ "timeout_s": native_output.get("timeout_s"),
952
+ "timed_out": timed_out,
953
+ "exit_code": exit_code,
954
+ "forced": list(native_output.get("forced") or []),
955
+ "run_dir": native_output.get("run_dir"),
956
+ "config": native_output.get("config"),
957
+ "stages": stages,
958
+ "gds": gds,
959
+ "netlist": netlist,
960
+ "spef": spef,
961
+ "drc_total": drc_total,
962
+ "lvs_match": lvs_match,
963
+ "state_files": state_files,
964
+ }
965
+ return {
966
+ "total": len(violations),
967
+ "violations": violations,
968
+ "measurements": measurements,
969
+ "stages": stages,
970
+ "gds": gds,
971
+ "netlist": netlist,
972
+ "spef": spef,
973
+ "drc_total": drc_total,
974
+ "lvs_match": lvs_match,
975
+ "librelane_version": native_output.get("librelane_version"),
976
+ "tag": native_output.get("tag"),
977
+ "run_dir": native_output.get("run_dir"),
978
+ "timed_out": timed_out,
979
+ }
980
+
981
+ @staticmethod
982
+ def _resolve(job):
983
+ tool_dir = job.get("tool_dir")
984
+ if tool_dir:
985
+ candidate = Path(tool_dir) / _TOOL
986
+ if candidate.is_file():
987
+ return str(candidate)
988
+ raise AdapterError(
989
+ "missing_dependency",
990
+ f"{_TOOL} not found in {tool_dir} — {_MISSING_REMEDY}.",
991
+ )
992
+ found = shutil.which(_TOOL)
993
+ if found is None:
994
+ raise AdapterError(
995
+ "missing_dependency",
996
+ f"{_TOOL} not on PATH — {_MISSING_REMEDY}.",
997
+ )
998
+ return found
999
+
1000
+ @staticmethod
1001
+ def _version(librelane):
1002
+ """Assert the binary answers with the pinned 3.0.17 banner.
1003
+
1004
+ Args:
1005
+ librelane: Resolved librelane binary path.
1006
+
1007
+ Returns:
1008
+ Full `librelane --version` banner line (kept as evidence).
1009
+
1010
+ Raises:
1011
+ AdapterError: missing_dependency when the probe fails,
1012
+ times out, or answers no pinned banner.
1013
+ """
1014
+ try:
1015
+ proc = subprocess.run(
1016
+ [librelane, "--version"],
1017
+ capture_output=True,
1018
+ text=True,
1019
+ timeout=_VERSION_TIMEOUT_S,
1020
+ )
1021
+ except subprocess.TimeoutExpired as exc:
1022
+ raise AdapterError(
1023
+ "missing_dependency",
1024
+ f"librelane version probe timed out at {librelane} — "
1025
+ f"{_MISSING_REMEDY}.",
1026
+ ) from exc
1027
+ except OSError as exc:
1028
+ raise AdapterError(
1029
+ "missing_dependency",
1030
+ f"librelane version probe failed at {librelane}: {exc} — "
1031
+ f"{_MISSING_REMEDY}.",
1032
+ ) from exc
1033
+ banner = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip().splitlines()
1034
+ versioned = next((line for line in banner if _PIN in line), None)
1035
+ if proc.returncode != 0 or versioned is None:
1036
+ got = banner[0] if banner else "empty banner"
1037
+ raise AdapterError(
1038
+ "missing_dependency",
1039
+ f"librelane at {librelane} reported {got!r}, want pin {_PIN} — "
1040
+ f"{_MISSING_REMEDY}.",
1041
+ )
1042
+ return versioned.strip()
1043
+
1044
+ def _run(self, argv, workdir, timeout_s, extra_binds=()):
1045
+ """(timed_out, proc) for one flow run; expiry kills the tree."""
1046
+ if self._runner is not None:
1047
+ try:
1048
+ proc = self._runner.run(argv, workdir, timeout_s, extra_binds)
1049
+ except ExecutionError as exc:
1050
+ if exc.code == "runner_unavailable" and "timed out after" in str(exc):
1051
+ return True, None
1052
+ raise AdapterError(exc.code, str(exc)) from exc
1053
+ return False, proc
1054
+ # Tee: stream the flow to the terminal live while keeping
1055
+ # bounded tails for verdicts (never swallow long builds).
1056
+ import collections
1057
+ import threading
1058
+
1059
+ proc = subprocess.Popen(
1060
+ argv,
1061
+ stdout=subprocess.PIPE,
1062
+ stderr=subprocess.PIPE,
1063
+ cwd=workdir,
1064
+ start_new_session=True,
1065
+ text=True,
1066
+ encoding="utf-8",
1067
+ errors="replace",
1068
+ bufsize=1,
1069
+ )
1070
+ tails = {
1071
+ "out": collections.deque(maxlen=500),
1072
+ "err": collections.deque(maxlen=500),
1073
+ }
1074
+
1075
+ def _pump(stream, key, sink):
1076
+ try:
1077
+ for line in iter(stream.readline, ""):
1078
+ sink.write(line)
1079
+ sink.flush()
1080
+ tails[key].append(line)
1081
+ except (OSError, ValueError):
1082
+ pass
1083
+ finally:
1084
+ try:
1085
+ stream.close()
1086
+ except (OSError, ValueError):
1087
+ pass
1088
+
1089
+ threads = [
1090
+ threading.Thread(
1091
+ target=_pump, args=(proc.stdout, "out", sys.stdout), daemon=True
1092
+ ),
1093
+ threading.Thread(
1094
+ target=_pump, args=(proc.stderr, "err", sys.stderr), daemon=True
1095
+ ),
1096
+ ]
1097
+ for thread in threads:
1098
+ thread.start()
1099
+ try:
1100
+ proc.wait(timeout=timeout_s)
1101
+ except subprocess.TimeoutExpired:
1102
+ try:
1103
+ os.killpg(proc.pid, signal.SIGKILL)
1104
+ except (ProcessLookupError, PermissionError, OSError):
1105
+ pass
1106
+ try:
1107
+ proc.kill()
1108
+ except (ProcessLookupError, OSError):
1109
+ pass
1110
+ proc.wait()
1111
+ for thread in threads:
1112
+ thread.join(timeout=5)
1113
+ return True, None
1114
+ for thread in threads:
1115
+ thread.join(timeout=30)
1116
+ return False, subprocess.CompletedProcess(
1117
+ proc.args,
1118
+ proc.returncode,
1119
+ "".join(tails["out"]),
1120
+ "".join(tails["err"]),
1121
+ )