openworldformat 0.4.4__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.
- openworldformat/__init__.py +1765 -0
- openworldformat/agent.py +142 -0
- openworldformat/cinematography.py +404 -0
- openworldformat/dataset.py +83 -0
- openworldformat/entity_refs.py +25 -0
- openworldformat/eval.py +226 -0
- openworldformat/physics.py +424 -0
- openworldformat/soundtrack.py +94 -0
- openworldformat-0.4.4.dist-info/METADATA +278 -0
- openworldformat-0.4.4.dist-info/RECORD +12 -0
- openworldformat-0.4.4.dist-info/WHEEL +5 -0
- openworldformat-0.4.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1765 @@
|
|
|
1
|
+
"""The Open World Format reference fold, in Python.
|
|
2
|
+
|
|
3
|
+
Pure standard library — no dependencies, no engine. It parses a world
|
|
4
|
+
document (manifest.json) and folds a session log (ops.jsonl) over it,
|
|
5
|
+
applying the same rules the specification states: only edits change the
|
|
6
|
+
document, history kinds fold to nothing, ops are recognized by shape
|
|
7
|
+
(edits first), and a batch applies all-or-nothing.
|
|
8
|
+
|
|
9
|
+
This is the format's research surface: benchmarks, dataset tooling and
|
|
10
|
+
notebooks want ``pip install openworldformat`` and a fold, not a
|
|
11
|
+
renderer. It shares no code or toolchain with the JS reference, which
|
|
12
|
+
makes it a cross-check on the fold contract as much as a consumer of
|
|
13
|
+
it. Around the fold sit the pieces the spec asks of every reference:
|
|
14
|
+
canonical entry identity, immediate name binding, the computed inverse
|
|
15
|
+
(undo is appending it), branch merges with id remapping, package
|
|
16
|
+
compaction, a strict reader, and the provenance accessor.
|
|
17
|
+
|
|
18
|
+
Spec: https://openworldformat.org · schema version 3
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import copy
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .entity_refs import ENTITY_REFS
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"SUPPORTED_SCHEMA_VERSION",
|
|
34
|
+
"SUPPORTED_FORMAT_VERSION",
|
|
35
|
+
"BASE_SNAPSHOT",
|
|
36
|
+
"WORLD_PATCH_KEYS",
|
|
37
|
+
"MAX_ENTITY_ID",
|
|
38
|
+
"REGISTERED_EXTENSIONS",
|
|
39
|
+
"EXT_PROVENANCE_FIELDS",
|
|
40
|
+
"ENTITY_REFS",
|
|
41
|
+
"WorldFormatError",
|
|
42
|
+
"parse_manifest",
|
|
43
|
+
"classify_op",
|
|
44
|
+
"op_kind_shape_ok",
|
|
45
|
+
"parse_log_line",
|
|
46
|
+
"edit_ops",
|
|
47
|
+
"canonical_json",
|
|
48
|
+
"compute_entry_id",
|
|
49
|
+
"manifest_text",
|
|
50
|
+
"fold_state",
|
|
51
|
+
"build_history",
|
|
52
|
+
"fold_path",
|
|
53
|
+
"fold_log",
|
|
54
|
+
"to_manifest",
|
|
55
|
+
"ingest",
|
|
56
|
+
"merge_patch",
|
|
57
|
+
"compute_inverse",
|
|
58
|
+
"merge_branch",
|
|
59
|
+
"snapshot_filename",
|
|
60
|
+
"compact_package",
|
|
61
|
+
"compact",
|
|
62
|
+
"read_package",
|
|
63
|
+
"ext_provenance",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WorldFormatError(ValueError):
|
|
68
|
+
"""A document or log that violates the format's rules."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
#: The manifest schema version this fold reads.
|
|
72
|
+
SUPPORTED_SCHEMA_VERSION = 3
|
|
73
|
+
|
|
74
|
+
#: The package format version this fold reads: 2, head-first —
|
|
75
|
+
#: ``manifest.json`` is the world at the tip of ``main``, the base lives
|
|
76
|
+
#: in ``snapshots/base.json`` (spec/package.md).
|
|
77
|
+
SUPPORTED_FORMAT_VERSION = 2
|
|
78
|
+
|
|
79
|
+
#: Where a head-first package keeps the state its log folds from.
|
|
80
|
+
BASE_SNAPSHOT = "snapshots/base.json"
|
|
81
|
+
|
|
82
|
+
#: The fields ``ModifyWorld``'s patch reaches (spec/session.md).
|
|
83
|
+
WORLD_PATCH_KEYS = (
|
|
84
|
+
"meta", "environment", "camera", "avatar", "tours", "soundtrack",
|
|
85
|
+
"ambience", "creations",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
#: The entity id ceiling: 2^53 − 1, the largest integer every JSON
|
|
89
|
+
#: number implementation reads exactly. Ids above it are refused at the
|
|
90
|
+
#: door, not silently rounded by whichever reader has the weakest
|
|
91
|
+
#: number type (spec/world.md "Identity").
|
|
92
|
+
MAX_ENTITY_ID = 9007199254740991
|
|
93
|
+
|
|
94
|
+
#: The extensions the registry knows (spec/extensions/registry.json) —
|
|
95
|
+
#: the names strict mode accepts wherever an ``ext-*`` key can appear.
|
|
96
|
+
REGISTERED_EXTENSIONS = (
|
|
97
|
+
"ext-physics",
|
|
98
|
+
"ext-strict-determinism",
|
|
99
|
+
"ext-visibility",
|
|
100
|
+
"ext-cinematography",
|
|
101
|
+
"ext-provenance",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
#: The edit kinds in the spec's own order — the order error messages
|
|
105
|
+
#: name them in, kept deterministic where a frozenset would not be.
|
|
106
|
+
_EDIT_KIND_ORDER = (
|
|
107
|
+
"SpawnEntity",
|
|
108
|
+
"DeleteEntity",
|
|
109
|
+
"ModifyEntity",
|
|
110
|
+
"SetEnvironment",
|
|
111
|
+
"SetCamera",
|
|
112
|
+
"SetAmbience",
|
|
113
|
+
"SpawnAudioEmitter",
|
|
114
|
+
"RemoveAudioEmitter",
|
|
115
|
+
"ModifyWorld",
|
|
116
|
+
"Batch",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
EDIT_KEYS = frozenset(_EDIT_KIND_ORDER)
|
|
120
|
+
|
|
121
|
+
#: The history kinds — lowercase, per the collision rule.
|
|
122
|
+
HISTORY_KEYS = frozenset({"tool", "input", "state", "clock", "merge"})
|
|
123
|
+
|
|
124
|
+
#: The keys strict mode allows at the manifest's top level: the
|
|
125
|
+
#: schema's own, plus any registered extension.
|
|
126
|
+
_MANIFEST_KEYS = frozenset({
|
|
127
|
+
"version", "meta", "entities", "environment", "camera", "avatar",
|
|
128
|
+
"ambience", "tours", "soundtrack", "creations", "next_entity_id",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
#: The keys strict mode allows inside ``meta``.
|
|
132
|
+
_META_KEYS = frozenset({
|
|
133
|
+
"name", "description", "time_of_day", "tags", "source",
|
|
134
|
+
"variation_group", "variation", "style_ref", "compliance",
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
#: The keys strict mode allows on an entity.
|
|
138
|
+
_ENTITY_KEYS = frozenset({
|
|
139
|
+
"id", "name", "parent", "transform", "chunk", "shape", "material",
|
|
140
|
+
"light", "audio", "behaviors", "modulations", "triggers",
|
|
141
|
+
"mesh_asset", "instance_of", "creation_id",
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
#: The keys a live-authored op may carry, by where it carries them
|
|
145
|
+
#: (spec/world.md) — what :func:`ingest` refuses beyond, not what the
|
|
146
|
+
#: fold ignores: the fold must tolerate, the authoring surface must not
|
|
147
|
+
#: write keys the format would silently drop.
|
|
148
|
+
_TRANSFORM_KEYS = frozenset({"position", "rotation_degrees", "scale", "visible"})
|
|
149
|
+
_MATERIAL_KEYS = frozenset({
|
|
150
|
+
"alpha_mode", "base_color_texture", "color", "double_sided", "emissive",
|
|
151
|
+
"emissive_texture", "metallic", "metallic_roughness_texture",
|
|
152
|
+
"normal_map_texture", "reflectance", "roughness", "unlit",
|
|
153
|
+
})
|
|
154
|
+
_LIGHT_KEYS = frozenset({
|
|
155
|
+
"color", "direction", "inner_angle", "intensity", "light_type",
|
|
156
|
+
"outer_angle", "range", "shadows",
|
|
157
|
+
})
|
|
158
|
+
_ENVIRONMENT_KEYS = frozenset({
|
|
159
|
+
"ambient_color", "ambient_intensity", "background_color", "fog_color",
|
|
160
|
+
"fog_density",
|
|
161
|
+
})
|
|
162
|
+
_CAMERA_KEYS = frozenset({"fov_degrees", "look_at", "position"})
|
|
163
|
+
|
|
164
|
+
_EXT_KEY = re.compile(r"ext-[a-z0-9-]+")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _is_num(v: Any) -> bool:
|
|
168
|
+
"""JSON number, not a bool riding int's coat tails."""
|
|
169
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _coalesce(v: Any, default: Any) -> Any:
|
|
173
|
+
"""JavaScript's ``??``: null/undefined fall back, everything else passes."""
|
|
174
|
+
return default if v is None else v
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Entity-reference fields (schema/entity-refs.json, spec/world.md
|
|
179
|
+
# "Identity") — name binding, merge rewriting and ingestion validation
|
|
180
|
+
# read the schema's one marked list (the embedded copy,
|
|
181
|
+
# openworldformat/entity_refs.py) instead of hand-written ones.
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _refs_of(scope: str, kind: str | None = None) -> list:
|
|
186
|
+
"""The marked refs of one scope (and optionally one kind)."""
|
|
187
|
+
return [
|
|
188
|
+
ref for ref in ENTITY_REFS
|
|
189
|
+
if ref["scope"] == scope and (kind is None or ref["kind"] == kind)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _walk_ref_path(node: Any, path: list, i: int, fn) -> None:
|
|
194
|
+
"""Walk a marked path, calling ``fn(current, set)`` at the leaf;
|
|
195
|
+
``*`` walks every array element. Missing keys walk to nothing."""
|
|
196
|
+
if not isinstance(node, (dict, list)):
|
|
197
|
+
return
|
|
198
|
+
key = path[i]
|
|
199
|
+
last = i == len(path) - 1
|
|
200
|
+
if key == "*":
|
|
201
|
+
if not isinstance(node, list):
|
|
202
|
+
return
|
|
203
|
+
for j, element in enumerate(node):
|
|
204
|
+
if last:
|
|
205
|
+
fn(element, lambda v, j=j: node.__setitem__(j, v))
|
|
206
|
+
else:
|
|
207
|
+
_walk_ref_path(element, path, i + 1, fn)
|
|
208
|
+
return
|
|
209
|
+
if not isinstance(node, dict) or key not in node:
|
|
210
|
+
return
|
|
211
|
+
if last:
|
|
212
|
+
fn(node[key], lambda v: node.__setitem__(key, v))
|
|
213
|
+
else:
|
|
214
|
+
_walk_ref_path(node[key], path, i + 1, fn)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _each_ref(scope: str, value: Any, fn) -> None:
|
|
218
|
+
"""Every marked entity-reference field of ``value`` — an entity or
|
|
219
|
+
patch ("entity"), the manifest's avatar ("avatar"), one creation
|
|
220
|
+
("creation") — calling ``fn(current, set)`` on each, in place."""
|
|
221
|
+
if not isinstance(value, (dict, list)):
|
|
222
|
+
return
|
|
223
|
+
for ref in _refs_of(scope):
|
|
224
|
+
_walk_ref_path(value, ref["path"], 0, fn)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Parsing
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _check_keys(where: str, holder: dict, allowed: frozenset,
|
|
233
|
+
moved: frozenset = frozenset()) -> None:
|
|
234
|
+
"""Strict mode's key check: every key must be in the schema's set or
|
|
235
|
+
a registered extension's — the fields that moved out of the core get
|
|
236
|
+
an error pointing at the extension they moved to instead."""
|
|
237
|
+
for key in holder:
|
|
238
|
+
if key in allowed or key in REGISTERED_EXTENSIONS:
|
|
239
|
+
continue
|
|
240
|
+
if key in moved:
|
|
241
|
+
raise WorldFormatError(
|
|
242
|
+
f"{where} key '{key}' moved to the ext-provenance extension — "
|
|
243
|
+
'write it in meta["ext-provenance"] '
|
|
244
|
+
"(spec/extensions/provenance.md)"
|
|
245
|
+
)
|
|
246
|
+
if _EXT_KEY.fullmatch(key):
|
|
247
|
+
raise WorldFormatError(
|
|
248
|
+
f"{where} key '{key}' isn't in the extension registry "
|
|
249
|
+
"(spec/extensions/registry.json)"
|
|
250
|
+
)
|
|
251
|
+
raise WorldFormatError(f"{where} key '{key}' isn't in the schema")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def parse_manifest(text: str, strict: bool = False) -> dict:
|
|
255
|
+
"""Parse and sanity-check a world document.
|
|
256
|
+
|
|
257
|
+
:param text: the manifest's JSON text
|
|
258
|
+
:param strict: also enforce the schema's key sets — top level,
|
|
259
|
+
``meta``, and every entity limited to the schema's keys plus
|
|
260
|
+
registered extensions, the provenance fields pointed at their
|
|
261
|
+
extension, unregistered ``ext-*`` named for what they are. The
|
|
262
|
+
default stays tolerant: must-ignore is the reader's side of the
|
|
263
|
+
contract, and history must never break a fold.
|
|
264
|
+
:return: the manifest
|
|
265
|
+
:raises ValueError: when the text isn't JSON
|
|
266
|
+
:raises WorldFormatError: when the schema version isn't supported,
|
|
267
|
+
or (strict) when a key is outside the schema
|
|
268
|
+
"""
|
|
269
|
+
manifest = json.loads(text)
|
|
270
|
+
if not isinstance(manifest, dict) or not _is_num(manifest.get("version")):
|
|
271
|
+
raise WorldFormatError("manifest has no schema version — refusing to guess")
|
|
272
|
+
if manifest["version"] > SUPPORTED_SCHEMA_VERSION:
|
|
273
|
+
raise WorldFormatError(
|
|
274
|
+
f"manifest schema version {manifest['version']} is newer than this "
|
|
275
|
+
f"reader ({SUPPORTED_SCHEMA_VERSION}); a newer reader must read it "
|
|
276
|
+
"— see the versioning policy"
|
|
277
|
+
)
|
|
278
|
+
if not isinstance(manifest.get("entities"), list):
|
|
279
|
+
raise WorldFormatError("manifest has no entities array")
|
|
280
|
+
if strict:
|
|
281
|
+
_check_keys("manifest", manifest, _MANIFEST_KEYS)
|
|
282
|
+
meta = manifest.get("meta")
|
|
283
|
+
if isinstance(meta, dict):
|
|
284
|
+
_check_keys("meta", meta, _META_KEYS, moved=EXT_PROVENANCE_FIELDS)
|
|
285
|
+
for n, entity in enumerate(manifest["entities"]):
|
|
286
|
+
if isinstance(entity, dict):
|
|
287
|
+
_check_keys(f"entity {entity.get('id', n)}", entity, _ENTITY_KEYS)
|
|
288
|
+
return manifest
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def classify_op(op: Any) -> dict:
|
|
292
|
+
"""Classify one op by its shape, edits first — the compatibility rule: a
|
|
293
|
+
log written before the history kinds existed parses as edits, and an
|
|
294
|
+
edit serializes today exactly as it always did. Extension ops
|
|
295
|
+
(``ext-*``, single key, object value) are recognized as a kind of their
|
|
296
|
+
own and, like every history kind, fold to nothing for the document.
|
|
297
|
+
|
|
298
|
+
:return: ``{"kind": "edit" | "tool" | "input" | "state" | "clock" |
|
|
299
|
+
"merge" | "extension" | "unknown", ...}`` — edits carry ``edit``
|
|
300
|
+
and ``value``, extensions carry ``name`` and ``value``.
|
|
301
|
+
"""
|
|
302
|
+
if not isinstance(op, dict):
|
|
303
|
+
return {"kind": "unknown"}
|
|
304
|
+
keys = list(op)
|
|
305
|
+
if len(keys) == 1 and keys[0] in EDIT_KEYS:
|
|
306
|
+
return {"kind": "edit", "edit": keys[0], "value": op[keys[0]]}
|
|
307
|
+
if isinstance(op.get("tool"), str) and "args" in op:
|
|
308
|
+
return {"kind": "tool", "value": op}
|
|
309
|
+
inp = op.get("input")
|
|
310
|
+
if isinstance(inp, dict) and isinstance(inp.get("actor"), str):
|
|
311
|
+
return {"kind": "input", "value": inp}
|
|
312
|
+
if isinstance(op.get("state"), dict):
|
|
313
|
+
return {"kind": "state", "value": op["state"]}
|
|
314
|
+
if isinstance(op.get("clock"), dict):
|
|
315
|
+
return {"kind": "clock", "value": op["clock"]}
|
|
316
|
+
if isinstance(op.get("merge"), dict):
|
|
317
|
+
return {"kind": "merge", "value": op["merge"]}
|
|
318
|
+
if len(keys) == 1 and isinstance(op[keys[0]], dict) and _EXT_KEY.fullmatch(keys[0]):
|
|
319
|
+
return {"kind": "extension", "name": keys[0], "value": op[keys[0]]}
|
|
320
|
+
return {"kind": "unknown"}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def op_kind_shape_ok(kind: str) -> bool:
|
|
324
|
+
"""The shape collision rule as a predicate (spec/session.md
|
|
325
|
+
"Compatibility"): edits MUST be PascalCase and history kinds MUST be
|
|
326
|
+
lowercase, so the two namespaces can never collide — a lowercase
|
|
327
|
+
kind a reader doesn't know folds to nothing, and nothing lowercase
|
|
328
|
+
can ever be mistaken for an edit by shape. Extension names sit
|
|
329
|
+
outside the predicate: they are a registry concern, not a casing
|
|
330
|
+
concern.
|
|
331
|
+
|
|
332
|
+
:param kind: an op kind name
|
|
333
|
+
:return: True when the kind is a cased edit kind, or one of the
|
|
334
|
+
lowercase history kinds
|
|
335
|
+
"""
|
|
336
|
+
return (kind in EDIT_KEYS and kind[:1].isupper()) or kind in HISTORY_KEYS
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def parse_log_line(line: str, strict: bool = False) -> dict:
|
|
340
|
+
"""Parse one log line into an entry with classified ops. Unreadable lines
|
|
341
|
+
are the writer's crash, not the reader's — the caller decides whether
|
|
342
|
+
to skip (the spec says skip the last one, count the rest).
|
|
343
|
+
|
|
344
|
+
:param line: one line of ops.jsonl
|
|
345
|
+
:param strict: also refuse ops that classify as unknown, and
|
|
346
|
+
extension ops the registry doesn't name. The default keeps the
|
|
347
|
+
tolerant read: an unknown op folds to nothing, and must.
|
|
348
|
+
:return: the entry, with ``classified`` added
|
|
349
|
+
:raises ValueError: when the line isn't JSON
|
|
350
|
+
:raises WorldFormatError: when the entry has no revision or ops
|
|
351
|
+
array, or (strict) when an op is unrecognized or unregistered
|
|
352
|
+
"""
|
|
353
|
+
entry = json.loads(line)
|
|
354
|
+
if (
|
|
355
|
+
not isinstance(entry, dict)
|
|
356
|
+
or not _is_num(entry.get("revision"))
|
|
357
|
+
or not isinstance(entry.get("ops"), list)
|
|
358
|
+
):
|
|
359
|
+
raise WorldFormatError("log entry needs a revision and an ops array")
|
|
360
|
+
classified = [classify_op(op) for op in entry["ops"]]
|
|
361
|
+
if strict:
|
|
362
|
+
for op, c in zip(entry["ops"], classified):
|
|
363
|
+
if c["kind"] == "unknown":
|
|
364
|
+
raise WorldFormatError(
|
|
365
|
+
"op shape not recognized (strict mode): "
|
|
366
|
+
+ json.dumps(op)[:100]
|
|
367
|
+
)
|
|
368
|
+
if c["kind"] == "extension" and c["name"] not in REGISTERED_EXTENSIONS:
|
|
369
|
+
raise WorldFormatError(
|
|
370
|
+
f"extension '{c['name']}' isn't in the extension registry "
|
|
371
|
+
"(spec/extensions/registry.json)"
|
|
372
|
+
)
|
|
373
|
+
entry["classified"] = classified
|
|
374
|
+
return entry
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def edit_ops(entry: dict) -> list:
|
|
378
|
+
"""An entry's edits, in order — the ops that change the document."""
|
|
379
|
+
classified = entry.get("classified")
|
|
380
|
+
if classified is None:
|
|
381
|
+
classified = [classify_op(op) for op in entry.get("ops") or []]
|
|
382
|
+
return [c for c in classified if c["kind"] == "edit"]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# State folding
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def fold_state(state_doc: dict | None, entries: list) -> dict:
|
|
391
|
+
"""Fold a session log's ``state`` ops over a state document: the values at
|
|
392
|
+
the last entry. Separate from the document fold — state ops never
|
|
393
|
+
touch entities — and equally tolerant: keys nothing declares are
|
|
394
|
+
carried, not refused (spec/state.md).
|
|
395
|
+
|
|
396
|
+
:param state_doc: parsed ``state.json`` (``{format_version, fields}``)
|
|
397
|
+
:param entries: parsed log entries, in order
|
|
398
|
+
:return: ``{"values": dict, "undeclared": list of str}``
|
|
399
|
+
"""
|
|
400
|
+
fields = state_doc.get("fields") if isinstance(state_doc, dict) else None
|
|
401
|
+
if not isinstance(fields, dict):
|
|
402
|
+
fields = {}
|
|
403
|
+
values = {}
|
|
404
|
+
for key, field in fields.items():
|
|
405
|
+
values[key] = copy.deepcopy(_coalesce(field.get("initial"), None))
|
|
406
|
+
undeclared: dict = {} # insertion-ordered set
|
|
407
|
+
|
|
408
|
+
for entry in entries:
|
|
409
|
+
classified = entry.get("classified")
|
|
410
|
+
if classified is None:
|
|
411
|
+
classified = [classify_op(op) for op in entry.get("ops") or []]
|
|
412
|
+
for c in classified:
|
|
413
|
+
if c["kind"] != "state":
|
|
414
|
+
continue
|
|
415
|
+
for key, value in c["value"].items():
|
|
416
|
+
if key in fields:
|
|
417
|
+
# A declared field: set it, or reset it to its initial value.
|
|
418
|
+
if value is None:
|
|
419
|
+
values[key] = copy.deepcopy(_coalesce(fields[key].get("initial"), None))
|
|
420
|
+
else:
|
|
421
|
+
values[key] = copy.deepcopy(value)
|
|
422
|
+
continue
|
|
423
|
+
# Maybe a subkey of a declared map field: "inventory.rope" under
|
|
424
|
+
# the declared map "inventory".
|
|
425
|
+
dot = key.find(".")
|
|
426
|
+
if dot > 0:
|
|
427
|
+
base, inner = key[:dot], key[dot + 1:]
|
|
428
|
+
if base in fields and fields[base].get("type") == "map":
|
|
429
|
+
m = values.get(base)
|
|
430
|
+
if not isinstance(m, dict):
|
|
431
|
+
m = {}
|
|
432
|
+
if value is None:
|
|
433
|
+
m.pop(inner, None)
|
|
434
|
+
else:
|
|
435
|
+
m[inner] = copy.deepcopy(value)
|
|
436
|
+
values[base] = m
|
|
437
|
+
continue
|
|
438
|
+
# Declared by no one: carry it, and say so.
|
|
439
|
+
if value is None:
|
|
440
|
+
values.pop(key, None)
|
|
441
|
+
else:
|
|
442
|
+
values[key] = copy.deepcopy(value)
|
|
443
|
+
undeclared[key] = None
|
|
444
|
+
return {"values": values, "undeclared": list(undeclared)}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# Branching histories
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def canonical_json(value: Any) -> str:
|
|
453
|
+
"""The canonical JSON form for hashing (spec/session.md "Entry
|
|
454
|
+
identity, forks and branches"): no whitespace, keys sorted
|
|
455
|
+
recursively, arrays in order. Identical entries therefore hash
|
|
456
|
+
identically across forks — and across languages, for integer-valued
|
|
457
|
+
JSON. Float formatting is each language's own (``1.0`` may print as
|
|
458
|
+
``1`` elsewhere, and neither is wrong), so cross-language hash
|
|
459
|
+
equality is only promised for documents whose numbers are integers.
|
|
460
|
+
|
|
461
|
+
:param value: any JSON value
|
|
462
|
+
:return: its canonical text
|
|
463
|
+
"""
|
|
464
|
+
return json.dumps(
|
|
465
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def compute_entry_id(entry: dict) -> str:
|
|
470
|
+
"""An entry's content id: the entry deep-copied, its own ``id``
|
|
471
|
+
dropped (an entry is content, not a self-addressing brick — the id
|
|
472
|
+
is the *of* the hash, never part of it), canonicalized and
|
|
473
|
+
SHA-256'd. Two peers appending the same entry to forked logs
|
|
474
|
+
compute the same id, which is the property that keeps identical
|
|
475
|
+
ids identical across branches.
|
|
476
|
+
|
|
477
|
+
:param entry: a log entry
|
|
478
|
+
:return: ``sha256:<hex>``
|
|
479
|
+
"""
|
|
480
|
+
body = copy.deepcopy(entry)
|
|
481
|
+
body.pop("id", None)
|
|
482
|
+
# ``classified`` is this reader's annotation of the ops, not
|
|
483
|
+
# content: :func:`parse_log_line` adds it, and the hash must not
|
|
484
|
+
# see it, or the same line hashed before and after parsing would
|
|
485
|
+
# name two entries.
|
|
486
|
+
body.pop("classified", None)
|
|
487
|
+
digest = hashlib.sha256(canonical_json(body).encode("utf-8")).hexdigest()
|
|
488
|
+
return f"sha256:{digest}"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def manifest_text(manifest: dict) -> str:
|
|
492
|
+
"""A manifest as its canonical text (spec/package.md "Canonical
|
|
493
|
+
text"): what an authority writes to ``manifest.json``, so that the
|
|
494
|
+
same world is always the same bytes — small diffs, ordinary git
|
|
495
|
+
merges, a ``world_sha256`` that means something. Members sorted by
|
|
496
|
+
code point, null members left out, entities in id order, two-space
|
|
497
|
+
indentation, arrays of plain values on one line, numbers in their
|
|
498
|
+
shortest form (integral values without a fraction), a trailing
|
|
499
|
+
newline.
|
|
500
|
+
|
|
501
|
+
:param manifest: a world document
|
|
502
|
+
:return: its canonical text
|
|
503
|
+
"""
|
|
504
|
+
world = copy.deepcopy(manifest)
|
|
505
|
+
if isinstance(world.get("entities"), list):
|
|
506
|
+
world["entities"].sort(key=lambda e: e.get("id", 0))
|
|
507
|
+
return _canonical_pretty(world, 0) + "\n"
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _canonical_scalar(value: Any) -> str:
|
|
511
|
+
"""One non-container value in its shortest JSON form — ``1.0`` is
|
|
512
|
+
``1``, as every JSON writer but Python's would print it."""
|
|
513
|
+
if isinstance(value, float) and value.is_integer() and abs(value) < 1e16:
|
|
514
|
+
return str(int(value))
|
|
515
|
+
return json.dumps(value, ensure_ascii=False)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _canonical_pretty(value: Any, depth: int) -> str:
|
|
519
|
+
"""The canonical pretty form: objects multi-line with sorted members,
|
|
520
|
+
arrays of plain values inline, nested arrays multi-line."""
|
|
521
|
+
if value is None or not isinstance(value, (dict, list)):
|
|
522
|
+
return _canonical_scalar(value)
|
|
523
|
+
pad = " " * depth
|
|
524
|
+
inner = " " * (depth + 1)
|
|
525
|
+
if isinstance(value, list):
|
|
526
|
+
if not value:
|
|
527
|
+
return "[]"
|
|
528
|
+
if all(x is None or not isinstance(x, (dict, list)) for x in value):
|
|
529
|
+
return "[" + ", ".join(_canonical_scalar(x) for x in value) + "]"
|
|
530
|
+
return ("[\n" + ",\n".join(inner + _canonical_pretty(x, depth + 1) for x in value)
|
|
531
|
+
+ "\n" + pad + "]")
|
|
532
|
+
keys = sorted(k for k in value if value[k] is not None)
|
|
533
|
+
if not keys:
|
|
534
|
+
return "{}"
|
|
535
|
+
return ("{\n" + ",\n".join(
|
|
536
|
+
f'{inner}{json.dumps(k, ensure_ascii=False)}: {_canonical_pretty(value[k], depth + 1)}'
|
|
537
|
+
for k in keys) + "\n" + pad + "}")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _with_identity(entries: list) -> tuple:
|
|
541
|
+
"""Give every entry an id and a parent, per spec/session.md: an entry's
|
|
542
|
+
own ``id`` if present, else a synthesized ``line-<n>``; its ``parent``
|
|
543
|
+
if present, else the previous entry (None for the first). A log with
|
|
544
|
+
no ids is therefore a chain in file order.
|
|
545
|
+
"""
|
|
546
|
+
by_id: dict = {}
|
|
547
|
+
ordered: list = []
|
|
548
|
+
previous = None
|
|
549
|
+
for n, raw in enumerate(entries):
|
|
550
|
+
id_ = raw["id"] if isinstance(raw.get("id"), str) else f"line-{n}"
|
|
551
|
+
if id_ in by_id:
|
|
552
|
+
raise WorldFormatError(f"duplicate entry id '{id_}'")
|
|
553
|
+
parent = raw["parent"] if isinstance(raw.get("parent"), str) else previous
|
|
554
|
+
if parent is not None and parent not in by_id:
|
|
555
|
+
raise WorldFormatError(
|
|
556
|
+
f"entry '{id_}' names parent '{parent}', which isn't in the log"
|
|
557
|
+
)
|
|
558
|
+
entry = dict(raw)
|
|
559
|
+
entry["id"] = id_
|
|
560
|
+
entry["parent"] = parent
|
|
561
|
+
by_id[id_] = entry
|
|
562
|
+
ordered.append(entry)
|
|
563
|
+
previous = id_
|
|
564
|
+
return ordered, by_id
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def build_history(entries: list) -> dict:
|
|
568
|
+
"""The history of a log: entries with identity, and its shape.
|
|
569
|
+
|
|
570
|
+
:param entries: parsed log entries, in file order
|
|
571
|
+
:return: ``{"ordered": list, "by_id": dict, "children": dict of
|
|
572
|
+
id -> [child ids], "tips": [ids with no children]}``
|
|
573
|
+
"""
|
|
574
|
+
ordered, by_id = _with_identity(entries)
|
|
575
|
+
children = {e["id"]: [] for e in ordered}
|
|
576
|
+
for entry in ordered:
|
|
577
|
+
if entry["parent"] is not None and entry["parent"] in children:
|
|
578
|
+
children[entry["parent"]].append(entry["id"])
|
|
579
|
+
tips = [e["id"] for e in ordered if not children[e["id"]]]
|
|
580
|
+
return {"ordered": ordered, "by_id": by_id, "children": children, "tips": tips}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def fold_path(manifest: dict, entries: list, tip: str | None = None) -> dict:
|
|
584
|
+
"""Fold one path of the history: the document at ``tip`` (default: the
|
|
585
|
+
last entry in file order), reached by walking parent links to the base
|
|
586
|
+
and folding that chain. A branch is just a different tip.
|
|
587
|
+
|
|
588
|
+
:param manifest: the base world document
|
|
589
|
+
:param entries: parsed log entries, in file order
|
|
590
|
+
:param tip: an entry id from :func:`build_history`
|
|
591
|
+
:return: the fold state (as :func:`fold_log` returns), plus ``path`` ids
|
|
592
|
+
:raises WorldFormatError: on an unknown tip, or the first entry that
|
|
593
|
+
no longer applies
|
|
594
|
+
"""
|
|
595
|
+
ordered, by_id = _with_identity(entries)
|
|
596
|
+
last = ordered[-1]["id"] if ordered else None
|
|
597
|
+
target = tip if tip is not None else last
|
|
598
|
+
if target is None or target not in by_id:
|
|
599
|
+
raise WorldFormatError(f"no entry '{target}' in this log")
|
|
600
|
+
chain = []
|
|
601
|
+
id_ = target
|
|
602
|
+
while id_ is not None:
|
|
603
|
+
chain.append(by_id[id_])
|
|
604
|
+
id_ = by_id[id_]["parent"]
|
|
605
|
+
chain.reverse()
|
|
606
|
+
state = fold_log(manifest, chain)
|
|
607
|
+
state["path"] = [e["id"] for e in chain]
|
|
608
|
+
return state
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# ---------------------------------------------------------------------------
|
|
612
|
+
# Folding
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _invalid(message: str) -> WorldFormatError:
|
|
617
|
+
return WorldFormatError(f"invalid: {message}")
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _resolve_names(state: dict, entity: dict) -> None:
|
|
621
|
+
"""Bind one entity's by-name references to ids, in place
|
|
622
|
+
(spec/world.md "Identity"): cross-entity references may be written
|
|
623
|
+
by name — what authors and models produce — and MUST resolve to id
|
|
624
|
+
at ingestion against the fold-so-far. Delaying resolution until
|
|
625
|
+
fold time is strictly forbidden: it breaks log determinism the
|
|
626
|
+
moment an entity is renamed. The fields are the schema's marked
|
|
627
|
+
entity refs (``entity`` scope, ``bindable``) except the top-level
|
|
628
|
+
ones (``parent``), which op intake binds *before* apply — the
|
|
629
|
+
fold's apply step validates them, so a raw string there is already
|
|
630
|
+
a refusal. ``modulations[]`` targets name properties, not entities,
|
|
631
|
+
and are never touched.
|
|
632
|
+
|
|
633
|
+
:raises WorldFormatError: when a named entity isn't in the fold
|
|
634
|
+
"""
|
|
635
|
+
|
|
636
|
+
def bind(current, set_):
|
|
637
|
+
if not isinstance(current, str):
|
|
638
|
+
return # already an id — saved worlds always contain ids
|
|
639
|
+
id_ = state["name_to_id"].get(current)
|
|
640
|
+
if id_ is None:
|
|
641
|
+
raise _invalid(f"no entity named '{current}'")
|
|
642
|
+
set_(id_)
|
|
643
|
+
|
|
644
|
+
for ref in _refs_of("entity", "bindable"):
|
|
645
|
+
if len(ref["path"]) == 1:
|
|
646
|
+
continue # bound at op intake (_bind_op)
|
|
647
|
+
_walk_ref_path(entity, ref["path"], 0, bind)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _touched_ids(edits: list) -> list:
|
|
651
|
+
"""The entity ids an entry's edits touch — spawned or modified,
|
|
652
|
+
batches recursed — the entities whose by-name references bind at
|
|
653
|
+
ingestion. Deletions need no binding: what they touched is gone."""
|
|
654
|
+
ids: list = []
|
|
655
|
+
for c in edits:
|
|
656
|
+
edit, value = c["edit"], c["value"]
|
|
657
|
+
if edit == "SpawnEntity":
|
|
658
|
+
entity = value.get("entity") if isinstance(value, dict) else None
|
|
659
|
+
if isinstance(entity, dict) and _is_num(entity.get("id")):
|
|
660
|
+
ids.append(entity["id"])
|
|
661
|
+
elif edit == "ModifyEntity":
|
|
662
|
+
if isinstance(value, dict):
|
|
663
|
+
ids.append(value.get("id"))
|
|
664
|
+
elif edit == "Batch":
|
|
665
|
+
inner = value.get("ops") if isinstance(value, dict) else None
|
|
666
|
+
if isinstance(inner, list):
|
|
667
|
+
classified = [classify_op(op) for op in inner]
|
|
668
|
+
ids.extend(_touched_ids(
|
|
669
|
+
[c for c in classified if c["kind"] == "edit"]
|
|
670
|
+
))
|
|
671
|
+
return ids
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _bind_committed_refs(state: dict, c: dict) -> None:
|
|
675
|
+
"""Bind the deeper marked refs in a committed op's own JSON — the
|
|
676
|
+
spawn's entity, the patch, a batch's inner ops — against the state
|
|
677
|
+
the op just applied to, so the log carries ids in every marked field
|
|
678
|
+
(spec/session.md: committed ops always carry ids), not only the
|
|
679
|
+
document. The top-level ones bound at intake."""
|
|
680
|
+
edit, value = c["edit"], c["value"]
|
|
681
|
+
if edit == "SpawnEntity":
|
|
682
|
+
entity = value.get("entity") if isinstance(value, dict) else None
|
|
683
|
+
if isinstance(entity, dict):
|
|
684
|
+
_resolve_names(state, entity)
|
|
685
|
+
elif edit == "ModifyEntity":
|
|
686
|
+
patch = value.get("patch") if isinstance(value, dict) else None
|
|
687
|
+
if isinstance(patch, dict):
|
|
688
|
+
_resolve_names(state, patch)
|
|
689
|
+
elif edit == "Batch":
|
|
690
|
+
inner = value.get("ops") if isinstance(value, dict) else None
|
|
691
|
+
for op in inner or []:
|
|
692
|
+
classified = classify_op(op)
|
|
693
|
+
if classified["kind"] == "edit":
|
|
694
|
+
_bind_committed_refs(state, classified)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _apply_edit(state: dict, edit: str, value: dict) -> None:
|
|
698
|
+
"""Apply one edit op to a fold state, all-or-nothing. Throws on refusal."""
|
|
699
|
+
if edit == "SpawnEntity":
|
|
700
|
+
entity = value.get("entity")
|
|
701
|
+
if (
|
|
702
|
+
not isinstance(entity, dict)
|
|
703
|
+
or not _is_num(entity.get("id"))
|
|
704
|
+
or not isinstance(entity.get("name"), str)
|
|
705
|
+
):
|
|
706
|
+
raise _invalid("SpawnEntity needs an entity with id and name")
|
|
707
|
+
if entity["id"] > MAX_ENTITY_ID:
|
|
708
|
+
raise _invalid(
|
|
709
|
+
f"entity {entity['id']} exceeds the id ceiling "
|
|
710
|
+
f"{MAX_ENTITY_ID} (2^53-1)"
|
|
711
|
+
)
|
|
712
|
+
if entity["id"] in state["by_id"]:
|
|
713
|
+
raise _invalid(f"entity {entity['id']} already exists")
|
|
714
|
+
if entity["name"] in state["names"]:
|
|
715
|
+
raise _invalid(f"an entity named '{entity['name']}' already exists")
|
|
716
|
+
parent = entity.get("parent")
|
|
717
|
+
if parent is not None and parent not in state["by_id"]:
|
|
718
|
+
raise _invalid(f"entity {entity['id']}'s parent {parent} isn't in the document")
|
|
719
|
+
# The fold owns its copy: name binding (and any later edit) writes
|
|
720
|
+
# the document's entity, never the entry the writer still holds.
|
|
721
|
+
entity = copy.deepcopy(entity)
|
|
722
|
+
scene = state["scene"]
|
|
723
|
+
scene["next_entity_id"] = max(scene["next_entity_id"], entity["id"] + 1)
|
|
724
|
+
state["entities"].append(entity)
|
|
725
|
+
state["by_id"][entity["id"]] = entity
|
|
726
|
+
state["names"].add(entity["name"])
|
|
727
|
+
state["name_to_id"][entity["name"]] = entity["id"]
|
|
728
|
+
elif edit == "DeleteEntity":
|
|
729
|
+
id_ = value.get("id")
|
|
730
|
+
entity = state["by_id"].get(id_)
|
|
731
|
+
if entity is None:
|
|
732
|
+
raise _invalid(f"no entity {id_}")
|
|
733
|
+
# Descendants go with it: collect the subtree, then remove.
|
|
734
|
+
doomed = {id_}
|
|
735
|
+
grew = True
|
|
736
|
+
while grew:
|
|
737
|
+
grew = False
|
|
738
|
+
for e in state["entities"]:
|
|
739
|
+
p = e.get("parent")
|
|
740
|
+
if p is not None and p in doomed and e["id"] not in doomed:
|
|
741
|
+
doomed.add(e["id"])
|
|
742
|
+
grew = True
|
|
743
|
+
state["entities"] = [e for e in state["entities"] if e["id"] not in doomed]
|
|
744
|
+
for d in doomed:
|
|
745
|
+
e = state["by_id"].pop(d, None)
|
|
746
|
+
if e is not None:
|
|
747
|
+
state["names"].discard(e["name"])
|
|
748
|
+
state["name_to_id"].pop(e["name"], None)
|
|
749
|
+
elif edit == "ModifyEntity":
|
|
750
|
+
entity = state["by_id"].get(value.get("id"))
|
|
751
|
+
if entity is None:
|
|
752
|
+
raise _invalid(f"no entity {value.get('id')}")
|
|
753
|
+
patch = _coalesce(value.get("patch"), {})
|
|
754
|
+
# Absent = unchanged; null = clear; value = set (Option<Option<T>>).
|
|
755
|
+
if "name" in patch:
|
|
756
|
+
if patch["name"] is None:
|
|
757
|
+
raise _invalid("an entity can't have no name")
|
|
758
|
+
if patch["name"] != entity["name"]:
|
|
759
|
+
if patch["name"] in state["names"]:
|
|
760
|
+
raise _invalid(f"an entity named '{patch['name']}' already exists")
|
|
761
|
+
state["names"].discard(entity["name"])
|
|
762
|
+
state["name_to_id"].pop(entity["name"], None)
|
|
763
|
+
entity["name"] = patch["name"]
|
|
764
|
+
state["names"].add(patch["name"])
|
|
765
|
+
state["name_to_id"][patch["name"]] = entity["id"]
|
|
766
|
+
if "parent" in patch:
|
|
767
|
+
if patch["parent"] is not None and patch["parent"] not in state["by_id"]:
|
|
768
|
+
raise _invalid(f"parent {patch['parent']} isn't in the document")
|
|
769
|
+
# A parent cycle would make the entity its own ancestor.
|
|
770
|
+
ancestor = patch["parent"]
|
|
771
|
+
seen = {entity["id"]}
|
|
772
|
+
while ancestor is not None:
|
|
773
|
+
if ancestor in seen:
|
|
774
|
+
raise _invalid(f"entity {entity['id']} can't be its own ancestor")
|
|
775
|
+
seen.add(ancestor)
|
|
776
|
+
a = state["by_id"].get(ancestor)
|
|
777
|
+
ancestor = a.get("parent") if a is not None else None
|
|
778
|
+
entity["parent"] = patch["parent"]
|
|
779
|
+
for field in (
|
|
780
|
+
"transform", "shape", "material", "light", "audio", "behaviors",
|
|
781
|
+
"mesh_asset", "modulations", "instance_of", "triggers",
|
|
782
|
+
):
|
|
783
|
+
if field in patch:
|
|
784
|
+
if patch[field] is None:
|
|
785
|
+
entity.pop(field, None)
|
|
786
|
+
else:
|
|
787
|
+
entity[field] = patch[field]
|
|
788
|
+
# Extension fields ride along: any `ext-*` key patches like the
|
|
789
|
+
# known ones — set, or clear on null — so a physics component (or
|
|
790
|
+
# any future extension's) survives a modify round-trip. The core
|
|
791
|
+
# schema leaves room for them; must-ignore is the reader's side.
|
|
792
|
+
for field in patch:
|
|
793
|
+
if field.startswith("ext-"):
|
|
794
|
+
if patch[field] is None:
|
|
795
|
+
entity.pop(field, None)
|
|
796
|
+
else:
|
|
797
|
+
entity[field] = patch[field]
|
|
798
|
+
elif edit == "SetEnvironment":
|
|
799
|
+
state["environment"] = value.get("env")
|
|
800
|
+
elif edit == "SetCamera":
|
|
801
|
+
state["camera"] = value.get("camera")
|
|
802
|
+
elif edit == "SetAmbience":
|
|
803
|
+
state["ambience"] = _coalesce(value.get("ambience"), [])
|
|
804
|
+
elif edit == "SpawnAudioEmitter":
|
|
805
|
+
if not isinstance(value.get("name"), str):
|
|
806
|
+
raise _invalid("SpawnAudioEmitter needs a name")
|
|
807
|
+
state["audio_emitters"][value["name"]] = value.get("audio")
|
|
808
|
+
elif edit == "RemoveAudioEmitter":
|
|
809
|
+
name = value.get("name")
|
|
810
|
+
if name not in state["audio_emitters"]:
|
|
811
|
+
raise _invalid(f"no audio emitter named '{name}'")
|
|
812
|
+
del state["audio_emitters"][name]
|
|
813
|
+
elif edit == "ModifyWorld":
|
|
814
|
+
# The scene-wide fields, patched like an entity: absent
|
|
815
|
+
# unchanged, None clears, a value sets (spec/session.md).
|
|
816
|
+
patch = _coalesce(value.get("patch"), {})
|
|
817
|
+
scene = state["scene"]
|
|
818
|
+
if "meta" in patch:
|
|
819
|
+
meta = patch["meta"]
|
|
820
|
+
if not isinstance(meta, dict) or not isinstance(meta.get("name"), str) \
|
|
821
|
+
or not meta["name"].strip():
|
|
822
|
+
raise _invalid(
|
|
823
|
+
"ModifyWorld.meta must be a meta object with a name; "
|
|
824
|
+
"it can't be cleared"
|
|
825
|
+
)
|
|
826
|
+
scene["meta"] = meta
|
|
827
|
+
state["name"] = meta["name"]
|
|
828
|
+
if "environment" in patch:
|
|
829
|
+
state["environment"] = patch["environment"]
|
|
830
|
+
if "camera" in patch:
|
|
831
|
+
state["camera"] = patch["camera"]
|
|
832
|
+
if "ambience" in patch:
|
|
833
|
+
state["ambience"] = _coalesce(patch["ambience"], [])
|
|
834
|
+
for field in ("avatar", "soundtrack"):
|
|
835
|
+
if field in patch:
|
|
836
|
+
if patch[field] is None:
|
|
837
|
+
scene.pop(field, None)
|
|
838
|
+
else:
|
|
839
|
+
scene[field] = patch[field]
|
|
840
|
+
for field in ("tours", "creations"):
|
|
841
|
+
if field in patch:
|
|
842
|
+
scene[field] = _coalesce(patch[field], [])
|
|
843
|
+
elif edit == "Batch":
|
|
844
|
+
ops = _coalesce(value.get("ops"), [])
|
|
845
|
+
# All-or-nothing: apply to a deep copy, commit on success.
|
|
846
|
+
trial = _fresh_trial(state)
|
|
847
|
+
for op in ops:
|
|
848
|
+
c = classify_op(op)
|
|
849
|
+
if c["kind"] != "edit":
|
|
850
|
+
continue # history inside a batch folds to nothing too
|
|
851
|
+
_apply_edit(trial["state"], c["edit"], c["value"])
|
|
852
|
+
_commit_trial(state, trial)
|
|
853
|
+
else:
|
|
854
|
+
raise _invalid(f"unknown edit {edit}")
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _fresh_trial(state: dict) -> dict:
|
|
858
|
+
"""A deep-copied trial state, with its id/name maps rebuilt."""
|
|
859
|
+
entities = copy.deepcopy(state["entities"])
|
|
860
|
+
return {
|
|
861
|
+
"state": {
|
|
862
|
+
"name": state.get("name", ""),
|
|
863
|
+
"scene": copy.deepcopy(state["scene"]),
|
|
864
|
+
"entities": entities,
|
|
865
|
+
"environment": copy.deepcopy(state["environment"]),
|
|
866
|
+
"camera": copy.deepcopy(state["camera"]),
|
|
867
|
+
"ambience": copy.deepcopy(state["ambience"]),
|
|
868
|
+
"audio_emitters": {
|
|
869
|
+
k: copy.deepcopy(v) for k, v in state["audio_emitters"].items()
|
|
870
|
+
},
|
|
871
|
+
"by_id": {e["id"]: e for e in entities},
|
|
872
|
+
"names": {e["name"] for e in entities},
|
|
873
|
+
"name_to_id": {e["name"]: e["id"] for e in entities},
|
|
874
|
+
},
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _commit_trial(state: dict, trial: dict) -> None:
|
|
879
|
+
"""Commit a trial's document fields and rebuilt maps onto the fold state."""
|
|
880
|
+
t = trial["state"]
|
|
881
|
+
state["name"] = t["name"]
|
|
882
|
+
state["scene"] = t["scene"]
|
|
883
|
+
state["entities"] = t["entities"]
|
|
884
|
+
state["environment"] = t["environment"]
|
|
885
|
+
state["camera"] = t["camera"]
|
|
886
|
+
state["ambience"] = t["ambience"]
|
|
887
|
+
state["audio_emitters"] = t["audio_emitters"]
|
|
888
|
+
state["by_id"] = t["by_id"]
|
|
889
|
+
state["names"] = t["names"]
|
|
890
|
+
state["name_to_id"] = t["name_to_id"]
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _scene_of(manifest: dict) -> dict:
|
|
894
|
+
"""The manifest fields a fold carries besides entities, environment,
|
|
895
|
+
camera and ambience. ``next_entity_id`` only grows: ids are never
|
|
896
|
+
reused."""
|
|
897
|
+
entities = _coalesce(manifest.get("entities"), [])
|
|
898
|
+
past = max((e.get("id", 0) + 1 for e in entities), default=1)
|
|
899
|
+
scene = {
|
|
900
|
+
"version": manifest.get("version"),
|
|
901
|
+
"meta": copy.deepcopy(manifest.get("meta")),
|
|
902
|
+
"tours": copy.deepcopy(_coalesce(manifest.get("tours"), [])),
|
|
903
|
+
"creations": copy.deepcopy(_coalesce(manifest.get("creations"), [])),
|
|
904
|
+
"next_entity_id": max(_coalesce(manifest.get("next_entity_id"), 1), past),
|
|
905
|
+
}
|
|
906
|
+
for field in ("avatar", "soundtrack"):
|
|
907
|
+
if manifest.get(field) is not None:
|
|
908
|
+
scene[field] = copy.deepcopy(manifest[field])
|
|
909
|
+
return scene
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def to_manifest(state: dict) -> dict:
|
|
913
|
+
"""The fold's state as a manifest — the whole document. The fold is
|
|
914
|
+
total (spec/session.md): ``to_manifest(fold_log(m, []))`` is ``m``
|
|
915
|
+
again, up to name binding, entity order and absent-versus-default
|
|
916
|
+
fields; a head-first package's ``manifest.json`` is ``to_manifest``
|
|
917
|
+
of its fold to ``main``.
|
|
918
|
+
|
|
919
|
+
:param state: a fold state, as :func:`fold_log` returns
|
|
920
|
+
:return: a manifest dict
|
|
921
|
+
"""
|
|
922
|
+
scene = state["scene"]
|
|
923
|
+
meta = copy.deepcopy(scene.get("meta")) if isinstance(scene.get("meta"), dict) else {}
|
|
924
|
+
meta["name"] = state.get("name", "")
|
|
925
|
+
manifest = {"version": scene.get("version"), "meta": meta}
|
|
926
|
+
if state.get("environment") is not None:
|
|
927
|
+
manifest["environment"] = copy.deepcopy(state["environment"])
|
|
928
|
+
if state.get("camera") is not None:
|
|
929
|
+
manifest["camera"] = copy.deepcopy(state["camera"])
|
|
930
|
+
if "avatar" in scene:
|
|
931
|
+
manifest["avatar"] = copy.deepcopy(scene["avatar"])
|
|
932
|
+
if scene.get("tours"):
|
|
933
|
+
manifest["tours"] = copy.deepcopy(scene["tours"])
|
|
934
|
+
if "soundtrack" in scene:
|
|
935
|
+
manifest["soundtrack"] = copy.deepcopy(scene["soundtrack"])
|
|
936
|
+
if state.get("ambience"):
|
|
937
|
+
manifest["ambience"] = copy.deepcopy(state["ambience"])
|
|
938
|
+
manifest["entities"] = copy.deepcopy(state["entities"])
|
|
939
|
+
if scene.get("creations"):
|
|
940
|
+
manifest["creations"] = copy.deepcopy(scene["creations"])
|
|
941
|
+
past = max((e["id"] + 1 for e in state["entities"]), default=1)
|
|
942
|
+
manifest["next_entity_id"] = max(scene["next_entity_id"], past)
|
|
943
|
+
return manifest
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def fold_log(manifest: dict, entries: list) -> dict:
|
|
947
|
+
"""Fold log entries over a manifest: the document at the last entry.
|
|
948
|
+
|
|
949
|
+
By-name references bind here, at ingestion: the base's entities
|
|
950
|
+
resolve against the complete base, each entry's against the
|
|
951
|
+
fold-so-far including the entry's own edits (spec/world.md
|
|
952
|
+
"Identity" — saved worlds always contain ids).
|
|
953
|
+
|
|
954
|
+
:param manifest: a parsed manifest (the base, at base_revision — in a
|
|
955
|
+
head-first package, ``snapshots/base.json``)
|
|
956
|
+
:param entries: parsed log entries, in order
|
|
957
|
+
:return: ``{"name", "entities", "environment", "camera", "ambience",
|
|
958
|
+
"audio_emitters", "applied_edits"}`` — plus the internal
|
|
959
|
+
``by_id``/``names``/``name_to_id`` maps the fold maintains
|
|
960
|
+
:raises WorldFormatError: at the first entry that no longer applies
|
|
961
|
+
— or whose names don't bind — the fold stops there, exactly as
|
|
962
|
+
the specification's readers do.
|
|
963
|
+
"""
|
|
964
|
+
meta = manifest.get("meta")
|
|
965
|
+
name = meta.get("name") if isinstance(meta, dict) else None
|
|
966
|
+
state = {
|
|
967
|
+
"name": _coalesce(name, ""),
|
|
968
|
+
"entities": copy.deepcopy(_coalesce(manifest.get("entities"), [])),
|
|
969
|
+
"environment": manifest.get("environment"),
|
|
970
|
+
"camera": manifest.get("camera"),
|
|
971
|
+
"ambience": copy.deepcopy(_coalesce(manifest.get("ambience"), [])),
|
|
972
|
+
"scene": _scene_of(manifest),
|
|
973
|
+
"audio_emitters": {},
|
|
974
|
+
"applied_edits": 0,
|
|
975
|
+
}
|
|
976
|
+
state["by_id"] = {e["id"]: e for e in state["entities"]}
|
|
977
|
+
state["names"] = {e["name"] for e in state["entities"]}
|
|
978
|
+
state["name_to_id"] = {e["name"]: e["id"] for e in state["entities"]}
|
|
979
|
+
|
|
980
|
+
# A manifest written by name binds now, against the complete base —
|
|
981
|
+
# an entity may reference a neighbor declared after it.
|
|
982
|
+
for entity in state["entities"]:
|
|
983
|
+
_resolve_names(state, entity)
|
|
984
|
+
|
|
985
|
+
for entry in entries:
|
|
986
|
+
edits = edit_ops(entry)
|
|
987
|
+
if not edits:
|
|
988
|
+
continue # history folds to nothing
|
|
989
|
+
# No trial copy here. ``fold_log`` owns ``state`` — its entities
|
|
990
|
+
# were deep-copied off the manifest above — and it raises without
|
|
991
|
+
# it when an entry no longer applies, so a per-entry copy
|
|
992
|
+
# protected a document nobody could observe. It deep-copied every
|
|
993
|
+
# entity per entry, which made folding quadratic in the log's
|
|
994
|
+
# length: 2,000 entries took 4.2 s. ``_apply_edit`` maintains
|
|
995
|
+
# ``entities``, ``by_id``, ``names`` and ``name_to_id`` itself, so
|
|
996
|
+
# the trial's rebuild was never load-bearing either.
|
|
997
|
+
for c in edits:
|
|
998
|
+
_apply_edit(state, c["edit"], c["value"])
|
|
999
|
+
# Names bind after the entry's edits apply: an entry is atomic in
|
|
1000
|
+
# its *ingestion*, and an unresolvable name fails it whole.
|
|
1001
|
+
for id_ in _touched_ids(edits):
|
|
1002
|
+
entity = state["by_id"].get(id_)
|
|
1003
|
+
if entity is not None:
|
|
1004
|
+
_resolve_names(state, entity)
|
|
1005
|
+
state["applied_edits"] += len(edits)
|
|
1006
|
+
return state
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# ---------------------------------------------------------------------------
|
|
1010
|
+
# Live authoring (spec/session.md "Authoring")
|
|
1011
|
+
# ---------------------------------------------------------------------------
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def merge_patch(current: Any, change: Any) -> Any:
|
|
1015
|
+
"""JSON merge patch (RFC 7396): objects merge key by key, ``None``
|
|
1016
|
+
removes, anything else replaces.
|
|
1017
|
+
|
|
1018
|
+
:param current: the value as it stands
|
|
1019
|
+
:param change: the patch
|
|
1020
|
+
:return: the patched value — neither input is touched
|
|
1021
|
+
"""
|
|
1022
|
+
if not isinstance(current, dict) or not isinstance(change, dict):
|
|
1023
|
+
return copy.deepcopy(change)
|
|
1024
|
+
out = copy.deepcopy(current)
|
|
1025
|
+
for key, value in change.items():
|
|
1026
|
+
if value is None:
|
|
1027
|
+
out.pop(key, None)
|
|
1028
|
+
else:
|
|
1029
|
+
out[key] = merge_patch(out[key], value) if key in out else copy.deepcopy(value)
|
|
1030
|
+
return out
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def _bind_op(trial: dict, op: dict, spawned: dict) -> None:
|
|
1034
|
+
"""Names to ids, ids for spawns, merged struct patches — in place
|
|
1035
|
+
(spec/session.md "Authoring"). A string where an entity id goes is a
|
|
1036
|
+
name; a spawn without an id gets the next one; the struct fields of
|
|
1037
|
+
a patch merge into the current value so an author may send a
|
|
1038
|
+
partial transform and keep the scale they never mentioned.
|
|
1039
|
+
|
|
1040
|
+
:raises WorldFormatError: when a name nothing owns is used
|
|
1041
|
+
"""
|
|
1042
|
+
if not isinstance(op, dict):
|
|
1043
|
+
raise WorldFormatError('an op is an object like {"SpawnEntity": {…}}')
|
|
1044
|
+
kinds = list(op)
|
|
1045
|
+
if len(kinds) != 1:
|
|
1046
|
+
raise WorldFormatError(
|
|
1047
|
+
f"an op holds exactly one kind, one of: {', '.join(_EDIT_KIND_ORDER)}"
|
|
1048
|
+
)
|
|
1049
|
+
kind = kinds[0]
|
|
1050
|
+
body = op[kind]
|
|
1051
|
+
|
|
1052
|
+
def resolve(ref):
|
|
1053
|
+
if not isinstance(ref, str):
|
|
1054
|
+
return ref
|
|
1055
|
+
id_ = spawned.get(ref)
|
|
1056
|
+
if id_ is None:
|
|
1057
|
+
id_ = trial["name_to_id"].get(ref)
|
|
1058
|
+
if id_ is None:
|
|
1059
|
+
raise WorldFormatError(f'no entity is named "{ref}"')
|
|
1060
|
+
return id_
|
|
1061
|
+
|
|
1062
|
+
def bind_top_level_refs(value):
|
|
1063
|
+
# Top-level bindable entity refs (today: ``parent``) bind at op
|
|
1064
|
+
# intake, before the op applies; behavior refs bind after,
|
|
1065
|
+
# against the spawned world (_resolve_names) — the same list,
|
|
1066
|
+
# walked per pass.
|
|
1067
|
+
for ref in _refs_of("entity", "bindable"):
|
|
1068
|
+
if len(ref["path"]) == 1:
|
|
1069
|
+
_walk_ref_path(value, ref["path"], 0,
|
|
1070
|
+
lambda current, set_: set_(resolve(current)))
|
|
1071
|
+
|
|
1072
|
+
if kind == "SpawnEntity":
|
|
1073
|
+
entity = body.get("entity") if isinstance(body, dict) else None
|
|
1074
|
+
if not isinstance(entity, dict):
|
|
1075
|
+
raise WorldFormatError('SpawnEntity needs an "entity" object')
|
|
1076
|
+
if entity.get("id") is None:
|
|
1077
|
+
entity["id"] = max(
|
|
1078
|
+
[trial["scene"]["next_entity_id"]] + [i + 1 for i in spawned.values()]
|
|
1079
|
+
)
|
|
1080
|
+
elif not _is_num(entity["id"]):
|
|
1081
|
+
raise WorldFormatError("a new entity's id is a number, or left out to get one")
|
|
1082
|
+
bind_top_level_refs(entity)
|
|
1083
|
+
if isinstance(entity.get("name"), str):
|
|
1084
|
+
spawned[entity["name"]] = entity["id"]
|
|
1085
|
+
elif kind == "ModifyEntity":
|
|
1086
|
+
if not isinstance(body, dict):
|
|
1087
|
+
raise WorldFormatError("ModifyEntity needs an object body")
|
|
1088
|
+
body["id"] = resolve(body.get("id"))
|
|
1089
|
+
current = trial["by_id"].get(body["id"])
|
|
1090
|
+
patch = body.get("patch")
|
|
1091
|
+
if isinstance(patch, dict):
|
|
1092
|
+
bind_top_level_refs(patch)
|
|
1093
|
+
if current is not None:
|
|
1094
|
+
for field in ("transform", "material", "light"):
|
|
1095
|
+
change, held = patch.get(field), current.get(field)
|
|
1096
|
+
if isinstance(change, dict) and isinstance(held, dict):
|
|
1097
|
+
patch[field] = merge_patch(held, change)
|
|
1098
|
+
elif kind == "DeleteEntity":
|
|
1099
|
+
if not isinstance(body, dict):
|
|
1100
|
+
raise WorldFormatError("DeleteEntity needs an object body")
|
|
1101
|
+
body["id"] = resolve(body.get("id"))
|
|
1102
|
+
elif kind == "SetEnvironment":
|
|
1103
|
+
env = body.get("env") if isinstance(body, dict) else None
|
|
1104
|
+
if isinstance(env, dict) and isinstance(trial.get("environment"), dict):
|
|
1105
|
+
body["env"] = merge_patch(trial["environment"], env)
|
|
1106
|
+
elif kind == "ModifyWorld":
|
|
1107
|
+
patch = body.get("patch") if isinstance(body, dict) else None
|
|
1108
|
+
if isinstance(patch, dict):
|
|
1109
|
+
# The avatar's marked refs (today: ``model_entity``) bind
|
|
1110
|
+
# like any other — a name where an entity id goes resolves
|
|
1111
|
+
# at intake (spec/world.md "Identity").
|
|
1112
|
+
avatar = patch.get("avatar")
|
|
1113
|
+
if isinstance(avatar, dict):
|
|
1114
|
+
_each_ref("avatar", avatar,
|
|
1115
|
+
lambda current, set_: set_(resolve(current)))
|
|
1116
|
+
now = to_manifest(trial)
|
|
1117
|
+
for field in ("meta", "environment", "camera", "avatar", "soundtrack"):
|
|
1118
|
+
change, held = patch.get(field), now.get(field)
|
|
1119
|
+
if isinstance(change, dict) and isinstance(held, dict):
|
|
1120
|
+
patch[field] = merge_patch(held, change)
|
|
1121
|
+
elif kind == "Batch":
|
|
1122
|
+
inner = body.get("ops") if isinstance(body, dict) else None
|
|
1123
|
+
if isinstance(inner, list):
|
|
1124
|
+
for op_ in inner:
|
|
1125
|
+
_bind_op(trial, op_, spawned)
|
|
1126
|
+
elif kind not in EDIT_KEYS:
|
|
1127
|
+
raise WorldFormatError(
|
|
1128
|
+
f'"{kind}" isn\'t an op kind; the format has: {", ".join(_EDIT_KIND_ORDER)}'
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _strict_op(op: dict) -> None:
|
|
1133
|
+
"""Refuse keys the format would drop, with a path to each — the
|
|
1134
|
+
authoring side of must-ignore: a tolerant fold would silently lose
|
|
1135
|
+
an unknown key, so the surface that writes ops refuses it instead.
|
|
1136
|
+
|
|
1137
|
+
:raises WorldFormatError: naming every key outside the schema
|
|
1138
|
+
"""
|
|
1139
|
+
unknown: list = []
|
|
1140
|
+
|
|
1141
|
+
def check(obj, allowed, path):
|
|
1142
|
+
if not isinstance(obj, dict):
|
|
1143
|
+
return
|
|
1144
|
+
for key in obj:
|
|
1145
|
+
if key in allowed or key in REGISTERED_EXTENSIONS:
|
|
1146
|
+
continue
|
|
1147
|
+
unknown.append(f"{path}/{key}")
|
|
1148
|
+
|
|
1149
|
+
def check_entity(entity, path, is_patch):
|
|
1150
|
+
keys = set(_ENTITY_KEYS)
|
|
1151
|
+
if is_patch:
|
|
1152
|
+
keys.discard("id")
|
|
1153
|
+
check(entity, keys, path)
|
|
1154
|
+
transform = entity.get("transform") if isinstance(entity, dict) else None
|
|
1155
|
+
check(transform, _TRANSFORM_KEYS, f"{path}/transform")
|
|
1156
|
+
material = entity.get("material") if isinstance(entity, dict) else None
|
|
1157
|
+
check(material, _MATERIAL_KEYS, f"{path}/material")
|
|
1158
|
+
light = entity.get("light") if isinstance(entity, dict) else None
|
|
1159
|
+
check(light, _LIGHT_KEYS, f"{path}/light")
|
|
1160
|
+
|
|
1161
|
+
kind = next(iter(op))
|
|
1162
|
+
body = op[kind]
|
|
1163
|
+
if kind == "SpawnEntity":
|
|
1164
|
+
check(body, {"entity"}, f"/{kind}")
|
|
1165
|
+
check_entity(body.get("entity") if isinstance(body, dict) else None,
|
|
1166
|
+
f"/{kind}/entity", False)
|
|
1167
|
+
elif kind == "ModifyEntity":
|
|
1168
|
+
check(body, {"id", "patch"}, f"/{kind}")
|
|
1169
|
+
check_entity(body.get("patch") if isinstance(body, dict) else None,
|
|
1170
|
+
f"/{kind}/patch", True)
|
|
1171
|
+
elif kind == "DeleteEntity":
|
|
1172
|
+
check(body, {"id"}, f"/{kind}")
|
|
1173
|
+
elif kind == "SetEnvironment":
|
|
1174
|
+
check(body.get("env") if isinstance(body, dict) else None,
|
|
1175
|
+
_ENVIRONMENT_KEYS, f"/{kind}/env")
|
|
1176
|
+
elif kind == "SetCamera":
|
|
1177
|
+
check(body.get("camera") if isinstance(body, dict) else None,
|
|
1178
|
+
_CAMERA_KEYS, f"/{kind}/camera")
|
|
1179
|
+
elif kind == "ModifyWorld":
|
|
1180
|
+
patch = body.get("patch") if isinstance(body, dict) else None
|
|
1181
|
+
check(patch, frozenset(WORLD_PATCH_KEYS), f"/{kind}/patch")
|
|
1182
|
+
check(patch.get("meta") if isinstance(patch, dict) else None,
|
|
1183
|
+
_META_KEYS, f"/{kind}/patch/meta")
|
|
1184
|
+
check(patch.get("environment") if isinstance(patch, dict) else None,
|
|
1185
|
+
_ENVIRONMENT_KEYS, f"/{kind}/patch/environment")
|
|
1186
|
+
check(patch.get("camera") if isinstance(patch, dict) else None,
|
|
1187
|
+
_CAMERA_KEYS, f"/{kind}/patch/camera")
|
|
1188
|
+
elif kind == "Batch":
|
|
1189
|
+
inner = body.get("ops") if isinstance(body, dict) else None
|
|
1190
|
+
if isinstance(inner, list):
|
|
1191
|
+
for op_ in inner:
|
|
1192
|
+
_strict_op(op_)
|
|
1193
|
+
if unknown:
|
|
1194
|
+
raise WorldFormatError(
|
|
1195
|
+
"; ".join(f"{p} is not a field of the format" for p in unknown)
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def ingest(state: dict, batch) -> dict:
|
|
1200
|
+
"""Ingest a batch — ``[op, …]`` or ``{"ops": [op, …]}`` — against a
|
|
1201
|
+
fold state (spec/session.md "Authoring": agents outside, ops in, the
|
|
1202
|
+
world in front). Each op, in order, against a trial holding the
|
|
1203
|
+
batch's earlier ops: names bind to ids (a string where an entity id
|
|
1204
|
+
goes is a name), a spawn without an id gets the next one, the struct
|
|
1205
|
+
fields of a patch (:func:`merge_patch` above) merge into the current
|
|
1206
|
+
value, no key the format would drop is allowed, and the op must
|
|
1207
|
+
apply. Any failure refuses the whole batch.
|
|
1208
|
+
|
|
1209
|
+
:param state: the world as it stands (unchanged by this call)
|
|
1210
|
+
:param batch: the ops an author or agent is sending
|
|
1211
|
+
:return: ``{"ok": True, "ops", "spawned", "state", "warnings"}`` — the
|
|
1212
|
+
committed ops as they bind (serialize into an entry and append),
|
|
1213
|
+
the names the batch spawned with the ids they got, the new world,
|
|
1214
|
+
and what validation warns about in it (a behavior whose target
|
|
1215
|
+
the batch deleted; warnings never refuse) — or ``{"ok": False,
|
|
1216
|
+
"errors"}``, one reason per op
|
|
1217
|
+
"""
|
|
1218
|
+
ops = batch if isinstance(batch, list) else (
|
|
1219
|
+
batch.get("ops") if isinstance(batch, dict) else None
|
|
1220
|
+
)
|
|
1221
|
+
if not isinstance(ops, list):
|
|
1222
|
+
return {"ok": False, "errors": ['a batch is [op, …] or {"ops": [op, …]}']}
|
|
1223
|
+
if not ops:
|
|
1224
|
+
return {"ok": False, "errors": ["the batch holds no ops"]}
|
|
1225
|
+
trial = _fresh_trial(state)["state"]
|
|
1226
|
+
trial["applied_edits"] = state.get("applied_edits", 0)
|
|
1227
|
+
spawned: dict = {}
|
|
1228
|
+
errors: list = []
|
|
1229
|
+
committed: list = []
|
|
1230
|
+
for i, raw in enumerate(ops):
|
|
1231
|
+
try:
|
|
1232
|
+
op = copy.deepcopy(raw)
|
|
1233
|
+
_bind_op(trial, op, spawned)
|
|
1234
|
+
_strict_op(op)
|
|
1235
|
+
c = classify_op(op)
|
|
1236
|
+
if c["kind"] != "edit":
|
|
1237
|
+
raise WorldFormatError("only edit ops can be sent")
|
|
1238
|
+
# An op is an entry of one: apply, then bind the names it wrote.
|
|
1239
|
+
step = _fresh_trial(trial)
|
|
1240
|
+
_apply_edit(step["state"], c["edit"], c["value"])
|
|
1241
|
+
for id_ in _touched_ids([c]):
|
|
1242
|
+
entity = step["state"]["by_id"].get(id_)
|
|
1243
|
+
if entity is not None:
|
|
1244
|
+
_resolve_names(step["state"], entity)
|
|
1245
|
+
_bind_committed_refs(step["state"], c)
|
|
1246
|
+
_commit_trial(trial, step)
|
|
1247
|
+
committed.append(op)
|
|
1248
|
+
except WorldFormatError as e:
|
|
1249
|
+
errors.append(f"op {i}: {e}")
|
|
1250
|
+
if errors:
|
|
1251
|
+
return {"ok": False, "errors": errors}
|
|
1252
|
+
trial["applied_edits"] += len(committed)
|
|
1253
|
+
return {
|
|
1254
|
+
"ok": True,
|
|
1255
|
+
"ops": committed,
|
|
1256
|
+
"spawned": spawned,
|
|
1257
|
+
"state": trial,
|
|
1258
|
+
"warnings": _receipt_warnings(trial),
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _receipt_warnings(state: dict) -> list:
|
|
1263
|
+
"""What the batch left that validation warns about and never refuses
|
|
1264
|
+
(spec/session.md "Authoring": warnings come back on the receipt): a
|
|
1265
|
+
behavior whose target the world no longer holds — a ``LookAt`` target
|
|
1266
|
+
or an ``Orbit`` center whose entity a delete took — in the words the
|
|
1267
|
+
Rust crate's validation uses, so a receipt reads the same from every
|
|
1268
|
+
reference."""
|
|
1269
|
+
ids = set(state["by_id"])
|
|
1270
|
+
names = set(state["names"])
|
|
1271
|
+
warnings: list = []
|
|
1272
|
+
for entity in state["entities"]:
|
|
1273
|
+
for behavior in entity.get("behaviors") or []:
|
|
1274
|
+
if not isinstance(behavior, dict):
|
|
1275
|
+
continue
|
|
1276
|
+
if isinstance(behavior.get("LookAt"), dict):
|
|
1277
|
+
kind, target = "look_at", behavior["LookAt"].get("target")
|
|
1278
|
+
elif isinstance(behavior.get("Orbit"), dict):
|
|
1279
|
+
kind, target = "orbit", behavior["Orbit"].get("center")
|
|
1280
|
+
else:
|
|
1281
|
+
continue
|
|
1282
|
+
if _is_num(target) and target not in ids:
|
|
1283
|
+
gone = f"entity {target}"
|
|
1284
|
+
elif isinstance(target, str) and target not in names:
|
|
1285
|
+
gone = f"'{target}'"
|
|
1286
|
+
else:
|
|
1287
|
+
continue
|
|
1288
|
+
warnings.append(
|
|
1289
|
+
f"Entity '{entity.get('name')}' has a {kind} behavior targeting {gone}, "
|
|
1290
|
+
"which is not in the world (it looks at nothing until retargeted or dropped)"
|
|
1291
|
+
)
|
|
1292
|
+
return warnings
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
# ---------------------------------------------------------------------------
|
|
1296
|
+
# Undo and merge
|
|
1297
|
+
# ---------------------------------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
def _subtree_parent_first(state: dict, id_) -> list:
|
|
1300
|
+
"""The entity and its descendants, every parent before its children —
|
|
1301
|
+
the order a Batch of SpawnEntity ops needs to re-plant a deleted
|
|
1302
|
+
tree, since a spawn's parent must already stand. Document order
|
|
1303
|
+
isn't that order: a re-parent can hang an entity below a younger
|
|
1304
|
+
sibling."""
|
|
1305
|
+
doomed = {id_}
|
|
1306
|
+
grew = True
|
|
1307
|
+
while grew:
|
|
1308
|
+
grew = False
|
|
1309
|
+
for e in state["entities"]:
|
|
1310
|
+
p = e.get("parent")
|
|
1311
|
+
if p is not None and p in doomed and e["id"] not in doomed:
|
|
1312
|
+
doomed.add(e["id"])
|
|
1313
|
+
grew = True
|
|
1314
|
+
remaining = [e for e in state["entities"] if e["id"] in doomed]
|
|
1315
|
+
ordered = []
|
|
1316
|
+
placed = set()
|
|
1317
|
+
while remaining:
|
|
1318
|
+
progress = False
|
|
1319
|
+
for e in list(remaining):
|
|
1320
|
+
p = e.get("parent")
|
|
1321
|
+
if p is None or p not in doomed or p in placed:
|
|
1322
|
+
ordered.append(e)
|
|
1323
|
+
placed.add(e["id"])
|
|
1324
|
+
remaining.remove(e)
|
|
1325
|
+
progress = True
|
|
1326
|
+
if not progress:
|
|
1327
|
+
# Only a malformed base reaches here: a parent cycle no
|
|
1328
|
+
# edit path can build but a hand-written manifest might.
|
|
1329
|
+
raise _invalid(f"entity {id_}'s subtree has a parent cycle")
|
|
1330
|
+
return ordered
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def compute_inverse(op: dict, state: dict) -> dict:
|
|
1334
|
+
"""The inverse of one edit op against the state it's about to change
|
|
1335
|
+
(spec/session.md: every edit has a computable inverse, and undo is
|
|
1336
|
+
appending it — the log never rewinds). Call with the fold from
|
|
1337
|
+
*before* the op applied; append the result after it, and the next
|
|
1338
|
+
fold stands where this one did.
|
|
1339
|
+
|
|
1340
|
+
:param op: the raw op dict (``{"SpawnEntity": {...}}``)
|
|
1341
|
+
:param state: the fold state the op is about to apply to (as
|
|
1342
|
+
:func:`fold_log` returns)
|
|
1343
|
+
:return: the inverse op dict
|
|
1344
|
+
:raises WorldFormatError: for a non-edit op (only edits invert), an
|
|
1345
|
+
unknown edit, or an edit naming an entity the state doesn't
|
|
1346
|
+
have — it would never apply
|
|
1347
|
+
"""
|
|
1348
|
+
c = classify_op(op)
|
|
1349
|
+
if c["kind"] != "edit":
|
|
1350
|
+
raise _invalid(f"no inverse for a '{c['kind']}' op — only edits have one")
|
|
1351
|
+
edit, value = c["edit"], c["value"]
|
|
1352
|
+
if edit == "SpawnEntity":
|
|
1353
|
+
entity = value.get("entity") if isinstance(value, dict) else None
|
|
1354
|
+
if not isinstance(entity, dict) or not _is_num(entity.get("id")):
|
|
1355
|
+
raise _invalid("SpawnEntity needs an entity with id and name")
|
|
1356
|
+
return {"DeleteEntity": {"id": entity["id"]}}
|
|
1357
|
+
if edit == "DeleteEntity":
|
|
1358
|
+
id_ = value.get("id") if isinstance(value, dict) else None
|
|
1359
|
+
if id_ not in state["by_id"]:
|
|
1360
|
+
raise _invalid(f"no entity {id_}")
|
|
1361
|
+
return {"Batch": {"ops": [
|
|
1362
|
+
{"SpawnEntity": {"entity": copy.deepcopy(e)}}
|
|
1363
|
+
for e in _subtree_parent_first(state, id_)
|
|
1364
|
+
]}}
|
|
1365
|
+
if edit == "ModifyEntity":
|
|
1366
|
+
id_ = value.get("id") if isinstance(value, dict) else None
|
|
1367
|
+
entity = state["by_id"].get(id_)
|
|
1368
|
+
if entity is None:
|
|
1369
|
+
raise _invalid(f"no entity {id_}")
|
|
1370
|
+
patch = _coalesce(value.get("patch"), {})
|
|
1371
|
+
# Every patched key set to what it holds now; None for a field
|
|
1372
|
+
# the entity lacks, which is exactly the patch that clears it
|
|
1373
|
+
# back off. Name and parent included.
|
|
1374
|
+
return {"ModifyEntity": {"id": entity["id"], "patch": {
|
|
1375
|
+
field: copy.deepcopy(entity.get(field)) for field in patch
|
|
1376
|
+
}}}
|
|
1377
|
+
# A scene setting that didn't exist comes back as absent, not as a
|
|
1378
|
+
# default one: ModifyWorld clears it.
|
|
1379
|
+
if edit == "SetEnvironment":
|
|
1380
|
+
if state.get("environment") is None:
|
|
1381
|
+
return {"ModifyWorld": {"patch": {"environment": None}}}
|
|
1382
|
+
return {"SetEnvironment": {"env": copy.deepcopy(state["environment"])}}
|
|
1383
|
+
if edit == "SetCamera":
|
|
1384
|
+
if state.get("camera") is None:
|
|
1385
|
+
return {"ModifyWorld": {"patch": {"camera": None}}}
|
|
1386
|
+
return {"SetCamera": {"camera": copy.deepcopy(state["camera"])}}
|
|
1387
|
+
if edit == "ModifyWorld":
|
|
1388
|
+
patch = _coalesce(value.get("patch"), {}) if isinstance(value, dict) else {}
|
|
1389
|
+
now = to_manifest(state)
|
|
1390
|
+
inverse = {}
|
|
1391
|
+
for field in patch:
|
|
1392
|
+
if field not in WORLD_PATCH_KEYS:
|
|
1393
|
+
continue # must-ignore
|
|
1394
|
+
if field in ("tours", "creations", "ambience"):
|
|
1395
|
+
inverse[field] = copy.deepcopy(now.get(field, []))
|
|
1396
|
+
else:
|
|
1397
|
+
inverse[field] = copy.deepcopy(now.get(field))
|
|
1398
|
+
return {"ModifyWorld": {"patch": inverse}}
|
|
1399
|
+
if edit == "SetAmbience":
|
|
1400
|
+
ambience = _coalesce(state.get("ambience"), [])
|
|
1401
|
+
return {"SetAmbience": {"ambience": copy.deepcopy(ambience)}}
|
|
1402
|
+
if edit == "SpawnAudioEmitter":
|
|
1403
|
+
if not isinstance(value, dict) or not isinstance(value.get("name"), str):
|
|
1404
|
+
raise _invalid("SpawnAudioEmitter needs a name")
|
|
1405
|
+
return {"RemoveAudioEmitter": {"name": value["name"]}}
|
|
1406
|
+
if edit == "RemoveAudioEmitter":
|
|
1407
|
+
name = value.get("name") if isinstance(value, dict) else None
|
|
1408
|
+
if name not in state.get("audio_emitters", {}):
|
|
1409
|
+
raise _invalid(f"no audio emitter named '{name}'")
|
|
1410
|
+
return {"SpawnAudioEmitter": {
|
|
1411
|
+
"name": name,
|
|
1412
|
+
"audio": copy.deepcopy(state["audio_emitters"][name]),
|
|
1413
|
+
}}
|
|
1414
|
+
if edit == "Batch":
|
|
1415
|
+
ops = _coalesce(value.get("ops"), []) if isinstance(value, dict) else []
|
|
1416
|
+
# Each inner inverse against the state its op was about to
|
|
1417
|
+
# change: fold forward over a trial, undoing as you go, then
|
|
1418
|
+
# reverse — the last undone first.
|
|
1419
|
+
trial = _fresh_trial(state)
|
|
1420
|
+
inverses = []
|
|
1421
|
+
for inner in ops:
|
|
1422
|
+
ic = classify_op(inner)
|
|
1423
|
+
if ic["kind"] != "edit":
|
|
1424
|
+
continue # history inside a batch has nothing to undo
|
|
1425
|
+
inverses.append(compute_inverse(inner, trial["state"]))
|
|
1426
|
+
_apply_edit(trial["state"], ic["edit"], ic["value"])
|
|
1427
|
+
inverses.reverse()
|
|
1428
|
+
return {"Batch": {"ops": inverses}}
|
|
1429
|
+
raise _invalid(f"unknown edit {edit}")
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _spawned_ids(ops: list) -> list:
|
|
1433
|
+
"""Every id these ops' SpawnEntity ops spawn, batches recursed — the
|
|
1434
|
+
ids a merge has to check against the trunk's own."""
|
|
1435
|
+
ids: list = []
|
|
1436
|
+
for op in ops:
|
|
1437
|
+
c = classify_op(op)
|
|
1438
|
+
if c["kind"] != "edit":
|
|
1439
|
+
continue
|
|
1440
|
+
edit, value = c["edit"], c["value"]
|
|
1441
|
+
if edit == "SpawnEntity":
|
|
1442
|
+
entity = value.get("entity") if isinstance(value, dict) else None
|
|
1443
|
+
if isinstance(entity, dict) and _is_num(entity.get("id")):
|
|
1444
|
+
ids.append(entity["id"])
|
|
1445
|
+
elif edit == "Batch":
|
|
1446
|
+
inner = value.get("ops") if isinstance(value, dict) else None
|
|
1447
|
+
if isinstance(inner, list):
|
|
1448
|
+
ids.extend(_spawned_ids(inner))
|
|
1449
|
+
return ids
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _merge_rewrite_op(op: dict, remapped: dict) -> dict:
|
|
1453
|
+
"""One op rewritten through a merge's id remapping — a new, deep
|
|
1454
|
+
copied op. The reference fields are the schema's marked entity
|
|
1455
|
+
refs: ``entity`` scope on spawn entities and modify patches,
|
|
1456
|
+
``avatar`` and ``creation`` scopes on ``ModifyWorld``'s patch;
|
|
1457
|
+
numeric references follow their entities to fresh ids, by-name
|
|
1458
|
+
references don't (they bind at ingestion, after the merge).
|
|
1459
|
+
Identity fields (``entity.id``, ``ModifyEntity.id``,
|
|
1460
|
+
``DeleteEntity.id``) are op addresses, not schema refs — they stay
|
|
1461
|
+
explicit. History ops pass through untouched, because they fold to
|
|
1462
|
+
nothing and their contents are nobody's to rewrite."""
|
|
1463
|
+
rewritten = copy.deepcopy(op)
|
|
1464
|
+
if not remapped:
|
|
1465
|
+
return rewritten
|
|
1466
|
+
c = classify_op(rewritten)
|
|
1467
|
+
if c["kind"] != "edit":
|
|
1468
|
+
return rewritten
|
|
1469
|
+
edit, value = c["edit"], c["value"]
|
|
1470
|
+
if not isinstance(value, dict):
|
|
1471
|
+
return rewritten
|
|
1472
|
+
|
|
1473
|
+
def remap(ref):
|
|
1474
|
+
if isinstance(ref, int) and not isinstance(ref, bool) and ref in remapped:
|
|
1475
|
+
return remapped[ref]
|
|
1476
|
+
return ref
|
|
1477
|
+
|
|
1478
|
+
def remap_refs(scope, node):
|
|
1479
|
+
_each_ref(scope, node, lambda current, set_: set_(remap(current)))
|
|
1480
|
+
|
|
1481
|
+
if edit == "SpawnEntity":
|
|
1482
|
+
entity = value.get("entity")
|
|
1483
|
+
if isinstance(entity, dict):
|
|
1484
|
+
if "id" in entity:
|
|
1485
|
+
entity["id"] = remap(entity["id"])
|
|
1486
|
+
remap_refs("entity", entity)
|
|
1487
|
+
elif edit == "ModifyEntity":
|
|
1488
|
+
if "id" in value:
|
|
1489
|
+
value["id"] = remap(value["id"])
|
|
1490
|
+
patch = value.get("patch")
|
|
1491
|
+
if isinstance(patch, dict):
|
|
1492
|
+
remap_refs("entity", patch)
|
|
1493
|
+
elif edit == "DeleteEntity":
|
|
1494
|
+
if "id" in value:
|
|
1495
|
+
value["id"] = remap(value["id"])
|
|
1496
|
+
elif edit == "Batch":
|
|
1497
|
+
inner = value.get("ops")
|
|
1498
|
+
if isinstance(inner, list):
|
|
1499
|
+
value["ops"] = [_merge_rewrite_op(o, remapped) for o in inner]
|
|
1500
|
+
elif edit == "ModifyWorld":
|
|
1501
|
+
patch = value.get("patch")
|
|
1502
|
+
if isinstance(patch, dict):
|
|
1503
|
+
remap_refs("avatar", patch.get("avatar"))
|
|
1504
|
+
creations = patch.get("creations")
|
|
1505
|
+
if isinstance(creations, list):
|
|
1506
|
+
for creation in creations:
|
|
1507
|
+
remap_refs("creation", creation)
|
|
1508
|
+
return rewritten
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _rename_colliding_spawns(state: dict, entries: list) -> None:
|
|
1512
|
+
"""The spec's name rule, in place on the rewritten entries: a spawned
|
|
1513
|
+
name that is taken — by main's fold, or by an earlier spawn in the
|
|
1514
|
+
same merge — becomes ``<name>-<n>``, n from 2 up, first unused. Only
|
|
1515
|
+
the ``SpawnEntity`` changes; references are ids by then, so a rename
|
|
1516
|
+
breaks nothing in the log."""
|
|
1517
|
+
taken = set(state["names"])
|
|
1518
|
+
|
|
1519
|
+
def rename_ops(ops: list) -> None:
|
|
1520
|
+
for op in ops:
|
|
1521
|
+
c = classify_op(op)
|
|
1522
|
+
if c["kind"] != "edit":
|
|
1523
|
+
continue
|
|
1524
|
+
edit, value = c["edit"], c["value"]
|
|
1525
|
+
if not isinstance(value, dict):
|
|
1526
|
+
continue
|
|
1527
|
+
if edit == "Batch":
|
|
1528
|
+
inner = value.get("ops")
|
|
1529
|
+
if isinstance(inner, list):
|
|
1530
|
+
rename_ops(inner)
|
|
1531
|
+
elif edit == "SpawnEntity":
|
|
1532
|
+
entity = value.get("entity")
|
|
1533
|
+
if not isinstance(entity, dict) or not isinstance(entity.get("name"), str):
|
|
1534
|
+
continue
|
|
1535
|
+
name = entity["name"]
|
|
1536
|
+
if name not in taken:
|
|
1537
|
+
taken.add(name)
|
|
1538
|
+
continue
|
|
1539
|
+
n = 2
|
|
1540
|
+
while f"{name}-{n}" in taken:
|
|
1541
|
+
n += 1
|
|
1542
|
+
entity["name"] = f"{name}-{n}"
|
|
1543
|
+
taken.add(entity["name"])
|
|
1544
|
+
|
|
1545
|
+
for entry in entries:
|
|
1546
|
+
ops = entry.get("ops")
|
|
1547
|
+
if isinstance(ops, list):
|
|
1548
|
+
rename_ops(ops)
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def merge_branch(state: dict, entries: list) -> dict:
|
|
1552
|
+
"""Merge a branch's entries onto a main branch's fold (spec/session.md
|
|
1553
|
+
"The merge rules, exactly"): an id the branch spawned that main's
|
|
1554
|
+
fold holds is reallocated — the collisions in ascending order — onto
|
|
1555
|
+
fresh ids from the fold's effective ``next_entity_id`` (the monotonic
|
|
1556
|
+
floor the fold carries: the larger of the declared value and one past
|
|
1557
|
+
every id main has ever held, so an id main deleted stays spent),
|
|
1558
|
+
counting up, skipping every id the branch spawns, never past the
|
|
1559
|
+
ceiling. Every reference to a reallocated id inside the merged batch
|
|
1560
|
+
is rewritten through the remap. A branch spawn whose name is taken —
|
|
1561
|
+
by main, or by an earlier spawn in the same merge — is renamed
|
|
1562
|
+
``<name>-<n>``, n from 2 up, first unused. The merged entries keep
|
|
1563
|
+
their ``id``, ``parent``, ``author`` and ``message`` — an entry's
|
|
1564
|
+
identity survives the merge.
|
|
1565
|
+
|
|
1566
|
+
:param state: the main branch's fold state at its head (as
|
|
1567
|
+
:func:`fold_log` returns)
|
|
1568
|
+
:param entries: the branch's parsed entries, in order
|
|
1569
|
+
:return: ``{"entries": new deep-copied entries with references
|
|
1570
|
+
rewritten — plain content, the parser's ``classified``
|
|
1571
|
+
annotation stripped —, "remapped": old id -> new id}``
|
|
1572
|
+
:raises WorldFormatError: when the ids run out under the ceiling
|
|
1573
|
+
"""
|
|
1574
|
+
main_ids = set(state["by_id"])
|
|
1575
|
+
ops = [op for entry in entries for op in (entry.get("ops") or [])]
|
|
1576
|
+
spawned = set(_spawned_ids(ops))
|
|
1577
|
+
remapped: dict = {}
|
|
1578
|
+
candidate = state["scene"]["next_entity_id"]
|
|
1579
|
+
for old in sorted(spawned):
|
|
1580
|
+
if old not in main_ids:
|
|
1581
|
+
continue # no collision, no remap
|
|
1582
|
+
while candidate in spawned:
|
|
1583
|
+
candidate += 1
|
|
1584
|
+
if candidate > MAX_ENTITY_ID:
|
|
1585
|
+
raise _invalid(
|
|
1586
|
+
f"merge ran out of entity ids under the ceiling {MAX_ENTITY_ID}"
|
|
1587
|
+
)
|
|
1588
|
+
remapped[old] = candidate
|
|
1589
|
+
candidate += 1
|
|
1590
|
+
|
|
1591
|
+
rewritten = []
|
|
1592
|
+
for entry in entries:
|
|
1593
|
+
new_entry = copy.deepcopy(entry)
|
|
1594
|
+
# ``classified`` is this reader's annotation of the ops, not the
|
|
1595
|
+
# log's content: it never rides out of a merge.
|
|
1596
|
+
new_entry.pop("classified", None)
|
|
1597
|
+
new_entry["ops"] = [
|
|
1598
|
+
_merge_rewrite_op(op, remapped) for op in new_entry.get("ops") or []
|
|
1599
|
+
]
|
|
1600
|
+
rewritten.append(new_entry)
|
|
1601
|
+
_rename_colliding_spawns(state, rewritten)
|
|
1602
|
+
return {"entries": rewritten, "remapped": remapped}
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
# ---------------------------------------------------------------------------
|
|
1606
|
+
# Extensions
|
|
1607
|
+
# ---------------------------------------------------------------------------
|
|
1608
|
+
|
|
1609
|
+
#: The fields the provenance extension defines
|
|
1610
|
+
#: (spec/extensions/provenance.md) — the LLM lineage fields that moved
|
|
1611
|
+
#: out of the core meta and into an extension of their own.
|
|
1612
|
+
EXT_PROVENANCE_FIELDS = (
|
|
1613
|
+
"prompt",
|
|
1614
|
+
"model",
|
|
1615
|
+
"generation_duration_ms",
|
|
1616
|
+
"biome",
|
|
1617
|
+
"semantic_category",
|
|
1618
|
+
)
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def ext_provenance(manifest: dict) -> dict | None:
|
|
1622
|
+
"""The provenance extension's fields, extracted from a manifest's
|
|
1623
|
+
``meta["ext-provenance"]``: the five the extension defines and
|
|
1624
|
+
nothing else — unknown keys inside an extension are must-ignore,
|
|
1625
|
+
same as everywhere else in the format. None when the manifest
|
|
1626
|
+
carries none.
|
|
1627
|
+
|
|
1628
|
+
A tool circulating worlds MUST warn the user or scrub ``prompt``
|
|
1629
|
+
before publishing (spec/extensions/provenance.md "Security and
|
|
1630
|
+
Privacy") — it often carries the exact text the author typed.
|
|
1631
|
+
|
|
1632
|
+
:param manifest: a parsed manifest
|
|
1633
|
+
:return: the extension's fields as a dict, or None
|
|
1634
|
+
"""
|
|
1635
|
+
meta = manifest.get("meta")
|
|
1636
|
+
block = meta.get("ext-provenance") if isinstance(meta, dict) else None
|
|
1637
|
+
if not isinstance(block, dict):
|
|
1638
|
+
return None
|
|
1639
|
+
return {
|
|
1640
|
+
field: copy.deepcopy(block[field])
|
|
1641
|
+
for field in EXT_PROVENANCE_FIELDS
|
|
1642
|
+
if field in block
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
# ---------------------------------------------------------------------------
|
|
1647
|
+
# Packages
|
|
1648
|
+
# ---------------------------------------------------------------------------
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def snapshot_filename(entry_id: str | None, revision: int) -> str:
|
|
1652
|
+
"""Where a snapshot goes (spec/session.md "Snapshots"):
|
|
1653
|
+
``snapshots/entry-<id>.json`` when the log's entries carry ids,
|
|
1654
|
+
``snapshots/rev-<N>.json`` for a linear log without them — derived,
|
|
1655
|
+
never authoritative, deletable without loss. Characters a
|
|
1656
|
+
filesystem can't be trusted with fold to ``_``, so a content id
|
|
1657
|
+
(``sha256:…``) becomes a plain filename.
|
|
1658
|
+
|
|
1659
|
+
:param entry_id: the entry's id, or None for a linear log
|
|
1660
|
+
:param revision: the document revision the snapshot holds
|
|
1661
|
+
:return: the path, relative to the package root
|
|
1662
|
+
"""
|
|
1663
|
+
if entry_id:
|
|
1664
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", entry_id)
|
|
1665
|
+
return f"snapshots/entry-{safe}.json"
|
|
1666
|
+
return f"snapshots/rev-{revision}.json"
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def compact_package(package_json: dict, head_revision: int) -> dict:
|
|
1670
|
+
"""The package.json of a compaction: the same metadata with
|
|
1671
|
+
``base_revision`` moved to the head the new base holds — the update
|
|
1672
|
+
the spec's compaction procedure demands of the producer. A new
|
|
1673
|
+
dict; the original is left as it was.
|
|
1674
|
+
|
|
1675
|
+
:param package_json: the parsed package.json
|
|
1676
|
+
:param head_revision: the revision the compacted manifest holds
|
|
1677
|
+
:return: the new package.json dict
|
|
1678
|
+
"""
|
|
1679
|
+
updated = copy.deepcopy(package_json)
|
|
1680
|
+
updated["base_revision"] = head_revision
|
|
1681
|
+
return updated
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def read_package(world_dir) -> tuple:
|
|
1685
|
+
"""Read a package the head-first way (spec/package.md): the base the
|
|
1686
|
+
log folds from, the log, the head and ``package.json``.
|
|
1687
|
+
|
|
1688
|
+
The base is ``snapshots/base.json``; a package whose log holds no
|
|
1689
|
+
edits may leave it out, and its base is then its head.
|
|
1690
|
+
|
|
1691
|
+
:param world_dir: the package directory
|
|
1692
|
+
:return: ``(base, entries, head, package)`` — ``package`` is None
|
|
1693
|
+
when there is no ``package.json``
|
|
1694
|
+
:raises WorldFormatError: when the log holds edits but the package
|
|
1695
|
+
has no base to fold them from
|
|
1696
|
+
"""
|
|
1697
|
+
world = Path(world_dir)
|
|
1698
|
+
head = parse_manifest((world / "manifest.json").read_text())
|
|
1699
|
+
log = world / "ops.jsonl"
|
|
1700
|
+
entries = [
|
|
1701
|
+
parse_log_line(line) for line in log.read_text().splitlines() if line.strip()
|
|
1702
|
+
] if log.exists() else []
|
|
1703
|
+
base_path = world / BASE_SNAPSHOT
|
|
1704
|
+
if base_path.exists():
|
|
1705
|
+
base = parse_manifest(base_path.read_text())
|
|
1706
|
+
elif any(edit_ops(e) for e in entries):
|
|
1707
|
+
raise WorldFormatError(
|
|
1708
|
+
f"{world.name}: the log holds edits but there is no {BASE_SNAPSHOT} to fold them from"
|
|
1709
|
+
)
|
|
1710
|
+
else:
|
|
1711
|
+
base = head
|
|
1712
|
+
package_path = world / "package.json"
|
|
1713
|
+
package = json.loads(package_path.read_text()) if package_path.exists() else None
|
|
1714
|
+
return base, entries, head, package
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def compact(world_dir, head_revision: int | None = None) -> Path:
|
|
1718
|
+
"""Compact a head-first package (spec/session.md "Snapshots"): move
|
|
1719
|
+
the base up to the head — or to the given revision — archive the
|
|
1720
|
+
entries the new base already holds as ``ops.archive.jsonl``, and keep
|
|
1721
|
+
the rest in ``ops.jsonl``. ``manifest.json`` (the head) is untouched:
|
|
1722
|
+
compaction changes nothing observable about the current state, it
|
|
1723
|
+
truncates structural replay and nothing else.
|
|
1724
|
+
|
|
1725
|
+
:param world_dir: the package directory
|
|
1726
|
+
:param head_revision: the revision the new base holds; None for the
|
|
1727
|
+
log's own head
|
|
1728
|
+
:return: the package directory, compacted
|
|
1729
|
+
:raises WorldFormatError: as :func:`fold_log` — moving the base to a
|
|
1730
|
+
revision is a fold, and it must succeed
|
|
1731
|
+
"""
|
|
1732
|
+
world = Path(world_dir)
|
|
1733
|
+
base, entries, _head, package = read_package(world)
|
|
1734
|
+
log_path = world / "ops.jsonl"
|
|
1735
|
+
lines = [line for line in log_path.read_text().splitlines() if line.strip()] \
|
|
1736
|
+
if log_path.exists() else []
|
|
1737
|
+
|
|
1738
|
+
if head_revision is None:
|
|
1739
|
+
head_revision = max(
|
|
1740
|
+
(e["revision"] for e in entries),
|
|
1741
|
+
default=_coalesce(_coalesce(package, {}).get("base_revision"), 0),
|
|
1742
|
+
)
|
|
1743
|
+
new_base = (world / "manifest.json").read_text()
|
|
1744
|
+
archived, kept = lines, []
|
|
1745
|
+
else:
|
|
1746
|
+
cut = [i for i, e in enumerate(entries) if e["revision"] <= head_revision]
|
|
1747
|
+
upto = (cut[-1] + 1) if cut else 0
|
|
1748
|
+
state = fold_log(base, entries[:upto])
|
|
1749
|
+
new_base = json.dumps(to_manifest(state), indent=2) + "\n"
|
|
1750
|
+
archived, kept = lines[:upto], lines[upto:]
|
|
1751
|
+
|
|
1752
|
+
(world / "snapshots").mkdir(exist_ok=True)
|
|
1753
|
+
(world / BASE_SNAPSHOT).write_text(new_base)
|
|
1754
|
+
|
|
1755
|
+
if package is None:
|
|
1756
|
+
package = {"format_version": SUPPORTED_FORMAT_VERSION}
|
|
1757
|
+
package_path = world / "package.json"
|
|
1758
|
+
package_path.write_text(
|
|
1759
|
+
json.dumps(compact_package(package, head_revision), indent=2) + "\n"
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
if archived:
|
|
1763
|
+
(world / "ops.archive.jsonl").write_text("\n".join(archived) + "\n")
|
|
1764
|
+
log_path.write_text("".join(line + "\n" for line in kept))
|
|
1765
|
+
return world
|