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.
- devagent_physical_engine/__init__.py +44 -0
- devagent_physical_engine/agent/__init__.py +40 -0
- devagent_physical_engine/agent/compiler.py +285 -0
- devagent_physical_engine/agent/contracts.py +129 -0
- devagent_physical_engine/agent/coordinator.py +108 -0
- devagent_physical_engine/agent/critic.py +72 -0
- devagent_physical_engine/agent/evidence.py +34 -0
- devagent_physical_engine/agent/interpreter.py +179 -0
- devagent_physical_engine/agent/planner.py +105 -0
- devagent_physical_engine/agent/recovery.py +54 -0
- devagent_physical_engine/agent/routing.py +76 -0
- devagent_physical_engine/agent/runtime.py +270 -0
- devagent_physical_engine/agent/semantic.py +304 -0
- devagent_physical_engine/agent/structured.py +423 -0
- devagent_physical_engine/ai_cli.py +226 -0
- devagent_physical_engine/cli.py +392 -0
- devagent_physical_engine/doctor.py +20 -0
- devagent_physical_engine/engineering_agent.py +243 -0
- devagent_physical_engine/engineering_request.py +630 -0
- devagent_physical_engine/execution.py +90 -0
- devagent_physical_engine/models.py +143 -0
- devagent_physical_engine/operating_envelope.py +120 -0
- devagent_physical_engine/optimization/__init__.py +50 -0
- devagent_physical_engine/optimization/benchmark.py +122 -0
- devagent_physical_engine/optimization/candidates.py +198 -0
- devagent_physical_engine/optimization/contracts.py +235 -0
- devagent_physical_engine/optimization/evaluator.py +107 -0
- devagent_physical_engine/optimization/evidence.py +53 -0
- devagent_physical_engine/optimization/experience.py +105 -0
- devagent_physical_engine/optimization/measured.py +125 -0
- devagent_physical_engine/optimization/optimizer.py +215 -0
- devagent_physical_engine/optimization/orchestrator.py +155 -0
- devagent_physical_engine/physical_campaign.py +413 -0
- devagent_physical_engine/physical_evidence.py +214 -0
- devagent_physical_engine/physical_motion.py +196 -0
- devagent_physical_engine/planning.py +80 -0
- devagent_physical_engine/preexecution_contract.py +65 -0
- devagent_physical_engine/provider_adapters/__init__.py +22 -0
- devagent_physical_engine/provider_adapters/anthropic.py +112 -0
- devagent_physical_engine/provider_adapters/common.py +187 -0
- devagent_physical_engine/provider_adapters/factory.py +20 -0
- devagent_physical_engine/provider_adapters/gemini.py +126 -0
- devagent_physical_engine/provider_adapters/openai.py +95 -0
- devagent_physical_engine/provider_qualification.py +268 -0
- devagent_physical_engine/providers.py +94 -0
- devagent_physical_engine/qualification.py +44 -0
- devagent_physical_engine/qualification_cli.py +195 -0
- devagent_physical_engine/qualification_harness.py +917 -0
- devagent_physical_engine/robot_platform.py +411 -0
- devagent_physical_engine/robots.py +76 -0
- devagent_physical_engine/ros2/__init__.py +35 -0
- devagent_physical_engine/ros2/acceptance.py +324 -0
- devagent_physical_engine/ros2/commands.py +175 -0
- devagent_physical_engine/ros2/doctor.py +116 -0
- devagent_physical_engine/ros2/fk_probe.py +83 -0
- devagent_physical_engine/ros2/frame_alignment.py +61 -0
- devagent_physical_engine/ros2/gazebo_world.py +125 -0
- devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
- devagent_physical_engine/ros2/measured_motion.py +233 -0
- devagent_physical_engine/ros2/moveit_scene.py +121 -0
- devagent_physical_engine/ros2/preexecution.py +113 -0
- devagent_physical_engine/ros2/qualification.py +81 -0
- devagent_physical_engine/ros2/qualification_v10.py +252 -0
- devagent_physical_engine/ros2/scene_probe.py +219 -0
- devagent_physical_engine/ros2/state_validity_probe.py +125 -0
- devagent_physical_engine/ros2/tf_probe.py +51 -0
- devagent_physical_engine/ros2/trajectory.py +188 -0
- devagent_physical_engine/ros2/ur5e.py +59 -0
- devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
- devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
- devagent_physical_engine/setup_profile.py +356 -0
- devagent_physical_engine/simulation.py +32 -0
- devagent_physical_engine/simulation_platform.py +269 -0
- devagent_physical_engine/trajectory_qualification.py +201 -0
- devagent_physical_engine/twin.py +939 -0
- devagent_physical_engine/twin_builder.py +309 -0
- devagent_physical_engine/twin_materialization.py +404 -0
- devagent_physical_engine/verification.py +46 -0
- devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
- devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
- devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
- devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
- devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
- 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
|