creature-lab 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. creature_lab/__init__.py +18 -0
  2. creature_lab/agents/__init__.py +5 -0
  3. creature_lab/agents/baseline.py +53 -0
  4. creature_lab/agents/llm.py +33 -0
  5. creature_lab/agents/loop.py +112 -0
  6. creature_lab/agents/prompts.py +63 -0
  7. creature_lab/agents/tools.py +101 -0
  8. creature_lab/autopsy.py +376 -0
  9. creature_lab/backends/__init__.py +1 -0
  10. creature_lab/backends/base.py +25 -0
  11. creature_lab/backends/mujoco_backend.py +253 -0
  12. creature_lab/backends/pybullet_backend.py +573 -0
  13. creature_lab/cli.py +3317 -0
  14. creature_lab/controllers/__init__.py +1 -0
  15. creature_lab/controllers/cpg.py +83 -0
  16. creature_lab/controllers/factory.py +208 -0
  17. creature_lab/controllers/pid.py +33 -0
  18. creature_lab/controllers/policy.py +91 -0
  19. creature_lab/controllers/pose_seq.py +38 -0
  20. creature_lab/controllers/posture.py +125 -0
  21. creature_lab/controllers/sinusoid.py +20 -0
  22. creature_lab/controllers/target_seek.py +128 -0
  23. creature_lab/diagnosis.py +435 -0
  24. creature_lab/diagnostics.py +261 -0
  25. creature_lab/editor/__init__.py +5 -0
  26. creature_lab/editor/controls.py +2495 -0
  27. creature_lab/editor/history.py +85 -0
  28. creature_lab/editor/jobs.py +176 -0
  29. creature_lab/editor/labels.py +91 -0
  30. creature_lab/editor/live.py +672 -0
  31. creature_lab/editor/playback.py +98 -0
  32. creature_lab/editor/presets.py +282 -0
  33. creature_lab/editor/robot_panel.py +288 -0
  34. creature_lab/editor/robot_stage.py +181 -0
  35. creature_lab/editor/session.py +1413 -0
  36. creature_lab/env.py +274 -0
  37. creature_lab/evolve.py +394 -0
  38. creature_lab/export/__init__.py +9 -0
  39. creature_lab/export/mjcf.py +199 -0
  40. creature_lab/export/urdf.py +152 -0
  41. creature_lab/export/urdf_import.py +142 -0
  42. creature_lab/exporting.py +360 -0
  43. creature_lab/failure_zoo.py +98 -0
  44. creature_lab/hashing.py +19 -0
  45. creature_lab/io_utils.py +37 -0
  46. creature_lab/library.py +311 -0
  47. creature_lab/qualification.py +299 -0
  48. creature_lab/reports.py +428 -0
  49. creature_lab/reports_html.py +549 -0
  50. creature_lab/rl/__init__.py +9 -0
  51. creature_lab/rl/gym_env.py +74 -0
  52. creature_lab/rl/train.py +148 -0
  53. creature_lab/robots/__init__.py +6 -0
  54. creature_lab/robots/actuator.py +115 -0
  55. creature_lab/robots/analysis.py +282 -0
  56. creature_lab/robots/api.py +30 -0
  57. creature_lab/robots/card.py +182 -0
  58. creature_lab/robots/catalog.py +153 -0
  59. creature_lab/robots/checkup.py +216 -0
  60. creature_lab/robots/cli.py +275 -0
  61. creature_lab/robots/compare.py +229 -0
  62. creature_lab/robots/data/UNITREE_RL_GYM_LICENSE.txt +23 -0
  63. creature_lab/robots/data/microduck.json +253 -0
  64. creature_lab/robots/data/unitree_g1.json +174 -0
  65. creature_lab/robots/data/unitree_g1_policy.npz +0 -0
  66. creature_lab/robots/fetch.py +115 -0
  67. creature_lab/robots/film.py +78 -0
  68. creature_lab/robots/g1.py +103 -0
  69. creature_lab/robots/microduck.py +153 -0
  70. creature_lab/robots/overlay.py +384 -0
  71. creature_lab/robots/policies.py +81 -0
  72. creature_lab/robots/render.py +236 -0
  73. creature_lab/robots/report.py +307 -0
  74. creature_lab/robots/rollout.py +185 -0
  75. creature_lab/robots/scene.py +53 -0
  76. creature_lab/robots/story.py +485 -0
  77. creature_lab/robots/types.py +120 -0
  78. creature_lab/robots/video.py +277 -0
  79. creature_lab/robots/visual.py +41 -0
  80. creature_lab/robustness.py +165 -0
  81. creature_lab/runs.py +242 -0
  82. creature_lab/scaffold/__init__.py +29 -0
  83. creature_lab/scaffold/humanoid.py +227 -0
  84. creature_lab/scaffold/legged.py +144 -0
  85. creature_lab/scaffold/mirror.py +90 -0
  86. creature_lab/scaffold/worm.py +80 -0
  87. creature_lab/schema/__init__.py +79 -0
  88. creature_lab/schema/agent.py +28 -0
  89. creature_lab/schema/base.py +33 -0
  90. creature_lab/schema/control.py +49 -0
  91. creature_lab/schema/controller.py +208 -0
  92. creature_lab/schema/creature.py +235 -0
  93. creature_lab/schema/summary.py +30 -0
  94. creature_lab/schema/task.py +137 -0
  95. creature_lab/schema/trace.py +110 -0
  96. creature_lab/scoring.py +56 -0
  97. creature_lab/terrain.py +172 -0
  98. creature_lab/validation.py +103 -0
  99. creature_lab/viewers/__init__.py +1 -0
  100. creature_lab/viewers/overlays.py +71 -0
  101. creature_lab/viewers/plotting.py +43 -0
  102. creature_lab/viewers/video_exporter.py +26 -0
  103. creature_lab/viewers/viser_viewer.py +489 -0
  104. creature_lab/web/__init__.py +6 -0
  105. creature_lab/web/builder.py +91 -0
  106. creature_lab/web/creatures.py +445 -0
  107. creature_lab/web/jobs.py +172 -0
  108. creature_lab/web/library.py +254 -0
  109. creature_lab/web/scene.py +112 -0
  110. creature_lab/web/server.py +394 -0
  111. creature_lab/web/static/assets/index-CVJ_iiu_.css +1 -0
  112. creature_lab/web/static/assets/index-D-2y8anQ.js +4233 -0
  113. creature_lab/web/static/clips/unitree_g1.webp +0 -0
  114. creature_lab/web/static/index.html +16 -0
  115. creature_lab/web/thumbs.py +120 -0
  116. creature_lab/web/whatif.py +149 -0
  117. creature_lab/zoo/damaged_quadruped/baselines/recover.json +14 -0
  118. creature_lab/zoo/damaged_quadruped/baselines/recover.mujoco.json +14 -0
  119. creature_lab/zoo/damaged_quadruped/creature.json +205 -0
  120. creature_lab/zoo/damaged_quadruped/tasks/recover.json +8 -0
  121. creature_lab/zoo/hexapod/baselines/crawl_forward.json +14 -0
  122. creature_lab/zoo/hexapod/baselines/crawl_forward.mujoco.json +14 -0
  123. creature_lab/zoo/hexapod/controller.json +42 -0
  124. creature_lab/zoo/hexapod/creature.json +295 -0
  125. creature_lab/zoo/hexapod/tasks/crawl_forward.json +7 -0
  126. creature_lab/zoo/humanoid_12dof/baselines/reach.json +14 -0
  127. creature_lab/zoo/humanoid_12dof/baselines/reach.mujoco.json +14 -0
  128. creature_lab/zoo/humanoid_12dof/baselines/walk.json +14 -0
  129. creature_lab/zoo/humanoid_12dof/baselines/walk.mujoco.json +14 -0
  130. creature_lab/zoo/humanoid_12dof/controller.json +18 -0
  131. creature_lab/zoo/humanoid_12dof/creature.json +628 -0
  132. creature_lab/zoo/humanoid_12dof/tasks/reach.json +8 -0
  133. creature_lab/zoo/humanoid_12dof/tasks/walk.json +7 -0
  134. creature_lab/zoo/humanoid_minimal/baselines/balance.json +14 -0
  135. creature_lab/zoo/humanoid_minimal/baselines/balance.mujoco.json +14 -0
  136. creature_lab/zoo/humanoid_minimal/baselines/push_recovery.json +14 -0
  137. creature_lab/zoo/humanoid_minimal/baselines/push_recovery.mujoco.json +14 -0
  138. creature_lab/zoo/humanoid_minimal/baselines/walk.json +14 -0
  139. creature_lab/zoo/humanoid_minimal/baselines/walk.mujoco.json +14 -0
  140. creature_lab/zoo/humanoid_minimal/creature.json +434 -0
  141. creature_lab/zoo/humanoid_minimal/tasks/balance.json +7 -0
  142. creature_lab/zoo/humanoid_minimal/tasks/push_recovery.json +8 -0
  143. creature_lab/zoo/humanoid_minimal/tasks/walk.json +7 -0
  144. creature_lab/zoo/quadruped/baselines/crawl_forward.json +14 -0
  145. creature_lab/zoo/quadruped/baselines/crawl_forward.mujoco.json +14 -0
  146. creature_lab/zoo/quadruped/baselines/gap_cross.json +14 -0
  147. creature_lab/zoo/quadruped/baselines/gap_cross.mujoco.json +14 -0
  148. creature_lab/zoo/quadruped/baselines/low_friction_crawl.mujoco.json +14 -0
  149. creature_lab/zoo/quadruped/baselines/push_recovery.mujoco.json +14 -0
  150. creature_lab/zoo/quadruped/baselines/reach_target.json +14 -0
  151. creature_lab/zoo/quadruped/baselines/reach_target.mujoco.json +14 -0
  152. creature_lab/zoo/quadruped/baselines/slope_climb.json +14 -0
  153. creature_lab/zoo/quadruped/baselines/slope_climb.mujoco.json +14 -0
  154. creature_lab/zoo/quadruped/baselines/stability_hold.mujoco.json +14 -0
  155. creature_lab/zoo/quadruped/baselines/step_over.json +14 -0
  156. creature_lab/zoo/quadruped/baselines/step_over.mujoco.json +14 -0
  157. creature_lab/zoo/quadruped/controller.json +30 -0
  158. creature_lab/zoo/quadruped/creature.json +205 -0
  159. creature_lab/zoo/quadruped/tasks/crawl_forward.json +7 -0
  160. creature_lab/zoo/quadruped/tasks/gap_cross.json +7 -0
  161. creature_lab/zoo/quadruped/tasks/low_friction_crawl.json +7 -0
  162. creature_lab/zoo/quadruped/tasks/push_recovery.json +8 -0
  163. creature_lab/zoo/quadruped/tasks/reach_target.json +8 -0
  164. creature_lab/zoo/quadruped/tasks/slope_climb.json +7 -0
  165. creature_lab/zoo/quadruped/tasks/stability_hold.json +7 -0
  166. creature_lab/zoo/quadruped/tasks/step_over.json +7 -0
  167. creature_lab/zoo/tripod/baselines/crawl_forward.json +14 -0
  168. creature_lab/zoo/tripod/baselines/crawl_forward.mujoco.json +14 -0
  169. creature_lab/zoo/tripod/controller.json +24 -0
  170. creature_lab/zoo/tripod/creature.json +145 -0
  171. creature_lab/zoo/tripod/tasks/crawl_forward.json +7 -0
  172. creature_lab/zoo/worm/baselines/crawl_forward.json +14 -0
  173. creature_lab/zoo/worm/baselines/crawl_forward.mujoco.json +14 -0
  174. creature_lab/zoo/worm/controller.json +36 -0
  175. creature_lab/zoo/worm/creature.json +247 -0
  176. creature_lab/zoo/worm/tasks/crawl_forward.json +7 -0
  177. creature_lab/zoo.py +171 -0
  178. creature_lab-0.3.0.dist-info/METADATA +370 -0
  179. creature_lab-0.3.0.dist-info/RECORD +182 -0
  180. creature_lab-0.3.0.dist-info/WHEEL +4 -0
  181. creature_lab-0.3.0.dist-info/entry_points.txt +2 -0
  182. creature_lab-0.3.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,376 @@
1
+ """Failure-first experiment autopsy, independent of any physics backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from collections.abc import Callable
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+
11
+ from creature_lab.diagnosis import diagnose
12
+ from creature_lab.diagnostics import summarize_episode
13
+ from creature_lab.qualification import (
14
+ BUILTIN_PROFILES,
15
+ QualificationProfile,
16
+ task_setup_check,
17
+ task_success,
18
+ )
19
+ from creature_lab.robustness import RobustnessResult, run_trials
20
+ from creature_lab.schema import CreatureSpec, EpisodeTrace, TaskSpec
21
+
22
+ SimulateFn = Callable[[CreatureSpec, TaskSpec], EpisodeTrace]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AutopsyEvidence:
27
+ name: str
28
+ passed: bool | None
29
+ detail: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TrialRecording:
34
+ creature: CreatureSpec
35
+ task: TaskSpec
36
+ trace: EpisodeTrace
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AutopsyResult:
41
+ creature: str
42
+ task: str
43
+ profile: str
44
+ primary_cause: str
45
+ confidence: str
46
+ summary: str
47
+ evidence: list[AutopsyEvidence]
48
+ recommendations: list[str]
49
+ baseline_trace: EpisodeTrace = field(repr=False)
50
+ reference_trace: EpisodeTrace | None = field(repr=False)
51
+ other_backend_trace: EpisodeTrace | None = field(default=None, repr=False)
52
+ robustness: RobustnessResult | None = field(default=None, repr=False)
53
+ trial_recordings: list[TrialRecording] = field(default_factory=list, repr=False)
54
+
55
+ def to_json_dict(self) -> dict[str, object]:
56
+ robustness = None
57
+ if self.robustness is not None:
58
+ robustness = {
59
+ "trials": len(self.robustness.trials),
60
+ "mean_score": self.robustness.mean_score,
61
+ "std_score": self.robustness.std_score,
62
+ "fail_rate": self.robustness.fail_rate,
63
+ "trial_details": [asdict(trial) for trial in self.robustness.trials],
64
+ }
65
+ return {
66
+ "creature": self.creature,
67
+ "task": self.task,
68
+ "profile": self.profile,
69
+ "primary_cause": self.primary_cause,
70
+ "confidence": self.confidence,
71
+ "confidence_method": "heuristic; not a calibrated probability",
72
+ "summary": self.summary,
73
+ "evidence": [evidence.__dict__ for evidence in self.evidence],
74
+ "recommendations": self.recommendations,
75
+ "baseline": {
76
+ "run_id": self.baseline_trace.run_id,
77
+ "backend": self.baseline_trace.backend,
78
+ "score": self.baseline_trace.score,
79
+ },
80
+ "reference": (
81
+ {
82
+ "run_id": self.reference_trace.run_id,
83
+ "backend": self.reference_trace.backend,
84
+ "score": self.reference_trace.score,
85
+ }
86
+ if self.reference_trace is not None
87
+ else None
88
+ ),
89
+ "other_backend": (
90
+ {
91
+ "run_id": self.other_backend_trace.run_id,
92
+ "backend": self.other_backend_trace.backend,
93
+ "score": self.other_backend_trace.score,
94
+ }
95
+ if self.other_backend_trace is not None
96
+ else None
97
+ ),
98
+ "robustness": robustness,
99
+ "evidence_packs": {
100
+ "reference": "evidence/reference" if self.reference_trace is not None else None,
101
+ "other_backend": (
102
+ "evidence/other_backend" if self.other_backend_trace is not None else None
103
+ ),
104
+ "trials": [
105
+ {"path": f"evidence/trial_{index + 1:03d}", "run_id": item.trace.run_id}
106
+ for index, item in enumerate(self.trial_recordings)
107
+ ],
108
+ },
109
+ }
110
+
111
+
112
+ def infer_profile(task: TaskSpec) -> QualificationProfile:
113
+ if task.target is not None:
114
+ return BUILTIN_PROFILES["target-reach"]
115
+ if task.damage_event is not None or task.impulse_event is not None:
116
+ return BUILTIN_PROFILES["push-recovery"]
117
+ return BUILTIN_PROFILES["basic-locomotion"]
118
+
119
+
120
+ def distinct_curated_reference(creature: CreatureSpec, controller: str) -> bool:
121
+ """Only claim a counterfactual when exact controller snapshots differ."""
122
+ from creature_lab.controllers.factory import curated_controller
123
+ from creature_lab.exporting import file_hash, resolve_controller_bundle
124
+
125
+ reference = curated_controller(creature)
126
+ selected = reference if controller == "curated" else controller
127
+ try:
128
+ a = resolve_controller_bundle(creature, selected)
129
+ b = resolve_controller_bundle(creature, reference)
130
+ if not a.exact or not b.exact:
131
+ return False
132
+
133
+ def behavior(bundle):
134
+ data = bundle.spec.model_dump(mode="json", exclude_none=True)
135
+ for key in ("name", "creature_hash", "task_hash", "runtime_versions"):
136
+ data.pop(key, None)
137
+ if bundle.policy_source is not None:
138
+ data["policy_file"] = file_hash(bundle.policy_source)
139
+ return data
140
+
141
+ return behavior(a) != behavior(b)
142
+ except (OSError, ValueError):
143
+ return False
144
+
145
+
146
+ def run_autopsy(
147
+ creature: CreatureSpec,
148
+ task: TaskSpec,
149
+ *,
150
+ simulate: SimulateFn,
151
+ simulate_reference: SimulateFn | None,
152
+ profile: QualificationProfile | None = None,
153
+ simulate_other_backend: SimulateFn | None = None,
154
+ robustness_trials: int = 5,
155
+ ) -> AutopsyResult:
156
+ """Run baseline/counterfactual/perturbation/backend evidence and attribute failure."""
157
+ profile = profile or infer_profile(task)
158
+ setup = task_setup_check(profile, task)
159
+ if setup is not None and not setup.passed:
160
+ raise ValueError(f"Task/profile mismatch: {setup.detail}")
161
+ if profile.max_sim2sim_gap is not None and simulate_other_backend is None:
162
+ raise ValueError("This profile requires a second physics backend.")
163
+ baseline = simulate(creature, task)
164
+ baseline_summary = summarize_episode(baseline, task, creature)
165
+ baseline_ok = task_success(profile, baseline, baseline_summary)
166
+
167
+ reference = simulate_reference(creature, task) if simulate_reference is not None else None
168
+ reference_summary = summarize_episode(reference, task, creature) if reference else None
169
+ reference_ok = task_success(profile, reference, reference_summary) if reference else None
170
+ trial_recordings: list[TrialRecording] = []
171
+
172
+ def evaluate(c: CreatureSpec, t: TaskSpec) -> tuple[float, bool, bool]:
173
+ trace = simulate(c, t)
174
+ trial_recordings.append(TrialRecording(c, t, trace))
175
+ summary = summarize_episode(trace, t, c)
176
+ return trace.score, bool(summary.fell), task_success(profile, trace, summary)
177
+
178
+ robustness = run_trials(
179
+ creature,
180
+ task,
181
+ evaluate,
182
+ trials=robustness_trials,
183
+ mass_jitter=profile.robustness_mass_jitter,
184
+ friction_jitter=profile.robustness_friction_jitter,
185
+ )
186
+ robust_ok = robustness.fail_rate <= profile.max_fail_rate
187
+
188
+ other = simulate_other_backend(creature, task) if simulate_other_backend is not None else None
189
+ portability_ok = True
190
+ normalized_gap = None
191
+ if other is not None:
192
+ normalized_gap = abs(baseline.score - other.score) / max(
193
+ 1.0, abs(baseline.score), abs(other.score)
194
+ )
195
+ threshold = profile.max_sim2sim_gap if profile.max_sim2sim_gap is not None else 0.2
196
+ portability_ok = normalized_gap <= threshold
197
+
198
+ diagnosis = diagnose(baseline, creature, task)
199
+ evidence = [
200
+ AutopsyEvidence(
201
+ "Selected controller task success",
202
+ baseline_ok,
203
+ f"score={baseline.score:.4f}, fell={bool(baseline_summary.fell)}",
204
+ ),
205
+ AutopsyEvidence(
206
+ "Curated-controller counterfactual",
207
+ reference_ok,
208
+ (
209
+ f"score={reference.score:.4f}, fell={bool(reference_summary.fell)}"
210
+ if reference is not None and reference_summary is not None
211
+ else "Not run: selected and curated controllers cannot be distinguished."
212
+ ),
213
+ ),
214
+ AutopsyEvidence(
215
+ "Perturbation robustness",
216
+ robust_ok,
217
+ f"task failure rate={robustness.fail_rate:.0%} over {len(robustness.trials)} trials",
218
+ ),
219
+ ]
220
+ if other is not None:
221
+ evidence.append(
222
+ AutopsyEvidence(
223
+ "Backend agreement",
224
+ portability_ok,
225
+ f"normalized score gap={normalized_gap:.3f} "
226
+ f"({baseline.backend} vs {other.backend})",
227
+ )
228
+ )
229
+
230
+ if not baseline_ok and reference_ok is None:
231
+ cause = "undetermined"
232
+ confidence = "low"
233
+ summary = "No distinct controller counterfactual was available; the cause is unknown."
234
+ recommendations = ["Select a different controller and rerun the analysis."]
235
+ elif not baseline_ok and reference_ok:
236
+ cause = "controller"
237
+ confidence = "high"
238
+ summary = "The same body and task succeed with the curated controller."
239
+ recommendations = [
240
+ "Compare joint targets and phase relationships against the curated controller.",
241
+ "Reduce gait amplitude/frequency or migrate the curated gait into controller.json.",
242
+ ]
243
+ elif not baseline_ok and not reference_ok:
244
+ cause = "undetermined"
245
+ confidence = "low"
246
+ summary = (
247
+ "Both tested controllers failed. The evidence does not distinguish a controller "
248
+ "problem from a body or task problem."
249
+ )
250
+ recommendations = [
251
+ "Try another controller or change one body or task variable at a time.",
252
+ "Check task scale, target/event placement, support polygon, and center of mass.",
253
+ ]
254
+ elif not robust_ok:
255
+ cause = "fragility"
256
+ confidence = "high"
257
+ summary = "The baseline works, but small mass/friction changes frequently break it."
258
+ recommendations = [
259
+ "Widen the stance, lower the center of mass, or slow the controller.",
260
+ "Tune against a distribution of perturbations instead of one nominal setup.",
261
+ ]
262
+ elif not portability_ok:
263
+ cause = "backend_sensitivity"
264
+ confidence = "medium"
265
+ summary = "Nominal behavior depends strongly on one backend's contact dynamics."
266
+ recommendations = [
267
+ "Inspect trajectory divergence and reduce contact-model-sensitive gait tuning.",
268
+ "Treat the artifact as structurally portable, not behaviorally portable yet.",
269
+ ]
270
+ else:
271
+ cause = "no_failure_detected"
272
+ confidence = "high"
273
+ summary = "The selected controller passed nominal, perturbation, and requested checks."
274
+ recommendations = [
275
+ "Increase perturbation strength or add a more demanding task-specific threshold."
276
+ ]
277
+
278
+ if diagnosis.patterns:
279
+ recommendations.extend(diagnosis.suggestions[:2])
280
+ return AutopsyResult(
281
+ creature=creature.name,
282
+ task=task.name,
283
+ profile=profile.name,
284
+ primary_cause=cause,
285
+ confidence=confidence,
286
+ summary=summary,
287
+ evidence=evidence,
288
+ recommendations=list(dict.fromkeys(recommendations)),
289
+ baseline_trace=baseline,
290
+ reference_trace=reference,
291
+ other_backend_trace=other,
292
+ robustness=robustness,
293
+ trial_recordings=trial_recordings,
294
+ )
295
+
296
+
297
+ def write_autopsy_evidence(
298
+ result: AutopsyResult,
299
+ creature: CreatureSpec,
300
+ task: TaskSpec,
301
+ out: Path,
302
+ *,
303
+ overwrite: bool = False,
304
+ ) -> None:
305
+ """Save hash-verifiable packs for each recorded counterfactual and trial."""
306
+ import shutil
307
+
308
+ from creature_lab.exporting import export_design_pack, verify_design_pack
309
+
310
+ evidence_dir = out / "evidence"
311
+ if evidence_dir.exists():
312
+ if not overwrite:
313
+ raise FileExistsError(f"evidence directory already exists: {evidence_dir}")
314
+ if evidence_dir.is_symlink() or evidence_dir.resolve().parent != out.resolve():
315
+ raise ValueError("refusing to replace an evidence directory outside the report")
316
+ shutil.rmtree(evidence_dir)
317
+ recordings: list[tuple[str, CreatureSpec, TaskSpec, EpisodeTrace]] = []
318
+ if result.reference_trace is not None:
319
+ recordings.append(("reference", creature, task, result.reference_trace))
320
+ if result.other_backend_trace is not None:
321
+ recordings.append(("other_backend", creature, task, result.other_backend_trace))
322
+ for index, trial in enumerate(result.trial_recordings):
323
+ recordings.append((f"trial_{index + 1:03d}", trial.creature, trial.task, trial.trace))
324
+ for name, body, scenario, trace in recordings:
325
+ destination = evidence_dir / name
326
+ export_design_pack(body, scenario, trace, out_dir=destination)
327
+ verification = verify_design_pack(destination)
328
+ if not verification.valid:
329
+ raise ValueError(
330
+ f"{name} evidence verification failed: {'; '.join(verification.errors)}"
331
+ )
332
+
333
+
334
+ def autopsy_to_markdown(result: AutopsyResult) -> str:
335
+ lines = [
336
+ f"# Creature Lab Experiment Autopsy: {result.creature}",
337
+ "",
338
+ f"**Candidate explanation:** `{result.primary_cause}` (heuristic)",
339
+ "",
340
+ "This rule-based assessment is not a calibrated probability or proof of causation.",
341
+ "",
342
+ result.summary,
343
+ "",
344
+ "## Evidence",
345
+ "",
346
+ ]
347
+ for item in result.evidence:
348
+ status = "SKIPPED" if item.passed is None else "PASS" if item.passed else "FAIL"
349
+ lines.append(f"- {status} — **{item.name}:** {item.detail}")
350
+ lines.extend(["", "## Recommended next experiments", ""])
351
+ lines.extend(f"- {item}" for item in result.recommendations)
352
+ lines.append("")
353
+ return "\n".join(lines)
354
+
355
+
356
+ def autopsy_to_html(result: AutopsyResult) -> str:
357
+ payload = result.to_json_dict()
358
+ evidence = "".join(
359
+ f"<li class='{'skipped' if item.passed is None else 'pass' if item.passed else 'fail'}'>"
360
+ "<strong>"
361
+ f"{html.escape(item.name)}</strong>: {html.escape(item.detail)}</li>"
362
+ for item in result.evidence
363
+ )
364
+ recommendations = "".join(f"<li>{html.escape(item)}</li>" for item in result.recommendations)
365
+ return f"""<!doctype html>
366
+ <html><head><meta charset="utf-8"><title>Creature Lab Autopsy</title>
367
+ <style>body{{font:16px system-ui;max-width:900px;margin:3rem auto;padding:0 1rem;color:#172033}}
368
+ .card{{border:1px solid #ccd3df;border-radius:12px;padding:1.2rem;margin:1rem 0}}
369
+ .pass{{color:#176b3a}}.fail{{color:#a52b2b}}code{{background:#eef1f6;padding:.15rem .35rem}}
370
+ </style></head><body><h1>Experiment Autopsy: {html.escape(result.creature)}</h1>
371
+ <div class="card"><h2>Candidate explanation: <code>{html.escape(result.primary_cause)}</code></h2>
372
+ <p>{html.escape(result.summary)}</p><p>This rule-based assessment is not a calibrated
373
+ probability or proof of causation.</p></div>
374
+ <h2>Evidence</h2><ul>{evidence}</ul><h2>Recommended next experiments</h2>
375
+ <ul>{recommendations}</ul><details><summary>Machine-readable result</summary>
376
+ <pre>{html.escape(json.dumps(payload, indent=2))}</pre></details></body></html>"""
@@ -0,0 +1 @@
1
+ """Simulator adapters. Only backend modules may import a physics engine."""
@@ -0,0 +1,25 @@
1
+ """Backend-neutral simulator protocol.
2
+
3
+ Every physics engine adapter (PyBullet first, others later) implements this
4
+ protocol. Code outside `backends/` must only depend on this interface, never
5
+ on a specific engine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from creature_lab.schema import CreatureSpec, FrameState, TaskSpec
13
+
14
+
15
+ @runtime_checkable
16
+ class SimBackend(Protocol):
17
+ def build(self, creature: CreatureSpec, task: TaskSpec, *, seed: int | None = None) -> None: ...
18
+ def reset(self) -> None: ...
19
+ def step(self, dt: float) -> FrameState: ...
20
+ def observe(self) -> FrameState: ...
21
+ def apply_motor_targets(self, targets: dict[str, float]) -> None: ...
22
+ def apply_joint_control(self, targets: dict[str, float], mode: str = "position") -> None: ...
23
+ def damage_part(self, part_id: str) -> None: ...
24
+ def score_summary(self) -> dict[str, float]: ...
25
+ def close(self) -> None: ...
@@ -0,0 +1,253 @@
1
+ """MuJoCo implementation of the SimBackend protocol.
2
+
3
+ Only this module imports ``mujoco``. The model is built at runtime from the MJCF
4
+ exporter (``export_mjcf``), so the creature spec stays the single source of truth.
5
+ Scoring reuses the backend-neutral ``scoring`` module, exactly like the PyBullet
6
+ backend, so the two engines score episodes the same way (even though their exact
7
+ dynamics differ — see docs/archive/MVP_PLAN.md on portability).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ import mujoco
15
+ import numpy as np
16
+
17
+ from creature_lab.export.mjcf import export_mjcf
18
+ from creature_lab.schema import CreatureSpec, PartPose, TaskSpec
19
+ from creature_lab.schema.creature import JointType
20
+ from creature_lab.schema.trace import ContactSpec, FrameState
21
+ from creature_lab.scoring import score_components
22
+ from creature_lab.terrain import is_flat, normalized_heightfield_data
23
+
24
+
25
+ def backend_version() -> str:
26
+ """Human-readable MuJoCo backend version."""
27
+ return f"mujoco {mujoco.__version__}"
28
+
29
+
30
+ class MuJoCoBackend:
31
+ """Builds a creature as a MuJoCo model and steps it."""
32
+
33
+ def __init__(self, gui: bool = False) -> None:
34
+ # gui is accepted for protocol parity; this backend renders via traces.
35
+ self._creature: CreatureSpec | None = None
36
+ self._task: TaskSpec | None = None
37
+ self._model: mujoco.MjModel | None = None
38
+ self._data: mujoco.MjData | None = None
39
+ self._root_body = ""
40
+ self._ground_geom = -1
41
+ self._geom_part: dict[int, str] = {}
42
+ self._damaged: set[str] = set()
43
+ self._t = 0.0
44
+ self._energy = 0.0
45
+ self._initial_root_x = 0.0
46
+ self._initial_target_distance = 0.0
47
+ self._last_score_components: dict[str, float] = {}
48
+ self._damage_fired = False
49
+ self._impulse_fired = False
50
+ self._seed: int | None = None
51
+
52
+ def build(self, creature: CreatureSpec, task: TaskSpec, *, seed: int | None = None) -> None:
53
+ self._creature = creature
54
+ self._task = task
55
+ self._seed = seed
56
+ xml = export_mjcf(
57
+ creature, friction=task.terrain.friction, timestep=task.timestep, terrain=task.terrain
58
+ )
59
+ self._model = mujoco.MjModel.from_xml_string(xml)
60
+ if not is_flat(task.terrain):
61
+ self._model.hfield_data[:] = normalized_heightfield_data(task.terrain)
62
+ self._data = mujoco.MjData(self._model)
63
+
64
+ child_ids = {joint.child for joint in creature.joints}
65
+ self._root_body = next(p.id for p in creature.parts if p.id not in child_ids)
66
+ self._ground_geom = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_GEOM, "ground")
67
+ # geom index -> part id (each part has exactly one geom, added in part order).
68
+ self._geom_part = {}
69
+ for part in creature.parts:
70
+ body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, part.id)
71
+ for geom in range(
72
+ self._model.body_geomadr[body_id],
73
+ self._model.body_geomadr[body_id] + self._model.body_geomnum[body_id],
74
+ ):
75
+ self._geom_part[geom] = part.id
76
+
77
+ self._t = 0.0
78
+ self._energy = 0.0
79
+ self._damaged.clear()
80
+ self._damage_fired = False
81
+ self._impulse_fired = False
82
+ mujoco.mj_forward(self._model, self._data)
83
+ self._initial_root_x = float(self._root_pos()[0])
84
+ self._initial_target_distance = (
85
+ math.dist(self._root_pos(), task.target.position) if task.target is not None else 0.0
86
+ )
87
+
88
+ def reset(self) -> None:
89
+ if self._creature is None or self._task is None:
90
+ raise RuntimeError("build() must be called before reset()")
91
+ self.build(self._creature, self._task, seed=self._seed)
92
+
93
+ def step(self, dt: float) -> FrameState:
94
+ if self._model is None or self._data is None or self._task is None:
95
+ raise RuntimeError("build() must be called before step()")
96
+ self._t += dt
97
+ events: list[str] = []
98
+
99
+ damage = self._task.damage_event
100
+ if damage is not None and not self._damage_fired and self._t >= damage.time:
101
+ self.damage_part(damage.part_id)
102
+ self._damage_fired = True
103
+ events.append(f"damage:{damage.part_id}")
104
+
105
+ impulse = self._task.impulse_event
106
+ if impulse is not None and not self._impulse_fired and self._t >= impulse.time:
107
+ body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, impulse.part_id)
108
+ self._data.xfrc_applied[body_id, :3] = impulse.force
109
+ self._impulse_fired = True
110
+ events.append(f"impulse:{impulse.part_id}")
111
+
112
+ # ``step(dt)`` is part of the backend contract. Keep MuJoCo's actual
113
+ # integrator step aligned with the requested duration instead of advancing
114
+ # physics by a stale model timestep while only bookkeeping uses ``dt``.
115
+ self._model.opt.timestep = dt
116
+ mujoco.mj_step(self._model, self._data)
117
+ if impulse is not None and self._impulse_fired:
118
+ body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, impulse.part_id)
119
+ self._data.xfrc_applied[body_id, :3] = 0.0 # one-step push only
120
+ self._accumulate_energy(dt)
121
+
122
+ frame = self._read_frame()
123
+ if events:
124
+ frame = frame.model_copy(update={"events": events})
125
+ return frame
126
+
127
+ def apply_motor_targets(self, targets: dict[str, float]) -> None:
128
+ if self._model is None or self._data is None:
129
+ raise RuntimeError("build() must be called before apply_motor_targets()")
130
+ for joint_id, value in targets.items():
131
+ if joint_id in self._damaged:
132
+ continue
133
+ actuator_id = mujoco.mj_name2id(
134
+ self._model, mujoco.mjtObj.mjOBJ_ACTUATOR, f"act_{joint_id}"
135
+ )
136
+ if actuator_id >= 0:
137
+ self._data.ctrl[actuator_id] = value
138
+
139
+ def apply_joint_control(self, targets: dict[str, float], mode: str = "position") -> None:
140
+ """Position control of motored joints (used by CreatureEnv).
141
+
142
+ The MJCF exports ``<position>`` servos, so only position mode is supported;
143
+ velocity/torque control would need a different actuator model.
144
+ """
145
+ if mode != "position":
146
+ raise NotImplementedError(
147
+ f"MuJoCo backend supports position control only, not {mode!r}"
148
+ )
149
+ self.apply_motor_targets(targets)
150
+
151
+ def damage_part(self, part_id: str) -> None:
152
+ if self._creature is None:
153
+ raise RuntimeError("build() must be called before damage_part()")
154
+ # Disable the actuator on the joint that drives this part (best-effort parity
155
+ # with PyBullet: the limb goes limp).
156
+ for joint in self._creature.joints:
157
+ if joint.child == part_id:
158
+ self._damaged.add(joint.id)
159
+ actuator_id = mujoco.mj_name2id(
160
+ self._model, mujoco.mjtObj.mjOBJ_ACTUATOR, f"act_{joint.id}"
161
+ )
162
+ if actuator_id >= 0:
163
+ self._data.ctrl[actuator_id] = 0.0
164
+
165
+ def close(self) -> None:
166
+ self._model = None
167
+ self._data = None
168
+
169
+ def score_summary(self) -> dict[str, float]:
170
+ return dict(self._last_score_components)
171
+
172
+ def observe(self) -> FrameState:
173
+ return self._read_frame()
174
+
175
+ # -- internals --------------------------------------------------------------
176
+
177
+ def _root_pos(self) -> tuple[float, float, float]:
178
+ body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, self._root_body)
179
+ return tuple(float(v) for v in self._data.xpos[body_id])
180
+
181
+ def _accumulate_energy(self, dt: float) -> None:
182
+ for joint in self._creature.joints:
183
+ if joint.type != JointType.HINGE:
184
+ continue
185
+ jid = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_JOINT, joint.id)
186
+ if jid < 0:
187
+ continue
188
+ velocity = float(self._data.qvel[self._model.jnt_dofadr[jid]])
189
+ self._energy += velocity * velocity * dt
190
+
191
+ def _read_frame(self) -> FrameState:
192
+ parts: dict[str, PartPose] = {}
193
+ for part in self._creature.parts:
194
+ body_id = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, part.id)
195
+ position = tuple(float(v) for v in self._data.xpos[body_id])
196
+ orientation = tuple(float(v) for v in self._data.xquat[body_id]) # MuJoCo: w,x,y,z
197
+ parts[part.id] = PartPose(position=position, orientation=orientation)
198
+
199
+ joint_angles: dict[str, float] = {}
200
+ for joint in self._creature.joints:
201
+ if joint.type != JointType.HINGE:
202
+ continue
203
+ jid = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_JOINT, joint.id)
204
+ if jid >= 0:
205
+ joint_angles[joint.id] = float(self._data.qpos[self._model.jnt_qposadr[jid]])
206
+
207
+ root_position = self._root_pos()
208
+ forward_distance = root_position[0] - self._initial_root_x
209
+ target_progress = 0.0
210
+ if self._task.target is not None:
211
+ target_progress = self._initial_target_distance - math.dist(
212
+ root_position, self._task.target.position
213
+ )
214
+ root_body = mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_BODY, self._root_body)
215
+ up_z = float(self._data.xmat[root_body].reshape(3, 3)[2, 2])
216
+
217
+ self._last_score_components = score_components(
218
+ self._task.reward,
219
+ forward_distance=forward_distance,
220
+ target_progress=target_progress,
221
+ energy=self._energy,
222
+ fallen=up_z < 0.5,
223
+ )
224
+ return FrameState(
225
+ t=self._t,
226
+ parts=parts,
227
+ joint_angles=joint_angles,
228
+ contacts=self._read_contacts(),
229
+ score=self._last_score_components["total"],
230
+ )
231
+
232
+ def _read_contacts(self) -> list[ContactSpec]:
233
+ contacts: list[ContactSpec] = []
234
+ force = np.zeros(6)
235
+ for i in range(self._data.ncon):
236
+ contact = self._data.contact[i]
237
+ part_geom = None
238
+ if contact.geom1 == self._ground_geom:
239
+ part_geom = contact.geom2
240
+ elif contact.geom2 == self._ground_geom:
241
+ part_geom = contact.geom1
242
+ if part_geom is None or part_geom not in self._geom_part:
243
+ continue
244
+ mujoco.mj_contactForce(self._model, self._data, i, force)
245
+ contacts.append(
246
+ ContactSpec(
247
+ part_id=self._geom_part[part_geom],
248
+ position=tuple(float(v) for v in contact.pos),
249
+ normal=tuple(float(v) for v in contact.frame[:3]),
250
+ force=max(0.0, float(force[0])), # normal component
251
+ )
252
+ )
253
+ return contacts