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,1508 @@
1
+ """Engine entity store (ENGINE-ECS): entities + components over docs/ledger.
2
+
3
+ One identity per thing. Every new engine surface invented its own —
4
+ doc paths for sources, refs for blocks, names for nets — so this
5
+ module ends it: engine-minted entity IDs, generic component CRUD
6
+ instead of N bespoke endpoints, uniform ``component_changed``
7
+ events instead of per-feature notification plumbing, and filter
8
+ queries instead of one-off getters. Wires terminate on stable pin
9
+ entities only this store can mint.
10
+
11
+ Storage projection (documented, no second identity system): an
12
+ entity with id ``<id>`` lives at exactly one doc path,
13
+ ``entities/<id>.json``, holding ``{"entity", "kind", "components"}``
14
+ as JSON. The path derives from the id alone — ``entity_doc_path``
15
+ and ``entity_from_doc_path`` are inverses — so CRUD, events, and
16
+ queries cannot drift: ``read_doc("entities/<id>.json")`` shows the
17
+ same bytes ``get_component`` serves, and ``list_docs`` lists every
18
+ entity. Canonical addressing is ``entity.component`` (one dot; ids
19
+ and component names never contain dots, so parsing is unambiguous).
20
+
21
+ Engine-provisioned IDs only: ``create_entity`` mints ``ent-<12hex>``
22
+ ids and takes no id argument — callers can never supply, guess, or
23
+ collide with an id. Backfill derives read-only ``doc-<12hex>`` ids
24
+ (sha1 of the doc path) for current project docs, so existing files
25
+ appear in the world snapshot and ``doc`` filters without a single
26
+ write: backfill never advances the revision.
27
+
28
+ Events: ``create_entity`` appends ``entity_created``,
29
+ ``set/attach/detach_component`` append ``component_changed`` with
30
+ ``{entity, component, revision}`` (detaches add ``detached: True``),
31
+ ``delete_entity`` appends ``entity_deleted``. Every mutation gates
32
+ on ``expected_revision``, honors idempotent ``request_id`` replay,
33
+ advances the revision once, and returns the CRUD approval-UI fields
34
+ (actor, diff_summary, revision_transition, rewind_handle).
35
+
36
+ World snapshot is miniplex-shaped: ``{"entities": [{"id", "kind",
37
+ "components"}]}`` sorted by id maps one-to-one onto miniplex
38
+ entities (``{id, ...components}``), so mirrors load it directly.
39
+ Filter queries ``components_of`` / ``entities_with`` replace
40
+ one-off getters (nets-of-block, pins-of-net, open-problems); the
41
+ ``entities_with`` predicate is an exact-match mapping over
42
+ top-level component fields.
43
+
44
+ ``EntityStore`` is the in-memory reference implementation the unit
45
+ tests run standalone; the wiring patch teaches ``ProjectService``
46
+ the same verbs over the real doc store + durable ledger, reusing
47
+ these pure helpers for validation, addressing, encoding, and
48
+ backfill. Stdlib + ``json`` only (leaf module, no new deps, no
49
+ package-internal imports — no import cycle).
50
+ """
51
+
52
+ import hashlib
53
+ import json
54
+ import re
55
+ import uuid
56
+
57
+ #: Doc prefix owning every entity record. Only ``ledger.yaml``,
58
+ #: ``events.yaml``, ``runs/``, and ``snapshots/`` are engine-owned
59
+ #: elsewhere, so ``entities/`` docs are plain documents: existing
60
+ #: ``list_docs`` / ``read_doc`` / ``write_doc`` keep working on them.
61
+ ENTITY_DOC_PREFIX = "entities/"
62
+
63
+ #: Entity records are JSON text (not YAML): one doc, one entity.
64
+ ENTITY_DOC_SUFFIX = ".json"
65
+
66
+ #: Engine ids (``ent-``) and backfill ids (``doc-``) share this shape:
67
+ #: lowercase, no dots — dots would make ``entity.component``
68
+ #: addresses ambiguous.
69
+ ENTITY_ID_PATTERN = r"^[a-z0-9][a-z0-9_-]*$"
70
+ _ENTITY_ID_RE = re.compile(ENTITY_ID_PATTERN)
71
+
72
+ #: Component names allow uppercase (``CellLink``-style) but no dots,
73
+ #: for the same addressing reason as entity ids.
74
+ COMPONENT_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_-]*$"
75
+ _COMPONENT_RE = re.compile(COMPONENT_PATTERN)
76
+
77
+ _KIND_RE = re.compile(r"^[A-Za-z0-9_.-]*$")
78
+ _HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
79
+ _REVISION_RE = re.compile(r"^(.*?)(\d+)$")
80
+
81
+ #: Component name every backfilled doc carries (``{"path", "bytes"}``).
82
+ BACKFILL_COMPONENT = "doc"
83
+
84
+ #: Kind stamp on backfilled world-snapshot entries.
85
+ BACKFILL_KIND = "backfilled-doc"
86
+
87
+ #: One component may not exceed the doc-store write limit on its own.
88
+ MAX_COMPONENT_BYTES = 1_000_000
89
+
90
+ #: Component JSON nests no deeper than validated documents may.
91
+ MAX_NESTING_DEPTH = 100
92
+
93
+ __all__ = [
94
+ "BACKFILL_COMPONENT",
95
+ "BACKFILL_KIND",
96
+ "ENTITY_DOC_PREFIX",
97
+ "ENTITY_DOC_SUFFIX",
98
+ "ENTITY_ID_PATTERN",
99
+ "COMPONENT_PATTERN",
100
+ "MAX_COMPONENT_BYTES",
101
+ "MAX_NESTING_DEPTH",
102
+ "EcsError",
103
+ "validate_entity_id",
104
+ "validate_component_name",
105
+ "validate_kind",
106
+ "validate_component_data",
107
+ "validate_predicate",
108
+ "mint_entity_id",
109
+ "entity_doc_path",
110
+ "entity_from_doc_path",
111
+ "is_entity_doc",
112
+ "parse_address",
113
+ "format_address",
114
+ "encode_entity_doc",
115
+ "decode_entity_doc",
116
+ "backfill_id_for_doc",
117
+ "is_backfill_id",
118
+ "backfill_entity_for_doc",
119
+ "backfill_entities",
120
+ "match_predicate",
121
+ "filter_entities",
122
+ "build_world_snapshot",
123
+ "component_changed_data",
124
+ "EntityStore",
125
+ ]
126
+
127
+
128
+ class EcsError(Exception):
129
+ """Entity-store violation with a stable machine code.
130
+
131
+ Codes: invalid_entity, unknown_entity, backfill_readonly,
132
+ invalid_component, unknown_component, component_exists,
133
+ invalid_address, invalid_kind, invalid_data, invalid_predicate,
134
+ invalid_document, unknown_document, invalid_path,
135
+ revision_conflict, request_conflict.
136
+
137
+ Args:
138
+ code: Stable machine-readable error code.
139
+ message: Human-readable detail.
140
+ fields: Itemized fields, when the error names any.
141
+ """
142
+
143
+ def __init__(self, code, message, fields=None):
144
+ super().__init__(message)
145
+ self.code = code
146
+ self.fields = fields or []
147
+
148
+
149
+ def validate_entity_id(entity):
150
+ """Accept one engine-shaped entity id.
151
+
152
+ Args:
153
+ entity: Candidate id (``ent-…`` minted, ``doc-…`` backfilled).
154
+
155
+ Returns:
156
+ The same id, unchanged, when valid.
157
+
158
+ Raises:
159
+ EcsError: invalid_entity for non-text, empty, dotted,
160
+ or otherwise malformed ids.
161
+ """
162
+ if not isinstance(entity, str) or _ENTITY_ID_RE.match(entity) is None:
163
+ raise EcsError("invalid_entity", f"Bad entity id: {entity!r}.")
164
+ return entity
165
+
166
+
167
+ def validate_component_name(name):
168
+ """Accept one component name.
169
+
170
+ Args:
171
+ name: Candidate component name.
172
+
173
+ Returns:
174
+ The same name, unchanged, when valid.
175
+
176
+ Raises:
177
+ EcsError: invalid_component for non-text, empty, or dotted
178
+ names (dots would break ``entity.component`` parsing).
179
+ """
180
+ if not isinstance(name, str) or _COMPONENT_RE.match(name) is None:
181
+ raise EcsError(
182
+ "invalid_component", f"Bad component name: {name!r}."
183
+ )
184
+ return name
185
+
186
+
187
+ def validate_kind(kind):
188
+ """Accept one entity kind label.
189
+
190
+ Args:
191
+ kind: Candidate kind ("" means unclassified).
192
+
193
+ Returns:
194
+ The same kind, unchanged, when valid.
195
+
196
+ Raises:
197
+ EcsError: invalid_kind for non-text or over-punctuated kinds.
198
+ """
199
+ if not isinstance(kind, str) or _KIND_RE.match(kind) is None:
200
+ raise EcsError("invalid_kind", f"Bad entity kind: {kind!r}.")
201
+ return kind
202
+
203
+
204
+ def _check_depth(data, depth=0):
205
+ if depth > MAX_NESTING_DEPTH:
206
+ raise EcsError(
207
+ "invalid_data", "Component data nests deeper than allowed."
208
+ )
209
+ if isinstance(data, dict):
210
+ for value in data.values():
211
+ _check_depth(value, depth + 1)
212
+ elif isinstance(data, list):
213
+ for value in data:
214
+ _check_depth(value, depth + 1)
215
+
216
+
217
+ def validate_component_data(data):
218
+ """Accept one JSON-serializable component payload.
219
+
220
+ Args:
221
+ data: Candidate payload (any JSON shape).
222
+
223
+ Returns:
224
+ The same payload, unchanged, when valid.
225
+
226
+ Raises:
227
+ EcsError: invalid_data for non-serializable payloads,
228
+ non-finite scalars, over-deep nesting, or payloads past
229
+ the byte limit.
230
+ """
231
+ try:
232
+ text = json.dumps(data, sort_keys=True, allow_nan=False)
233
+ except (TypeError, ValueError) as exc:
234
+ raise EcsError(
235
+ "invalid_data", f"Component data must be JSON: {exc}."
236
+ ) from exc
237
+ _check_depth(data)
238
+ if len(text.encode("utf-8")) > MAX_COMPONENT_BYTES:
239
+ raise EcsError(
240
+ "invalid_data", "Component data exceeds the size limit."
241
+ )
242
+ return data
243
+
244
+
245
+ def validate_predicate(predicate):
246
+ """Accept one entities_with predicate mapping.
247
+
248
+ Args:
249
+ predicate: Candidate predicate, or None for no filtering.
250
+
251
+ Returns:
252
+ The same predicate, unchanged, when valid.
253
+
254
+ Raises:
255
+ EcsError: invalid_predicate for a non-mapping predicate.
256
+ """
257
+ if predicate is not None and not isinstance(predicate, dict):
258
+ raise EcsError(
259
+ "invalid_predicate",
260
+ f"Predicate must be a mapping, got {predicate!r}.",
261
+ )
262
+ return predicate
263
+
264
+
265
+ def mint_entity_id(existing=()):
266
+ """Mint one fresh engine entity id.
267
+
268
+ Args:
269
+ existing: Container of taken ids the mint must avoid.
270
+
271
+ Returns:
272
+ Fresh ``ent-<12hex>`` id outside ``existing``.
273
+ """
274
+ while True:
275
+ candidate = f"ent-{uuid.uuid4().hex[:12]}"
276
+ if candidate not in existing:
277
+ return candidate
278
+
279
+
280
+ def entity_doc_path(entity):
281
+ """Derive the one doc path storing an entity.
282
+
283
+ Args:
284
+ entity: Entity id to project.
285
+
286
+ Returns:
287
+ Canonical ``entities/<id>.json`` path — the documented
288
+ storage projection, derived from the id alone.
289
+
290
+ Raises:
291
+ EcsError: invalid_entity for a malformed id.
292
+ """
293
+ validate_entity_id(entity)
294
+ return f"{ENTITY_DOC_PREFIX}{entity}{ENTITY_DOC_SUFFIX}"
295
+
296
+
297
+ def entity_from_doc_path(path):
298
+ """Recover the entity id a doc path stores, if any.
299
+
300
+ Args:
301
+ path: Canonical doc path to inspect.
302
+
303
+ Returns:
304
+ Entity id for ``entities/<id>.json`` (single level),
305
+ else None — never raises, so scans stay total.
306
+ """
307
+ if not isinstance(path, str):
308
+ return None
309
+ if not path.startswith(ENTITY_DOC_PREFIX):
310
+ return None
311
+ if not path.endswith(ENTITY_DOC_SUFFIX):
312
+ return None
313
+ middle = path[len(ENTITY_DOC_PREFIX):-len(ENTITY_DOC_SUFFIX)]
314
+ if not middle or "/" in middle:
315
+ return None
316
+ return middle
317
+
318
+
319
+ def is_entity_doc(path):
320
+ """True when a path is an entity record (not a plain doc).
321
+
322
+ Args:
323
+ path: Canonical doc path to inspect.
324
+
325
+ Returns:
326
+ True for single-level ``entities/*.json`` paths.
327
+ """
328
+ return entity_from_doc_path(path) is not None
329
+
330
+
331
+ def parse_address(address):
332
+ """Split one canonical ``entity.component`` address.
333
+
334
+ Args:
335
+ address: Address text with exactly one dot.
336
+
337
+ Returns:
338
+ (entity, component) pair, both validated.
339
+
340
+ Raises:
341
+ EcsError: invalid_address for missing/extra dots or empty
342
+ parts; invalid_entity / invalid_component for malformed
343
+ parts.
344
+ """
345
+ if not isinstance(address, str) or address.count(".") != 1:
346
+ raise EcsError(
347
+ "invalid_address",
348
+ f"Address must be entity.component, got {address!r}.",
349
+ )
350
+ entity, component = address.split(".")
351
+ if not entity or not component:
352
+ raise EcsError(
353
+ "invalid_address",
354
+ f"Address must be entity.component, got {address!r}.",
355
+ )
356
+ try:
357
+ validate_entity_id(entity)
358
+ except EcsError as exc:
359
+ raise EcsError("invalid_address", str(exc)) from exc
360
+ try:
361
+ validate_component_name(component)
362
+ except EcsError as exc:
363
+ raise EcsError("invalid_address", str(exc)) from exc
364
+ return entity, component
365
+
366
+
367
+ def format_address(entity, component):
368
+ """Render one canonical ``entity.component`` address.
369
+
370
+ Args:
371
+ entity: Entity id part.
372
+ component: Component name part.
373
+
374
+ Returns:
375
+ Canonical address text.
376
+
377
+ Raises:
378
+ EcsError: invalid_entity / invalid_component for malformed
379
+ parts.
380
+ """
381
+ validate_entity_id(entity)
382
+ validate_component_name(component)
383
+ return f"{entity}.{component}"
384
+
385
+
386
+ def encode_entity_doc(entity, kind, components, entity_revision=1):
387
+ """Render one entity record as canonical JSON text.
388
+
389
+ Args:
390
+ entity: Entity id the record stores.
391
+ kind: Kind label carried beside the components.
392
+ components: Mapping of component name to JSON payload.
393
+ entity_revision: Per-entity revision (ENTITY-CAS): starts
394
+ at 1, bumps on every accepted entity mutation.
395
+
396
+ Returns:
397
+ Deterministic JSON text (sorted keys): same record in,
398
+ same bytes out.
399
+
400
+ Raises:
401
+ EcsError: invalid_entity / invalid_kind / invalid_component
402
+ / invalid_data for malformed parts.
403
+ """
404
+ validate_entity_id(entity)
405
+ validate_kind(kind)
406
+ if not isinstance(components, dict):
407
+ raise EcsError(
408
+ "invalid_data", "Entity components must be a mapping."
409
+ )
410
+ for name, data in components.items():
411
+ validate_component_name(name)
412
+ validate_component_data(data)
413
+ if (
414
+ not isinstance(entity_revision, int)
415
+ or isinstance(entity_revision, bool)
416
+ or entity_revision < 1
417
+ ):
418
+ raise EcsError(
419
+ "invalid_data", "Entity revision must be a positive int."
420
+ )
421
+ return (
422
+ json.dumps(
423
+ {
424
+ "components": components,
425
+ "entity": entity,
426
+ "entity_revision": entity_revision,
427
+ "kind": kind,
428
+ },
429
+ sort_keys=True,
430
+ indent=2,
431
+ )
432
+ + "\n"
433
+ )
434
+
435
+
436
+ def decode_entity_doc(text, path="<entity>"):
437
+ """Parse one entity record back into its parts.
438
+
439
+ Args:
440
+ text: Entity doc text to parse.
441
+ path: Doc path named in errors.
442
+
443
+ Returns:
444
+ Mapping with entity, kind, components, and
445
+ entity_revision (legacy records without one read as 1).
446
+
447
+ Raises:
448
+ EcsError: invalid_document for unparsable text or a record
449
+ whose shape breaks the projection.
450
+ """
451
+ try:
452
+ data = json.loads(text)
453
+ except (TypeError, ValueError) as exc:
454
+ raise EcsError(
455
+ "invalid_document",
456
+ f"Entity record does not parse: {path} ({exc}).",
457
+ ) from exc
458
+ if not isinstance(data, dict):
459
+ raise EcsError(
460
+ "invalid_document",
461
+ f"Entity record must be a mapping: {path}.",
462
+ )
463
+ entity = data.get("entity")
464
+ kind = data.get("kind", "")
465
+ components = data.get("components")
466
+ try:
467
+ validate_entity_id(entity)
468
+ validate_kind(kind)
469
+ except EcsError as exc:
470
+ raise EcsError("invalid_document", f"{path}: {exc}.") from exc
471
+ if not isinstance(components, dict):
472
+ raise EcsError(
473
+ "invalid_document",
474
+ f"Entity record carries no components mapping: {path}.",
475
+ )
476
+ for name in components:
477
+ try:
478
+ validate_component_name(name)
479
+ except EcsError as exc:
480
+ raise EcsError(
481
+ "invalid_document", f"{path}: {exc}."
482
+ ) from exc
483
+ entity_revision = data.get("entity_revision", 1)
484
+ if (
485
+ not isinstance(entity_revision, int)
486
+ or isinstance(entity_revision, bool)
487
+ or entity_revision < 1
488
+ ):
489
+ raise EcsError(
490
+ "invalid_document",
491
+ f"Entity record carries a bad revision: {path}.",
492
+ )
493
+ return {
494
+ "entity": entity,
495
+ "kind": kind,
496
+ "components": components,
497
+ "entity_revision": entity_revision,
498
+ }
499
+
500
+
501
+ def backfill_id_for_doc(path):
502
+ """Derive the stable read-only id backfilling one doc.
503
+
504
+ Args:
505
+ path: Canonical doc path to project.
506
+
507
+ Returns:
508
+ Deterministic ``doc-<12hex>`` id (sha1 of the path):
509
+ same path in, same id out, across restarts.
510
+ """
511
+ digest = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:12]
512
+ return f"doc-{digest}"
513
+
514
+
515
+ def is_backfill_id(entity):
516
+ """True when an id is a backfilled (read-only) doc id.
517
+
518
+ Args:
519
+ entity: Entity id to inspect.
520
+
521
+ Returns:
522
+ True for ``doc-<12hex>`` ids.
523
+ """
524
+ return (
525
+ isinstance(entity, str)
526
+ and entity.startswith("doc-")
527
+ and _HEX12_RE.match(entity[len("doc-"):]) is not None
528
+ )
529
+
530
+
531
+ def backfill_entity_for_doc(path, size):
532
+ """Project one plain doc as a read-only virtual entity.
533
+
534
+ Args:
535
+ path: Canonical doc path to project.
536
+ size: Byte size reported on the ``doc`` component.
537
+
538
+ Returns:
539
+ Virtual entity record (id, backfilled-doc kind, and a
540
+ single ``doc`` component with path + bytes).
541
+ """
542
+ return {
543
+ "id": backfill_id_for_doc(path),
544
+ "kind": BACKFILL_KIND,
545
+ "components": {
546
+ BACKFILL_COMPONENT: {"bytes": size, "path": path}
547
+ },
548
+ }
549
+
550
+
551
+ def backfill_entities(entries):
552
+ """Project plain docs as virtual entities (a pure read).
553
+
554
+ Args:
555
+ entries: Iterable of ``{"path", "bytes"}`` doc listings
556
+ (the ``list_docs`` shape).
557
+
558
+ Returns:
559
+ Virtual entity records for every non-entity doc, sorted
560
+ by path. Entity records are skipped — they already have
561
+ engine ids, so backfilling them would mint a second
562
+ identity. Never writes, never advances any revision.
563
+ """
564
+ backfilled = []
565
+ for entry in entries:
566
+ path = entry["path"]
567
+ if is_entity_doc(path):
568
+ continue
569
+ backfilled.append(
570
+ backfill_entity_for_doc(path, entry.get("bytes", 0))
571
+ )
572
+ return sorted(backfilled, key=lambda record: record["id"])
573
+
574
+
575
+ def match_predicate(data, predicate):
576
+ """Test one component payload against an exact-match predicate.
577
+
578
+ Args:
579
+ data: Component payload to test.
580
+ predicate: Exact-match mapping over top-level fields, or
581
+ None for no filtering.
582
+
583
+ Returns:
584
+ True when the predicate is None or every predicate field
585
+ equals the payload's; non-dict payloads never match a
586
+ mapping predicate.
587
+
588
+ Raises:
589
+ EcsError: invalid_predicate for a non-mapping predicate.
590
+ """
591
+ validate_predicate(predicate)
592
+ if predicate is None:
593
+ return True
594
+ if not isinstance(data, dict):
595
+ return False
596
+ return all(
597
+ key in data and data[key] == value
598
+ for key, value in predicate.items()
599
+ )
600
+
601
+
602
+ def filter_entities(records, component, predicate=None):
603
+ """Filter entity records to holders of one component.
604
+
605
+ Args:
606
+ records: Iterable of ``{"id", "kind", "components"}``
607
+ records (real or backfilled).
608
+ component: Component name holders must carry.
609
+ predicate: Exact-match mapping the component payload must
610
+ satisfy, or None for no filtering.
611
+
612
+ Returns:
613
+ Sorted holder ids (every holder when the predicate is
614
+ None).
615
+
616
+ Raises:
617
+ EcsError: invalid_component for a malformed component
618
+ name; invalid_predicate for a non-mapping predicate.
619
+ """
620
+ validate_component_name(component)
621
+ validate_predicate(predicate)
622
+ holders = [
623
+ record["id"]
624
+ for record in records
625
+ if component in record["components"]
626
+ and match_predicate(record["components"][component], predicate)
627
+ ]
628
+ return sorted(holders)
629
+
630
+
631
+ def build_world_snapshot(project_id, current_revision, records):
632
+ """Bundle entity records as a miniplex-shaped world snapshot.
633
+
634
+ Args:
635
+ project_id: Project the snapshot mirrors.
636
+ current_revision: Head revision the snapshot mirrors.
637
+ records: Iterable of ``{"id", "kind", "components"}``
638
+ records (real plus, optionally, backfilled).
639
+
640
+ Returns:
641
+ Snapshot mapping with project id, head revision, and the
642
+ entities sorted by id — each entry maps one-to-one onto a
643
+ miniplex entity (``{id, ...components}``), so mirrors load
644
+ it directly.
645
+ """
646
+ ordered = sorted(records, key=lambda record: record["id"])
647
+ return {
648
+ "current_revision": current_revision,
649
+ "entities": [
650
+ {
651
+ "components": dict(record["components"]),
652
+ "id": record["id"],
653
+ "kind": record["kind"],
654
+ }
655
+ for record in ordered
656
+ ],
657
+ "project_id": project_id,
658
+ }
659
+
660
+
661
+ def component_changed_data(
662
+ entity, component, revision, detached=False, entity_revision=None
663
+ ):
664
+ """Build one component_changed event payload.
665
+
666
+ Args:
667
+ entity: Entity whose component changed.
668
+ component: Component that changed.
669
+ revision: Head revision after the change.
670
+ detached: True when the change removed the component.
671
+ entity_revision: Per-entity revision after the change
672
+ (ENTITY-CAS); omitted when unknown so legacy exact
673
+ shapes keep passing.
674
+
675
+ Returns:
676
+ Event data mapping with entity, component, revision (plus
677
+ detached True for removals, plus entity_revision when
678
+ supplied).
679
+ """
680
+ data = {"component": component, "entity": entity, "revision": revision}
681
+ if detached:
682
+ data["detached"] = True
683
+ if entity_revision is not None:
684
+ data["entity_revision"] = entity_revision
685
+ return data
686
+
687
+
688
+ def _advance_revision(revision):
689
+ """Next revision: trailing digits increment, else `-2` is suffixed."""
690
+ match = _REVISION_RE.match(revision or "")
691
+ if match is None:
692
+ return f"{revision}-2"
693
+ return f"{match.group(1)}{int(match.group(2)) + 1}"
694
+
695
+
696
+ def _frozen_identity(operation, arguments):
697
+ """Canonical identity text for idempotent request comparison."""
698
+ return json.dumps(
699
+ {"arguments": arguments, "operation": operation},
700
+ sort_keys=True,
701
+ default=str,
702
+ )
703
+
704
+ # TODO: No need to handroll this. bro just get an ECS library
705
+ # Utilize the ESPER lib context and modify the context of esper to seet the default context for the runtime to world context
706
+ # WIth esper's ECS, a lot of the tools can just move to their own dedicated tools/ module outside of ports, for the simple reason that ports will be used for communicating with the engine
707
+ # While tools are tools the engine uses to power its services, and the result of said tools needs to propagate to all adapter ports to fan out the results, while port-specific formatting remains the responsibility of the adapter itself, it just needs to know what the tool sends back out; pydantic dataclass
708
+ class EntityStore:
709
+ """In-memory entity store: the reference ECS implementation.
710
+
711
+ Projects entities over an in-memory doc map plus an ordered
712
+ event log with the ledger law: every accepted mutation gates on
713
+ ``expected_revision``, honors idempotent ``request_id`` replay,
714
+ advances the revision once, and appends one actor-attributed
715
+ event. The wiring patch teaches ``ProjectService`` these same
716
+ verbs over the real doc store + durable ledger; response shapes
717
+ match field for field so unit and wiring tests assert one
718
+ contract.
719
+
720
+ Args:
721
+ project_id: Project this store is bound to.
722
+ actor: Attribution recorded on every appended event.
723
+ current_revision: Starting head revision.
724
+
725
+ Caution: An omitted expected_revision defaults to the live head
726
+ (a test-seam leniency); the wired path follows
727
+ ``ProjectService`` strictness instead — callers supply the
728
+ head they read (MCP fills it), and stale writers fail.
729
+ """
730
+
731
+ def __init__(
732
+ self, project_id="ecs-demo", actor="test",
733
+ current_revision="p12",
734
+ ):
735
+ self._project_id = project_id
736
+ self._actor = actor
737
+ self._current_revision = current_revision
738
+ self._docs = {}
739
+ self._events = []
740
+ self._requests = {}
741
+
742
+ def _cursor(self):
743
+ return self._events[-1]["seq"] if self._events else 0
744
+
745
+ def _append_event(self, event, request_id, data):
746
+ record = {
747
+ "actor": self._actor,
748
+ "data": data,
749
+ "event": event,
750
+ "project_id": self._project_id,
751
+ "request_id": request_id,
752
+ "seq": self._cursor() + 1,
753
+ }
754
+ self._events.append(record)
755
+ return record
756
+
757
+ def _check_replay(self, request_id, identity):
758
+ prev = self._requests.get(request_id)
759
+ if prev is None:
760
+ return None
761
+ prev_identity, prev_response = prev
762
+ if prev_identity != identity:
763
+ raise EcsError(
764
+ "request_conflict",
765
+ f"Request id reused with different input: {request_id}.",
766
+ )
767
+ return prev_response
768
+
769
+ def _gate_revision(self, expected_revision):
770
+ if expected_revision != self._current_revision:
771
+ raise EcsError(
772
+ "revision_conflict",
773
+ f"Expected revision {self._current_revision}, "
774
+ f"got {expected_revision}.",
775
+ )
776
+
777
+ def _refresh_expected(self, expected_revision):
778
+ if expected_revision is None:
779
+ return self._current_revision
780
+ return expected_revision
781
+
782
+ def _mutation_response(
783
+ self, request_id, previous, diff, record, extra=None
784
+ ):
785
+ response = {
786
+ "actor": self._actor,
787
+ "current_revision": self._current_revision,
788
+ "diff_summary": diff,
789
+ "event_cursor": self._cursor(),
790
+ "events": [record],
791
+ "previous_revision": previous,
792
+ "project_id": self._project_id,
793
+ "request_id": request_id,
794
+ "revision_transition": {
795
+ "from": previous,
796
+ "to": self._current_revision,
797
+ },
798
+ "rewind_handle": {
799
+ "project_id": self._project_id,
800
+ "request_id": request_id,
801
+ "revision": previous,
802
+ },
803
+ }
804
+ response.update(extra or {})
805
+ return response
806
+
807
+ def _read_entity(self, entity):
808
+ validate_entity_id(entity)
809
+ if is_backfill_id(entity):
810
+ raise EcsError(
811
+ "backfill_readonly",
812
+ f"Backfilled entity is read-only: {entity}.",
813
+ )
814
+ path = entity_doc_path(entity)
815
+ text = self._docs.get(path)
816
+ if text is None:
817
+ raise EcsError("unknown_entity", f"No such entity: {entity}.")
818
+ decoded = decode_entity_doc(text, path)
819
+ if decoded["entity"] != entity:
820
+ raise EcsError(
821
+ "invalid_document",
822
+ f"Entity record id drift at {path}: stored "
823
+ f"{decoded['entity']!r} for {entity!r}.",
824
+ )
825
+ return path, decoded
826
+
827
+ def _doc_entries(self):
828
+ return [
829
+ {"bytes": len(text.encode("utf-8")), "path": path}
830
+ for path, text in self._docs.items()
831
+ ]
832
+
833
+ def _entity_records(self):
834
+ records = []
835
+ for path in sorted(self._docs):
836
+ entity = entity_from_doc_path(path)
837
+ if entity is None:
838
+ continue
839
+ decoded = decode_entity_doc(self._docs[path], path)
840
+ records.append({
841
+ "components": decoded["components"],
842
+ "id": decoded["entity"],
843
+ "kind": decoded["kind"],
844
+ })
845
+ return records
846
+
847
+ def _backfill_records(self):
848
+ return backfill_entities(self._doc_entries())
849
+
850
+ def _resolve_backfill(self, entity):
851
+ for record in self._backfill_records():
852
+ if record["id"] == entity:
853
+ return record
854
+ raise EcsError("unknown_entity", f"No such entity: {entity}.")
855
+
856
+ def snapshot(self):
857
+ """Read the store head: project, revision, and event cursor.
858
+
859
+ Returns:
860
+ Snapshot mapping with project_id, current_revision,
861
+ and event_cursor.
862
+ """
863
+ return {
864
+ "current_revision": self._current_revision,
865
+ "event_cursor": self._cursor(),
866
+ "project_id": self._project_id,
867
+ }
868
+
869
+ def events_since(self, cursor):
870
+ """Read logged events after a cursor, oldest first.
871
+
872
+ Args:
873
+ cursor: Only events with a higher seq are returned.
874
+
875
+ Returns:
876
+ Logged events after the cursor, seq-ordered.
877
+ """
878
+ return [e for e in self._events if e["seq"] > cursor]
879
+
880
+ def write_doc(
881
+ self, path, content, request_id=None, expected_revision=None
882
+ ):
883
+ """Write one plain document: gate, store, advance, log.
884
+
885
+ The existing CRUD spelling, kept working alongside
886
+ entities — entity records written through here decode
887
+ through the same projection on read.
888
+
889
+ Args:
890
+ path: Document path to create or overwrite.
891
+ content: UTF-8 text to store.
892
+ request_id: Client envelope id for idempotent replay;
893
+ a fresh id when omitted.
894
+ expected_revision: Revision the writer read; the live
895
+ head when omitted.
896
+
897
+ Returns:
898
+ Mutation mapping with path, revision transition, new
899
+ head, actor, diff summary, rewind handle, the
900
+ appended event, cursor, and request id.
901
+
902
+ Raises:
903
+ EcsError: request_conflict for a reused id with new
904
+ text; invalid_path for a bad path; invalid_command
905
+ for non-text content; revision_conflict for a
906
+ stale head.
907
+ """
908
+ request_id = request_id or uuid.uuid4().hex
909
+ identity = _frozen_identity(
910
+ "write_doc", {"content": content, "path": path}
911
+ )
912
+ replay = self._check_replay(request_id, identity)
913
+ if replay is not None:
914
+ return replay
915
+ if (
916
+ not isinstance(path, str)
917
+ or not path
918
+ or path.startswith("/")
919
+ or ".." in path.split("/")
920
+ ):
921
+ raise EcsError("invalid_path", f"Bad document path: {path!r}.")
922
+ if (
923
+ path in ("ledger.yaml", "events.yaml")
924
+ or path.startswith("runs/")
925
+ or path.startswith("snapshots/")
926
+ ):
927
+ raise EcsError(
928
+ "invalid_path",
929
+ f"Engine-owned path is not a document: {path!r}.",
930
+ )
931
+ if not isinstance(content, str):
932
+ raise EcsError(
933
+ "invalid_command",
934
+ f"Write needs text content for document {path}.",
935
+ )
936
+ self._gate_revision(self._refresh_expected(expected_revision))
937
+ previous = self._current_revision
938
+ old = self._docs.get(path)
939
+ self._docs[path] = content
940
+ self._current_revision = _advance_revision(previous)
941
+ new_bytes = len(content.encode("utf-8"))
942
+ if old is None:
943
+ diff = f"created {path} ({new_bytes} bytes)"
944
+ else:
945
+ diff = (
946
+ f"updated {path} "
947
+ f"({len(old.encode('utf-8'))} -> {new_bytes} bytes)"
948
+ )
949
+ record = self._append_event(
950
+ "doc_written",
951
+ request_id,
952
+ {
953
+ "bytes": new_bytes,
954
+ "diff_summary": diff,
955
+ "operation": "write_doc",
956
+ "path": path,
957
+ "revision": self._current_revision,
958
+ },
959
+ )
960
+ response = self._mutation_response(
961
+ request_id, previous, diff, record, {"path": path}
962
+ )
963
+ self._requests[request_id] = (identity, response)
964
+ return response
965
+
966
+ def read_doc(self, path):
967
+ """Read one document as text at the current head.
968
+
969
+ Args:
970
+ path: Document path to read.
971
+
972
+ Returns:
973
+ Mapping with project id, head revision, path,
974
+ content text, and byte size.
975
+
976
+ Raises:
977
+ EcsError: unknown_document for a missing document.
978
+ """
979
+ content = self._docs.get(path)
980
+ if content is None:
981
+ raise EcsError("unknown_document", f"No such document: {path}.")
982
+ return {
983
+ "bytes": len(content.encode("utf-8")),
984
+ "content": content,
985
+ "current_revision": self._current_revision,
986
+ "path": path,
987
+ "project_id": self._project_id,
988
+ }
989
+
990
+ def list_docs(self, prefix=None):
991
+ """List documents, optionally filtered by a path prefix.
992
+
993
+ Args:
994
+ prefix: Optional path prefix; empty matches everything.
995
+
996
+ Returns:
997
+ Mapping with project id, head revision, event cursor,
998
+ and sorted document entries (path + bytes).
999
+ """
1000
+ want = prefix or ""
1001
+ documents = []
1002
+ for path in sorted(self._docs):
1003
+ if want and not (path == want or path.startswith(want + "/")):
1004
+ if not path.startswith(want):
1005
+ continue
1006
+ documents.append({
1007
+ "bytes": len(self._docs[path].encode("utf-8")),
1008
+ "path": path,
1009
+ })
1010
+ return {
1011
+ "current_revision": self._current_revision,
1012
+ "documents": documents,
1013
+ "event_cursor": self._cursor(),
1014
+ "project_id": self._project_id,
1015
+ }
1016
+
1017
+ def create_entity(
1018
+ self, kind="", components=None, request_id=None,
1019
+ expected_revision=None,
1020
+ ):
1021
+ """Create one entity with an engine-minted id.
1022
+
1023
+ The id is minted, never supplied: there is no id argument,
1024
+ so callers cannot collide with, guess, or squat on engine
1025
+ identity. The record persists as its projected doc path.
1026
+
1027
+ Args:
1028
+ kind: Kind label carried beside the components.
1029
+ components: Optional initial component mapping.
1030
+ request_id: Client envelope id for idempotent replay;
1031
+ a fresh id when omitted.
1032
+ expected_revision: Revision the writer read; the live
1033
+ head when omitted.
1034
+
1035
+ Returns:
1036
+ Mutation mapping with the minted entity, kind, storage
1037
+ path, revision transition, new head, actor, diff
1038
+ summary, rewind handle, the entity_created event,
1039
+ cursor, request id, and initial component names.
1040
+
1041
+ Raises:
1042
+ EcsError: request_conflict for a reused id with new
1043
+ text; invalid_kind / invalid_component /
1044
+ invalid_data for malformed parts;
1045
+ revision_conflict for a stale head.
1046
+ """
1047
+ request_id = request_id or uuid.uuid4().hex
1048
+ validate_kind(kind)
1049
+ initial = dict(components or {})
1050
+ for name, data in initial.items():
1051
+ validate_component_name(name)
1052
+ validate_component_data(data)
1053
+ identity = _frozen_identity(
1054
+ "create_entity", {"components": initial, "kind": kind}
1055
+ )
1056
+ replay = self._check_replay(request_id, identity)
1057
+ if replay is not None:
1058
+ return replay
1059
+ self._gate_revision(self._refresh_expected(expected_revision))
1060
+ previous = self._current_revision
1061
+ taken = {
1062
+ entity_from_doc_path(path) for path in self._docs
1063
+ } - {None}
1064
+ entity = mint_entity_id(existing=taken)
1065
+ path = entity_doc_path(entity)
1066
+ self._docs[path] = encode_entity_doc(entity, kind, initial)
1067
+ self._current_revision = _advance_revision(previous)
1068
+ diff = f"created entity {entity} ({path})"
1069
+ record = self._append_event(
1070
+ "entity_created",
1071
+ request_id,
1072
+ {
1073
+ "components": sorted(initial),
1074
+ "entity": entity,
1075
+ "kind": kind,
1076
+ "revision": self._current_revision,
1077
+ },
1078
+ )
1079
+ response = self._mutation_response(
1080
+ request_id, previous, diff, record,
1081
+ {
1082
+ "components": sorted(initial),
1083
+ "entity": entity,
1084
+ "kind": kind,
1085
+ "path": path,
1086
+ },
1087
+ )
1088
+ self._requests[request_id] = (identity, response)
1089
+ return response
1090
+
1091
+ def get_component(self, entity, component):
1092
+ """Read one component payload at the current head.
1093
+
1094
+ Backfilled doc entities serve their projected ``doc``
1095
+ component here, so every world-snapshot entry stays
1096
+ addressable.
1097
+
1098
+ Args:
1099
+ entity: Entity id holding the component.
1100
+ component: Component name to read.
1101
+
1102
+ Returns:
1103
+ Mapping with project id, head revision, entity,
1104
+ component, canonical address, storage path (the
1105
+ projected doc path, or the backfilled doc path), and
1106
+ the component data.
1107
+
1108
+ Raises:
1109
+ EcsError: invalid_entity / invalid_component for
1110
+ malformed parts; unknown_entity for a missing
1111
+ entity; unknown_component for a missing component;
1112
+ invalid_document for a corrupt entity record.
1113
+ """
1114
+ validate_entity_id(entity)
1115
+ validate_component_name(component)
1116
+ if is_backfill_id(entity):
1117
+ record = self._resolve_backfill(entity)
1118
+ if component != BACKFILL_COMPONENT:
1119
+ raise EcsError(
1120
+ "unknown_component",
1121
+ f"No component {component!r} on {entity}.",
1122
+ )
1123
+ data = record["components"][BACKFILL_COMPONENT]
1124
+ return {
1125
+ "address": format_address(entity, component),
1126
+ "component": component,
1127
+ "current_revision": self._current_revision,
1128
+ "data": dict(data),
1129
+ "entity": entity,
1130
+ "path": data["path"],
1131
+ "project_id": self._project_id,
1132
+ }
1133
+ path, decoded = self._read_entity(entity)
1134
+ if component not in decoded["components"]:
1135
+ raise EcsError(
1136
+ "unknown_component",
1137
+ f"No component {component!r} on {entity}.",
1138
+ )
1139
+ return {
1140
+ "address": format_address(entity, component),
1141
+ "component": component,
1142
+ "current_revision": self._current_revision,
1143
+ "data": decoded["components"][component],
1144
+ "entity": entity,
1145
+ "path": path,
1146
+ "project_id": self._project_id,
1147
+ }
1148
+
1149
+ def set_component(
1150
+ self, entity, component, data, request_id=None,
1151
+ expected_revision=None,
1152
+ ):
1153
+ """Upsert one component payload (create or overwrite).
1154
+
1155
+ Args:
1156
+ entity: Entity id holding the component.
1157
+ component: Component name to write.
1158
+ data: JSON payload to store.
1159
+ request_id: Client envelope id for idempotent replay;
1160
+ a fresh id when omitted.
1161
+ expected_revision: Revision the writer read; the live
1162
+ head when omitted.
1163
+
1164
+ Returns:
1165
+ Mutation mapping with entity, component, canonical
1166
+ address, storage path, revision transition, new head,
1167
+ actor, diff summary, rewind handle, the
1168
+ component_changed event, cursor, and request id.
1169
+
1170
+ Raises:
1171
+ EcsError: request_conflict for a reused id with new
1172
+ text; invalid_entity / invalid_component /
1173
+ invalid_data for malformed parts; unknown_entity
1174
+ for a missing entity; backfill_readonly for a
1175
+ backfilled id; invalid_document for a corrupt
1176
+ entity record; revision_conflict for a stale head.
1177
+ """
1178
+ request_id = request_id or uuid.uuid4().hex
1179
+ validate_entity_id(entity)
1180
+ validate_component_name(component)
1181
+ validate_component_data(data)
1182
+ identity = _frozen_identity(
1183
+ "set_component",
1184
+ {"component": component, "data": data, "entity": entity},
1185
+ )
1186
+ replay = self._check_replay(request_id, identity)
1187
+ if replay is not None:
1188
+ return replay
1189
+ path, decoded = self._read_entity(entity)
1190
+ self._gate_revision(self._refresh_expected(expected_revision))
1191
+ previous = self._current_revision
1192
+ existed = component in decoded["components"]
1193
+ merged = dict(decoded["components"])
1194
+ merged[component] = data
1195
+ self._docs[path] = encode_entity_doc(
1196
+ entity, decoded["kind"], merged
1197
+ )
1198
+ self._current_revision = _advance_revision(previous)
1199
+ verb = "updated" if existed else "set"
1200
+ diff = (
1201
+ f"{verb} {format_address(entity, component)} ({path})"
1202
+ )
1203
+ record = self._append_event(
1204
+ "component_changed",
1205
+ request_id,
1206
+ component_changed_data(
1207
+ entity, component, self._current_revision
1208
+ ),
1209
+ )
1210
+ response = self._mutation_response(
1211
+ request_id, previous, diff, record,
1212
+ {
1213
+ "address": format_address(entity, component),
1214
+ "component": component,
1215
+ "entity": entity,
1216
+ "path": path,
1217
+ },
1218
+ )
1219
+ self._requests[request_id] = (identity, response)
1220
+ return response
1221
+
1222
+ def attach_component(
1223
+ self, entity, component, data, request_id=None,
1224
+ expected_revision=None,
1225
+ ):
1226
+ """Attach one new component (refuses overwrites).
1227
+
1228
+ Args:
1229
+ entity: Entity id holding the component.
1230
+ component: Component name to attach.
1231
+ data: JSON payload to store.
1232
+ request_id: Client envelope id for idempotent replay;
1233
+ a fresh id when omitted.
1234
+ expected_revision: Revision the writer read; the live
1235
+ head when omitted.
1236
+
1237
+ Returns:
1238
+ Mutation mapping (see set_component) with the
1239
+ component_changed event.
1240
+
1241
+ Raises:
1242
+ EcsError: component_exists when the component already
1243
+ lives on the entity; otherwise like set_component.
1244
+ """
1245
+ request_id = request_id or uuid.uuid4().hex
1246
+ validate_entity_id(entity)
1247
+ validate_component_name(component)
1248
+ validate_component_data(data)
1249
+ identity = _frozen_identity(
1250
+ "attach_component",
1251
+ {"component": component, "data": data, "entity": entity},
1252
+ )
1253
+ replay = self._check_replay(request_id, identity)
1254
+ if replay is not None:
1255
+ return replay
1256
+ path, decoded = self._read_entity(entity)
1257
+ if component in decoded["components"]:
1258
+ raise EcsError(
1259
+ "component_exists",
1260
+ f"Component already attached: "
1261
+ f"{format_address(entity, component)}.",
1262
+ )
1263
+ self._gate_revision(self._refresh_expected(expected_revision))
1264
+ previous = self._current_revision
1265
+ merged = dict(decoded["components"])
1266
+ merged[component] = data
1267
+ self._docs[path] = encode_entity_doc(
1268
+ entity, decoded["kind"], merged
1269
+ )
1270
+ self._current_revision = _advance_revision(previous)
1271
+ diff = (
1272
+ f"attached {format_address(entity, component)} ({path})"
1273
+ )
1274
+ record = self._append_event(
1275
+ "component_changed",
1276
+ request_id,
1277
+ component_changed_data(
1278
+ entity, component, self._current_revision
1279
+ ),
1280
+ )
1281
+ response = self._mutation_response(
1282
+ request_id, previous, diff, record,
1283
+ {
1284
+ "address": format_address(entity, component),
1285
+ "component": component,
1286
+ "entity": entity,
1287
+ "path": path,
1288
+ },
1289
+ )
1290
+ self._requests[request_id] = (identity, response)
1291
+ return response
1292
+
1293
+ def detach_component(
1294
+ self, entity, component, request_id=None,
1295
+ expected_revision=None,
1296
+ ):
1297
+ """Detach one component from its entity.
1298
+
1299
+ Args:
1300
+ entity: Entity id holding the component.
1301
+ component: Component name to remove.
1302
+ request_id: Client envelope id for idempotent replay;
1303
+ a fresh id when omitted.
1304
+ expected_revision: Revision the writer read; the live
1305
+ head when omitted.
1306
+
1307
+ Returns:
1308
+ Mutation mapping (see set_component); the
1309
+ component_changed event carries detached True.
1310
+
1311
+ Raises:
1312
+ EcsError: unknown_component for a missing component;
1313
+ otherwise like set_component.
1314
+ """
1315
+ request_id = request_id or uuid.uuid4().hex
1316
+ validate_entity_id(entity)
1317
+ validate_component_name(component)
1318
+ identity = _frozen_identity(
1319
+ "detach_component",
1320
+ {"component": component, "entity": entity},
1321
+ )
1322
+ replay = self._check_replay(request_id, identity)
1323
+ if replay is not None:
1324
+ return replay
1325
+ path, decoded = self._read_entity(entity)
1326
+ if component not in decoded["components"]:
1327
+ raise EcsError(
1328
+ "unknown_component",
1329
+ f"No component {component!r} on {entity}.",
1330
+ )
1331
+ self._gate_revision(self._refresh_expected(expected_revision))
1332
+ previous = self._current_revision
1333
+ merged = dict(decoded["components"])
1334
+ del merged[component]
1335
+ self._docs[path] = encode_entity_doc(
1336
+ entity, decoded["kind"], merged
1337
+ )
1338
+ self._current_revision = _advance_revision(previous)
1339
+ diff = (
1340
+ f"detached {format_address(entity, component)} ({path})"
1341
+ )
1342
+ record = self._append_event(
1343
+ "component_changed",
1344
+ request_id,
1345
+ component_changed_data(
1346
+ entity, component, self._current_revision,
1347
+ detached=True,
1348
+ ),
1349
+ )
1350
+ response = self._mutation_response(
1351
+ request_id, previous, diff, record,
1352
+ {
1353
+ "address": format_address(entity, component),
1354
+ "component": component,
1355
+ "entity": entity,
1356
+ "path": path,
1357
+ },
1358
+ )
1359
+ self._requests[request_id] = (identity, response)
1360
+ return response
1361
+
1362
+ def delete_entity(
1363
+ self, entity, request_id=None, expected_revision=None
1364
+ ):
1365
+ """Delete one entity and its projected record.
1366
+
1367
+ Args:
1368
+ entity: Entity id to delete.
1369
+ request_id: Client envelope id for idempotent replay;
1370
+ a fresh id when omitted.
1371
+ expected_revision: Revision the writer read; the live
1372
+ head when omitted.
1373
+
1374
+ Returns:
1375
+ Mutation mapping with entity, storage path, revision
1376
+ transition, new head, actor, diff summary, rewind
1377
+ handle, the entity_deleted event, cursor, and request
1378
+ id.
1379
+
1380
+ Raises:
1381
+ EcsError: request_conflict for a reused id with new
1382
+ text; invalid_entity for a malformed id;
1383
+ unknown_entity for a missing entity;
1384
+ backfill_readonly for a backfilled id;
1385
+ invalid_document for a corrupt entity record;
1386
+ revision_conflict for a stale head.
1387
+ """
1388
+ request_id = request_id or uuid.uuid4().hex
1389
+ validate_entity_id(entity)
1390
+ identity = _frozen_identity("delete_entity", {"entity": entity})
1391
+ replay = self._check_replay(request_id, identity)
1392
+ if replay is not None:
1393
+ return replay
1394
+ path, _decoded = self._read_entity(entity)
1395
+ self._gate_revision(self._refresh_expected(expected_revision))
1396
+ previous = self._current_revision
1397
+ del self._docs[path]
1398
+ self._current_revision = _advance_revision(previous)
1399
+ diff = f"deleted entity {entity} ({path})"
1400
+ record = self._append_event(
1401
+ "entity_deleted",
1402
+ request_id,
1403
+ {"entity": entity, "revision": self._current_revision},
1404
+ )
1405
+ response = self._mutation_response(
1406
+ request_id, previous, diff, record,
1407
+ {"entity": entity, "path": path},
1408
+ )
1409
+ self._requests[request_id] = (identity, response)
1410
+ return response
1411
+
1412
+ def components_of(self, entity):
1413
+ """List the component names one entity carries.
1414
+
1415
+ Args:
1416
+ entity: Entity id to inspect (backfilled ids serve
1417
+ their projected ``doc`` component).
1418
+
1419
+ Returns:
1420
+ Mapping with project id, head revision, entity,
1421
+ storage path, and sorted component names.
1422
+
1423
+ Raises:
1424
+ EcsError: invalid_entity for a malformed id;
1425
+ unknown_entity for a missing entity;
1426
+ invalid_document for a corrupt entity record.
1427
+ """
1428
+ validate_entity_id(entity)
1429
+ if is_backfill_id(entity):
1430
+ record = self._resolve_backfill(entity)
1431
+ return {
1432
+ "components": sorted(record["components"]),
1433
+ "current_revision": self._current_revision,
1434
+ "entity": entity,
1435
+ "path": record["components"][BACKFILL_COMPONENT]["path"],
1436
+ "project_id": self._project_id,
1437
+ }
1438
+ path, decoded = self._read_entity(entity)
1439
+ return {
1440
+ "components": sorted(decoded["components"]),
1441
+ "current_revision": self._current_revision,
1442
+ "entity": entity,
1443
+ "path": path,
1444
+ "project_id": self._project_id,
1445
+ }
1446
+
1447
+ def entities_with(self, component, predicate=None):
1448
+ """Filter entities to holders of one component.
1449
+
1450
+ Nets-of-block, pins-of-net, and open-problems become
1451
+ filters here, not endpoints; backfilled docs answer the
1452
+ ``doc`` component, so current project files stay
1453
+ filterable without migration.
1454
+
1455
+ Args:
1456
+ component: Component name holders must carry.
1457
+ predicate: Exact-match mapping the component payload
1458
+ must satisfy, or None for no filtering.
1459
+
1460
+ Returns:
1461
+ Mapping with project id, head revision, component,
1462
+ predicate, and sorted holder ids.
1463
+
1464
+ Raises:
1465
+ EcsError: invalid_component for a malformed component
1466
+ name; invalid_predicate for a non-mapping
1467
+ predicate; invalid_document for a corrupt entity
1468
+ record.
1469
+ """
1470
+ validate_component_name(component)
1471
+ validate_predicate(predicate)
1472
+ records = self._entity_records()
1473
+ if component == BACKFILL_COMPONENT:
1474
+ records.extend(self._backfill_records())
1475
+ return {
1476
+ "component": component,
1477
+ "current_revision": self._current_revision,
1478
+ "entities": filter_entities(records, component, predicate),
1479
+ "predicate": predicate,
1480
+ "project_id": self._project_id,
1481
+ }
1482
+
1483
+ def world_snapshot(self, include_backfill=True):
1484
+ """Bundle the miniplex-shaped world snapshot for mirrors.
1485
+
1486
+ A pure read: snapshotting never writes and never advances
1487
+ the revision, so backfill cannot break any revision.
1488
+
1489
+ Args:
1490
+ include_backfill: Project current plain docs as
1491
+ read-only virtual entities alongside engine
1492
+ entities; engine entities only when False.
1493
+
1494
+ Returns:
1495
+ Snapshot mapping with project id, head revision, and
1496
+ entities sorted by id (each with id, kind, and
1497
+ components).
1498
+
1499
+ Raises:
1500
+ EcsError: invalid_document for a corrupt entity
1501
+ record.
1502
+ """
1503
+ records = self._entity_records()
1504
+ if include_backfill:
1505
+ records.extend(self._backfill_records())
1506
+ return build_world_snapshot(
1507
+ self._project_id, self._current_revision, records
1508
+ )