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,142 @@
1
+ """Agents: the benchmark's other half — something that emits ops.
2
+
3
+ Scoring a recorded trajectory is one loop; scoring a live agent is the
4
+ same loop with the recording still wet. :func:`run_agent` keeps the
5
+ entries, folds between steps so the agent observes its own edits, and
6
+ scores with :func:`openworldformat.eval.run_task` — no engine, no
7
+ renderer, an agent and a fold.
8
+
9
+ :func:`template_agent` is the baseline: it compiles a task's goal
10
+ predicates into ops — spawn what must exist, move what must be near,
11
+ delete what must be gone, set what fields must hold — and stops when
12
+ the goal reads satisfied. It is a compiler, not a brain: it exists so
13
+ benchmark authors have a floor to beat and a skeleton to copy, and so
14
+ the agent interface has a reference user. Real agents read the world,
15
+ not the answer key; the interesting ones will too.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from . import fold_log, fold_state
21
+ from .eval import run_task
22
+
23
+ __all__ = ["run_agent", "template_agent"]
24
+
25
+ #: What run_agent names the baseline in the log's author field.
26
+ TEMPLATE_NAME = "template"
27
+
28
+
29
+ def _find(document: dict, name):
30
+ return next((e for e in document["entities"] if e.get("name") == name), None)
31
+
32
+
33
+ def _is_num(v) -> bool:
34
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
35
+
36
+
37
+ def template_agent(document: dict, values: dict, task: dict, entries: list) -> dict | None:
38
+ """Compile the goal into one entry of ops — the baseline solver.
39
+
40
+ Observes the folded world (``document``) and the state values, and
41
+ emits what's missing: one entry per call, or None when the goal
42
+ already reads satisfied (or holds nothing this template knows how
43
+ to do — unknown predicates are beyond it, by design).
44
+ """
45
+ # Per-entity requirements, merged so `exists` + `near` on one entity
46
+ # spawn it once, at the target. `gone` wins over both.
47
+ targets: dict = {}
48
+ deletions: set = set()
49
+ field_ops: dict = {}
50
+ for predicate in task.get("goal") or []:
51
+ if "exists" in predicate:
52
+ targets.setdefault(predicate["exists"].get("entity"), None)
53
+ elif "near" in predicate:
54
+ near = predicate["near"]
55
+ targets[near.get("entity")] = list(near.get("position") or [0.0, 0.0, 0.0])
56
+ elif "gone" in predicate:
57
+ name = predicate["gone"].get("entity")
58
+ deletions.add(name)
59
+ targets.pop(name, None)
60
+ elif "field" in predicate:
61
+ field = predicate["field"]
62
+ name, v = field.get("name"), values.get(field.get("name"))
63
+ want = None
64
+ if "equals" in field and v != field["equals"]:
65
+ want = field["equals"]
66
+ if "at_least" in field and _is_num(v) and v < field["at_least"]:
67
+ want = field["at_least"]
68
+ if "at_most" in field and _is_num(v) and v > field["at_most"]:
69
+ want = field["at_most"]
70
+ if want is not None:
71
+ field_ops[name] = want
72
+
73
+ ops: list = []
74
+ next_id = max((e.get("id", 0) for e in document["entities"]), default=0) + 1
75
+ for name, position in targets.items():
76
+ entity = _find(document, name)
77
+ if entity is None:
78
+ entity_doc = {"id": next_id, "name": name}
79
+ if position is not None:
80
+ entity_doc["transform"] = {"position": position}
81
+ ops.append({"SpawnEntity": {"entity": entity_doc}})
82
+ next_id += 1
83
+ elif position is not None:
84
+ transform = entity.get("transform")
85
+ current = transform.get("position") if isinstance(transform, dict) else None
86
+ if current != position:
87
+ # Keep rotation/scale if the entity had them; move the position.
88
+ moved = dict(transform) if isinstance(transform, dict) else {}
89
+ moved["position"] = position
90
+ ops.append({"ModifyEntity": {"id": entity["id"], "patch": {"transform": moved}}})
91
+ for name in sorted(deletions):
92
+ entity = _find(document, name)
93
+ if entity is not None:
94
+ ops.append({"DeleteEntity": {"id": entity["id"]}})
95
+ for name, want in field_ops.items():
96
+ ops.append({"state": {name: want}})
97
+
98
+ if not ops:
99
+ return None
100
+ revision = max((e.get("revision", 0) for e in entries), default=0) + 1
101
+ timestamp = max((e.get("timestamp_ms", 0) for e in entries), default=0) + 1
102
+ return {
103
+ "revision": revision,
104
+ "timestamp_ms": timestamp,
105
+ "author": {"name": TEMPLATE_NAME},
106
+ "ops": ops,
107
+ }
108
+
109
+
110
+ def run_agent(manifest: dict, task: dict, state_doc: dict | None = None,
111
+ agent=None, max_steps: int = 8) -> dict:
112
+ """Run an agent against a task: observe, emit, fold, score, repeat.
113
+
114
+ :param manifest: the base world document (parsed)
115
+ :param task: as :func:`openworldformat.eval.run_task` takes it
116
+ :param state_doc: the world's parsed ``state.json``, if it has one
117
+ :param agent: ``f(document, values, task, entries) -> entry | None``
118
+ — the folded world and state values as observation, one entry of
119
+ ops as action, None to stop. Defaults to :func:`template_agent`
120
+ :param max_steps: the step budget — an agent that never stops, stops
121
+ :return: the :func:`run_task` result, plus ``entries`` (the agent's
122
+ ops — serialize to ``ops.jsonl`` and they're a recording)
123
+ """
124
+ if agent is None:
125
+ agent = template_agent
126
+ entries: list = []
127
+ result = None
128
+ for _ in range(max_steps):
129
+ document = fold_log(manifest, entries)
130
+ values = fold_state(state_doc or {}, entries)["values"]
131
+ entry = agent(document, values, task, entries)
132
+ if entry is None:
133
+ break
134
+ entries.append(entry)
135
+ result = run_task(manifest, entries, task, state_doc)
136
+ if result["ok"]:
137
+ break
138
+ if result is None:
139
+ result = run_task(manifest, entries, task, state_doc)
140
+ result = dict(result)
141
+ result["entries"] = entries
142
+ return result
@@ -0,0 +1,404 @@
1
+ """The ext-cinematography reference implementation (extension version 0.3), in Python.
2
+
3
+ Spec: spec/extensions/cinematography.md. An entity carrying
4
+ ``ext-cinematography.camera`` is a camera — a filmback and a lens — and
5
+ one also carrying ``ext-cinematography.shot`` is a setup in the shot
6
+ list. What this module provides is the contract's executable half —
7
+
8
+ - the normative crop math (frame size, horizontal and vertical FOV) —
9
+ Unreal's crop-to-aspect rule written out: cropping never widens the
10
+ frame past the sensor, it only trims;
11
+ - the view (aim look-at, +Y up, else the entity's local −Z) and a
12
+ pinhole projection, so "is it in frame" is a predicate;
13
+ - entity origins in world space (the document's TRS hierarchy) — the
14
+ point every assertion projects, and the point ``aim_entity`` and
15
+ ``focus_entity`` resolve to;
16
+ - the shot list (shot.order, ties by entity id);
17
+ - the outcome runner for conformance/outcomes/*.json.
18
+
19
+ Conformance is math, not pixels: implementations agree on the numbers,
20
+ within the stated tolerance — never on rendered output.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import math
27
+
28
+ __all__ = [
29
+ "EXTENSION_NAME",
30
+ "EXTENSION_VERSION",
31
+ "DEFAULT_SENSOR",
32
+ "DEFAULT_FOCAL_LENGTH",
33
+ "camera_of",
34
+ "frame_of",
35
+ "entity_of",
36
+ "world_origin",
37
+ "aim_point_of",
38
+ "focus_distance_of",
39
+ "view_of",
40
+ "project",
41
+ "shot_list",
42
+ "run_outcomes",
43
+ ]
44
+
45
+ #: The extension this module implements.
46
+ EXTENSION_NAME = "ext-cinematography"
47
+
48
+ #: The extension version this module implements.
49
+ EXTENSION_VERSION = "0.3.0"
50
+
51
+ #: The default sensor (filmback): Super 35, [w, h] in mm.
52
+ DEFAULT_SENSOR = (24.89, 18.66)
53
+
54
+ #: The default focal length, in mm.
55
+ DEFAULT_FOCAL_LENGTH = 35
56
+
57
+ _sub = lambda a, b: [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
58
+ _add = lambda a, b: [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
59
+ _dot = lambda a, b: a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
60
+ _len = lambda a: math.sqrt(_dot(a, a))
61
+ _cross = lambda a, b: [
62
+ a[1] * b[2] - a[2] * b[1],
63
+ a[2] * b[0] - a[0] * b[2],
64
+ a[0] * b[1] - a[1] * b[0],
65
+ ]
66
+ _is_num = lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)
67
+
68
+
69
+ def _norm(v: list) -> list:
70
+ length = _len(v)
71
+ return [x / length for x in v]
72
+
73
+
74
+ def _rotate_intrinsic_xyz(v: list, degrees: list) -> list:
75
+ """Rotate ``v`` by intrinsic XYZ Euler degrees — R = Rx·Ry·Rz, the
76
+ transform convention of spec/world.md "Conventions"."""
77
+ rx, ry, rz = (math.radians(d) for d in degrees)
78
+ x, y, z = v
79
+ # Rz first (rightmost), then Ry, then Rx — R = Rx·Ry·Rz applied to v.
80
+ c, s = math.cos(rz), math.sin(rz)
81
+ x, y = c * x - s * y, s * x + c * y
82
+ c, s = math.cos(ry), math.sin(ry)
83
+ x, z = c * x + s * z, -s * x + c * z
84
+ c, s = math.cos(rx), math.sin(rx)
85
+ y, z = c * y - s * z, s * y + c * z
86
+ return [x, y, z]
87
+
88
+
89
+ def _position_of(entity: dict) -> list:
90
+ transform = entity.get("transform")
91
+ pos = transform.get("position") if isinstance(transform, dict) else None
92
+ return list(pos) if pos is not None else [0.0, 0.0, 0.0]
93
+
94
+
95
+ def camera_of(entity: dict) -> dict | None:
96
+ """An entity's ``ext-cinematography.camera`` with defaults applied per
97
+ absent field (``{}`` is a 35 mm on Super 35), or None when the entity
98
+ carries no camera component.
99
+
100
+ :param entity: a parsed entity
101
+ :return: ``{"sensor_mm", "focal_length_mm", "aspect_ratio", "squeeze",
102
+ "aim", "aim_entity", "focus_distance_m", "focus_entity", "f_stop"}``
103
+ or None — ``aim_entity`` and ``focus_entity`` are entity
104
+ references, the id (bound) or the name (at intake)
105
+ """
106
+ ext = entity.get(EXTENSION_NAME)
107
+ camera = ext.get("camera") if isinstance(ext, dict) else None
108
+ if not isinstance(camera, dict):
109
+ return None
110
+ sensor = camera.get("sensor_mm")
111
+ return {
112
+ "sensor_mm": list(sensor) if sensor is not None else list(DEFAULT_SENSOR),
113
+ "focal_length_mm": camera.get("focal_length_mm") if camera.get("focal_length_mm") is not None else DEFAULT_FOCAL_LENGTH,
114
+ "aspect_ratio": camera.get("aspect_ratio"),
115
+ "squeeze": camera.get("squeeze") if camera.get("squeeze") is not None else 1,
116
+ "aim": list(camera["aim"]) if camera.get("aim") is not None else None,
117
+ "aim_entity": camera.get("aim_entity"),
118
+ "focus_distance_m": camera.get("focus_distance_m"),
119
+ "focus_entity": camera.get("focus_entity"),
120
+ "f_stop": camera.get("f_stop"),
121
+ }
122
+
123
+
124
+ def frame_of(camera: dict) -> dict:
125
+ """The normative crop math (spec "Derived values"): with sensor
126
+ ``w × h``, squeeze ``s``, focal length ``f`` and aspect ``a`` —
127
+ desqueezed sensor aspect ``A = w·s / h``; frame ``W = w·s · min(1, a/A)``,
128
+ ``H = h · min(1, A/a)`` (no aspect: the whole desqueezed sensor); FOVs
129
+ ``2·atan(W / 2f)``, ``2·atan(H / 2f)``. Cropping never widens the frame
130
+ past the sensor, it only trims.
131
+
132
+ :param camera: a :func:`camera_of` result
133
+ :return: ``{"width_mm", "height_mm", "hfov_degrees", "vfov_degrees", "aspect"}``
134
+ """
135
+ w, h = camera["sensor_mm"]
136
+ s, f = camera["squeeze"], camera["focal_length_mm"]
137
+ a = camera.get("aspect_ratio")
138
+ A = w * s / h
139
+ width_mm = w * s * min(1, a / A if a else 1)
140
+ height_mm = h * min(1, A / a if a else 1)
141
+ return {
142
+ "width_mm": width_mm,
143
+ "height_mm": height_mm,
144
+ "hfov_degrees": math.degrees(2 * math.atan(width_mm / (2 * f))),
145
+ "vfov_degrees": math.degrees(2 * math.atan(height_mm / (2 * f))),
146
+ "aspect": width_mm / height_mm,
147
+ }
148
+
149
+
150
+ def entity_of(reference, world: dict | None) -> dict | None:
151
+ """The entity a reference names, in a world: by id (a bound reference,
152
+ what a document holds) or by name (an authoring-time one, before
153
+ ingestion binds it). None when nothing answers.
154
+
155
+ :param reference: an id, a name, or None
156
+ :param world: a manifest or a fold result (``{entities}``)
157
+ """
158
+ if reference is None or world is None:
159
+ return None
160
+ entities = world.get("entities") or []
161
+ if _is_num(reference):
162
+ return next((e for e in entities if e.get("id") == reference), None)
163
+ if isinstance(reference, str):
164
+ return next((e for e in entities if e.get("name") == reference), None)
165
+ return None
166
+
167
+
168
+ def world_origin(entity: dict, world: dict) -> list:
169
+ """An entity's origin in world space: its ``transform.position``
170
+ composed through its parents — the document's TRS hierarchy,
171
+ ``p = P + R·(S ⊙ p)`` up the chain (spec/world.md "Conventions"). An
172
+ unparented entity's origin is its position as written; a parent the
173
+ world doesn't hold ends the chain. This is the point every outcome
174
+ assertion projects and the point ``aim_entity`` and ``focus_entity``
175
+ resolve to.
176
+
177
+ :param entity: a parsed entity
178
+ :param world: the world it lives in (``{entities}``)
179
+ :return: the world point
180
+ """
181
+ entities = world.get("entities") or []
182
+ point = _position_of(entity)
183
+ current = entity
184
+ for _ in range(4096):
185
+ parent_id = current.get("parent")
186
+ if parent_id is None:
187
+ break
188
+ parent = next((e for e in entities if e.get("id") == parent_id), None)
189
+ if parent is None:
190
+ break
191
+ transform = parent.get("transform") if isinstance(parent.get("transform"), dict) else {}
192
+ P = list(transform.get("position") or [0.0, 0.0, 0.0])
193
+ R = list(transform.get("rotation_degrees") or [0.0, 0.0, 0.0])
194
+ S = list(transform.get("scale") or [1.0, 1.0, 1.0])
195
+ point = _add(P, _rotate_intrinsic_xyz([point[0] * S[0], point[1] * S[1], point[2] * S[2]], R))
196
+ current = parent
197
+ return point
198
+
199
+
200
+ def aim_point_of(camera: dict, world: dict | None = None) -> list | None:
201
+ """The point a camera aims at: ``aim_entity``'s world origin while
202
+ that entity exists in the world, else ``aim``, else None (the camera
203
+ looks down its local −Z). Without a world only ``aim`` can answer.
204
+
205
+ :param camera: a :func:`camera_of` result
206
+ :param world: the world the reference resolves in
207
+ """
208
+ target = entity_of(camera.get("aim_entity"), world)
209
+ if target is not None and world is not None:
210
+ return world_origin(target, world)
211
+ aim = camera.get("aim")
212
+ return list(aim) if aim is not None else None
213
+
214
+
215
+ def focus_distance_of(entity: dict, camera: dict, world: dict | None = None) -> float | None:
216
+ """A camera's focus distance, metres: the distance from the camera
217
+ entity's world origin to ``focus_entity``'s while that entity exists,
218
+ else ``focus_distance_m``, else None.
219
+
220
+ :param entity: the camera entity
221
+ :param camera: a :func:`camera_of` result
222
+ :param world: the world the reference resolves in
223
+ """
224
+ target = entity_of(camera.get("focus_entity"), world)
225
+ if target is not None and world is not None:
226
+ return _len(_sub(world_origin(target, world), world_origin(entity, world)))
227
+ return camera.get("focus_distance_m")
228
+
229
+
230
+ def view_of(entity: dict, camera: dict, world: dict | None = None) -> dict:
231
+ """A camera's view: with an aim point — ``aim_entity``'s origin when
232
+ the world is given and the entity exists, else ``aim`` — the camera
233
+ looks at it, +Y up; without one it looks down the entity's local −Z
234
+ (the glTF / three.js / Bevy convention), its frame carried by the
235
+ entity's rotation. The camera stands at its own world origin.
236
+
237
+ :param entity: the camera entity (its transform places it)
238
+ :param camera: a :func:`camera_of` result
239
+ :param world: the world the entity references resolve in
240
+ :return: ``{"position", "forward", "right", "up"}``
241
+ """
242
+ position = world_origin(entity, world) if world is not None else _position_of(entity)
243
+ aim = aim_point_of(camera, world)
244
+ if aim is not None:
245
+ forward = _norm(_sub(aim, position))
246
+ right = _norm(_cross(forward, [0, 1, 0]))
247
+ up = _cross(right, forward)
248
+ return {"position": position, "forward": forward, "right": right, "up": up}
249
+ transform = entity.get("transform")
250
+ rot = transform.get("rotation_degrees") if isinstance(transform, dict) else None
251
+ rotation = list(rot) if rot is not None else [0.0, 0.0, 0.0]
252
+ return {
253
+ "position": position,
254
+ "forward": _rotate_intrinsic_xyz([0, 0, -1], rotation),
255
+ "right": _rotate_intrinsic_xyz([1, 0, 0], rotation),
256
+ "up": _rotate_intrinsic_xyz([0, 1, 0], rotation),
257
+ }
258
+
259
+
260
+ def project(view: dict, frame: dict, camera: dict, point: list) -> dict:
261
+ """Project a world point through a camera: the point in the camera's
262
+ normalized frame — ``x`` and ``y`` in frame-half units (inside when
263
+ ``|x| <= 1`` and ``|y| <= 1``), ``z`` the distance along the look
264
+ direction (in front when positive).
265
+
266
+ :param view: a :func:`view_of` result
267
+ :param frame: a :func:`frame_of` result
268
+ :param camera: a :func:`camera_of` result
269
+ :param point: the world point
270
+ :return: ``{"x", "y", "z"}``
271
+ """
272
+ d = _sub(point, view["position"])
273
+ z = _dot(d, view["forward"])
274
+ scale = camera["focal_length_mm"] / z
275
+ return {
276
+ "x": (_dot(d, view["right"]) * scale) / (frame["width_mm"] / 2),
277
+ "y": (_dot(d, view["up"]) * scale) / (frame["height_mm"] / 2),
278
+ "z": z,
279
+ }
280
+
281
+
282
+ def shot_list(world: dict) -> list:
283
+ """The shot list: every entity carrying ``ext-cinematography.shot``, as
284
+ ``{id, name, shot}``, ordered by ``shot.order`` (absent last), ties by
285
+ entity id. The entity's name is the shot's name.
286
+
287
+ :param world: a manifest or a fold result (``{entities}``)
288
+ :return: the setups, in order
289
+ """
290
+ shots = []
291
+ for entity in world.get("entities") or []:
292
+ ext = entity.get(EXTENSION_NAME)
293
+ shot = ext.get("shot") if isinstance(ext, dict) else None
294
+ if not isinstance(shot, dict):
295
+ continue
296
+ shots.append({"id": entity.get("id"), "name": entity.get("name"), "shot": shot})
297
+
298
+ def rank(s):
299
+ order = s["shot"].get("order")
300
+ return order if _is_num(order) else math.inf
301
+
302
+ shots.sort(key=lambda s: (rank(s), s["id"]))
303
+ return shots
304
+
305
+
306
+ def run_outcomes(manifest: dict, outcomes: dict) -> dict:
307
+ """Run a conformance outcomes document against a world: derive, then
308
+ check every assertion. This is the extension's conformance — math,
309
+ not pixels.
310
+
311
+ :param manifest: the world (parsed)
312
+ :param outcomes: ``{expect: [...]}``
313
+ :return: ``{"ok", "failures"}``
314
+ """
315
+ failures: list = []
316
+ by_name = {e.get("name"): e for e in manifest.get("entities") or []}
317
+
318
+ def camera_entity(name):
319
+ entity = by_name.get(name)
320
+ camera = camera_of(entity) if entity else None
321
+ if not entity or not camera:
322
+ failures.append(f"no camera named {name}")
323
+ return None
324
+ return {
325
+ "entity": entity,
326
+ "camera": camera,
327
+ "frame": frame_of(camera),
328
+ "view": view_of(entity, camera, manifest),
329
+ }
330
+
331
+ def origin_of(name):
332
+ entity = by_name.get(name)
333
+ if not entity:
334
+ failures.append(f"no entity named {name}")
335
+ return [0.0, 0.0, 0.0]
336
+ return world_origin(entity, manifest)
337
+
338
+ for assertion in outcomes.get("expect") or []:
339
+ fov = assertion.get("fov")
340
+ centred = assertion.get("centred")
341
+ focus = assertion.get("focus")
342
+ if isinstance(fov, dict):
343
+ name = fov.get("camera")
344
+ tolerance = fov.get("tolerance")
345
+ tolerance = 0.001 if tolerance is None else tolerance
346
+ c = camera_entity(name)
347
+ if not c:
348
+ continue
349
+ frame = c["frame"]
350
+ if abs(frame["hfov_degrees"] - fov["hfov_degrees"]) > tolerance:
351
+ failures.append(f"{name}: hfov {frame['hfov_degrees']} != {fov['hfov_degrees']} (tolerance {tolerance})")
352
+ if abs(frame["vfov_degrees"] - fov["vfov_degrees"]) > tolerance:
353
+ failures.append(f"{name}: vfov {frame['vfov_degrees']} != {fov['vfov_degrees']} (tolerance {tolerance})")
354
+ if abs(frame["aspect"] - fov["aspect"]) > tolerance:
355
+ failures.append(f"{name}: aspect {frame['aspect']} != {fov['aspect']} (tolerance {tolerance})")
356
+ elif assertion.get("in_frame") or assertion.get("out_of_frame"):
357
+ target = assertion.get("in_frame") or assertion.get("out_of_frame")
358
+ name, entity_name = target.get("camera"), target.get("entity")
359
+ c = camera_entity(name)
360
+ if not c:
361
+ continue
362
+ p = project(c["view"], c["frame"], c["camera"], origin_of(entity_name))
363
+ inside = p["z"] > 0 and abs(p["x"]) <= 1 and abs(p["y"]) <= 1
364
+ if assertion.get("in_frame") and not inside:
365
+ failures.append(f"{name}: {entity_name} projects outside the frame ({p['x']:.3f}, {p['y']:.3f})")
366
+ if assertion.get("out_of_frame") and inside:
367
+ failures.append(f"{name}: {entity_name} projects inside the frame ({p['x']:.3f}, {p['y']:.3f})")
368
+ elif isinstance(centred, dict):
369
+ # The camera aims at the entity: its origin projects at the
370
+ # frame's centre, in front — what ``aim_entity`` promises.
371
+ name, entity_name = centred.get("camera"), centred.get("entity")
372
+ tolerance = centred.get("tolerance")
373
+ tolerance = 0.001 if tolerance is None else tolerance
374
+ c = camera_entity(name)
375
+ if not c:
376
+ continue
377
+ p = project(c["view"], c["frame"], c["camera"], origin_of(entity_name))
378
+ if not (p["z"] > 0 and abs(p["x"]) <= tolerance and abs(p["y"]) <= tolerance):
379
+ failures.append(
380
+ f"{name}: {entity_name} projects at ({p['x']:.4f}, {p['y']:.4f}), z {p['z']:.3f}"
381
+ f" — not the frame's centre (tolerance {tolerance})"
382
+ )
383
+ elif isinstance(focus, dict):
384
+ # The derived focus distance — ``focus_entity``'s origin from
385
+ # the camera, else ``focus_distance_m`` — within ``tolerance``.
386
+ name = focus.get("camera")
387
+ tolerance = focus.get("tolerance")
388
+ tolerance = 0.001 if tolerance is None else tolerance
389
+ c = camera_entity(name)
390
+ if not c:
391
+ continue
392
+ actual = focus_distance_of(c["entity"], c["camera"], manifest)
393
+ if actual is None:
394
+ failures.append(f"{name}: no focus distance")
395
+ elif abs(actual - focus["distance_m"]) > tolerance:
396
+ failures.append(f"{name}: focus distance {actual} != {focus['distance_m']} (tolerance {tolerance})")
397
+ elif isinstance(assertion.get("shot_list"), list):
398
+ actual = [s["name"] for s in shot_list(manifest)]
399
+ expected = assertion["shot_list"]
400
+ if actual != expected:
401
+ failures.append(f"shot list {json.dumps(actual)} != {json.dumps(expected)}")
402
+ else:
403
+ failures.append(f"unknown assertion {json.dumps(assertion)}")
404
+ return {"ok": not failures, "failures": failures}
@@ -0,0 +1,83 @@
1
+ """Dataset tooling: fold a corpus of recordings, compute one number.
2
+
3
+ The notebook pitch, as a module: a folder of `.world` packages is a
4
+ dataset — each ``ops.jsonl`` a recording, each fold a row.
5
+ :func:`summarize` computes the row; ``python -m openworldformat.dataset``
6
+ prints the table (walk a directory tree for recordings, or pass
7
+ explicit ones). Scale is the only difference from the demo: the fold
8
+ is linear in log length, the format is JSON all the way down, and
9
+ there is no engine to boot.
10
+
11
+ The branches column counts tips — recordings whose authors forked the
12
+ history. Fold each tip with :func:`openworldformat.fold_path` and the
13
+ counterfactuals come with the corpus.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from . import build_history, fold_log, parse_log_line, read_package
22
+ from .eval import entry_metrics
23
+
24
+ __all__ = ["summarize", "main"]
25
+
26
+ #: The columns the report prints, in order.
27
+ COLUMNS = ("world", "entities", "edits", "ops", "entries", "authors", "tips", "span_s")
28
+
29
+
30
+ def _entries_of(world: Path) -> list:
31
+ log = world / "ops.jsonl"
32
+ if not log.exists():
33
+ return []
34
+ return [parse_log_line(l) for l in log.read_text().splitlines() if l.strip()]
35
+
36
+
37
+ def summarize(world_dir) -> dict:
38
+ """One recording's row: fold it, measure the trajectory.
39
+
40
+ :param world_dir: a `.world` package directory (``manifest.json``
41
+ plus ``ops.jsonl`` when the world was recorded)
42
+ :return: ``{world, entities, edits, ops, entries, authors, tips,
43
+ span_s, revision, span_ms}``
44
+ """
45
+ world = Path(world_dir)
46
+ manifest, entries, _head, _package = read_package(world)
47
+ state = fold_log(manifest, entries)
48
+ row = entry_metrics(entries, state["applied_edits"])
49
+ row["span_s"] = round(row["span_ms"] / 1000, 3)
50
+ row["world"] = world.name
51
+ row["entities"] = len(state["entities"])
52
+ row["tips"] = len(build_history(entries)["tips"])
53
+ return row
54
+
55
+
56
+ def main(argv: list | None = None) -> int:
57
+ """The CLI: report on a corpus. Arguments are roots to walk for
58
+ ``ops.jsonl`` (default: the current directory)."""
59
+ argv = sys.argv[1:] if argv is None else argv
60
+ roots = [Path(a) for a in argv] or [Path(".")]
61
+ dirs = sorted({log.parent for root in roots for log in root.rglob("ops.jsonl")})
62
+ if not dirs:
63
+ print("no recordings found (no ops.jsonl under the given roots)", file=sys.stderr)
64
+ return 1
65
+ rows = [summarize(d) for d in dirs]
66
+
67
+ def cell(row, column):
68
+ v = row[column]
69
+ return ", ".join(v) if isinstance(v, list) else str(v)
70
+
71
+ widths = {c: max(len(c), *(len(cell(r, c)) for r in rows)) for c in COLUMNS}
72
+ print(" ".join(c.ljust(widths[c]) for c in COLUMNS))
73
+ for r in rows:
74
+ print(" ".join(cell(r, c).ljust(widths[c]) for c in COLUMNS))
75
+ edits = sum(r["edits"] for r in rows)
76
+ tips = sum(r["tips"] for r in rows)
77
+ print(f"\n{len(rows)} recordings · {edits} edits · {tips} tips "
78
+ f"(branches included in the count)")
79
+ return 0
80
+
81
+
82
+ if __name__ == "__main__":
83
+ raise SystemExit(main())
@@ -0,0 +1,25 @@
1
+ """The one list of entity-reference fields (spec/world.md "Identity",
2
+ schema/entity-refs.json) — the package's embedded copy. The canonical
3
+ list is generated from world.schema.json's ``x-entity-ref`` markers by
4
+ schema/generate-entity-refs.mjs, but a published package can't read the
5
+ repo's schema/ at runtime, so the passes read this table instead, and
6
+ tests/test_entity_refs.py fails when it drifts from the canonical file.
7
+
8
+ ``kind: "bindable"`` — a name is accepted at intake and MUST bind to an
9
+ id at ingestion; ``kind: "id"`` — numeric only. ``scope`` is the object
10
+ the path walks from: an entity (or its patch, the same fields), the
11
+ manifest's avatar, or one creation in ``creations[]``. ``"*"`` walks
12
+ every array element.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ ENTITY_REFS = [
18
+ {"scope": "entity", "path": ["behaviors", "*", "LookAt", "target"], "kind": "bindable"},
19
+ {"scope": "entity", "path": ["behaviors", "*", "Orbit", "center"], "kind": "bindable"},
20
+ {"scope": "entity", "path": ["ext-cinematography", "camera", "aim_entity"], "kind": "bindable"},
21
+ {"scope": "entity", "path": ["ext-cinematography", "camera", "focus_entity"], "kind": "bindable"},
22
+ {"scope": "entity", "path": ["parent"], "kind": "bindable"},
23
+ {"scope": "avatar", "path": ["model_entity"], "kind": "bindable"},
24
+ {"scope": "creation", "path": ["entities", "*"], "kind": "id"},
25
+ ]