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,603 @@
1
+ """Canvas loading, validation, closure hashing, and emission.
2
+
3
+ A validated canvas is the design intent the compiler (COMPILE
4
+ slice) consumes: this module proves the file exists, is
5
+ versioned, names only known metrics and knobs with checked
6
+ units, pins its PDK revisions, and hashes its delegated content
7
+ into the run closure. Anything violating a rule fails loudly
8
+ naming the rule and the section — never silent, never a merge.
9
+
10
+ Seam: `load_project_canvas` reads and validates the project
11
+ `canvas.toml` but does not hook project creation or submit
12
+ validation — the adapt lane wires those call sites (and the I28
13
+ run closure) through `validate_canvas`/`canvas_closure`. The
14
+ `[meta]` section from early ticket prose is superseded by the
15
+ frozen contract: `technology`/`pdks` carry the revision pin, and
16
+ unknown top-level sections are rejected.
17
+ """
18
+
19
+ import hashlib
20
+ import json
21
+ import math
22
+ import tomllib
23
+ from pathlib import Path, PurePosixPath
24
+
25
+ from ...interfaces.paths import PathError, join_contained
26
+ from .schema import (
27
+ GENERIC_TOOL_KNOBS,
28
+ HDL_LANGUAGES,
29
+ KNOWN_METRICS,
30
+ KNOWN_UNITS,
31
+ OPERATORS,
32
+ REF_SCOPES,
33
+ TOOL_KNOBS,
34
+ ui_schema,
35
+ )
36
+
37
+ CANVAS_FILENAME = "canvas.toml"
38
+ LEGACY_FILENAME = "canvas.cst"
39
+ CANVAS_VERSION = "rocketeda/canvas/v1"
40
+
41
+ _TOP_LEVEL = frozenset(
42
+ {"schema", "hdl", "technology", "pdks", "targets", "tools",
43
+ "references"}
44
+ )
45
+ _REQUIRED_TOP = ("schema", "pdks", "targets", "tools")
46
+
47
+ CANVAS_SCHEMA = ui_schema()
48
+
49
+
50
+ class CanvasError(Exception):
51
+ """Canvas violation with a stable machine code.
52
+
53
+ Args:
54
+ code: Stable machine-readable rule code.
55
+ message: Human detail naming the rule and the section.
56
+ """
57
+
58
+ def __init__(self, code, message):
59
+ super().__init__(message)
60
+ self.code = code
61
+
62
+
63
+ def _fail(code, section, detail):
64
+ raise CanvasError(code, f"canvas.{section}: {detail}.")
65
+
66
+
67
+ def _need_mapping(value, section, code="bad_shape"):
68
+ if not isinstance(value, dict):
69
+ _fail(code, section, f"must be a mapping, got {value!r}")
70
+
71
+
72
+ def _check_ref(ref, section, require_revision=True):
73
+ _need_mapping(ref, section, code="bad_ref")
74
+ scope = ref.get("scope")
75
+ if scope not in REF_SCOPES:
76
+ _fail("bad_ref", section,
77
+ f"scope must be one of {sorted(REF_SCOPES)}, "
78
+ f"got {scope!r} (rule ref-shape)")
79
+ ident = ref.get("id")
80
+ if not isinstance(ident, str) or not ident:
81
+ _fail("bad_ref", section,
82
+ "id must be a non-empty string (rule ref-shape)")
83
+ revision = ref.get("revision")
84
+ if scope == "project" and revision is None:
85
+ project = ref.get("project_id")
86
+ if not isinstance(project, str) or not project:
87
+ _fail("bad_ref", section,
88
+ "project refs need project_id without a revision "
89
+ "(rule ref-shape)")
90
+ return
91
+ if not isinstance(revision, str) or not revision:
92
+ _fail("unpinned_revision", section,
93
+ "revision must be a non-empty pin (rule revision-pinned)")
94
+ extra = set(ref) - {"scope", "id", "revision", "project_id"}
95
+ if extra:
96
+ _fail("bad_ref", section,
97
+ f"unknown ref keys {sorted(extra)} (rule ref-shape)")
98
+
99
+
100
+ def _check_quantity(value, section, allowed_units=None, note=None):
101
+ _need_mapping(value, section, code="bad_limit")
102
+ tail = f" for {note}" if note else ""
103
+ number = value.get("value")
104
+ if (
105
+ isinstance(number, bool)
106
+ or not isinstance(number, (int, float))
107
+ or not math.isfinite(number)
108
+ ):
109
+ _fail("bad_limit", section,
110
+ f"value must be a finite number, got {number!r}{tail} "
111
+ "(rule units-checked)")
112
+ unit = value.get("unit")
113
+ universe = allowed_units if allowed_units is not None else KNOWN_UNITS
114
+ if not isinstance(unit, str) or unit not in universe:
115
+ _fail("bad_unit", section,
116
+ f"unit {unit!r}{tail} not in {sorted(universe)} "
117
+ "(rule units-checked)")
118
+ extra = set(value) - {"value", "unit"}
119
+ if extra:
120
+ _fail("bad_limit", section,
121
+ f"unknown limit keys {sorted(extra)} (rule units-checked)")
122
+
123
+
124
+ def _check_target(target, index):
125
+ section = f"targets[{index}]"
126
+ _need_mapping(target, section, code="bad_target")
127
+ metric = target.get("metric")
128
+ if metric not in KNOWN_METRICS:
129
+ _fail("unknown_metric", f"{section}.metric",
130
+ f"unknown metric {metric!r}; register it in "
131
+ "schema.KNOWN_METRICS (rule known-metrics)")
132
+ operator = target.get("operator")
133
+ if operator not in OPERATORS:
134
+ _fail("bad_operator", f"{section}.operator",
135
+ f"operator must be one of {list(OPERATORS)}, "
136
+ f"got {operator!r} (rule known-operators)")
137
+ if "limit" not in target:
138
+ _fail("bad_limit", f"{section}.limit",
139
+ "limit is required (rule units-checked)")
140
+ _, units = KNOWN_METRICS[metric]
141
+ _check_quantity(target["limit"], f"{section}.limit",
142
+ allowed_units=units,
143
+ note=f"metric {metric!r}")
144
+ required = target.get("required", True)
145
+ if not isinstance(required, bool):
146
+ _fail("bad_required", f"{section}.required",
147
+ f"required must be a bool, got {required!r}")
148
+ extra = set(target) - {"metric", "operator", "limit", "required",
149
+ "target"}
150
+ if extra:
151
+ _fail("bad_target", section,
152
+ f"unknown target keys {sorted(extra)}")
153
+
154
+
155
+ def _check_knob_value(tool, knob, spec, value, section):
156
+ kind = spec["kind"]
157
+ if kind == "ref":
158
+ _check_ref(value, section, require_revision=True)
159
+ elif kind == "text":
160
+ if not isinstance(value, str) or not value:
161
+ _fail("bad_knob", section,
162
+ "must be non-empty text (rule known-knobs)")
163
+ elif kind == "hash":
164
+ if (
165
+ not isinstance(value, str)
166
+ or len(value) != 64
167
+ or any(c not in "0123456789abcdef" for c in value)
168
+ ):
169
+ _fail("bad_knob", section,
170
+ "must be a 64-hex sha256 (rule known-knobs)")
171
+ elif kind == "boolean":
172
+ if not isinstance(value, bool):
173
+ _fail("bad_knob", section,
174
+ f"must be a bool, got {value!r} (rule known-knobs)")
175
+ elif kind == "number":
176
+ if (
177
+ isinstance(value, bool)
178
+ or not isinstance(value, (int, float))
179
+ or not math.isfinite(value)
180
+ ):
181
+ _fail("bad_knob", section,
182
+ f"must be a finite number, got {value!r} "
183
+ "(rule units-checked)")
184
+ low = spec.get("min_exclusive")
185
+ if low is not None and not value > low:
186
+ _fail("bad_knob", section,
187
+ f"must exceed {low}, got {value} (rule knob-bounds)")
188
+ high = spec.get("max")
189
+ if high is not None and not value <= high:
190
+ _fail("bad_knob", section,
191
+ f"must be at most {high}, got {value} "
192
+ "(rule knob-bounds)")
193
+ elif kind == "integer":
194
+ if isinstance(value, bool) or not isinstance(value, int):
195
+ _fail("bad_knob", section,
196
+ f"must be an int, got {value!r} (rule known-knobs)")
197
+ if not spec["min"] <= value <= spec["max"]:
198
+ _fail("bad_knob", section,
199
+ f"must be {spec['min']}..{spec['max']}, got "
200
+ f"{value} (rule knob-bounds)")
201
+ elif kind == "enum":
202
+ if value not in spec["values"]:
203
+ _fail("bad_knob", section,
204
+ f"must be one of {list(spec['values'])}, "
205
+ f"got {value!r} (rule known-knobs)")
206
+ elif kind == "string_list":
207
+ if (
208
+ not isinstance(value, list)
209
+ or not value
210
+ or any(not isinstance(item, str) or not item
211
+ for item in value)
212
+ ):
213
+ _fail("bad_knob", section,
214
+ "must be a non-empty list of text "
215
+ "(rule known-knobs)")
216
+ elif kind == "quantity":
217
+ _check_quantity(value, section,
218
+ allowed_units=spec.get("units"))
219
+
220
+
221
+ def _resolve_contained(project_root, relpath, section):
222
+ if not isinstance(relpath, str) or not relpath:
223
+ _fail("bad_ref", section,
224
+ "path must be non-empty text (rule refs-contained)")
225
+ parsed = PurePosixPath(relpath)
226
+ if parsed.is_absolute() or ".." in parsed.parts:
227
+ _fail("path_escape", section,
228
+ f"path escapes the project: {relpath!r} "
229
+ "(rule refs-contained)")
230
+ try:
231
+ resolved = join_contained(str(project_root), relpath)
232
+ except PathError as exc:
233
+ _fail("path_escape", section,
234
+ f"{relpath!r} escapes the project ({exc}) "
235
+ "(rule refs-contained)")
236
+ if not resolved.is_file():
237
+ _fail("missing_ref", section,
238
+ f"no such project file: {relpath!r} "
239
+ "(rule refs-contained)")
240
+ digest = hashlib.sha256()
241
+ with open(resolved, "rb") as handle:
242
+ for chunk in iter(lambda: handle.read(65536), b""):
243
+ digest.update(chunk)
244
+ return parsed.as_posix(), digest.hexdigest()
245
+
246
+
247
+ def _check_tools(tools, project_root, refs):
248
+ _need_mapping(tools, "tools", code="bad_tools")
249
+ for name, entry in tools.items():
250
+ section = f"tools.{name}"
251
+ _need_mapping(entry, section, code="bad_tool")
252
+ known = TOOL_KNOBS.get(name)
253
+ allowed = known if known is not None else {
254
+ knob: {"kind": "generic"} for knob in GENERIC_TOOL_KNOBS
255
+ }
256
+ for knob in entry:
257
+ if knob not in allowed:
258
+ _fail("unknown_knob", f"{section}.{knob}",
259
+ f"unknown knob for tool {name!r}; register it "
260
+ "in schema.TOOL_KNOBS (rule known-knobs)")
261
+ spec = allowed
262
+ for knob, value in entry.items():
263
+ kind = spec[knob]["kind"]
264
+ if kind == "generic":
265
+ if knob == "profile":
266
+ _check_ref(value, section)
267
+ elif knob in ("configuration",):
268
+ if not isinstance(value, str) or not value:
269
+ _fail("bad_knob", section,
270
+ "must be non-empty text")
271
+ elif knob == "sha256":
272
+ _check_knob_value(name, knob, {"kind": "hash"},
273
+ value, section)
274
+ continue
275
+ _check_knob_value(name, knob, spec[knob], value,
276
+ f"{section}.{knob}")
277
+ inline = {
278
+ knob for knob in entry
279
+ if knob not in ("profile", "sha256", "configuration")
280
+ }
281
+ if "configuration" in entry and inline:
282
+ _fail("duplicate_configuration", section,
283
+ f"inline knobs {sorted(inline)} conflict with "
284
+ "delegated configuration; keep one "
285
+ "(rule single-source)")
286
+ if "configuration" in entry:
287
+ rel, digest = _resolve_contained(
288
+ project_root, entry["configuration"], section)
289
+ refs.append({"path": rel, "sha256": digest})
290
+
291
+
292
+ def validate_canvas(doc, project_root):
293
+ """Validate a parsed canvas document against the schema.
294
+
295
+ Args:
296
+ doc: Parsed `canvas.toml` mapping.
297
+ project_root: Project dir delegated refs resolve inside;
298
+ refs stay contained or fail loud.
299
+
300
+ Returns:
301
+ Validated config mapping plus the closure: refs (path,
302
+ sha256) and closure_hash pinning the intent.
303
+
304
+ Raises:
305
+ CanvasError: The first rule violated, naming the rule
306
+ and the section (bad_schema, unknown_metric,
307
+ bad_unit, unknown_knob, duplicate_configuration,
308
+ unpinned_revision, path_escape, missing_ref, ...).
309
+ """
310
+ _need_mapping(doc, "", code="bad_shape")
311
+ unknown = set(doc) - _TOP_LEVEL
312
+ if unknown:
313
+ _fail("unknown_section", sorted(unknown)[0],
314
+ f"unknown top-level section "
315
+ f"(rule schema-closed): {sorted(unknown)}")
316
+ for key in _REQUIRED_TOP:
317
+ if key not in doc:
318
+ _fail("missing_section", key,
319
+ "required by the v1 canvas contract "
320
+ "(rule schema-closed)")
321
+ if doc["schema"] != CANVAS_VERSION:
322
+ _fail("bad_schema", "schema",
323
+ f"must be {CANVAS_VERSION!r}, got "
324
+ f"{doc['schema']!r} (rule schema-version)")
325
+ refs = []
326
+ # An empty table (`[technology]` with only comments) carries no
327
+ # claim, so it reads as absent; anything stated validates fully.
328
+ if doc.get("hdl"):
329
+ _need_mapping(doc["hdl"], "hdl", code="bad_hdl")
330
+ language = doc["hdl"].get("language")
331
+ if language not in HDL_LANGUAGES:
332
+ _fail("bad_hdl", "hdl.language",
333
+ f"language must be one of {list(HDL_LANGUAGES)}, "
334
+ f"got {language!r} (rule one-dialect)")
335
+ top = doc["hdl"].get("top")
336
+ if top is not None and (not isinstance(top, str) or not top):
337
+ _fail("bad_hdl", "hdl.top",
338
+ "top must be non-empty text (rule one-dialect)")
339
+ extra = set(doc["hdl"]) - {"language", "top"}
340
+ if extra:
341
+ _fail("bad_hdl", "hdl",
342
+ f"unknown hdl keys {sorted(extra)}")
343
+ if doc.get("technology"):
344
+ _check_ref(doc["technology"], "technology")
345
+ pdks = doc["pdks"]
346
+ if not isinstance(pdks, list):
347
+ _fail("bad_pdks", "pdks", "must be a list (rule pdk-pinned)")
348
+ for index, ref in enumerate(pdks):
349
+ _check_ref(ref, f"pdks[{index}]")
350
+ targets = doc["targets"]
351
+ if not isinstance(targets, list):
352
+ _fail("bad_targets", "targets",
353
+ "must be a list (rule known-metrics)")
354
+ for index, target in enumerate(targets):
355
+ _check_target(target, index)
356
+ _check_tools(doc["tools"], project_root, refs)
357
+ references = doc.get("references", [])
358
+ if not isinstance(references, list):
359
+ _fail("bad_references", "references",
360
+ "must be a list (rule refs-contained)")
361
+ for index, entry in enumerate(references):
362
+ section = f"references[{index}]"
363
+ _need_mapping(entry, section, code="bad_ref")
364
+ if "path" not in entry or not isinstance(entry["path"], str):
365
+ _fail("bad_ref", section,
366
+ "path is required text (rule refs-contained)")
367
+ rel, digest = _resolve_contained(
368
+ project_root, entry["path"], section)
369
+ pinned = entry.get("sha256")
370
+ if pinned is not None and pinned != digest:
371
+ _fail("ref_changed", section,
372
+ f"{rel!r} changed under its pin "
373
+ f"(rule drift-loud): have {digest}")
374
+ refs.append({"path": rel, "sha256": digest})
375
+ extra = set(entry) - {"path", "sha256"}
376
+ if extra:
377
+ _fail("bad_ref", section,
378
+ f"unknown reference keys {sorted(extra)}")
379
+ config = {
380
+ "schema": doc["schema"],
381
+ "pdks": doc["pdks"],
382
+ "targets": doc["targets"],
383
+ "tools": doc["tools"],
384
+ }
385
+ if doc.get("hdl"):
386
+ config["hdl"] = doc["hdl"]
387
+ if doc.get("technology"):
388
+ config["technology"] = doc["technology"]
389
+ if "references" in doc:
390
+ config["references"] = doc["references"]
391
+ refs.sort(key=lambda entry: entry["path"])
392
+ return {"config": config, "closure": canvas_closure(refs)}
393
+
394
+
395
+ def canvas_closure(refs):
396
+ """Hash pinned refs into a run-closure identity.
397
+
398
+ The hash is content-addressed and key-order stable, mirroring
399
+ the block-run closure: equal intents hash equally, so a rerun
400
+ after a sibling edit proves isolation by hashing differently.
401
+
402
+ Args:
403
+ refs: Sorted ref entries (path, sha256).
404
+
405
+ Returns:
406
+ Closure mapping with refs and closure_hash (hex sha256
407
+ over the canonical JSON encoding).
408
+ """
409
+ canonical = json.dumps(
410
+ refs, sort_keys=True, separators=(",", ":"), default=str
411
+ )
412
+ return {
413
+ "refs": [dict(entry) for entry in refs],
414
+ "closure_hash": hashlib.sha256(
415
+ canonical.encode("utf-8")).hexdigest(),
416
+ }
417
+
418
+
419
+ def load_project_canvas(project_root, filename=CANVAS_FILENAME):
420
+ """Load, validate, and close over a project's canvas file.
421
+
422
+ Args:
423
+ project_root: Project dir holding `canvas.toml`.
424
+ filename: Canvas filename; explicit migration only, so
425
+ the legacy name never loads here.
426
+
427
+ Returns:
428
+ Mapping with path, config (validated), and closure
429
+ (canvas_sha256 over the raw bytes, refs, and
430
+ closure_hash pinning canvas plus refs).
431
+
432
+ Raises:
433
+ CanvasError: canvas_missing for no canvas file,
434
+ conflicting_canvas when a legacy `canvas.cst` sits
435
+ beside `canvas.toml`, bad_toml for unparsable text,
436
+ or whatever `validate_canvas` raises.
437
+ """
438
+ root = Path(project_root)
439
+ legacy = root / LEGACY_FILENAME
440
+ if legacy.is_file():
441
+ _fail("conflicting_canvas", LEGACY_FILENAME,
442
+ f"{LEGACY_FILENAME} and {filename} both exist; "
443
+ "migrate explicitly, never both "
444
+ "(rule explicit-migration)")
445
+ path = root / filename
446
+ if not path.is_file():
447
+ _fail("canvas_missing", filename,
448
+ f"no {filename} in {root} (rule canvas-exists)")
449
+ raw = path.read_bytes()
450
+ try:
451
+ doc = tomllib.loads(raw.decode("utf-8"))
452
+ except Exception as exc:
453
+ _fail("bad_toml", filename,
454
+ f"does not parse as TOML ({exc}) (rule toml-shape)")
455
+ validated = validate_canvas(doc, root)
456
+ refs = list(validated["closure"]["refs"])
457
+ closure = dict(validated["closure"])
458
+ closure["canvas_sha256"] = hashlib.sha256(raw).hexdigest()
459
+ closure["closure_hash"] = hashlib.sha256(
460
+ json.dumps(
461
+ {"canvas_sha256": closure["canvas_sha256"],
462
+ "refs": refs},
463
+ sort_keys=True, separators=(",", ":"),
464
+ default=str,
465
+ ).encode("utf-8")).hexdigest()
466
+ return {
467
+ "path": str(path),
468
+ "config": validated["config"],
469
+ "closure": closure,
470
+ }
471
+
472
+
473
+ def dump_canvas_toml(doc):
474
+ """Serialize a canvas mapping to canonical TOML text.
475
+
476
+ The project-creation wizard writes through here, so a filled
477
+ canvas always round-trips through `validate_canvas`.
478
+
479
+ Args:
480
+ doc: Canvas mapping (nested mappings, lists of mappings
481
+ for pdks/targets/references, scalars otherwise).
482
+
483
+ Returns:
484
+ TOML text with `schema` first and stable key order.
485
+
486
+ Raises:
487
+ CanvasError: bad_value for values TOML cannot carry
488
+ (None, NaN/inf, or unknown types).
489
+ """
490
+ lines = []
491
+
492
+ def emit_scalar(value, section):
493
+ if isinstance(value, bool):
494
+ return "true" if value else "false"
495
+ if isinstance(value, int):
496
+ return str(value)
497
+ if isinstance(value, float):
498
+ if not math.isfinite(value):
499
+ _fail("bad_value", section,
500
+ "TOML cannot carry NaN/inf (rule toml-shape)")
501
+ text = repr(value)
502
+ return text if "." in text or "e" in text else text + ".0"
503
+ if isinstance(value, str):
504
+ escaped = (
505
+ value.replace("\\", "\\\\")
506
+ .replace('"', '\\"')
507
+ .replace("\n", "\\n")
508
+ .replace("\t", "\\t")
509
+ )
510
+ return f'"{escaped}"'
511
+ _fail("bad_value", section,
512
+ f"TOML cannot carry {type(value).__name__} "
513
+ "(rule toml-shape)")
514
+
515
+ def emit_table(mapping, prefix, section):
516
+ # Scalars first: a sub-table header re-points every later
517
+ # key, so parent keys must precede `[table.child]` lines.
518
+ for key in sorted(mapping):
519
+ value = mapping[key]
520
+ if isinstance(value, dict):
521
+ continue
522
+ if isinstance(value, list) and value and all(
523
+ isinstance(item, dict) for item in value
524
+ ):
525
+ continue
526
+ if isinstance(value, list):
527
+ items = ", ".join(
528
+ emit_scalar(item, f"{section}.{key}")
529
+ for item in value
530
+ )
531
+ lines.append(f"{key} = [{items}]")
532
+ else:
533
+ lines.append(
534
+ f"{key} = {emit_scalar(value, f'{section}.{key}')}"
535
+ )
536
+ for key in sorted(mapping):
537
+ value = mapping[key]
538
+ if isinstance(value, dict):
539
+ lines.append(f"[{prefix}{key}]")
540
+ emit_table(value, f"{prefix}{key}.",
541
+ f"{section}.{key}")
542
+ elif isinstance(value, list) and value and all(
543
+ isinstance(item, dict) for item in value
544
+ ):
545
+ for item in value:
546
+ lines.append(f"[[{prefix}{key}]]")
547
+ emit_table(item, f"{prefix}{key}.",
548
+ f"{section}.{key}")
549
+
550
+ top_scalars = {}
551
+ top_tables = {}
552
+ for key in doc:
553
+ value = doc[key]
554
+ # Empty lists are scalars (`key = []`); emitting them after
555
+ # a `[table]` header would re-point them into that table.
556
+ if isinstance(value, dict) or (
557
+ isinstance(value, list) and value and all(
558
+ isinstance(item, dict) for item in value
559
+ )
560
+ ):
561
+ top_tables[key] = value
562
+ else:
563
+ top_scalars[key] = value
564
+ ordered = {}
565
+ if "schema" in top_scalars:
566
+ ordered["schema"] = top_scalars.pop("schema")
567
+ ordered.update(sorted(top_scalars.items()))
568
+ for key, value in ordered.items():
569
+ if isinstance(value, list):
570
+ items = ", ".join(
571
+ emit_scalar(item, key) for item in value
572
+ )
573
+ lines.append(f"{key} = [{items}]")
574
+ else:
575
+ lines.append(f"{key} = {emit_scalar(value, key)}")
576
+ if ordered and top_tables:
577
+ lines.append("")
578
+ first = True
579
+ order = ["hdl", "technology", "pdks", "targets", "tools",
580
+ "references"]
581
+ names = order + sorted(set(top_tables) - set(order))
582
+ for key in names:
583
+ if key not in top_tables:
584
+ continue
585
+ if not first:
586
+ lines.append("")
587
+ first = False
588
+ value = top_tables[key]
589
+ if isinstance(value, dict):
590
+ lines.append(f"[{key}]")
591
+ emit_table(value, f"{key}.", key)
592
+ elif isinstance(value, list) and value and all(
593
+ isinstance(item, dict) for item in value
594
+ ):
595
+ for item in value:
596
+ lines.append(f"[[{key}]]")
597
+ emit_table(item, f"{key}.", key)
598
+ else:
599
+ items = ", ".join(
600
+ emit_scalar(item, key) for item in value
601
+ )
602
+ lines.append(f"{key} = [{items}]")
603
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,5 @@
1
+ """Typed edit transactions and CAS helpers for the project ledger."""
2
+
3
+ from .cas import cas_set, read_entity_revision
4
+
5
+ __all__ = ["cas_set", "read_entity_revision"]
@@ -0,0 +1,87 @@
1
+ """Per-entity compare-and-swap reads and writes.
2
+
3
+ Thin helpers over the ProjectService entity verbs: reads report
4
+ the live per-entity revision and writes gate on it, so editors
5
+ fail loudly with revision_conflict instead of merging.
6
+ """
7
+
8
+ _MISSING = object()
9
+
10
+
11
+ def read_entity_revision(service, entity):
12
+ """Return the live per-entity revision of one entity.
13
+
14
+ Args:
15
+ service: ProjectService handle to read through.
16
+ entity: Engine-minted entity id to inspect.
17
+
18
+ Returns:
19
+ Integer entity revision (legacy records read as 1).
20
+
21
+ Raises:
22
+ Exception: Whatever the service raises for unknown or
23
+ malformed entity ids.
24
+ """
25
+ names = service.components_of(entity)["components"]
26
+ if not names:
27
+ return 1
28
+ got = service.get_component(entity, names[0])
29
+ return got.get("entity_revision", 1)
30
+
31
+
32
+ def require_revision(service, entity, expected):
33
+ """Check one entity revision without writing.
34
+
35
+ Args:
36
+ service: ProjectService handle to read through.
37
+ entity: Engine-minted entity id to inspect.
38
+ expected: Revision the writer read, or None to skip.
39
+
40
+ Returns:
41
+ Live entity revision the write may build on.
42
+
43
+ Raises:
44
+ Exception: revision_conflict when the live revision
45
+ differs from the expected one.
46
+ """
47
+ live = read_entity_revision(service, entity)
48
+ if expected is not None and expected != live:
49
+ error = ValueError(
50
+ f"Entity {entity} at revision {live}, "
51
+ f"writer expected {expected}.",
52
+ )
53
+ error.code = "revision_conflict"
54
+ raise error
55
+ return live
56
+
57
+
58
+ def cas_set(service, entity, component, data, request_id,
59
+ command_text, expected_entity_revision,
60
+ expected_revision=None):
61
+ """Upsert one component gated on its entity revision.
62
+
63
+ Args:
64
+ service: ProjectService handle to write through.
65
+ entity: Entity id holding the component.
66
+ component: Component name to write.
67
+ data: JSON payload to store.
68
+ request_id: Client envelope id for idempotent replay.
69
+ command_text: Command text for identity comparison.
70
+ expected_entity_revision: Entity revision the writer
71
+ read; a stale value fails instead of merging.
72
+ expected_revision: Global head gate, skipped when an
73
+ entity revision is named (disjoint edits land).
74
+
75
+ Returns:
76
+ Service mutation mapping with the bumped
77
+ entity_revision.
78
+
79
+ Raises:
80
+ Exception: revision_conflict for a stale entity
81
+ revision, or whatever the service raises.
82
+ """
83
+ return service.set_component(
84
+ entity, component, data, request_id, command_text,
85
+ expected_revision=expected_revision,
86
+ expected_entity_revision=expected_entity_revision,
87
+ )