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,226 @@
1
+ """Tasks: goals as predicates over the fold — the benchmark harness.
2
+
3
+ A task is a world, a trajectory, and a goal: a list of predicates over
4
+ what the fold produces — the document (entities, positions) and the
5
+ state document's values. Scoring is therefore deterministic and needs
6
+ no engine: fold the log, evaluate the predicates. That is the whole
7
+ difference from the embodied-agent benchmarks that require a
8
+ simulator, and it is why the predicates live here and not there.
9
+
10
+ Deliberately library-level, not a spec extension: this module is the
11
+ format's research surface growing a task language, and it graduates to
12
+ `spec/extensions/` only when a second implementer wants it — the
13
+ repository's own rule, applied to itself.
14
+
15
+ Run it::
16
+
17
+ python -m openworldformat.eval examples/tasks/
18
+
19
+ or from code, to score an agent's ops as they happen::
20
+
21
+ result = run_task(manifest, entries, task, state_doc)
22
+ result["ok"] # every predicate held, every budget respected
23
+ result["failures"] # why not
24
+ result["metrics"] # edits, ops, entries, authors, span_ms, revision
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import math
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ from . import (
35
+ WorldFormatError,
36
+ build_history,
37
+ fold_log,
38
+ fold_path,
39
+ fold_state,
40
+ parse_log_line,
41
+ parse_manifest,
42
+ read_package,
43
+ )
44
+
45
+ __all__ = ["run_task", "load_task_file", "entry_metrics", "main"]
46
+
47
+ #: Default tolerance for the `near` predicate — the outcomes precedent.
48
+ NEAR_TOLERANCE = 0.15
49
+
50
+
51
+ def _entity_by_name(state: dict, name):
52
+ return next((e for e in state["entities"] if e.get("name") == name), None)
53
+
54
+
55
+ def _is_num(v) -> bool:
56
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
57
+
58
+
59
+ def _check_predicate(predicate: dict, state: dict, values: dict) -> list:
60
+ """One goal predicate against one fold. Returns its failures (empty =
61
+ held)."""
62
+ if "exists" in predicate:
63
+ name = predicate["exists"].get("entity")
64
+ if _entity_by_name(state, name) is None:
65
+ return [f"entity '{name}' does not exist"]
66
+ return []
67
+ if "gone" in predicate:
68
+ name = predicate["gone"].get("entity")
69
+ if _entity_by_name(state, name) is not None:
70
+ return [f"entity '{name}' still exists"]
71
+ return []
72
+ if "near" in predicate:
73
+ near = predicate["near"]
74
+ name = near.get("entity")
75
+ entity = _entity_by_name(state, name)
76
+ if entity is None:
77
+ return [f"entity '{name}' does not exist"]
78
+ transform = entity.get("transform")
79
+ position = transform.get("position") if isinstance(transform, dict) else None
80
+ if not isinstance(position, list) or len(position) != 3:
81
+ return [f"entity '{name}' has no position"]
82
+ tolerance = near.get("tolerance")
83
+ tolerance = NEAR_TOLERANCE if tolerance is None else tolerance
84
+ want = near.get("position")
85
+ distance = math.dist(position, want)
86
+ if distance > tolerance:
87
+ return [f"entity '{name}' at {position} is {distance:.3f} from {want} (tolerance {tolerance})"]
88
+ return []
89
+ if "field" in predicate:
90
+ field = predicate["field"]
91
+ name = field.get("name")
92
+ if name not in values:
93
+ return [f"field '{name}' has no value"]
94
+ v = values[name]
95
+ failures = []
96
+ if "equals" in field and v != field["equals"]:
97
+ failures.append(f"field '{name}' is {v!r}, expected {field['equals']!r}")
98
+ for comparator, ok in (("at_least", lambda a, b: a >= b), ("at_most", lambda a, b: a <= b)):
99
+ if comparator in field:
100
+ bound = field[comparator]
101
+ if not _is_num(v) or not _is_num(bound):
102
+ failures.append(f"field '{name}' is {v!r}, not comparable to {bound!r}")
103
+ elif not ok(v, bound):
104
+ failures.append(f"field '{name}' is {v!r}, expected {comparator.replace('_', ' ')} {bound!r}")
105
+ return failures
106
+ return [f"unknown predicate {json.dumps(predicate)}"]
107
+
108
+
109
+ def _check_budget(budget: dict, metrics: dict) -> list:
110
+ """Budgets are the cost side of a task: solve it, but not by a million
111
+ edits. Each limit is inclusive."""
112
+ failures = []
113
+ for key, metric in (("max_edits", "edits"), ("max_ops", "ops"), ("max_entries", "entries")):
114
+ if key in budget and metrics[metric] > budget[key]:
115
+ failures.append(f"budget exceeded: {metrics[metric]} {metric}, max {budget[key]}")
116
+ return failures
117
+
118
+
119
+ def entry_metrics(chain: list, applied_edits: int) -> dict:
120
+ """The trajectory's shape: entries, ops, edits, authors, span, revision —
121
+ what task scoring and dataset tooling both want per recording. The
122
+ authors field is the audit trail's dimension: filter it on one name
123
+ and you have what that one author did, model or visitor."""
124
+ return {
125
+ "entries": len(chain),
126
+ "ops": sum(len(e.get("ops") or []) for e in chain),
127
+ "edits": applied_edits,
128
+ "revision": chain[-1].get("revision") if chain else None,
129
+ "authors": sorted({(e.get("author") or {}).get("name") for e in chain} - {None}),
130
+ "span_ms": (
131
+ chain[-1].get("timestamp_ms", 0) - chain[0].get("timestamp_ms", 0)
132
+ if len(chain) > 1 else 0
133
+ ),
134
+ }
135
+
136
+
137
+ def run_task(manifest: dict, entries: list, task: dict, state_doc: dict | None = None) -> dict:
138
+ """Score one trajectory against one task.
139
+
140
+ :param manifest: the base world document (parsed)
141
+ :param entries: the trajectory — parsed log entries, in order (the
142
+ agent's ops, or a recorded session's)
143
+ :param task: ``{tip?, budget?, goal: [...]}`` — predicates per the
144
+ module docstring; ``tip`` folds a branch instead of the head
145
+ :param state_doc: the world's parsed ``state.json``, if it has one
146
+ :return: ``{"ok", "failures", "metrics", "state", "values"}``
147
+ :raises WorldFormatError: when the task has no goal
148
+ """
149
+ tip = task.get("tip")
150
+ if tip is not None:
151
+ state = fold_path(manifest, entries, tip)
152
+ by_id = build_history(entries)["by_id"]
153
+ chain = [by_id[i] for i in state["path"]]
154
+ else:
155
+ state = fold_log(manifest, entries)
156
+ chain = list(entries)
157
+ values = fold_state(state_doc or {}, chain)["values"]
158
+ metrics = entry_metrics(chain, state["applied_edits"])
159
+
160
+ goal = task.get("goal")
161
+ if not isinstance(goal, list) or not goal:
162
+ raise WorldFormatError("task needs a goal: a non-empty list of predicates")
163
+ failures = []
164
+ for predicate in goal:
165
+ failures += _check_predicate(predicate, state, values)
166
+ failures += _check_budget(task.get("budget") or {}, metrics)
167
+ return {"ok": not failures, "failures": failures, "metrics": metrics, "state": state, "values": values}
168
+
169
+
170
+ def load_task_file(path) -> tuple:
171
+ """Load a task file: the task document, plus the world it names.
172
+
173
+ Paths resolve relative to the task file. ``world`` is a `.world`
174
+ package directory (``manifest.json``, ``ops.jsonl`` unless an agent
175
+ is expected to supply the log, ``state.json`` if the world declares
176
+ state).
177
+
178
+ :return: ``(manifest, entries, task, state_doc)``
179
+ """
180
+ path = Path(path)
181
+ task = json.loads(path.read_text())
182
+ world = Path(task["world"])
183
+ if not world.is_absolute():
184
+ world = (path.parent / world).resolve()
185
+ manifest, entries, _head, _package = read_package(world)
186
+ state_file = world / (task.get("state") or "state.json")
187
+ state_doc = json.loads(state_file.read_text()) if state_file.exists() else None
188
+ return manifest, entries, task, state_doc
189
+
190
+
191
+ def main(argv: list | None = None) -> int:
192
+ """The CLI: score task files (or directories of them). Exit 1 when any
193
+ task fails — the benchmark's teeth."""
194
+ argv = sys.argv[1:] if argv is None else argv
195
+ if not argv:
196
+ print("usage: python -m openworldformat.eval TASK.json [TASK.json | DIR ...]", file=sys.stderr)
197
+ return 2
198
+ paths = []
199
+ for arg in argv:
200
+ p = Path(arg)
201
+ paths += sorted(p.glob("*.json")) if p.is_dir() else [p]
202
+
203
+ failed = 0
204
+ for p in paths:
205
+ manifest, entries, task, state_doc = load_task_file(p)
206
+ result = run_task(manifest, entries, task, state_doc)
207
+ name = task.get("task", p.stem)
208
+ m = result["metrics"]
209
+ if result["ok"]:
210
+ print(f"ok {name} ({m['edits']} edits, {m['ops']} ops, "
211
+ f"{len(m['authors'])} authors, revision {m['revision']})")
212
+ else:
213
+ failed += 1
214
+ print(f"FAIL {name}")
215
+ for failure in result["failures"]:
216
+ print(f" - {failure}")
217
+ total = len(paths)
218
+ if failed:
219
+ print(f"\n{failed} of {total} tasks failed")
220
+ return 1
221
+ print(f"\n{total} tasks passed")
222
+ return 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ raise SystemExit(main())
@@ -0,0 +1,424 @@
1
+ """The ext-physics reference implementation (extension version 0.1), in Python.
2
+
3
+ Spec: spec/extensions/physics.md. The extension declares bodies, not
4
+ simulation: how a world moves is an engine's business. What this module
5
+ provides is the contract's executable half —
6
+
7
+ - a deliberately minimal deterministic solver (spheres against
8
+ floors and axis-aligned statics, fixed-timestep semi-implicit
9
+ Euler) so the conformance outcome assertions run anywhere and in
10
+ CI. It is reference-grade, not production physics;
11
+ - trajectory write/fold — the op kind that lets a device with no
12
+ solver scrub recorded motion instead of simulating it;
13
+ - the outcome runner for conformance/outcomes/*.json.
14
+
15
+ The solver is deterministic by construction: no randomness, bodies in
16
+ document order, fixed dt, IEEE-754 doubles. Same engine and version,
17
+ same trajectory. Cross-engine, only the semantic contract holds —
18
+ never bit-exact, exactly as the core spec refuses.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import math
25
+
26
+ from . import classify_op
27
+
28
+ __all__ = [
29
+ "EXTENSION_NAME",
30
+ "EXTENSION_VERSION",
31
+ "BOUNCE_SPEED",
32
+ "SLEEP_SPEED",
33
+ "collect_physics",
34
+ "simulate_physics",
35
+ "fold_trajectories",
36
+ "trajectory_op",
37
+ "run_outcomes",
38
+ ]
39
+
40
+ #: The extension this module implements.
41
+ EXTENSION_NAME = "ext-physics"
42
+
43
+ #: The extension version this module implements.
44
+ EXTENSION_VERSION = "0.1.0"
45
+
46
+ #: An impact at or above this speed (m/s) counts as a bounce.
47
+ BOUNCE_SPEED = 0.5
48
+
49
+ #: A dynamic body in contact moving slower than this (m/s) sleeps.
50
+ SLEEP_SPEED = 0.05
51
+
52
+ #: Default gravity when the environment block is absent (spec).
53
+ DEFAULT_GRAVITY = (0.0, -9.81, 0.0)
54
+
55
+ # Body-component defaults (spec: the extension page's field table).
56
+ DEFAULTS = {
57
+ "mass": 1.0,
58
+ "restitution": 0.5,
59
+ "friction": 0.5,
60
+ "gravity_scale": 1.0,
61
+ "linear_damping": 0.0,
62
+ }
63
+
64
+ _v3 = lambda x, y, z: [x, y, z]
65
+ _sub = lambda a, b: [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
66
+ _dot = lambda a, b: a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
67
+ _len = lambda a: math.sqrt(_dot(a, a))
68
+ _clamp = lambda v, lo, hi: min(hi, max(lo, v))
69
+ _is_num = lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)
70
+
71
+
72
+ def _component(entity: dict):
73
+ """An entity's ``ext-physics`` component, or None."""
74
+ c = entity.get(EXTENSION_NAME)
75
+ return c if isinstance(c, dict) else None
76
+
77
+
78
+ def _shape_extents(entity: dict):
79
+ """The entity's parametric shape as a bounding box [x, y, z] extents."""
80
+ s = entity.get("shape")
81
+ if not isinstance(s, dict) or not s:
82
+ return None
83
+ e = next(iter(s.values()))
84
+ if not isinstance(e, dict):
85
+ return None
86
+ if "x" in e and "y" in e and "z" in e:
87
+ return _v3(e["x"], e["y"], e["z"]) # Cuboid, Wedge
88
+ if "radius" in e and "height" in e:
89
+ return _v3(2 * e["radius"], e["height"], 2 * e["radius"]) # Cylinder, Cone
90
+ if "radius" in e and "half_length" in e:
91
+ return _v3(2 * e["radius"], 2 * (e["half_length"] + e["radius"]), 2 * e["radius"]) # Capsule
92
+ if "radius" in e:
93
+ return _v3(2 * e["radius"], 2 * e["radius"], 2 * e["radius"]) # Sphere, Tetrahedron, Icosahedron
94
+ if "major_radius" in e and "minor_radius" in e:
95
+ w = 2 * (e["major_radius"] + e["minor_radius"]) # Torus, laid flat
96
+ return _v3(w, 2 * e["minor_radius"], w)
97
+ if "base_x" in e and "base_z" in e and "height" in e:
98
+ return _v3(e["base_x"], e["height"], e["base_z"]) # Pyramid
99
+ return None
100
+
101
+
102
+ def _collider_of(entity: dict):
103
+ """An entity's collider: ``{"kind": "sphere", "radius"}`` or
104
+ ``{"kind": "box", "extents"}``, from the explicit collider when
105
+ present, else the parametric shape."""
106
+ comp = _component(entity)
107
+ c = comp.get("collider") if comp else None
108
+ if isinstance(c, dict):
109
+ if _is_num(c.get("sphere")):
110
+ return {"kind": "sphere", "radius": c["sphere"]}
111
+ if isinstance(c.get("cuboid"), list):
112
+ return {"kind": "box", "extents": list(c["cuboid"])}
113
+ extents = _shape_extents(entity)
114
+ if c == "shape" or c is None:
115
+ if extents:
116
+ shape = entity.get("shape")
117
+ if isinstance(shape, dict) and "Sphere" in shape:
118
+ return {"kind": "sphere", "radius": extents[0] / 2}
119
+ return {"kind": "box", "extents": extents}
120
+ return None
121
+ return None
122
+
123
+
124
+ def collect_physics(state: dict) -> dict:
125
+ """Collect a world's physics: gravity, the dynamic bodies, and the
126
+ static colliders. Only entities carrying the component participate —
127
+ a body is declared, never inferred. Kinematic bodies are collected
128
+ for completeness; the reference treats them as static colliders (they
129
+ move by behaviors, which live outside this solver).
130
+
131
+ :param state: a manifest or a fold result (``{entities, environment}``)
132
+ :return: ``{"gravity", "dynamic", "kinematic", "statics"}``
133
+ """
134
+ env = state.get("environment")
135
+ env = env if isinstance(env, dict) else {}
136
+ ext = env.get(EXTENSION_NAME)
137
+ ext = ext if isinstance(ext, dict) else {}
138
+ gravity = ext.get("gravity")
139
+ if gravity is None:
140
+ gravity = list(DEFAULT_GRAVITY)
141
+
142
+ dynamic: list = []
143
+ kinematic: list = []
144
+ statics: list = []
145
+ for entity in state.get("entities") or []:
146
+ c = _component(entity)
147
+ if c is None:
148
+ continue
149
+ transform = entity.get("transform")
150
+ pos = transform.get("position") if isinstance(transform, dict) else None
151
+ position = list(pos) if pos is not None else [0.0, 0.0, 0.0]
152
+ collider = _collider_of(entity)
153
+ if c.get("body") == "dynamic":
154
+ # Dynamics are spheres in the reference: the collider's radius, or
155
+ # the bounding sphere of whatever the entity declared.
156
+ extents = collider["extents"] if collider and collider["kind"] == "box" else None
157
+ if collider and collider["kind"] == "sphere":
158
+ radius = collider["radius"]
159
+ elif extents:
160
+ radius = _len([x / 2 for x in extents])
161
+ else:
162
+ radius = 0.5
163
+ dynamic.append({
164
+ "id": entity.get("id"),
165
+ "name": entity.get("name"),
166
+ "position": position,
167
+ "velocity": _v3(0.0, 0.0, 0.0),
168
+ "radius": radius,
169
+ "mass": c.get("mass") if c.get("mass") is not None else DEFAULTS["mass"],
170
+ "restitution": c.get("restitution") if c.get("restitution") is not None else DEFAULTS["restitution"],
171
+ "friction": c.get("friction") if c.get("friction") is not None else DEFAULTS["friction"],
172
+ "gravity_scale": c.get("gravity_scale") if c.get("gravity_scale") is not None else DEFAULTS["gravity_scale"],
173
+ "damping": c.get("linear_damping") if c.get("linear_damping") is not None else DEFAULTS["linear_damping"],
174
+ "asleep": False,
175
+ })
176
+ elif c.get("body") == "kinematic":
177
+ kinematic.append({"id": entity.get("id"), "name": entity.get("name")})
178
+ _push_static(statics, entity, position, collider)
179
+ else:
180
+ _push_static(statics, entity, position, collider)
181
+ return {"gravity": gravity, "dynamic": dynamic, "kinematic": kinematic, "statics": statics}
182
+
183
+
184
+ def _push_static(statics: list, entity: dict, position: list, collider) -> None:
185
+ # A Plane is the floor at its y (the reference ignores its rotation);
186
+ # anything else is an axis-aligned box around its extents.
187
+ shape = entity.get("shape")
188
+ if isinstance(shape, dict) and "Plane" in shape and (collider is None or collider["kind"] != "sphere"):
189
+ statics.append({"id": entity.get("id"), "name": entity.get("name"), "kind": "floor", "y": position[1]})
190
+ return
191
+ if collider and collider["kind"] == "box":
192
+ extents = collider["extents"]
193
+ elif collider and collider["kind"] == "sphere":
194
+ extents = [2 * collider["radius"], 2 * collider["radius"], 2 * collider["radius"]]
195
+ else:
196
+ extents = _shape_extents(entity)
197
+ if not extents:
198
+ return
199
+ half = [x / 2 for x in extents]
200
+ statics.append({
201
+ "id": entity.get("id"),
202
+ "name": entity.get("name"),
203
+ "kind": "box",
204
+ "min": _v3(position[0] - half[0], position[1] - half[1], position[2] - half[2]),
205
+ "max": _v3(position[0] + half[0], position[1] + half[1], position[2] + half[2]),
206
+ })
207
+
208
+
209
+ def _resolve_contact(body: dict, stat: dict) -> float:
210
+ """Resolve one body against one static. Returns the impact speed, or -1."""
211
+ if stat["kind"] == "floor":
212
+ pen = stat["y"] + body["radius"] - body["position"][1]
213
+ if pen <= 0:
214
+ return -1
215
+ body["position"][1] = stat["y"] + body["radius"]
216
+ n = _v3(0.0, 1.0, 0.0)
217
+ else:
218
+ p = body["position"]
219
+ c = _v3(
220
+ _clamp(p[0], stat["min"][0], stat["max"][0]),
221
+ _clamp(p[1], stat["min"][1], stat["max"][1]),
222
+ _clamp(p[2], stat["min"][2], stat["max"][2]),
223
+ )
224
+ d = _sub(p, c)
225
+ dist = _len(d)
226
+ if dist >= body["radius"]:
227
+ return -1
228
+ n = [x / dist for x in d] if dist > 0 else _v3(0.0, 1.0, 0.0)
229
+ body["position"] = [c[0] + n[0] * body["radius"], c[1] + n[1] * body["radius"], c[2] + n[2] * body["radius"]]
230
+ vn = _dot(body["velocity"], n)
231
+ if vn >= 0:
232
+ return 0 # resting against it, no impact
233
+ # Reflect by restitution, damp tangentially by friction, one contact.
234
+ vt = _sub(body["velocity"], [x * vn for x in n])
235
+ restitution = -vn * body["restitution"]
236
+ keep = 1 - body["friction"]
237
+ body["velocity"] = _v3(
238
+ n[0] * restitution + vt[0] * keep,
239
+ n[1] * restitution + vt[1] * keep,
240
+ n[2] * restitution + vt[2] * keep,
241
+ )
242
+ return -vn
243
+
244
+
245
+ def simulate_physics(state: dict, opts: dict | None = None) -> dict:
246
+ """Simulate a world: deterministic, fixed-timestep, semi-implicit Euler.
247
+ Spheres against floors and axis-aligned statics, bodies in document
248
+ order, sleep on rest. Contacts are recorded on impact (normal speed
249
+ ≥ SLEEP_SPEED); impacts at or above BOUNCE_SPEED are bounces.
250
+
251
+ :param state: a manifest or a fold result
252
+ :param opts: ``{until_s, dt_s, sample_dt_s}``
253
+ :return: ``{"samples", "contacts", "bounces", "resting", "settled_s"}``
254
+ """
255
+ opts = opts or {}
256
+ dt = opts.get("dt_s")
257
+ dt = 1 / 120 if dt is None else dt
258
+ until = opts.get("until_s")
259
+ until = 5 if until is None else until
260
+ sample_dt = opts.get("sample_dt_s")
261
+ sample_dt = 0.1 if sample_dt is None else sample_dt
262
+ collected = collect_physics(state)
263
+ gravity, dynamic, statics = collected["gravity"], collected["dynamic"], collected["statics"]
264
+
265
+ samples: list = []
266
+ contacts: list = []
267
+ bounces: list = []
268
+ t = 0.0
269
+ next_sample = 0.0
270
+ all_asleep_at = None
271
+
272
+ def sample(at: float) -> None:
273
+ samples.append({
274
+ "t_s": round(at, 4),
275
+ "bodies": {b["name"]: list(b["position"]) for b in dynamic},
276
+ })
277
+
278
+ sample(t)
279
+
280
+ while t < until and all_asleep_at is None:
281
+ for body in dynamic:
282
+ if body["asleep"]:
283
+ continue
284
+ # Integrate, then resolve against every static in document order.
285
+ g = [x * body["gravity_scale"] for x in gravity]
286
+ damp = body["damping"] * dt
287
+ v = body["velocity"]
288
+ body["velocity"] = _v3(
289
+ v[0] + (g[0] - damp * v[0]) * dt,
290
+ v[1] + (g[1] - damp * v[1]) * dt,
291
+ v[2] + (g[2] - damp * v[2]) * dt,
292
+ )
293
+ p = body["position"]
294
+ v = body["velocity"]
295
+ body["position"] = _v3(p[0] + v[0] * dt, p[1] + v[1] * dt, p[2] + v[2] * dt)
296
+ touching = False
297
+ for stat in statics:
298
+ impact = _resolve_contact(body, stat)
299
+ if impact < 0:
300
+ continue
301
+ touching = True
302
+ if impact >= SLEEP_SPEED:
303
+ contacts.append({
304
+ "t_s": round(t, 4),
305
+ "body": body["name"],
306
+ "other": stat["name"],
307
+ "position": list(body["position"]),
308
+ "normal_speed": round(impact, 4),
309
+ })
310
+ if impact >= BOUNCE_SPEED:
311
+ bounces.append({"body": body["name"], "other": stat["name"], "t_s": round(t, 4)})
312
+ if touching and _len(body["velocity"]) < SLEEP_SPEED:
313
+ body["velocity"] = _v3(0.0, 0.0, 0.0)
314
+ body["asleep"] = True
315
+ t += dt
316
+ if dynamic and all(b["asleep"] for b in dynamic) and all_asleep_at is None:
317
+ all_asleep_at = t
318
+ if t >= next_sample:
319
+ sample(t)
320
+ next_sample += sample_dt
321
+
322
+ return {
323
+ "samples": samples,
324
+ "contacts": contacts,
325
+ "bounces": bounces,
326
+ "resting": {b["name"]: list(b["position"]) for b in dynamic},
327
+ "settled_s": round(all_asleep_at if all_asleep_at is not None else t, 4),
328
+ }
329
+
330
+
331
+ def fold_trajectories(entries: list) -> dict:
332
+ """Fold the ``ext-physics`` trajectory ops out of a log: the sampled
333
+ transforms of dynamic bodies, for playback without a solver. The op
334
+ folds to nothing for the document; this reads what it carried.
335
+
336
+ :param entries: parsed log entries, in order
337
+ :return: ``{"bodies": {name: [{"t_s", "position"}]}, "span_s": float}``
338
+ """
339
+ bodies: dict = {}
340
+ span = 0.0
341
+ for entry in entries:
342
+ classified = entry.get("classified")
343
+ if classified is None:
344
+ classified = [classify_op(op) for op in entry.get("ops") or []]
345
+ for c in classified:
346
+ if c["kind"] != "extension" or c["name"] != EXTENSION_NAME:
347
+ continue
348
+ value = c["value"] or {}
349
+ t_s, sampled = value.get("t_s"), value.get("bodies")
350
+ if not isinstance(t_s, list) or not isinstance(sampled, dict):
351
+ continue
352
+ for name, positions in sampled.items():
353
+ if not isinstance(positions, list):
354
+ continue
355
+ track = bodies.setdefault(name, [])
356
+ for i in range(min(len(positions), len(t_s))):
357
+ track.append({"t_s": t_s[i], "position": positions[i]})
358
+ if t_s[i] > span:
359
+ span = t_s[i]
360
+ return {"bodies": bodies, "span_s": span}
361
+
362
+
363
+ def trajectory_op(sim: dict) -> dict:
364
+ """Build an ``ext-physics`` trajectory op from simulation samples (or any
365
+ per-body sample lists): the writer's half of playback. Sample at or
366
+ below 10 Hz, per the spec.
367
+
368
+ :param sim: a :func:`simulate_physics` result
369
+ :return: the op — put it in an entry's ops array
370
+ """
371
+ t_s = [s["t_s"] for s in sim["samples"]]
372
+ bodies: dict = {}
373
+ for s in sim["samples"]:
374
+ for name, p in s["bodies"].items():
375
+ bodies.setdefault(name, []).append(p)
376
+ return {EXTENSION_NAME: {"t_s": t_s, "bodies": bodies}}
377
+
378
+
379
+ def run_outcomes(manifest: dict, outcomes: dict) -> dict:
380
+ """Run a conformance outcomes document against a world: simulate, then
381
+ check every assertion. This is the extension's conformance — outcome
382
+ predicates, not pixels.
383
+
384
+ :param manifest: the world (parsed)
385
+ :param outcomes: ``{simulate_s, options?, expect: [...]}``
386
+ :return: ``{"ok", "failures", "simulation"}``
387
+ """
388
+ opts = {"until_s": outcomes.get("simulate_s")}
389
+ opts.update(outcomes.get("options") or {})
390
+ sim = simulate_physics(manifest, opts)
391
+ failures: list = []
392
+
393
+ for assertion in outcomes.get("expect") or []:
394
+ contact = assertion.get("contact")
395
+ if isinstance(contact, list):
396
+ a, b = contact[0], contact[1]
397
+ limit = assertion.get("within_s")
398
+ limit = math.inf if limit is None else limit
399
+ hit = any(
400
+ c["t_s"] <= limit and ((c["body"] == a and c["other"] == b) or (c["body"] == b and c["other"] == a))
401
+ for c in sim["contacts"]
402
+ )
403
+ if not hit:
404
+ failures.append(f"contact {a}/{b} within {limit}s never happened")
405
+ elif "rest" in assertion:
406
+ rest = assertion["rest"]
407
+ body, near = rest.get("body"), rest.get("near")
408
+ tolerance = rest.get("tolerance")
409
+ tolerance = 0.15 if tolerance is None else tolerance
410
+ at = sim["resting"].get(body)
411
+ ok = at is not None and _len(_sub(at, near)) <= tolerance
412
+ if not ok:
413
+ failures.append(
414
+ f"{body} rests at {json.dumps(at)}, not within {tolerance} of {json.dumps(near)}"
415
+ )
416
+ elif "bounces" in assertion:
417
+ want = assertion["bounces"]
418
+ body, minimum = want.get("body"), want.get("min")
419
+ count = sum(1 for x in sim["bounces"] if x["body"] == body)
420
+ if count < minimum:
421
+ failures.append(f"{body} bounced {count} times, expected at least {minimum}")
422
+ else:
423
+ failures.append(f"unknown assertion {json.dumps(assertion)}")
424
+ return {"ok": not failures, "failures": failures, "simulation": sim}