devagent-physical-engine 0.10.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 (84) hide show
  1. devagent_physical_engine/__init__.py +44 -0
  2. devagent_physical_engine/agent/__init__.py +40 -0
  3. devagent_physical_engine/agent/compiler.py +285 -0
  4. devagent_physical_engine/agent/contracts.py +129 -0
  5. devagent_physical_engine/agent/coordinator.py +108 -0
  6. devagent_physical_engine/agent/critic.py +72 -0
  7. devagent_physical_engine/agent/evidence.py +34 -0
  8. devagent_physical_engine/agent/interpreter.py +179 -0
  9. devagent_physical_engine/agent/planner.py +105 -0
  10. devagent_physical_engine/agent/recovery.py +54 -0
  11. devagent_physical_engine/agent/routing.py +76 -0
  12. devagent_physical_engine/agent/runtime.py +270 -0
  13. devagent_physical_engine/agent/semantic.py +304 -0
  14. devagent_physical_engine/agent/structured.py +423 -0
  15. devagent_physical_engine/ai_cli.py +226 -0
  16. devagent_physical_engine/cli.py +392 -0
  17. devagent_physical_engine/doctor.py +20 -0
  18. devagent_physical_engine/engineering_agent.py +243 -0
  19. devagent_physical_engine/engineering_request.py +630 -0
  20. devagent_physical_engine/execution.py +90 -0
  21. devagent_physical_engine/models.py +143 -0
  22. devagent_physical_engine/operating_envelope.py +120 -0
  23. devagent_physical_engine/optimization/__init__.py +50 -0
  24. devagent_physical_engine/optimization/benchmark.py +122 -0
  25. devagent_physical_engine/optimization/candidates.py +198 -0
  26. devagent_physical_engine/optimization/contracts.py +235 -0
  27. devagent_physical_engine/optimization/evaluator.py +107 -0
  28. devagent_physical_engine/optimization/evidence.py +53 -0
  29. devagent_physical_engine/optimization/experience.py +105 -0
  30. devagent_physical_engine/optimization/measured.py +125 -0
  31. devagent_physical_engine/optimization/optimizer.py +215 -0
  32. devagent_physical_engine/optimization/orchestrator.py +155 -0
  33. devagent_physical_engine/physical_campaign.py +413 -0
  34. devagent_physical_engine/physical_evidence.py +214 -0
  35. devagent_physical_engine/physical_motion.py +196 -0
  36. devagent_physical_engine/planning.py +80 -0
  37. devagent_physical_engine/preexecution_contract.py +65 -0
  38. devagent_physical_engine/provider_adapters/__init__.py +22 -0
  39. devagent_physical_engine/provider_adapters/anthropic.py +112 -0
  40. devagent_physical_engine/provider_adapters/common.py +187 -0
  41. devagent_physical_engine/provider_adapters/factory.py +20 -0
  42. devagent_physical_engine/provider_adapters/gemini.py +126 -0
  43. devagent_physical_engine/provider_adapters/openai.py +95 -0
  44. devagent_physical_engine/provider_qualification.py +268 -0
  45. devagent_physical_engine/providers.py +94 -0
  46. devagent_physical_engine/qualification.py +44 -0
  47. devagent_physical_engine/qualification_cli.py +195 -0
  48. devagent_physical_engine/qualification_harness.py +917 -0
  49. devagent_physical_engine/robot_platform.py +411 -0
  50. devagent_physical_engine/robots.py +76 -0
  51. devagent_physical_engine/ros2/__init__.py +35 -0
  52. devagent_physical_engine/ros2/acceptance.py +324 -0
  53. devagent_physical_engine/ros2/commands.py +175 -0
  54. devagent_physical_engine/ros2/doctor.py +116 -0
  55. devagent_physical_engine/ros2/fk_probe.py +83 -0
  56. devagent_physical_engine/ros2/frame_alignment.py +61 -0
  57. devagent_physical_engine/ros2/gazebo_world.py +125 -0
  58. devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
  59. devagent_physical_engine/ros2/measured_motion.py +233 -0
  60. devagent_physical_engine/ros2/moveit_scene.py +121 -0
  61. devagent_physical_engine/ros2/preexecution.py +113 -0
  62. devagent_physical_engine/ros2/qualification.py +81 -0
  63. devagent_physical_engine/ros2/qualification_v10.py +252 -0
  64. devagent_physical_engine/ros2/scene_probe.py +219 -0
  65. devagent_physical_engine/ros2/state_validity_probe.py +125 -0
  66. devagent_physical_engine/ros2/tf_probe.py +51 -0
  67. devagent_physical_engine/ros2/trajectory.py +188 -0
  68. devagent_physical_engine/ros2/ur5e.py +59 -0
  69. devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
  70. devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
  71. devagent_physical_engine/setup_profile.py +356 -0
  72. devagent_physical_engine/simulation.py +32 -0
  73. devagent_physical_engine/simulation_platform.py +269 -0
  74. devagent_physical_engine/trajectory_qualification.py +201 -0
  75. devagent_physical_engine/twin.py +939 -0
  76. devagent_physical_engine/twin_builder.py +309 -0
  77. devagent_physical_engine/twin_materialization.py +404 -0
  78. devagent_physical_engine/verification.py +46 -0
  79. devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
  80. devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
  81. devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
  82. devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
  83. devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
  84. devagent_physical_engine-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,413 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import datetime, timezone
5
+ from hashlib import sha256
6
+ import json
7
+ from math import isfinite
8
+ from time import perf_counter
9
+ from typing import Any, Mapping, Sequence
10
+
11
+ from .agent.coordinator import VerifiedPlanArtifact
12
+ from .agent.evidence import task_graph_hash
13
+ from .physical_motion import MotionExecutionMetrics, PhysicalMotionPlan
14
+ from .robot_platform import QualificationState, RobotProfileRegistry, SimulationTier
15
+ from .simulation_platform import RobotSimulationAdapter
16
+ from .twin import TwinSpec, TwinValidator
17
+
18
+
19
+ MAX_PHYSICAL_CASES = 2_000
20
+
21
+
22
+ class PhysicalCampaignError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def _json_safe(payload: object, *, code: str) -> None:
27
+ try:
28
+ json.dumps(payload, sort_keys=True, allow_nan=False)
29
+ except (TypeError, ValueError) as exc:
30
+ raise PhysicalCampaignError(code) from exc
31
+
32
+
33
+ def _stable_hash(payload: object) -> str:
34
+ _json_safe(payload, code="physical_evidence_not_json_safe")
35
+ text = json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False)
36
+ return sha256(text.encode("utf-8")).hexdigest()
37
+
38
+
39
+ def _verified_plan_hashes(
40
+ plans: Sequence[VerifiedPlanArtifact],
41
+ ) -> tuple[str, ...]:
42
+ if not plans:
43
+ raise PhysicalCampaignError("physical_campaign_verified_plan_required")
44
+ hashes: set[str] = set()
45
+ for artifact in plans:
46
+ if not artifact.planner_evidence or not artifact.critic_evidence:
47
+ raise PhysicalCampaignError("physical_campaign_plan_evidence_missing")
48
+ computed = task_graph_hash(artifact.graph)
49
+ if artifact.graph_hash != computed:
50
+ raise PhysicalCampaignError("physical_campaign_plan_hash_mismatch")
51
+ hashes.add(computed)
52
+ return tuple(sorted(hashes))
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class PhysicalCase:
57
+ case_id: str
58
+ index: int
59
+ seed: int
60
+ motion: PhysicalMotionPlan
61
+ reset_state: Mapping[str, Any]
62
+ parameters: Mapping[str, float | int | str | bool] = field(default_factory=dict)
63
+ expected_success: bool | None = None
64
+ expected_failure_codes: tuple[str, ...] = ()
65
+ sweep_parameter: str | None = None
66
+ sweep_group: str | None = None
67
+
68
+ def __post_init__(self) -> None:
69
+ if not self.case_id.strip():
70
+ raise PhysicalCampaignError("physical_case_id_required")
71
+ if self.index < 0:
72
+ raise PhysicalCampaignError("physical_case_index_invalid")
73
+ if self.seed < 0:
74
+ raise PhysicalCampaignError("physical_case_seed_invalid")
75
+ if not isinstance(self.reset_state, Mapping) or not isinstance(self.parameters, Mapping):
76
+ raise PhysicalCampaignError("physical_case_mapping_invalid")
77
+ if any(not key.strip() for key in self.parameters):
78
+ raise PhysicalCampaignError("physical_case_parameter_name_invalid")
79
+ for key, value in self.parameters.items():
80
+ if isinstance(value, float) and not isfinite(value):
81
+ raise PhysicalCampaignError(f"physical_case_parameter_non_finite:{key}")
82
+ if any(not code.strip() for code in self.expected_failure_codes):
83
+ raise PhysicalCampaignError("physical_case_failure_code_empty")
84
+ if self.expected_success is True and self.expected_failure_codes:
85
+ raise PhysicalCampaignError("passing_case_cannot_expect_failure_codes")
86
+ if self.expected_success is None and self.expected_failure_codes:
87
+ raise PhysicalCampaignError("exploratory_case_cannot_expect_failure_codes")
88
+ if (self.sweep_parameter is None) != (self.sweep_group is None):
89
+ raise PhysicalCampaignError("physical_case_sweep_fields_must_pair")
90
+ if self.sweep_parameter is not None:
91
+ if not self.sweep_parameter.strip() or not self.sweep_group or not self.sweep_group.strip():
92
+ raise PhysicalCampaignError("physical_case_sweep_identity_invalid")
93
+ if self.sweep_parameter not in self.parameters:
94
+ raise PhysicalCampaignError("physical_case_sweep_parameter_missing")
95
+ _json_safe(self.reset_state, code="physical_case_reset_state_not_json_safe")
96
+ _json_safe(self.parameters, code="physical_case_parameters_not_json_safe")
97
+
98
+ @property
99
+ def oracle_backed(self) -> bool:
100
+ return self.expected_success is not None
101
+
102
+ def to_dict(self, *, include_motion: bool = False) -> dict[str, Any]:
103
+ payload: dict[str, Any] = {
104
+ "case_id": self.case_id,
105
+ "index": self.index,
106
+ "seed": self.seed,
107
+ "motion_hash": self.motion.fingerprint,
108
+ "motion_id": self.motion.motion_id,
109
+ "robot_profile_key": self.motion.robot_profile_key,
110
+ "twin_hash": self.motion.twin_hash,
111
+ "reset_state": dict(self.reset_state),
112
+ "parameters": dict(self.parameters),
113
+ "expected_success": self.expected_success,
114
+ "expected_failure_codes": list(self.expected_failure_codes),
115
+ "sweep_parameter": self.sweep_parameter,
116
+ "sweep_group": self.sweep_group,
117
+ }
118
+ if include_motion:
119
+ payload["motion"] = self.motion.to_dict()
120
+ return payload
121
+
122
+ @property
123
+ def fingerprint(self) -> str:
124
+ return _stable_hash(self.to_dict(include_motion=True))
125
+
126
+
127
+ def make_physical_case(
128
+ *,
129
+ index: int,
130
+ seed: int,
131
+ motion: PhysicalMotionPlan,
132
+ reset_state: Mapping[str, Any],
133
+ parameters: Mapping[str, float | int | str | bool] | None = None,
134
+ expected_success: bool | None = None,
135
+ expected_failure_codes: tuple[str, ...] = (),
136
+ sweep_parameter: str | None = None,
137
+ sweep_group: str | None = None,
138
+ ) -> PhysicalCase:
139
+ identity = {
140
+ "index": index,
141
+ "seed": seed,
142
+ "motion_hash": motion.fingerprint,
143
+ "reset_state": dict(reset_state),
144
+ "parameters": dict(parameters or {}),
145
+ "expected_success": expected_success,
146
+ "expected_failure_codes": list(expected_failure_codes),
147
+ "sweep_parameter": sweep_parameter,
148
+ "sweep_group": sweep_group,
149
+ }
150
+ digest = _stable_hash(identity)[:8]
151
+ return PhysicalCase(
152
+ case_id=f"PHY-{index:06d}-{digest}",
153
+ index=index,
154
+ seed=seed,
155
+ motion=motion,
156
+ reset_state=dict(reset_state),
157
+ parameters=dict(parameters or {}),
158
+ expected_success=expected_success,
159
+ expected_failure_codes=expected_failure_codes,
160
+ sweep_parameter=sweep_parameter,
161
+ sweep_group=sweep_group,
162
+ )
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class PhysicalCaseResult:
167
+ case: PhysicalCase
168
+ actual_success: bool
169
+ metrics: MotionExecutionMetrics
170
+ adapter_state: Mapping[str, Any]
171
+ duration_ms: float
172
+ runner_failure_code: str | None = None
173
+
174
+ @property
175
+ def verdict_correct(self) -> bool | None:
176
+ if self.case.expected_success is None:
177
+ return None
178
+ return self.actual_success is self.case.expected_success
179
+
180
+ @property
181
+ def reason_correct(self) -> bool | None:
182
+ if self.case.expected_success is None:
183
+ return None
184
+ if self.case.expected_success:
185
+ return self.actual_success and not self.metrics.failure_codes
186
+ if self.actual_success:
187
+ return False
188
+ if not self.case.expected_failure_codes:
189
+ return True
190
+ return set(self.case.expected_failure_codes).issubset(self.metrics.failure_codes)
191
+
192
+ @property
193
+ def unsafe_false_pass(self) -> bool:
194
+ return self.case.expected_success is False and self.actual_success
195
+
196
+ @property
197
+ def false_fail(self) -> bool:
198
+ return self.case.expected_success is True and not self.actual_success
199
+
200
+ def to_dict(self) -> dict[str, Any]:
201
+ payload = {
202
+ "case": self.case.to_dict(),
203
+ "actual_success": self.actual_success,
204
+ "verdict_correct": self.verdict_correct,
205
+ "reason_correct": self.reason_correct,
206
+ "unsafe_false_pass": self.unsafe_false_pass,
207
+ "false_fail": self.false_fail,
208
+ "metrics": self.metrics.to_dict(),
209
+ "adapter_state": dict(self.adapter_state),
210
+ "runner_failure_code": self.runner_failure_code,
211
+ "duration_ms": round(self.duration_ms, 6),
212
+ }
213
+ payload["evidence_hash"] = _stable_hash(payload)
214
+ return payload
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class PhysicalCampaignSummary:
219
+ run_id: str
220
+ robot_profile_key: str
221
+ twin_hash: str
222
+ verified_plan_hashes: tuple[str, ...]
223
+ adapter_id: str
224
+ adapter_qualification_at_start: str
225
+ profile_physics_qualification_at_start: str
226
+ physical_qualification: bool
227
+ qualification_candidate: bool
228
+ total_cases: int
229
+ oracle_backed_cases: int
230
+ exploratory_cases: int
231
+ correct_verdicts: int
232
+ correct_reasons: int
233
+ unsafe_false_passes: int
234
+ false_fails: int
235
+ execution_failures: int
236
+ corpus_hash: str
237
+ duration_ms: float
238
+
239
+ @property
240
+ def verdict_accuracy(self) -> float | None:
241
+ if self.oracle_backed_cases == 0:
242
+ return None
243
+ return self.correct_verdicts / self.oracle_backed_cases
244
+
245
+ @property
246
+ def reason_accuracy(self) -> float | None:
247
+ if self.oracle_backed_cases == 0:
248
+ return None
249
+ return self.correct_reasons / self.oracle_backed_cases
250
+
251
+ def to_dict(self) -> dict[str, Any]:
252
+ return {
253
+ **asdict(self),
254
+ "verdict_accuracy": (
255
+ None if self.verdict_accuracy is None else round(self.verdict_accuracy, 8)
256
+ ),
257
+ "reason_accuracy": (
258
+ None if self.reason_accuracy is None else round(self.reason_accuracy, 8)
259
+ ),
260
+ "sim_real_correlation": False,
261
+ "site_qualification": False,
262
+ "real_execution_allowed": False,
263
+ }
264
+
265
+
266
+ @dataclass(frozen=True, slots=True)
267
+ class PhysicalCampaignReport:
268
+ summary: PhysicalCampaignSummary
269
+ results: tuple[PhysicalCaseResult, ...]
270
+ session_id: str
271
+
272
+
273
+ class PhysicalCampaignRunner:
274
+ """Run commissioning-grade simulation campaigns on verified plan authority.
275
+
276
+ Experimental adapter qualification belongs to scope-specific harnesses. This
277
+ runner additionally requires the motion source hashes to correspond to exact
278
+ ``VerifiedPlanArtifact`` objects whose graph hashes recompute correctly and
279
+ whose planner/critic evidence is present.
280
+ """
281
+
282
+ def __init__(self, robot_profiles: RobotProfileRegistry) -> None:
283
+ self.robot_profiles = robot_profiles
284
+ self.twin_validator = TwinValidator(robot_profiles)
285
+
286
+ def _preflight(
287
+ self,
288
+ twin: TwinSpec,
289
+ adapter: RobotSimulationAdapter,
290
+ cases: Sequence[PhysicalCase],
291
+ verified_plans: Sequence[VerifiedPlanArtifact],
292
+ ) -> tuple[str, QualificationState, QualificationState, tuple[str, ...]]:
293
+ if not cases:
294
+ raise PhysicalCampaignError("physical_campaign_empty")
295
+ if len(cases) > MAX_PHYSICAL_CASES:
296
+ raise PhysicalCampaignError("physical_campaign_case_limit_exceeded")
297
+ validation = self.twin_validator.validate(twin)
298
+ if not validation.ready_for_physics:
299
+ raise PhysicalCampaignError("physical_campaign_requires_level3_twin")
300
+
301
+ verified_hashes = _verified_plan_hashes(verified_plans)
302
+ verified_set = set(verified_hashes)
303
+ profile = self.robot_profiles.get(twin.robot_profile_key)
304
+ profile_state = profile.qualification.state_for(SimulationTier.PHYSICS)
305
+ descriptor = adapter.descriptor
306
+ if descriptor.tier is not SimulationTier.PHYSICS:
307
+ raise PhysicalCampaignError("physical_campaign_requires_physics_adapter")
308
+ if profile.key not in descriptor.robot_profile_keys:
309
+ raise PhysicalCampaignError("physical_campaign_adapter_robot_mismatch")
310
+ if profile_state is not QualificationState.QUALIFIED:
311
+ raise PhysicalCampaignError("robot_profile_physics_not_qualified")
312
+ if not descriptor.commissioning_scope_qualified:
313
+ raise PhysicalCampaignError("simulation_adapter_commissioning_scope_not_qualified")
314
+
315
+ for case in cases:
316
+ if case.motion.robot_profile_key != profile.key:
317
+ raise PhysicalCampaignError("physical_case_robot_mismatch")
318
+ if case.motion.twin_hash != twin.fingerprint:
319
+ raise PhysicalCampaignError("physical_case_twin_hash_mismatch")
320
+ if case.motion.source_graph_hash not in verified_set:
321
+ raise PhysicalCampaignError("physical_case_unverified_source_graph")
322
+ corpus_hash = _stable_hash([case.to_dict(include_motion=True) for case in cases])
323
+ return corpus_hash, descriptor.qualification, profile_state, verified_hashes
324
+
325
+ def run(
326
+ self,
327
+ *,
328
+ twin: TwinSpec,
329
+ adapter: RobotSimulationAdapter,
330
+ cases: Sequence[PhysicalCase],
331
+ verified_plans: Sequence[VerifiedPlanArtifact],
332
+ ) -> PhysicalCampaignReport:
333
+ started = perf_counter()
334
+ corpus_hash, adapter_state, profile_state, verified_hashes = self._preflight(
335
+ twin, adapter, cases, verified_plans
336
+ )
337
+ health = adapter.doctor(robot_profile_key=twin.robot_profile_key)
338
+ if not health.ready:
339
+ raise PhysicalCampaignError(f"physical_adapter_doctor_failed:{health.code}")
340
+
341
+ session_id = ""
342
+ results: list[PhysicalCaseResult] = []
343
+ try:
344
+ session_id = adapter.prepare(twin=twin)
345
+ for case in cases:
346
+ case_started = perf_counter()
347
+ runner_failure: str | None = None
348
+ try:
349
+ adapter.reset(case_id=case.case_id, state=case.reset_state)
350
+ metrics = adapter.execute_motion(motion=case.motion)
351
+ state = dict(adapter.capture_state())
352
+ except Exception as exc:
353
+ runner_failure = f"physical_runner_error:{type(exc).__name__}"
354
+ metrics = MotionExecutionMetrics(
355
+ success=False,
356
+ observed_duration_s=None,
357
+ planned_duration_s=case.motion.planned_duration_s,
358
+ planned_joint_travel_rad=case.motion.joint_travel_rad,
359
+ failure_codes=(runner_failure,),
360
+ metrics_origin="physical_campaign_runner",
361
+ )
362
+ state = {"case_id": case.case_id, "runner_failure": runner_failure}
363
+ results.append(
364
+ PhysicalCaseResult(
365
+ case=case,
366
+ actual_success=metrics.success,
367
+ metrics=metrics,
368
+ adapter_state=state,
369
+ runner_failure_code=runner_failure,
370
+ duration_ms=(perf_counter() - case_started) * 1000.0,
371
+ )
372
+ )
373
+ finally:
374
+ adapter.stop()
375
+
376
+ oracle_results = [item for item in results if item.case.oracle_backed]
377
+ all_oracle = len(oracle_results) == len(results)
378
+ correct_verdicts = sum(item.verdict_correct is True for item in oracle_results)
379
+ correct_reasons = sum(item.reason_correct is True for item in oracle_results)
380
+ unsafe = sum(item.unsafe_false_pass for item in results)
381
+ false_fails = sum(item.false_fail for item in results)
382
+ execution_failures = sum(item.runner_failure_code is not None for item in results)
383
+ candidate = (
384
+ bool(results)
385
+ and all_oracle
386
+ and unsafe == 0
387
+ and correct_verdicts == len(oracle_results)
388
+ and correct_reasons == len(oracle_results)
389
+ and execution_failures == 0
390
+ )
391
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
392
+ summary = PhysicalCampaignSummary(
393
+ run_id=f"physical-{timestamp}",
394
+ robot_profile_key=twin.robot_profile_key,
395
+ twin_hash=twin.fingerprint,
396
+ verified_plan_hashes=verified_hashes,
397
+ adapter_id=adapter.descriptor.adapter_id,
398
+ adapter_qualification_at_start=adapter_state.value,
399
+ profile_physics_qualification_at_start=profile_state.value,
400
+ physical_qualification=candidate,
401
+ qualification_candidate=candidate,
402
+ total_cases=len(results),
403
+ oracle_backed_cases=len(oracle_results),
404
+ exploratory_cases=len(results) - len(oracle_results),
405
+ correct_verdicts=correct_verdicts,
406
+ correct_reasons=correct_reasons,
407
+ unsafe_false_passes=unsafe,
408
+ false_fails=false_fails,
409
+ execution_failures=execution_failures,
410
+ corpus_hash=corpus_hash,
411
+ duration_ms=(perf_counter() - started) * 1000.0,
412
+ )
413
+ return PhysicalCampaignReport(summary, tuple(results), session_id)
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ import json
5
+ from pathlib import Path
6
+ import re
7
+ from typing import Any, Mapping
8
+
9
+ from .physical_campaign import PhysicalCampaignReport, PhysicalCase
10
+ from .physical_motion import JointTrajectoryPoint, PhysicalMotionPlan
11
+ from .twin import TwinSpec
12
+
13
+
14
+ _CASE_ID_RE = re.compile(r"^PHY-\d{6}-[0-9a-f]{8}$")
15
+ _RUN_ID_RE = re.compile(r"^physical-[0-9TZ]+$")
16
+
17
+
18
+ class PhysicalEvidenceError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def _json_default(value: object) -> object:
23
+ if isinstance(value, Enum):
24
+ return value.value
25
+ raise TypeError(f"not_json_serializable:{type(value).__name__}")
26
+
27
+
28
+ def _write_json(path: Path, payload: object) -> None:
29
+ path.write_text(
30
+ json.dumps(
31
+ payload,
32
+ indent=2,
33
+ sort_keys=True,
34
+ allow_nan=False,
35
+ default=_json_default,
36
+ )
37
+ + "\n",
38
+ encoding="utf-8",
39
+ )
40
+
41
+
42
+ def motion_from_dict(payload: Mapping[str, Any]) -> PhysicalMotionPlan:
43
+ try:
44
+ raw_points = payload["points"]
45
+ if not isinstance(raw_points, list):
46
+ raise TypeError("points")
47
+ points = tuple(
48
+ JointTrajectoryPoint(
49
+ positions_rad=tuple(float(x) for x in item["positions_rad"]),
50
+ time_from_start_s=float(item["time_from_start_s"]),
51
+ velocities_rad_s=tuple(float(x) for x in item.get("velocities_rad_s", ())),
52
+ accelerations_rad_s2=tuple(
53
+ float(x) for x in item.get("accelerations_rad_s2", ())
54
+ ),
55
+ )
56
+ for item in raw_points
57
+ )
58
+ return PhysicalMotionPlan(
59
+ motion_id=str(payload["motion_id"]),
60
+ robot_profile_key=str(payload["robot_profile_key"]),
61
+ joint_names=tuple(str(x) for x in payload["joint_names"]),
62
+ points=points,
63
+ source_graph_hash=str(payload["source_graph_hash"]),
64
+ planner_id=str(payload["planner_id"]),
65
+ twin_hash=str(payload["twin_hash"]),
66
+ constraints=tuple(str(x) for x in payload.get("constraints", ())),
67
+ metadata=dict(payload.get("metadata") or {}),
68
+ )
69
+ except Exception as exc:
70
+ raise PhysicalEvidenceError("physical_motion_record_invalid") from exc
71
+
72
+
73
+ class PhysicalQualificationStore:
74
+ """Write replayable physical campaign evidence under a bounded local root."""
75
+
76
+ def __init__(self, root: Path | str = "~/.devagent/physical-qualification") -> None:
77
+ self.root = Path(root).expanduser().resolve()
78
+
79
+ def write_run(self, *, twin: TwinSpec, report: PhysicalCampaignReport) -> Path:
80
+ summary = report.summary
81
+ if twin.fingerprint != summary.twin_hash:
82
+ raise PhysicalEvidenceError("physical_evidence_twin_hash_mismatch")
83
+ if not _RUN_ID_RE.fullmatch(summary.run_id):
84
+ raise PhysicalEvidenceError("physical_evidence_run_id_invalid")
85
+ run_dir = (self.root / summary.run_id).resolve()
86
+ try:
87
+ run_dir.relative_to(self.root)
88
+ except ValueError as exc:
89
+ raise PhysicalEvidenceError("physical_evidence_path_escape") from exc
90
+ run_dir.mkdir(parents=True, exist_ok=False)
91
+ motions_dir = run_dir / "motions"
92
+ motions_dir.mkdir()
93
+
94
+ _write_json(run_dir / "twin.json", twin.to_dict())
95
+ _write_json(run_dir / "summary.json", summary.to_dict())
96
+ _write_json(
97
+ run_dir / "session.json",
98
+ {
99
+ "session_id": report.session_id,
100
+ "adapter_id": summary.adapter_id,
101
+ "twin_hash": summary.twin_hash,
102
+ },
103
+ )
104
+
105
+ motion_hashes: set[str] = set()
106
+ with (run_dir / "cases.jsonl").open("w", encoding="utf-8") as handle:
107
+ for result in report.results:
108
+ record = result.to_dict()
109
+ handle.write(
110
+ json.dumps(
111
+ record,
112
+ sort_keys=True,
113
+ allow_nan=False,
114
+ default=_json_default,
115
+ )
116
+ + "\n"
117
+ )
118
+ motion = result.case.motion
119
+ motion_hash = motion.fingerprint
120
+ if motion_hash not in motion_hashes:
121
+ _write_json(motions_dir / f"{motion_hash}.json", motion.to_dict())
122
+ motion_hashes.add(motion_hash)
123
+
124
+ self.root.mkdir(parents=True, exist_ok=True)
125
+ _write_json(
126
+ self.root / "latest-run.json",
127
+ {"run_id": summary.run_id, "run_dir": str(run_dir)},
128
+ )
129
+ return run_dir
130
+
131
+ def resolve_run(self, run_ref: Path | str | None = None) -> Path:
132
+ if run_ref is None:
133
+ pointer = self.root / "latest-run.json"
134
+ if not pointer.is_file():
135
+ raise PhysicalEvidenceError("physical_latest_run_not_found")
136
+ try:
137
+ payload = json.loads(pointer.read_text(encoding="utf-8"))
138
+ run_dir = Path(str(payload["run_dir"])).expanduser().resolve()
139
+ except Exception as exc:
140
+ raise PhysicalEvidenceError("physical_latest_run_pointer_invalid") from exc
141
+ else:
142
+ raw = Path(run_ref).expanduser()
143
+ run_dir = (self.root / raw).resolve() if not raw.is_absolute() else raw.resolve()
144
+ try:
145
+ run_dir.relative_to(self.root)
146
+ except ValueError as exc:
147
+ raise PhysicalEvidenceError("physical_run_outside_store") from exc
148
+ if not run_dir.is_dir() or not (run_dir / "cases.jsonl").is_file():
149
+ raise PhysicalEvidenceError("physical_run_not_found")
150
+ return run_dir
151
+
152
+ def load_case_record(
153
+ self, case_id: str, *, run_ref: Path | str | None = None
154
+ ) -> dict[str, Any]:
155
+ if not _CASE_ID_RE.fullmatch(case_id):
156
+ raise PhysicalEvidenceError("physical_case_id_invalid")
157
+ run_dir = self.resolve_run(run_ref)
158
+ try:
159
+ with (run_dir / "cases.jsonl").open("r", encoding="utf-8") as handle:
160
+ for line in handle:
161
+ payload = json.loads(line)
162
+ case = payload.get("case") or {}
163
+ if case.get("case_id") == case_id:
164
+ return payload
165
+ except (OSError, json.JSONDecodeError) as exc:
166
+ raise PhysicalEvidenceError("physical_case_store_invalid") from exc
167
+ raise PhysicalEvidenceError("physical_case_not_found")
168
+
169
+ def load_motion(
170
+ self, motion_hash: str, *, run_ref: Path | str | None = None
171
+ ) -> PhysicalMotionPlan:
172
+ if not re.fullmatch(r"[0-9a-f]{64}", motion_hash):
173
+ raise PhysicalEvidenceError("physical_motion_hash_invalid")
174
+ run_dir = self.resolve_run(run_ref)
175
+ target = (run_dir / "motions" / f"{motion_hash}.json").resolve()
176
+ try:
177
+ target.relative_to(run_dir / "motions")
178
+ except ValueError as exc:
179
+ raise PhysicalEvidenceError("physical_motion_path_escape") from exc
180
+ if not target.is_file():
181
+ raise PhysicalEvidenceError("physical_motion_not_found")
182
+ try:
183
+ payload = json.loads(target.read_text(encoding="utf-8"))
184
+ except Exception as exc:
185
+ raise PhysicalEvidenceError("physical_motion_record_invalid") from exc
186
+ motion = motion_from_dict(payload)
187
+ if motion.fingerprint != motion_hash:
188
+ raise PhysicalEvidenceError("physical_motion_hash_mismatch")
189
+ return motion
190
+
191
+ def load_replay_case(
192
+ self, case_id: str, *, run_ref: Path | str | None = None
193
+ ) -> PhysicalCase:
194
+ record = self.load_case_record(case_id, run_ref=run_ref)
195
+ case = record.get("case")
196
+ if not isinstance(case, Mapping):
197
+ raise PhysicalEvidenceError("physical_case_record_invalid")
198
+ motion_hash = str(case.get("motion_hash", ""))
199
+ motion = self.load_motion(motion_hash, run_ref=run_ref)
200
+ try:
201
+ return PhysicalCase(
202
+ case_id=str(case["case_id"]),
203
+ index=int(case["index"]),
204
+ seed=int(case["seed"]),
205
+ motion=motion,
206
+ reset_state=dict(case["reset_state"]),
207
+ parameters=dict(case.get("parameters") or {}),
208
+ expected_success=case.get("expected_success"),
209
+ expected_failure_codes=tuple(case.get("expected_failure_codes") or ()),
210
+ sweep_parameter=case.get("sweep_parameter"),
211
+ sweep_group=case.get("sweep_group"),
212
+ )
213
+ except Exception as exc:
214
+ raise PhysicalEvidenceError("physical_case_record_invalid") from exc