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.
@@ -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