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,1324 @@
1
+ """Wire/junction entities + connect ops (WIRE-PLACE).
2
+
3
+ Wires are first-class entities — placeable, movable, splittable,
4
+ deletable, rewindable — with endpoints terminating on stable
5
+ pin/port/junction/net entity IDs. Junctions are explicit dots: two
6
+ wires join only at a shared endpoint, a T-tap, or a junction they
7
+ both reference; crossing interiors never connect. Nets derive from
8
+ CONNECT-GRAPH extraction, then materialize as Net entities keyed by
9
+ the shared `net_name()` authority, so emitters, ERC, and agents all
10
+ read the same net identity.
11
+
12
+ Every op runs through the ENGINE-ECS entity store seam as exactly
13
+ one attributed transaction (one revision, one description, actor on
14
+ every event); a no-op returns a receipt with a None revision and
15
+ writes nothing. Dangling endpoints (null refs) are legal transiently
16
+ and ERC-flagged, never silently dropped; refs to deleted entities
17
+ are ERC errors, and deleting a referenced junction is refused
18
+ loudly. This module owns no storage: `store` is any object serving
19
+ the seam documented in `docs/work/WIRE-PLACE/report.md` (tests carry
20
+ an in-memory fake; the ENGINE-ECS lane owns the durable one).
21
+
22
+ Component schemas (JSON-safe, edit-time validated):
23
+
24
+ - Wire: {segments, layer, endpoints}; segments is a non-empty list
25
+ of integer polylines ([x, y] points, no zero-length runs);
26
+ endpoints holds exactly [a, b] with xy pinned to the first and
27
+ last polyline points and ref a pin/port/junction/net entity ID
28
+ or null when dangling.
29
+ - Junction: {xy, layer}; the explicit dot.
30
+ - Net: {name, extraction {cell, net_ids}, members {pins, labels,
31
+ wires, junctions}, has_wires}; materialized, keyed by name.
32
+
33
+ Extraction groups that resolve to the same canonical name merge
34
+ into one Net entity: a name is global, so a net-ref tap and a
35
+ repeated label both join every group carrying the name.
36
+ """
37
+
38
+ from ..interfaces.errors import ContractError
39
+
40
+ __all__ = [
41
+ "DEFAULT_WIRE_POLICY",
42
+ "ENDPOINTS",
43
+ "JUNCTION_COMPONENT",
44
+ "NET_COMPONENT",
45
+ "RULE_DANGLING_ENDPOINT",
46
+ "RULE_ORPHAN_JUNCTION",
47
+ "RULE_STALE_GEOMETRY",
48
+ "RULE_UNKNOWN_REF",
49
+ "WIRE_COMPONENT",
50
+ "collect_wire_erc",
51
+ "connect_endpoint",
52
+ "delete_junction",
53
+ "delete_wire",
54
+ "materialize_nets",
55
+ "move_wire",
56
+ "place_junction",
57
+ "place_wire",
58
+ "split_wire",
59
+ "validate_junction",
60
+ "validate_label",
61
+ "validate_net",
62
+ "validate_pin",
63
+ "validate_wire",
64
+ "world_schematic",
65
+ ]
66
+
67
+ #: Component names as stored on entities.
68
+ WIRE_COMPONENT = "Wire"
69
+ JUNCTION_COMPONENT = "Junction"
70
+ NET_COMPONENT = "Net"
71
+
72
+ #: ERC rule codes for wire/junction findings.
73
+ RULE_DANGLING_ENDPOINT = "wire.dangling_endpoint"
74
+ RULE_UNKNOWN_REF = "wire.unknown_ref"
75
+ RULE_STALE_GEOMETRY = "wire.stale_geometry"
76
+ RULE_ORPHAN_JUNCTION = "wire.orphan_junction"
77
+
78
+ #: Default finding severities; unknown-ref is the only error.
79
+ DEFAULT_WIRE_POLICY = {
80
+ RULE_DANGLING_ENDPOINT: "warning",
81
+ RULE_UNKNOWN_REF: "error",
82
+ RULE_STALE_GEOMETRY: "warning",
83
+ RULE_ORPHAN_JUNCTION: "warning",
84
+ }
85
+
86
+ #: Endpoint selector order: a is the first polyline point.
87
+ ENDPOINTS = ("a", "b")
88
+
89
+ _PORT_DIRECTIONS = ("input", "output", "inout")
90
+
91
+
92
+ def _is_int(value):
93
+ return isinstance(value, int) and not isinstance(value, bool)
94
+
95
+
96
+ def _need_point(value, what, code):
97
+ if (
98
+ not isinstance(value, (list, tuple))
99
+ or len(value) != 2
100
+ or not all(_is_int(c) for c in value)
101
+ ):
102
+ raise ContractError(code, f"{what} must be [x, y] ints, got {value!r}.")
103
+ return [value[0], value[1]]
104
+
105
+
106
+ def _need_nonempty_str(value, what, code):
107
+ if not isinstance(value, str) or not value:
108
+ raise ContractError(code, f"{what} must be a non-empty string.")
109
+ return value
110
+
111
+
112
+ def _need_actor(actor):
113
+ return _need_nonempty_str(actor, "actor", "invalid_actor")
114
+
115
+
116
+ def _point_on_segment(point, seg_a, seg_b):
117
+ (px, py), (ax, ay), (bx, by) = point, seg_a, seg_b
118
+ if (bx - ax) * (py - ay) != (by - ay) * (px - ax):
119
+ return False
120
+ return min(ax, bx) <= px <= max(ax, bx) and min(ay, by) <= py <= max(ay, by)
121
+
122
+
123
+ def _point_on_wire(point, wire):
124
+ for polyline in wire["segments"]:
125
+ for seg_a, seg_b in zip(polyline, polyline[1:]):
126
+ if _point_on_segment(point, seg_a, seg_b):
127
+ return True
128
+ return False
129
+
130
+
131
+ def _sorted_str_list(value, what, code):
132
+ if not isinstance(value, list) or not all(
133
+ isinstance(v, str) and v for v in value
134
+ ):
135
+ raise ContractError(code, f"{what} must be a non-empty-str list.")
136
+ if sorted(value) != value or len(set(value)) != len(value):
137
+ raise ContractError(code, f"{what} must be sorted and distinct.")
138
+ return list(value)
139
+
140
+
141
+ def validate_wire(wire):
142
+ """Validate a Wire component mapping at edit time.
143
+
144
+ Checks shape only — segment geometry, layer text, endpoint
145
+ count, endpoint xy pinned to the polyline ends — and returns a
146
+ normalized copy. Ref existence is never checked here; the ops
147
+ resolve refs through the store and fail unknown_ref loudly.
148
+
149
+ Args:
150
+ wire: Wire mapping with segments, layer, endpoints keys.
151
+
152
+ Returns:
153
+ Normalized Wire copy (lists, storage untouched).
154
+
155
+ Raises:
156
+ ContractError: invalid_wire for any shape violation.
157
+ """
158
+ if not isinstance(wire, dict):
159
+ raise ContractError(
160
+ "invalid_wire", f"Wire must be a mapping, got {wire!r}."
161
+ )
162
+ for key in wire:
163
+ if key not in ("segments", "layer", "endpoints"):
164
+ raise ContractError("invalid_wire", f"Unknown Wire key: {key}.")
165
+ segments = wire.get("segments")
166
+ if not isinstance(segments, list) or not segments:
167
+ raise ContractError("invalid_wire", "segments must be a non-empty list.")
168
+ clean_segments = []
169
+ for index, polyline in enumerate(segments):
170
+ if not isinstance(polyline, (list, tuple)) or len(polyline) < 2:
171
+ raise ContractError(
172
+ "invalid_wire",
173
+ f"segments[{index}] must hold at least 2 points.",
174
+ )
175
+ points = [
176
+ _need_point(p, f"segments[{index}]", "invalid_wire")
177
+ for p in polyline
178
+ ]
179
+ for first, second in zip(points, points[1:]):
180
+ if first == second:
181
+ raise ContractError(
182
+ "invalid_wire",
183
+ f"segments[{index}] holds a zero-length run.",
184
+ )
185
+ clean_segments.append(points)
186
+ layer = _need_nonempty_str(wire.get("layer"), "layer", "invalid_wire")
187
+ endpoints = wire.get("endpoints")
188
+ if not isinstance(endpoints, list) or len(endpoints) != 2:
189
+ raise ContractError("invalid_wire", "endpoints must hold exactly [a, b].")
190
+ clean_endpoints = []
191
+ for index, endpoint in enumerate(endpoints):
192
+ if not isinstance(endpoint, dict):
193
+ raise ContractError("invalid_wire", "endpoints must be mappings.")
194
+ for key in endpoint:
195
+ if key not in ("ref", "xy"):
196
+ raise ContractError(
197
+ "invalid_wire", f"Unknown endpoint key: {key}."
198
+ )
199
+ ref = endpoint.get("ref")
200
+ if ref is not None and (not isinstance(ref, str) or not ref):
201
+ raise ContractError(
202
+ "invalid_wire", "endpoint ref must be an ID or null."
203
+ )
204
+ xy = _need_point(
205
+ endpoint.get("xy"), f"endpoints[{ENDPOINTS[index]}].xy",
206
+ "invalid_wire",
207
+ )
208
+ clean_endpoints.append({"ref": ref, "xy": xy})
209
+ if clean_endpoints[0]["xy"] != clean_segments[0][0]:
210
+ raise ContractError(
211
+ "invalid_wire", "endpoint a.xy must equal the first polyline point."
212
+ )
213
+ if clean_endpoints[1]["xy"] != clean_segments[-1][-1]:
214
+ raise ContractError(
215
+ "invalid_wire", "endpoint b.xy must equal the last polyline point."
216
+ )
217
+ return {"segments": clean_segments, "layer": layer,
218
+ "endpoints": clean_endpoints}
219
+
220
+
221
+ def validate_junction(junction):
222
+ """Validate a Junction component mapping at edit time.
223
+
224
+ Args:
225
+ junction: Junction mapping with xy and layer keys.
226
+
227
+ Returns:
228
+ Normalized Junction copy.
229
+
230
+ Raises:
231
+ ContractError: invalid_junction for any shape violation.
232
+ """
233
+ if not isinstance(junction, dict):
234
+ raise ContractError(
235
+ "invalid_junction", f"Junction must be a mapping, got {junction!r}."
236
+ )
237
+ for key in junction:
238
+ if key not in ("xy", "layer"):
239
+ raise ContractError(
240
+ "invalid_junction", f"Unknown Junction key: {key}."
241
+ )
242
+ return {
243
+ "xy": _need_point(junction.get("xy"), "xy", "invalid_junction"),
244
+ "layer": _need_nonempty_str(
245
+ junction.get("layer"), "layer", "invalid_junction"
246
+ ),
247
+ }
248
+
249
+
250
+ def validate_net(net):
251
+ """Validate a Net component mapping at edit time.
252
+
253
+ Args:
254
+ net: Net mapping with name, extraction, members, has_wires.
255
+
256
+ Returns:
257
+ Normalized Net copy.
258
+
259
+ Raises:
260
+ ContractError: invalid_net for any shape violation.
261
+ """
262
+ if not isinstance(net, dict):
263
+ raise ContractError(
264
+ "invalid_net", f"Net must be a mapping, got {net!r}."
265
+ )
266
+ for key in net:
267
+ if key not in ("name", "extraction", "members", "has_wires"):
268
+ raise ContractError("invalid_net", f"Unknown Net key: {key}.")
269
+ name = _need_nonempty_str(net.get("name"), "name", "invalid_net")
270
+ extraction = net.get("extraction")
271
+ if not isinstance(extraction, dict):
272
+ raise ContractError("invalid_net", "extraction must be a mapping.")
273
+ cell = _need_nonempty_str(
274
+ extraction.get("cell"), "extraction.cell", "invalid_net"
275
+ )
276
+ net_ids = extraction.get("net_ids")
277
+ if (
278
+ not isinstance(net_ids, list)
279
+ or not net_ids
280
+ or not all(_is_int(n) for n in net_ids)
281
+ or sorted(net_ids) != list(net_ids)
282
+ ):
283
+ raise ContractError(
284
+ "invalid_net", "extraction.net_ids must be a sorted int list."
285
+ )
286
+ members = net.get("members")
287
+ if not isinstance(members, dict):
288
+ raise ContractError("invalid_net", "members must be a mapping.")
289
+ for key in members:
290
+ if key not in ("pins", "labels", "wires", "junctions"):
291
+ raise ContractError(
292
+ "invalid_net", f"Unknown members key: {key}."
293
+ )
294
+ pins = members.get("pins")
295
+ if not isinstance(pins, list):
296
+ raise ContractError("invalid_net", "members.pins must be a list.")
297
+ clean_pins = []
298
+ for entry in pins:
299
+ if (
300
+ not isinstance(entry, dict)
301
+ or set(entry) != {"instance", "pin"}
302
+ or not entry["instance"]
303
+ or not isinstance(entry["instance"], str)
304
+ or not entry["pin"]
305
+ or not isinstance(entry["pin"], str)
306
+ ):
307
+ raise ContractError(
308
+ "invalid_net", "members.pins entries need instance + pin."
309
+ )
310
+ clean_pins.append(
311
+ {"instance": entry["instance"], "pin": entry["pin"]}
312
+ )
313
+ keys = [(p["instance"], p["pin"]) for p in clean_pins]
314
+ if sorted(keys) != keys or len(set(keys)) != len(keys):
315
+ raise ContractError(
316
+ "invalid_net", "members.pins must be sorted and distinct."
317
+ )
318
+ clean_members = {
319
+ "pins": clean_pins,
320
+ "labels": _sorted_str_list(
321
+ members.get("labels"), "members.labels", "invalid_net"
322
+ ),
323
+ "wires": _sorted_str_list(
324
+ members.get("wires"), "members.wires", "invalid_net"
325
+ ),
326
+ "junctions": _sorted_str_list(
327
+ members.get("junctions"), "members.junctions", "invalid_net"
328
+ ),
329
+ }
330
+ has_wires = net.get("has_wires")
331
+ if not isinstance(has_wires, bool):
332
+ raise ContractError("invalid_net", "has_wires must be a bool.")
333
+ return {
334
+ "name": name,
335
+ "extraction": {"cell": cell, "net_ids": list(net_ids)},
336
+ "members": clean_members,
337
+ "has_wires": has_wires,
338
+ }
339
+
340
+
341
+ def validate_pin(pin):
342
+ """Validate one caller-supplied pin record at edit time.
343
+
344
+ Pin entities belong to the Block lane; callers map each entity
345
+ to its schematic (instance, pin, xy) triple through these
346
+ records.
347
+
348
+ Args:
349
+ pin: Pin mapping with entity, instance, pin, xy keys.
350
+
351
+ Returns:
352
+ Normalized pin copy.
353
+
354
+ Raises:
355
+ ContractError: invalid_pin for any shape violation.
356
+ """
357
+ if not isinstance(pin, dict):
358
+ raise ContractError(
359
+ "invalid_pin", f"Pin must be a mapping, got {pin!r}."
360
+ )
361
+ for key in pin:
362
+ if key not in ("entity", "instance", "pin", "xy"):
363
+ raise ContractError("invalid_pin", f"Unknown pin key: {key}.")
364
+ return {
365
+ "entity": _need_nonempty_str(
366
+ pin.get("entity"), "entity", "invalid_pin"
367
+ ),
368
+ "instance": _need_nonempty_str(
369
+ pin.get("instance"), "instance", "invalid_pin"
370
+ ),
371
+ "pin": _need_nonempty_str(pin.get("pin"), "pin", "invalid_pin"),
372
+ "xy": _need_point(pin.get("xy"), "xy", "invalid_pin"),
373
+ }
374
+
375
+
376
+ def validate_label(label):
377
+ """Validate one caller-supplied label record at edit time.
378
+
379
+ Args:
380
+ label: Label mapping with name and xy keys.
381
+
382
+ Returns:
383
+ Normalized label copy.
384
+
385
+ Raises:
386
+ ContractError: invalid_label for any shape violation.
387
+ """
388
+ if not isinstance(label, dict):
389
+ raise ContractError(
390
+ "invalid_label", f"Label must be a mapping, got {label!r}."
391
+ )
392
+ for key in label:
393
+ if key not in ("name", "xy"):
394
+ raise ContractError("invalid_label", f"Unknown label key: {key}.")
395
+ return {
396
+ "name": _need_nonempty_str(
397
+ label.get("name"), "name", "invalid_label"
398
+ ),
399
+ "xy": _need_point(label.get("xy"), "xy", "invalid_label"),
400
+ }
401
+
402
+
403
+ def _read_wire(store, wire_id):
404
+ try:
405
+ components = store.components_of(wire_id)
406
+ except LookupError:
407
+ raise ContractError(
408
+ "unknown_wire", f"No such wire entity: {wire_id!r}."
409
+ ) from None
410
+ if WIRE_COMPONENT not in components:
411
+ raise ContractError(
412
+ "unknown_wire", f"Entity {wire_id!r} carries no Wire component."
413
+ )
414
+ return validate_wire(components[WIRE_COMPONENT])
415
+
416
+
417
+ def _check_ref(store, ref, wire_id=None):
418
+ """Resolve one endpoint ref; fail loudly when unusable.
419
+
420
+ Args:
421
+ store: Entity store serving the seam.
422
+ ref: Referenced entity ID.
423
+ wire_id: Connecting wire, rejected as a self-ref.
424
+
425
+ Returns:
426
+ The referenced entity's components.
427
+
428
+ Raises:
429
+ ContractError: invalid_ref for a wire/self ref, unknown_ref
430
+ for a missing entity.
431
+ """
432
+ if not isinstance(ref, str) or not ref:
433
+ raise ContractError("invalid_ref", "endpoint ref must be an ID or null.")
434
+ if ref == wire_id:
435
+ raise ContractError(
436
+ "invalid_ref", f"Wire {wire_id!r} cannot terminate on itself."
437
+ )
438
+ try:
439
+ components = store.components_of(ref)
440
+ except LookupError:
441
+ raise ContractError(
442
+ "unknown_ref", f"Endpoint ref names no entity: {ref!r}."
443
+ ) from None
444
+ if WIRE_COMPONENT in components:
445
+ raise ContractError(
446
+ "invalid_ref",
447
+ f"Endpoint ref {ref!r} names a Wire entity; endpoints "
448
+ "terminate on pin/port/junction/net IDs only.",
449
+ )
450
+ return components
451
+
452
+
453
+ def _receipt(op, actor, transact_receipt, **ids):
454
+ receipt = {
455
+ "op": op, "actor": actor,
456
+ "revision": transact_receipt["revision"],
457
+ "events": transact_receipt["events"],
458
+ }
459
+ receipt.update(ids)
460
+ return receipt
461
+
462
+
463
+ def _describe(op, detail):
464
+ return f"{op} {detail}"
465
+
466
+
467
+ def place_wire(store, actor, segments, layer, endpoints):
468
+ """Place one wire as a durable attributed revision.
469
+
470
+ Args:
471
+ store: Entity store serving the seam.
472
+ actor: Attribution recorded on the revision and events.
473
+ segments: Non-empty list of integer polylines.
474
+ layer: Layer name for every segment.
475
+ endpoints: [a, b] endpoint mappings; each ref is a
476
+ pin/port/junction/net entity ID or null when dangling.
477
+
478
+ Returns:
479
+ Receipt with op, actor, revision, wire ID, and events.
480
+
481
+ Raises:
482
+ ContractError: invalid_actor for empty attribution,
483
+ invalid_wire for a malformed wire, unknown_ref for a
484
+ ref naming no entity, invalid_ref for a wire ref.
485
+ """
486
+ _need_actor(actor)
487
+ value = validate_wire(
488
+ {"segments": segments, "layer": layer, "endpoints": endpoints}
489
+ )
490
+ for endpoint in value["endpoints"]:
491
+ if endpoint["ref"] is not None:
492
+ _check_ref(store, endpoint["ref"])
493
+ result = store.transact(
494
+ actor, _describe("place_wire", f"layer={value['layer']}"),
495
+ [
496
+ {"verb": "create_entity", "alias": "wire"},
497
+ {
498
+ "verb": "attach_component",
499
+ "args": {
500
+ "entity": {"alias": "wire"},
501
+ "component": WIRE_COMPONENT, "value": value,
502
+ },
503
+ },
504
+ ],
505
+ )
506
+ return _receipt("place_wire", actor, result, wire=result["aliases"]["wire"])
507
+
508
+
509
+ def place_junction(store, actor, xy, layer):
510
+ """Place one explicit junction dot as a durable revision.
511
+
512
+ Args:
513
+ store: Entity store serving the seam.
514
+ actor: Attribution recorded on the revision and events.
515
+ xy: Integer [x, y] dot position.
516
+ layer: Layer name for the dot.
517
+
518
+ Returns:
519
+ Receipt with op, actor, revision, junction ID, and events.
520
+
521
+ Raises:
522
+ ContractError: invalid_actor for empty attribution,
523
+ invalid_junction for a malformed dot.
524
+ """
525
+ _need_actor(actor)
526
+ value = validate_junction({"xy": xy, "layer": layer})
527
+ result = store.transact(
528
+ actor, _describe("place_junction", f"xy={value['xy']}"),
529
+ [
530
+ {"verb": "create_entity", "alias": "junction"},
531
+ {
532
+ "verb": "attach_component",
533
+ "args": {
534
+ "entity": {"alias": "junction"},
535
+ "component": JUNCTION_COMPONENT, "value": value,
536
+ },
537
+ },
538
+ ],
539
+ )
540
+ return _receipt(
541
+ "place_junction", actor, result,
542
+ junction=result["aliases"]["junction"],
543
+ )
544
+
545
+
546
+ def connect_endpoint(store, actor, wire_id, end, ref):
547
+ """Point one wire endpoint at a new terminus entity.
548
+
549
+ Geometry never moves: the endpoint keeps its xy and only the
550
+ ref changes, so a caller that moved the target re-resolves
551
+ through move_wire while ERC flags the stale span. A no-change
552
+ call writes nothing and reports a None revision.
553
+
554
+ Args:
555
+ store: Entity store serving the seam.
556
+ actor: Attribution recorded on the revision and events.
557
+ wire_id: Wire entity to rewire.
558
+ end: "a" or "b" selecting the endpoint.
559
+ ref: Pin/port/junction/net entity ID, or null to dangle.
560
+
561
+ Returns:
562
+ Receipt with op, actor, revision, wire ID, and events —
563
+ or a noop receipt (revision None) when the ref is
564
+ already set.
565
+
566
+ Raises:
567
+ ContractError: invalid_actor for empty attribution,
568
+ unknown_wire for a missing wire, invalid_endpoint for
569
+ a bad selector, unknown_ref for a missing entity,
570
+ invalid_ref for a wire/self ref.
571
+ """
572
+ _need_actor(actor)
573
+ if end not in ENDPOINTS:
574
+ raise ContractError(
575
+ "invalid_endpoint", f"end must be one of {ENDPOINTS}, got {end!r}."
576
+ )
577
+ if ref is not None:
578
+ _check_ref(store, ref, wire_id=wire_id)
579
+ wire = _read_wire(store, wire_id)
580
+ index = ENDPOINTS.index(end)
581
+ if wire["endpoints"][index]["ref"] == ref:
582
+ return {"op": "connect_endpoint", "actor": actor, "revision": None,
583
+ "noop": True, "wire": wire_id}
584
+ wire["endpoints"][index]["ref"] = ref
585
+ result = store.transact(
586
+ actor, _describe("connect_endpoint", f"wire={wire_id} end={end}"),
587
+ [
588
+ {
589
+ "verb": "set_component",
590
+ "args": {
591
+ "entity": wire_id, "component": WIRE_COMPONENT,
592
+ "value": wire,
593
+ },
594
+ },
595
+ ],
596
+ )
597
+ return _receipt("connect_endpoint", actor, result, wire=wire_id)
598
+
599
+
600
+ def move_wire(store, actor, wire_id, dx, dy):
601
+ """Translate one wire, keeping every endpoint ref attached.
602
+
603
+ Attachment is by entity ID, not geometry: refs survive the
604
+ move while ERC flags spans whose xy no longer meets the
605
+ target. A zero move writes nothing.
606
+
607
+ Args:
608
+ store: Entity store serving the seam.
609
+ actor: Attribution recorded on the revision and events.
610
+ wire_id: Wire entity to move.
611
+ dx: Integer x translation.
612
+ dy: Integer y translation.
613
+
614
+ Returns:
615
+ Receipt with op, actor, revision, wire ID, and events —
616
+ or a noop receipt (revision None) for a zero move.
617
+
618
+ Raises:
619
+ ContractError: invalid_actor for empty attribution,
620
+ unknown_wire for a missing wire, invalid_geometry for
621
+ non-integer deltas.
622
+ """
623
+ _need_actor(actor)
624
+ if not _is_int(dx) or not _is_int(dy):
625
+ raise ContractError(
626
+ "invalid_geometry",
627
+ f"dx/dy must be ints, got {(dx, dy)!r}.",
628
+ )
629
+ if dx == 0 and dy == 0:
630
+ return {"op": "move_wire", "actor": actor, "revision": None,
631
+ "noop": True, "wire": wire_id}
632
+ wire = _read_wire(store, wire_id)
633
+ wire["segments"] = [
634
+ [[x + dx, y + dy] for x, y in polyline]
635
+ for polyline in wire["segments"]
636
+ ]
637
+ for endpoint in wire["endpoints"]:
638
+ endpoint["xy"] = [endpoint["xy"][0] + dx, endpoint["xy"][1] + dy]
639
+ result = store.transact(
640
+ actor, _describe("move_wire", f"wire={wire_id} d=({dx}, {dy})"),
641
+ [
642
+ {
643
+ "verb": "set_component",
644
+ "args": {
645
+ "entity": wire_id, "component": WIRE_COMPONENT,
646
+ "value": wire,
647
+ },
648
+ },
649
+ ],
650
+ )
651
+ return _receipt("move_wire", actor, result, wire=wire_id)
652
+
653
+
654
+ def split_wire(store, actor, wire_id, at):
655
+ """Split one single-polyline wire at an interior point.
656
+
657
+ A junction dot is placed at the split, the original wire keeps
658
+ its head plus endpoint a, and a new wire entity carries the
659
+ tail plus endpoint b; both abutting ends reference the new
660
+ junction. All three land in one revision.
661
+
662
+ Args:
663
+ store: Entity store serving the seam.
664
+ actor: Attribution recorded on the revision and events.
665
+ wire_id: Wire entity to split.
666
+ at: Integer [x, y] point on the polyline interior (never a
667
+ wire end).
668
+
669
+ Returns:
670
+ Receipt with op, actor, revision, wire, junction, and
671
+ new_wire IDs, plus events.
672
+
673
+ Raises:
674
+ ContractError: invalid_actor for empty attribution,
675
+ unknown_wire for a missing wire, invalid_split for a
676
+ point off the interior or a multi-polyline wire.
677
+ """
678
+ _need_actor(actor)
679
+ point = _need_point(at, "at", "invalid_split")
680
+ wire = _read_wire(store, wire_id)
681
+ if len(wire["segments"]) != 1:
682
+ raise ContractError(
683
+ "invalid_split",
684
+ "split_wire needs a single-polyline wire.",
685
+ )
686
+ (polyline,) = wire["segments"]
687
+ if point == wire["endpoints"][0]["xy"] or point == wire["endpoints"][1]["xy"]:
688
+ raise ContractError(
689
+ "invalid_split", f"Split point {point} is a wire end."
690
+ )
691
+ cut = None
692
+ for index, (seg_a, seg_b) in enumerate(zip(polyline, polyline[1:])):
693
+ if _point_on_segment(point, seg_a, seg_b):
694
+ cut = index
695
+ break
696
+ if cut is None:
697
+ raise ContractError(
698
+ "invalid_split", f"Split point {point} lies off the wire."
699
+ )
700
+ head_points = polyline[:cut + 1] + [point]
701
+ tail_points = [point] + polyline[cut + 1:]
702
+ head = {
703
+ "segments": [head_points], "layer": wire["layer"],
704
+ "endpoints": [
705
+ dict(wire["endpoints"][0]),
706
+ {"ref": {"alias": "junction"}, "xy": list(point)},
707
+ ],
708
+ }
709
+ tail = {
710
+ "segments": [tail_points], "layer": wire["layer"],
711
+ "endpoints": [
712
+ {"ref": {"alias": "junction"}, "xy": list(point)},
713
+ dict(wire["endpoints"][1]),
714
+ ],
715
+ }
716
+ result = store.transact(
717
+ actor, _describe("split_wire", f"wire={wire_id} at={point}"),
718
+ [
719
+ {"verb": "create_entity", "alias": "junction"},
720
+ {
721
+ "verb": "attach_component",
722
+ "args": {
723
+ "entity": {"alias": "junction"},
724
+ "component": JUNCTION_COMPONENT,
725
+ "value": {"xy": list(point), "layer": wire["layer"]},
726
+ },
727
+ },
728
+ {"verb": "create_entity", "alias": "tail"},
729
+ {
730
+ "verb": "attach_component",
731
+ "args": {
732
+ "entity": {"alias": "tail"},
733
+ "component": WIRE_COMPONENT, "value": tail,
734
+ },
735
+ },
736
+ {
737
+ "verb": "set_component",
738
+ "args": {
739
+ "entity": wire_id, "component": WIRE_COMPONENT,
740
+ "value": head,
741
+ },
742
+ },
743
+ ],
744
+ )
745
+ return _receipt(
746
+ "split_wire", actor, result, wire=wire_id,
747
+ junction=result["aliases"]["junction"],
748
+ new_wire=result["aliases"]["tail"],
749
+ )
750
+
751
+
752
+ def delete_wire(store, actor, wire_id):
753
+ """Delete one wire entity as a durable revision.
754
+
755
+ Junctions the wire referenced persist — explicit dots are
756
+ never cascade-deleted — and Net entities rematerialize on the
757
+ next materialize_nets call, which prunes names the extraction
758
+ no longer yields.
759
+
760
+ Args:
761
+ store: Entity store serving the seam.
762
+ actor: Attribution recorded on the revision and events.
763
+ wire_id: Wire entity to delete.
764
+
765
+ Returns:
766
+ Receipt with op, actor, revision, wire ID, and events.
767
+
768
+ Raises:
769
+ ContractError: invalid_actor for empty attribution,
770
+ unknown_wire for a missing wire.
771
+ """
772
+ _need_actor(actor)
773
+ _read_wire(store, wire_id)
774
+ result = store.transact(
775
+ actor, _describe("delete_wire", f"wire={wire_id}"),
776
+ [{"verb": "delete_entity", "args": {"entity": wire_id}}],
777
+ )
778
+ return _receipt("delete_wire", actor, result, wire=wire_id)
779
+
780
+
781
+ def delete_junction(store, actor, junction_id):
782
+ """Delete one unreferenced junction as a durable revision.
783
+
784
+ Args:
785
+ store: Entity store serving the seam.
786
+ actor: Attribution recorded on the revision and events.
787
+ junction_id: Junction entity to delete.
788
+
789
+ Returns:
790
+ Receipt with op, actor, revision, junction ID, and events.
791
+
792
+ Raises:
793
+ ContractError: invalid_actor for empty attribution,
794
+ unknown_junction for a missing junction,
795
+ junction_in_use naming the wires still referencing it.
796
+ """
797
+ _need_actor(actor)
798
+ try:
799
+ components = store.components_of(junction_id)
800
+ except LookupError:
801
+ raise ContractError(
802
+ "unknown_junction", f"No such junction entity: {junction_id!r}."
803
+ ) from None
804
+ if JUNCTION_COMPONENT not in components:
805
+ raise ContractError(
806
+ "unknown_junction",
807
+ f"Entity {junction_id!r} carries no Junction component.",
808
+ )
809
+ users = []
810
+ for wire_id in store.entities_with(WIRE_COMPONENT):
811
+ wire = validate_wire(
812
+ store.get_component(wire_id, WIRE_COMPONENT)
813
+ )
814
+ if any(e["ref"] == junction_id for e in wire["endpoints"]):
815
+ users.append(wire_id)
816
+ if users:
817
+ raise ContractError(
818
+ "junction_in_use",
819
+ f"Junction {junction_id!r} is referenced by wires "
820
+ f"{sorted(users)}; rewire them first.",
821
+ )
822
+ result = store.transact(
823
+ actor, _describe("delete_junction", f"junction={junction_id}"),
824
+ [{"verb": "delete_entity", "args": {"entity": junction_id}}],
825
+ )
826
+ return _receipt("delete_junction", actor, result, junction=junction_id)
827
+
828
+
829
+ def _index_pins(pins):
830
+ if not isinstance(pins, (list, tuple)):
831
+ raise ContractError("invalid_pin", "pins must be a list.")
832
+ indexed = {}
833
+ seen = set()
834
+ for pin in pins:
835
+ clean = validate_pin(pin)
836
+ if clean["entity"] in indexed:
837
+ raise ContractError(
838
+ "invalid_pin",
839
+ f"Duplicate pin entity: {clean['entity']!r}.",
840
+ )
841
+ key = (clean["instance"], clean["pin"])
842
+ if key in seen:
843
+ raise ContractError(
844
+ "invalid_pin", f"Duplicate pin: {key[0]}.{key[1]}."
845
+ )
846
+ seen.add(key)
847
+ indexed[clean["entity"]] = clean
848
+ return indexed
849
+
850
+
851
+ def _check_named_refs(named_refs):
852
+ if named_refs is None:
853
+ return {}
854
+ if not isinstance(named_refs, dict):
855
+ raise ContractError("invalid_ref", "named_refs must be a mapping.")
856
+ clean = {}
857
+ for entity, name in named_refs.items():
858
+ _need_nonempty_str(entity, "named_refs entity", "invalid_ref")
859
+ _need_nonempty_str(name, f"named_refs[{entity}]", "invalid_ref")
860
+ clean[entity] = name
861
+ return clean
862
+
863
+
864
+ def _check_ports(ports):
865
+ if not isinstance(ports, (list, tuple)):
866
+ raise ContractError("invalid_port", "ports must be a list.")
867
+ clean = []
868
+ for index, port in enumerate(ports):
869
+ if not isinstance(port, dict):
870
+ raise ContractError(
871
+ "invalid_port", f"ports[{index}] must be a mapping."
872
+ )
873
+ name = port.get("name")
874
+ direction = port.get("direction")
875
+ if not isinstance(name, str) or not name:
876
+ raise ContractError("invalid_port", f"ports[{index}] needs a name.")
877
+ if direction not in _PORT_DIRECTIONS:
878
+ raise ContractError(
879
+ "invalid_port",
880
+ f"ports[{index}] direction must be one of "
881
+ f"{_PORT_DIRECTIONS}, got {direction!r}.",
882
+ )
883
+ clean.append({"name": name, "direction": direction})
884
+ return clean
885
+
886
+
887
+ def _read_world(store):
888
+ wires = {}
889
+ for wire_id in store.entities_with(WIRE_COMPONENT):
890
+ wires[wire_id] = validate_wire(
891
+ store.get_component(wire_id, WIRE_COMPONENT)
892
+ )
893
+ junctions = {}
894
+ for junction_id in store.entities_with(JUNCTION_COMPONENT):
895
+ junctions[junction_id] = validate_junction(
896
+ store.get_component(junction_id, JUNCTION_COMPONENT)
897
+ )
898
+ nets = {}
899
+ for net_id in store.entities_with(NET_COMPONENT):
900
+ nets[net_id] = validate_net(store.get_component(net_id, NET_COMPONENT))
901
+ return wires, junctions, nets
902
+
903
+
904
+ def world_schematic(store, pins, labels=(), ports=(), cell="top",
905
+ named_refs=None):
906
+ """Build the CONNECT-GRAPH schematic for the stored wires.
907
+
908
+ Wire polylines feed extraction verbatim; endpoints that
909
+ reference Net entities (or named_refs entries such as ports)
910
+ become labels carrying the referenced name at the endpoint xy,
911
+ so a tap joins every group already carrying that name. Stale
912
+ refs are reported, never silently dropped.
913
+
914
+ Args:
915
+ store: Entity store serving the seam.
916
+ pins: Caller pin records [{entity, instance, pin, xy}].
917
+ labels: Caller label records [{name, xy}].
918
+ ports: Caller port records [{name, direction}].
919
+ cell: Top cell name.
920
+ named_refs: {entity ID: net name} for port-style refs the
921
+ store cannot resolve to geometry.
922
+
923
+ Returns:
924
+ World view with the schematic mapping, the pin index by
925
+ entity, and unresolved [{wire, end, ref}] entries.
926
+
927
+ Raises:
928
+ ContractError: invalid_pin for malformed pins,
929
+ invalid_label for malformed labels, invalid_port for
930
+ malformed ports, invalid_cell for a bad cell name,
931
+ invalid_ref for malformed named_refs, invalid_wire /
932
+ invalid_junction / invalid_net for corrupt storage.
933
+ """
934
+ _need_nonempty_str(cell, "cell", "invalid_cell")
935
+ indexed = _index_pins(pins)
936
+ if not isinstance(labels, (list, tuple)):
937
+ raise ContractError("invalid_label", "labels must be a list.")
938
+ clean_labels = [validate_label(label) for label in labels]
939
+ clean_ports = _check_ports(ports)
940
+ names = _check_named_refs(named_refs)
941
+ wires, _junctions, nets = _read_world(store)
942
+ net_names = {net_id: net["name"] for net_id, net in nets.items()}
943
+ known = set(indexed) | set(wires) | set(_junctions) | set(nets) | set(names)
944
+ schematic_labels = list(clean_labels)
945
+ unresolved = []
946
+ for wire_id in sorted(wires):
947
+ for index, end in enumerate(ENDPOINTS):
948
+ ref = wires[wire_id]["endpoints"][index]["ref"]
949
+ if ref is None or ref in indexed or ref in _junctions:
950
+ continue
951
+ if ref in net_names:
952
+ schematic_labels.append(
953
+ {"name": net_names[ref],
954
+ "xy": wires[wire_id]["endpoints"][index]["xy"]}
955
+ )
956
+ elif ref in names:
957
+ schematic_labels.append(
958
+ {"name": names[ref],
959
+ "xy": wires[wire_id]["endpoints"][index]["xy"]}
960
+ )
961
+ elif ref in known:
962
+ continue
963
+ else:
964
+ try:
965
+ store.components_of(ref)
966
+ except LookupError:
967
+ unresolved.append(
968
+ {"wire": wire_id, "end": end, "ref": ref}
969
+ )
970
+ by_instance = {}
971
+ for pin in indexed.values():
972
+ by_instance.setdefault(pin["instance"], []).append(pin["pin"])
973
+ schematic = {
974
+ "cell": cell,
975
+ "wires": [
976
+ polyline
977
+ for wire_id in sorted(wires)
978
+ for polyline in wires[wire_id]["segments"]
979
+ ],
980
+ "pins": [
981
+ {"instance": pin["instance"], "pin": pin["pin"], "xy": pin["xy"]}
982
+ for pin in indexed.values()
983
+ ],
984
+ "labels": schematic_labels,
985
+ "instances": [
986
+ {"name": name, "symbol": name} for name in sorted(by_instance)
987
+ ],
988
+ "symbols": {
989
+ name: {"pins": sorted(pins)} for name, pins in by_instance.items()
990
+ },
991
+ "ports": clean_ports,
992
+ }
993
+ return {"schematic": schematic, "pins": indexed, "unresolved": unresolved}
994
+
995
+
996
+ def _default_extract(schematic):
997
+ from ..workflows.connectgraph import prepare_netlist_structs
998
+
999
+ return prepare_netlist_structs(schematic)
1000
+
1001
+
1002
+ def _merge_by_name(connectivity):
1003
+ merged = {}
1004
+ for net in connectivity.nets:
1005
+ name = connectivity.net_name(net.id)
1006
+ slot = merged.setdefault(
1007
+ name, {"net_ids": [], "pins": set(), "labels": set(),
1008
+ "has_wires": False}
1009
+ )
1010
+ slot["net_ids"].append(net.id)
1011
+ slot["pins"].update(net.pins)
1012
+ slot["labels"].update(net.labels)
1013
+ slot["has_wires"] = slot["has_wires"] or net.has_wires
1014
+ for slot in merged.values():
1015
+ slot["net_ids"].sort()
1016
+ return merged
1017
+
1018
+
1019
+ def _membership(names_of_net, wires, junctions, indexed, nets, names,
1020
+ connectivity):
1021
+ """Map every wire/junction onto its net names by ref closure."""
1022
+ stored_names = {net_id: net["name"] for net_id, net in nets.items()}
1023
+ pin_net = {}
1024
+ for pin in indexed.values():
1025
+ net_id = connectivity.pin_net(pin["instance"], pin["pin"])
1026
+ if net_id is not None:
1027
+ pin_net[pin["entity"]] = names_of_net[net_id]
1028
+ wire_names = {wire_id: set() for wire_id in wires}
1029
+ for wire_id, wire in wires.items():
1030
+ for endpoint in wire["endpoints"]:
1031
+ ref = endpoint["ref"]
1032
+ if ref is None:
1033
+ continue
1034
+ if ref in pin_net:
1035
+ wire_names[wire_id].add(pin_net[ref])
1036
+ elif ref in stored_names and stored_names[ref] in names_of_net.values():
1037
+ wire_names[wire_id].add(stored_names[ref])
1038
+ elif ref in names and names[ref] in names_of_net.values():
1039
+ wire_names[wire_id].add(names[ref])
1040
+ junction_names = {junction_id: set() for junction_id in junctions}
1041
+ changed = True
1042
+ while changed:
1043
+ changed = False
1044
+ for wire_id, wire in wires.items():
1045
+ for endpoint in wire["endpoints"]:
1046
+ ref = endpoint["ref"]
1047
+ if ref in junction_names:
1048
+ before = len(wire_names[wire_id])
1049
+ wire_names[wire_id] |= junction_names[ref]
1050
+ changed = changed or len(wire_names[wire_id]) != before
1051
+ for wire_id, wire in wires.items():
1052
+ for endpoint in wire["endpoints"]:
1053
+ ref = endpoint["ref"]
1054
+ if ref in junction_names:
1055
+ before = len(junction_names[ref])
1056
+ junction_names[ref] |= wire_names[wire_id]
1057
+ changed = changed or len(junction_names[ref]) != before
1058
+ for junction_id, junction in junctions.items():
1059
+ if junction_names[junction_id]:
1060
+ continue
1061
+ if any(
1062
+ wire["endpoints"][i]["ref"] == junction_id
1063
+ for wire in wires.values() for i in range(2)
1064
+ ):
1065
+ continue
1066
+ for wire_id, wire in wires.items():
1067
+ if _point_on_wire(junction["xy"], wire):
1068
+ junction_names[junction_id] |= wire_names[wire_id]
1069
+ return wire_names, junction_names
1070
+
1071
+
1072
+ def materialize_nets(store, actor, pins, labels=(), ports=(), cell="top",
1073
+ named_refs=None, extract=None):
1074
+ """Extract connectivity, then upsert Net entities by name.
1075
+
1076
+ Extraction always rebuilds (never a cached run): the world
1077
+ view feeds CONNECT-GRAPH, groups sharing a canonical name
1078
+ merge into one Net, wires/junctions attach by ref closure,
1079
+ and stored Nets whose names vanish are pruned — all in one
1080
+ revision. An unchanged world writes nothing (noop receipt).
1081
+
1082
+ Args:
1083
+ store: Entity store serving the seam.
1084
+ actor: Attribution recorded on the revision and events.
1085
+ pins: Caller pin records [{entity, instance, pin, xy}].
1086
+ labels: Caller label records [{name, xy}].
1087
+ ports: Caller port records [{name, direction}].
1088
+ cell: Top cell name.
1089
+ named_refs: {entity ID: net name} for port-style refs the
1090
+ store cannot resolve to geometry.
1091
+ extract: Optional extraction callable (schematic) ->
1092
+ NetlistStructs; defaults to the in-tree CONNECT-GRAPH
1093
+ extraction.
1094
+
1095
+ Returns:
1096
+ Receipt with op, actor, revision, nets {name: entity ID},
1097
+ created/updated/pruned ID lists, unresolved refs, and
1098
+ events — or a noop receipt (revision None) when every
1099
+ Net already matches.
1100
+
1101
+ Raises:
1102
+ ContractError: invalid_actor for empty attribution,
1103
+ invalid_pin / invalid_label / invalid_port /
1104
+ invalid_cell / invalid_ref for malformed inputs,
1105
+ extraction_failed when extraction refuses the world.
1106
+ """
1107
+ _need_actor(actor)
1108
+ names = _check_named_refs(named_refs)
1109
+ view = world_schematic(
1110
+ store, pins, labels=labels, ports=ports, cell=cell,
1111
+ named_refs=named_refs,
1112
+ )
1113
+ extractor = extract if extract is not None else _default_extract
1114
+ try:
1115
+ structs = extractor(view["schematic"])
1116
+ connectivity = structs.connectivity
1117
+ merged = _merge_by_name(connectivity)
1118
+ except ContractError:
1119
+ raise
1120
+ except Exception as exc:
1121
+ raise ContractError(
1122
+ "extraction_failed", f"Connectivity extraction failed: {exc}."
1123
+ ) from exc
1124
+ wires, junctions, nets = _read_world(store)
1125
+ names_of_net = {
1126
+ net.id: connectivity.net_name(net.id) for net in connectivity.nets
1127
+ }
1128
+ wire_names, junction_names = _membership(
1129
+ names_of_net, wires, junctions, view["pins"], nets, names,
1130
+ connectivity,
1131
+ )
1132
+ by_name = {net["name"]: net_id for net_id, net in nets.items()}
1133
+ calls = []
1134
+ created = []
1135
+ updated = []
1136
+ nets_out = {}
1137
+ for name in sorted(merged):
1138
+ slot = merged[name]
1139
+ value = validate_net({
1140
+ "name": name,
1141
+ "extraction": {"cell": cell, "net_ids": slot["net_ids"]},
1142
+ "members": {
1143
+ "pins": [
1144
+ {"instance": instance, "pin": pin}
1145
+ for instance, pin in sorted(slot["pins"])
1146
+ ],
1147
+ "labels": sorted(slot["labels"]),
1148
+ "wires": sorted(
1149
+ w for w, found in wire_names.items() if name in found
1150
+ ),
1151
+ "junctions": sorted(
1152
+ j for j, found in junction_names.items() if name in found
1153
+ ),
1154
+ },
1155
+ "has_wires": slot["has_wires"],
1156
+ })
1157
+ if name in by_name and nets[by_name[name]] == value:
1158
+ nets_out[name] = by_name[name]
1159
+ continue
1160
+ if name in by_name:
1161
+ nets_out[name] = by_name[name]
1162
+ updated.append(by_name[name])
1163
+ calls.append({
1164
+ "verb": "set_component",
1165
+ "args": {
1166
+ "entity": by_name[name], "component": NET_COMPONENT,
1167
+ "value": value,
1168
+ },
1169
+ })
1170
+ else:
1171
+ alias = f"net:{name}"
1172
+ calls.append({"verb": "create_entity", "alias": alias})
1173
+ calls.append({
1174
+ "verb": "attach_component",
1175
+ "args": {
1176
+ "entity": {"alias": alias}, "component": NET_COMPONENT,
1177
+ "value": value,
1178
+ },
1179
+ })
1180
+ created.append(alias)
1181
+ pruned = [
1182
+ net_id for name, net_id in sorted(by_name.items()) if name not in merged
1183
+ ]
1184
+ for net_id in pruned:
1185
+ calls.append({
1186
+ "verb": "delete_entity", "args": {"entity": net_id},
1187
+ })
1188
+ if not calls:
1189
+ return {
1190
+ "op": "materialize_nets", "actor": actor, "revision": None,
1191
+ "noop": True, "nets": nets_out, "created": [], "updated": [],
1192
+ "pruned": [], "unresolved": view["unresolved"],
1193
+ }
1194
+ result = store.transact(
1195
+ actor, _describe("materialize_nets", f"cell={cell}"),
1196
+ calls,
1197
+ )
1198
+ for alias in created:
1199
+ name = alias[len("net:"):]
1200
+ nets_out[name] = result["aliases"][alias]
1201
+ receipt = _receipt(
1202
+ "materialize_nets", actor, result, nets=nets_out,
1203
+ unresolved=view["unresolved"],
1204
+ )
1205
+ receipt["created"] = [nets_out[alias[len("net:"):]] for alias in created]
1206
+ receipt["updated"] = updated
1207
+ receipt["pruned"] = pruned
1208
+ return receipt
1209
+
1210
+
1211
+ def collect_wire_erc(store, pins, policy=None, named_refs=None):
1212
+ """Collect wire/junction ERC findings over the stored world.
1213
+
1214
+ Dangling endpoints are warnings, refs to deleted entities are
1215
+ errors, spans whose xy no longer meets a pin/junction target
1216
+ are warnings, and junctions touching nothing are warnings.
1217
+ Nothing is ever silently dropped: every anomaly names its
1218
+ entity.
1219
+
1220
+ Args:
1221
+ store: Entity store serving the seam.
1222
+ pins: Caller pin records [{entity, instance, pin, xy}].
1223
+ policy: rule -> severity overrides.
1224
+ named_refs: {entity ID: net name} for port-style refs the
1225
+ store cannot resolve to geometry.
1226
+
1227
+ Returns:
1228
+ Findings sorted by (rule, entity, end), each with rule,
1229
+ severity, message, and locator keys.
1230
+
1231
+ Raises:
1232
+ ContractError: invalid_pin for malformed pins,
1233
+ invalid_policy for an unknown rule, invalid_wire /
1234
+ invalid_junction for corrupt storage.
1235
+ """
1236
+ merged = dict(DEFAULT_WIRE_POLICY)
1237
+ for rule in (policy or {}):
1238
+ if rule not in merged:
1239
+ raise ContractError(
1240
+ "invalid_policy", f"Unknown ERC rule: {rule!r}."
1241
+ )
1242
+ merged[rule] = policy[rule]
1243
+ indexed = _index_pins(pins)
1244
+ _check_named_refs(named_refs)
1245
+ wires, junctions, _nets = _read_world(store)
1246
+ findings = []
1247
+ for wire_id in sorted(wires):
1248
+ wire = wires[wire_id]
1249
+ for index, end in enumerate(ENDPOINTS):
1250
+ endpoint = wire["endpoints"][index]
1251
+ ref = endpoint["ref"]
1252
+ if ref is None:
1253
+ findings.append({
1254
+ "rule": RULE_DANGLING_ENDPOINT,
1255
+ "severity": merged[RULE_DANGLING_ENDPOINT],
1256
+ "message": (
1257
+ f"wire {wire_id} end {end} dangles "
1258
+ f"at {endpoint['xy']}"
1259
+ ),
1260
+ "wire": wire_id, "end": end, "xy": endpoint["xy"],
1261
+ })
1262
+ continue
1263
+ try:
1264
+ components = store.components_of(ref)
1265
+ except LookupError:
1266
+ findings.append({
1267
+ "rule": RULE_UNKNOWN_REF,
1268
+ "severity": merged[RULE_UNKNOWN_REF],
1269
+ "message": (
1270
+ f"wire {wire_id} end {end} references "
1271
+ f"deleted entity {ref!r}"
1272
+ ),
1273
+ "wire": wire_id, "end": end, "ref": ref,
1274
+ })
1275
+ continue
1276
+ if WIRE_COMPONENT in components:
1277
+ findings.append({
1278
+ "rule": RULE_UNKNOWN_REF,
1279
+ "severity": merged[RULE_UNKNOWN_REF],
1280
+ "message": (
1281
+ f"wire {wire_id} end {end} references wire "
1282
+ f"entity {ref!r}; endpoints terminate on "
1283
+ "pin/port/junction/net IDs only"
1284
+ ),
1285
+ "wire": wire_id, "end": end, "ref": ref,
1286
+ })
1287
+ continue
1288
+ target = None
1289
+ if ref in indexed:
1290
+ target = indexed[ref]["xy"]
1291
+ elif ref in junctions:
1292
+ target = junctions[ref]["xy"]
1293
+ if target is not None and endpoint["xy"] != target:
1294
+ findings.append({
1295
+ "rule": RULE_STALE_GEOMETRY,
1296
+ "severity": merged[RULE_STALE_GEOMETRY],
1297
+ "message": (
1298
+ f"wire {wire_id} end {end} sits at "
1299
+ f"{endpoint['xy']} but {ref!r} is at {target}"
1300
+ ),
1301
+ "wire": wire_id, "end": end, "ref": ref,
1302
+ "xy": endpoint["xy"], "target": target,
1303
+ })
1304
+ referenced = {
1305
+ endpoint["ref"]
1306
+ for wire in wires.values() for endpoint in wire["endpoints"]
1307
+ }
1308
+ for junction_id in sorted(junctions):
1309
+ if junction_id in referenced:
1310
+ continue
1311
+ xy = junctions[junction_id]["xy"]
1312
+ if any(_point_on_wire(xy, wire) for wire in wires.values()):
1313
+ continue
1314
+ findings.append({
1315
+ "rule": RULE_ORPHAN_JUNCTION,
1316
+ "severity": merged[RULE_ORPHAN_JUNCTION],
1317
+ "message": f"junction {junction_id} at {xy} touches no wire",
1318
+ "junction": junction_id, "xy": xy,
1319
+ })
1320
+ findings.sort(
1321
+ key=lambda f: (f["rule"], f.get("wire", f.get("junction", "")),
1322
+ f.get("end", ""))
1323
+ )
1324
+ return findings