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,917 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from enum import Enum
6
+ import hashlib
7
+ import itertools
8
+ import json
9
+ from pathlib import Path
10
+ import random
11
+ import re
12
+ from time import perf_counter
13
+ from typing import Callable, Iterable, Mapping, Sequence
14
+
15
+ from .execution import ExecutionMode, ExecutionSupervisor
16
+ from .models import Goal, WorldState
17
+ from .planning import CapabilityPlanner, PlanningError
18
+ from .robots import CATALOG
19
+ from .simulation import SimulationBackend, SimulationStep
20
+
21
+
22
+ class QualificationError(RuntimeError):
23
+ """Stable qualification failure safe to surface through the CLI."""
24
+
25
+
26
+ class Verdict(str, Enum):
27
+ PASS = "PASS"
28
+ FAIL = "FAIL"
29
+
30
+
31
+ FAULT_MODES = frozenset(
32
+ {
33
+ "none",
34
+ "missing_object",
35
+ "robot_not_ready",
36
+ "collision_t1",
37
+ "collision_t2",
38
+ "collision_t3",
39
+ "joint_limit_t1",
40
+ "joint_limit_t2",
41
+ "joint_limit_t3",
42
+ "simulation_t1",
43
+ "simulation_t2",
44
+ "simulation_t3",
45
+ "unsupported_goal",
46
+ }
47
+ )
48
+
49
+ _CASE_ID_RE = re.compile(r"^CASE-\d{6}-[0-9a-f]{8}$")
50
+
51
+
52
+ def _tuple_of_strings(value: object, *, field_name: str) -> tuple[str, ...]:
53
+ if value is None:
54
+ return ()
55
+ if isinstance(value, str):
56
+ values = (value,)
57
+ elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
58
+ values = tuple(str(item) for item in value)
59
+ else:
60
+ raise QualificationError(f"invalid_{field_name}")
61
+ normalized = tuple(item.strip() for item in values)
62
+ if any(not item for item in normalized):
63
+ raise QualificationError(f"empty_{field_name}")
64
+ return normalized
65
+
66
+
67
+ def _normalize_expected(value: object) -> str:
68
+ text = str(value if value is not None else "AUTO").strip().upper()
69
+ if text not in {"AUTO", Verdict.PASS.value, Verdict.FAIL.value}:
70
+ raise QualificationError("invalid_expected_verdict")
71
+ return text
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ScenarioSpec:
76
+ name: str
77
+ robot: str
78
+ action: str
79
+ object_id: str
80
+ source: str
81
+ destination: str
82
+ initial_facts: tuple[str, ...] = ("robot_ready", "object_available")
83
+ fault_mode: str = "none"
84
+ expected: str = "AUTO"
85
+ variations: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
86
+
87
+ def __post_init__(self) -> None:
88
+ for field_name, value in (
89
+ ("name", self.name),
90
+ ("robot", self.robot),
91
+ ("action", self.action),
92
+ ("object_id", self.object_id),
93
+ ("source", self.source),
94
+ ("destination", self.destination),
95
+ ):
96
+ if not value.strip():
97
+ raise QualificationError(f"empty_{field_name}")
98
+ if self.robot not in CATALOG:
99
+ raise QualificationError(f"unknown_robot:{self.robot}")
100
+ if self.fault_mode not in FAULT_MODES:
101
+ raise QualificationError(f"unknown_fault_mode:{self.fault_mode}")
102
+ _normalize_expected(self.expected)
103
+ if any(not fact.strip() for fact in self.initial_facts):
104
+ raise QualificationError("empty_initial_fact")
105
+
106
+ allowed = {"robots", "objects", "sources", "destinations", "fault_modes"}
107
+ unknown = set(self.variations) - allowed
108
+ if unknown:
109
+ raise QualificationError(
110
+ "unknown_variation_keys:" + ",".join(sorted(unknown))
111
+ )
112
+ for key, values in self.variations.items():
113
+ if not values:
114
+ raise QualificationError(f"empty_variation:{key}")
115
+ if key == "robots":
116
+ unknown_robots = sorted(set(values) - set(CATALOG))
117
+ if unknown_robots:
118
+ raise QualificationError(
119
+ "unknown_variation_robot:" + ",".join(unknown_robots)
120
+ )
121
+ if key == "fault_modes":
122
+ unknown_faults = sorted(set(values) - FAULT_MODES)
123
+ if unknown_faults:
124
+ raise QualificationError(
125
+ "unknown_variation_fault:" + ",".join(unknown_faults)
126
+ )
127
+
128
+ def to_dict(self) -> dict:
129
+ return {
130
+ "name": self.name,
131
+ "robot": self.robot,
132
+ "goal": {
133
+ "action": self.action,
134
+ "object": self.object_id,
135
+ "source": self.source,
136
+ "destination": self.destination,
137
+ },
138
+ "initial_facts": list(self.initial_facts),
139
+ "fault_mode": self.fault_mode,
140
+ "expected": self.expected,
141
+ "variations": {
142
+ key: list(values) for key, values in sorted(self.variations.items())
143
+ },
144
+ }
145
+
146
+
147
+ def load_scenario(path: Path | str) -> ScenarioSpec:
148
+ target = Path(path).expanduser().resolve()
149
+ if not target.is_file():
150
+ raise QualificationError(f"scenario_not_found:{target}")
151
+ try:
152
+ text = target.read_text(encoding="utf-8")
153
+ except OSError as exc:
154
+ raise QualificationError(f"scenario_read_failed:{target}") from exc
155
+
156
+ try:
157
+ if target.suffix.lower() == ".json":
158
+ payload = json.loads(text)
159
+ else:
160
+ import yaml
161
+
162
+ payload = yaml.safe_load(text)
163
+ except Exception as exc:
164
+ raise QualificationError(f"scenario_parse_failed:{target.name}") from exc
165
+
166
+ if not isinstance(payload, dict):
167
+ raise QualificationError("scenario_root_must_be_mapping")
168
+ goal = payload.get("goal")
169
+ if not isinstance(goal, dict):
170
+ raise QualificationError("scenario_goal_must_be_mapping")
171
+ variations_raw = payload.get("variations") or {}
172
+ if not isinstance(variations_raw, dict):
173
+ raise QualificationError("scenario_variations_must_be_mapping")
174
+ variations = {
175
+ str(key): _tuple_of_strings(value, field_name=f"variation_{key}")
176
+ for key, value in variations_raw.items()
177
+ }
178
+
179
+ return ScenarioSpec(
180
+ name=str(payload.get("name", target.stem)).strip(),
181
+ robot=str(payload.get("robot", "ur5e")).strip(),
182
+ action=str(goal.get("action", "load")).strip(),
183
+ object_id=str(goal.get("object", "P17")).strip(),
184
+ source=str(goal.get("source", "conveyor_a")).strip(),
185
+ destination=str(goal.get("destination", "cnc_04")).strip(),
186
+ initial_facts=_tuple_of_strings(
187
+ payload.get("initial_facts", ("robot_ready", "object_available")),
188
+ field_name="initial_facts",
189
+ ),
190
+ fault_mode=str(payload.get("fault_mode", "none")).strip().lower(),
191
+ expected=_normalize_expected(payload.get("expected", "AUTO")),
192
+ variations=variations,
193
+ )
194
+
195
+
196
+ def load_scenario_suite(path: Path | str) -> tuple[ScenarioSpec, ...]:
197
+ root = Path(path).expanduser().resolve()
198
+ if not root.is_dir():
199
+ raise QualificationError(f"scenario_suite_not_found:{root}")
200
+ files = tuple(
201
+ item
202
+ for item in sorted(root.iterdir())
203
+ if item.is_file() and item.suffix.lower() in {".yaml", ".yml", ".json"}
204
+ )
205
+ if not files:
206
+ raise QualificationError("scenario_suite_empty")
207
+ return tuple(load_scenario(item) for item in files)
208
+
209
+
210
+ def expected_verdict_for_fault(fault_mode: str) -> Verdict:
211
+ return Verdict.PASS if fault_mode == "none" else Verdict.FAIL
212
+
213
+
214
+ def expected_reason_for_fault(fault_mode: str) -> str:
215
+ if fault_mode == "none":
216
+ return "goal_complete"
217
+ if fault_mode == "missing_object":
218
+ return "missing_precondition:object_available"
219
+ if fault_mode == "robot_not_ready":
220
+ return "missing_precondition:robot_ready"
221
+ if fault_mode.startswith("collision_"):
222
+ return "unsatisfied_constraint:collision_free"
223
+ if fault_mode.startswith("joint_limit_"):
224
+ return "unsatisfied_constraint:joint_limits"
225
+ if fault_mode.startswith("simulation_"):
226
+ return "simulation_failed"
227
+ if fault_mode == "unsupported_goal":
228
+ return "planning_error"
229
+ raise QualificationError(f"unknown_fault_mode:{fault_mode}")
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class ScenarioCase:
234
+ case_id: str
235
+ scenario_name: str
236
+ index: int
237
+ seed: int
238
+ robot: str
239
+ action: str
240
+ object_id: str
241
+ source: str
242
+ destination: str
243
+ initial_facts: tuple[str, ...]
244
+ fault_mode: str
245
+ expected: Verdict
246
+ expected_reason: str
247
+
248
+ def to_dict(self) -> dict:
249
+ return {
250
+ "case_id": self.case_id,
251
+ "scenario_name": self.scenario_name,
252
+ "index": self.index,
253
+ "seed": self.seed,
254
+ "robot": self.robot,
255
+ "goal": {
256
+ "action": self.action,
257
+ "object": self.object_id,
258
+ "source": self.source,
259
+ "destination": self.destination,
260
+ },
261
+ "initial_facts": list(self.initial_facts),
262
+ "fault_mode": self.fault_mode,
263
+ "expected": self.expected.value,
264
+ "expected_reason": self.expected_reason,
265
+ }
266
+
267
+ def behavior_fingerprint(self) -> str:
268
+ payload = self.to_dict()
269
+ payload.pop("case_id", None)
270
+ payload.pop("index", None)
271
+ payload.pop("seed", None)
272
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
273
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
274
+
275
+
276
+ def _case_id(payload: Mapping[str, object], *, index: int) -> str:
277
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
278
+ digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:8]
279
+ return f"CASE-{index:06d}-{digest}"
280
+
281
+
282
+ def _oracle_for_case(
283
+ initial_facts: Sequence[str],
284
+ fault_mode: str,
285
+ ) -> tuple[Verdict, str]:
286
+ facts = set(initial_facts)
287
+ if fault_mode == "missing_object":
288
+ facts.discard("object_available")
289
+ elif fault_mode == "robot_not_ready":
290
+ facts.discard("robot_ready")
291
+
292
+ if "robot_ready" not in facts:
293
+ return Verdict.FAIL, "missing_precondition:robot_ready"
294
+ if "object_available" not in facts:
295
+ return Verdict.FAIL, "missing_precondition:object_available"
296
+ return expected_verdict_for_fault(fault_mode), expected_reason_for_fault(fault_mode)
297
+
298
+
299
+ def _build_case(
300
+ spec: ScenarioSpec,
301
+ *,
302
+ index: int,
303
+ seed: int,
304
+ robot: str,
305
+ object_id: str,
306
+ source: str,
307
+ destination: str,
308
+ fault_mode: str,
309
+ ) -> ScenarioCase:
310
+ expected, expected_reason = _oracle_for_case(spec.initial_facts, fault_mode)
311
+ if spec.expected != "AUTO" and Verdict(spec.expected) is not expected:
312
+ raise QualificationError(
313
+ f"scenario_expected_conflicts_with_oracle:{spec.expected}:{expected.value}"
314
+ )
315
+ payload = {
316
+ "scenario_name": spec.name,
317
+ "seed": seed,
318
+ "robot": robot,
319
+ "action": spec.action,
320
+ "object": object_id,
321
+ "source": source,
322
+ "destination": destination,
323
+ "initial_facts": spec.initial_facts,
324
+ "fault_mode": fault_mode,
325
+ "expected": expected.value,
326
+ "expected_reason": expected_reason,
327
+ }
328
+ return ScenarioCase(
329
+ case_id=_case_id(payload, index=index),
330
+ scenario_name=spec.name,
331
+ index=index,
332
+ seed=seed,
333
+ robot=robot,
334
+ action=spec.action,
335
+ object_id=object_id,
336
+ source=source,
337
+ destination=destination,
338
+ initial_facts=spec.initial_facts,
339
+ fault_mode=fault_mode,
340
+ expected=expected,
341
+ expected_reason=expected_reason,
342
+ )
343
+
344
+
345
+ def base_case(spec: ScenarioSpec, *, index: int = 1, seed: int = 0) -> ScenarioCase:
346
+ return _build_case(
347
+ spec,
348
+ index=index,
349
+ seed=seed,
350
+ robot=spec.robot,
351
+ object_id=spec.object_id,
352
+ source=spec.source,
353
+ destination=spec.destination,
354
+ fault_mode=spec.fault_mode,
355
+ )
356
+
357
+
358
+ def variation_space_size(spec: ScenarioSpec) -> int:
359
+ options = (
360
+ spec.variations.get("robots", (spec.robot,)),
361
+ spec.variations.get("objects", (spec.object_id,)),
362
+ spec.variations.get("sources", (spec.source,)),
363
+ spec.variations.get("destinations", (spec.destination,)),
364
+ spec.variations.get("fault_modes", (spec.fault_mode,)),
365
+ )
366
+ size = 1
367
+ for values in options:
368
+ size *= len(values)
369
+ return size
370
+
371
+
372
+ def generate_fuzz_cases(
373
+ spec: ScenarioSpec,
374
+ *,
375
+ runs: int,
376
+ seed: int,
377
+ ) -> tuple[ScenarioCase, ...]:
378
+ if runs <= 0:
379
+ raise QualificationError("runs_must_be_positive")
380
+ if runs > 1_000_000:
381
+ raise QualificationError("runs_exceed_limit:1000000")
382
+
383
+ options = (
384
+ spec.variations.get("robots", (spec.robot,)),
385
+ spec.variations.get("objects", (spec.object_id,)),
386
+ spec.variations.get("sources", (spec.source,)),
387
+ spec.variations.get("destinations", (spec.destination,)),
388
+ spec.variations.get("fault_modes", (spec.fault_mode,)),
389
+ )
390
+ if runs > 1 and variation_space_size(spec) == 1:
391
+ raise QualificationError("fuzz_requires_variations")
392
+
393
+ rng = random.Random(seed)
394
+ space = variation_space_size(spec)
395
+ selections: list[tuple[str, str, str, str, str]] = []
396
+
397
+ if runs <= space and space <= 200_000:
398
+ population = list(itertools.product(*options))
399
+ selections = rng.sample(population, runs)
400
+ elif runs <= space:
401
+ selected: set[tuple[str, str, str, str, str]] = set()
402
+ while len(selections) < runs:
403
+ choice = tuple(rng.choice(values) for values in options)
404
+ if choice in selected:
405
+ continue
406
+ selected.add(choice)
407
+ selections.append(choice)
408
+ else:
409
+ selections = [
410
+ tuple(rng.choice(values) for values in options)
411
+ for _ in range(runs)
412
+ ]
413
+
414
+ cases: list[ScenarioCase] = []
415
+ for index, selection in enumerate(selections, start=1):
416
+ robot, object_id, source, destination, fault_mode = selection
417
+ cases.append(
418
+ _build_case(
419
+ spec,
420
+ index=index,
421
+ seed=seed,
422
+ robot=robot,
423
+ object_id=object_id,
424
+ source=source,
425
+ destination=destination,
426
+ fault_mode=fault_mode,
427
+ )
428
+ )
429
+ return tuple(cases)
430
+
431
+
432
+ def _target_task(fault_mode: str) -> str | None:
433
+ match = re.search(r"_t([123])$", fault_mode)
434
+ return f"T{match.group(1)}" if match else None
435
+
436
+
437
+ class QualificationSimulationBackend:
438
+ """Deterministic model backend used only by the qualification harness.
439
+
440
+ It exercises the engine's runtime precondition, simulation-result and declared
441
+ constraint gates. It is not a physics engine and its results are explicitly
442
+ reported as non-physical qualification.
443
+ """
444
+
445
+ def __init__(self, fault_mode: str) -> None:
446
+ if fault_mode not in FAULT_MODES:
447
+ raise QualificationError(f"unknown_fault_mode:{fault_mode}")
448
+ self.fault_mode = fault_mode
449
+
450
+ def run_task(self, task, world: WorldState) -> SimulationStep:
451
+ constraints = set(task.contract.constraints)
452
+ target = _target_task(self.fault_mode)
453
+ if target == task.task_id and self.fault_mode.startswith("collision_"):
454
+ constraints.discard("collision_free")
455
+ return SimulationStep(
456
+ task.task_id,
457
+ False,
458
+ frozenset(constraints),
459
+ f"qualification_fault:{self.fault_mode}",
460
+ )
461
+ if target == task.task_id and self.fault_mode.startswith("joint_limit_"):
462
+ constraints.discard("joint_limits")
463
+ return SimulationStep(
464
+ task.task_id,
465
+ False,
466
+ frozenset(constraints),
467
+ f"qualification_fault:{self.fault_mode}",
468
+ )
469
+ if target == task.task_id and self.fault_mode.startswith("simulation_"):
470
+ return SimulationStep(
471
+ task.task_id,
472
+ False,
473
+ frozenset(constraints),
474
+ f"qualification_fault:{self.fault_mode}",
475
+ )
476
+ return SimulationStep(task.task_id, True, frozenset(constraints))
477
+
478
+
479
+ @dataclass(frozen=True, slots=True)
480
+ class CaseResult:
481
+ case: ScenarioCase
482
+ actual: Verdict
483
+ actual_reason: str
484
+ verdict_correct: bool
485
+ reason_correct: bool
486
+ unsafe_false_pass: bool
487
+ false_fail: bool
488
+ executed_tasks: tuple[str, ...]
489
+ blocked_task: str | None
490
+ duration_ms: float
491
+
492
+ def to_dict(self) -> dict:
493
+ return {
494
+ "case": self.case.to_dict(),
495
+ "actual": self.actual.value,
496
+ "actual_reason": self.actual_reason,
497
+ "verdict_correct": self.verdict_correct,
498
+ "reason_correct": self.reason_correct,
499
+ "unsafe_false_pass": self.unsafe_false_pass,
500
+ "false_fail": self.false_fail,
501
+ "executed_tasks": list(self.executed_tasks),
502
+ "blocked_task": self.blocked_task,
503
+ "duration_ms": round(self.duration_ms, 6),
504
+ }
505
+
506
+
507
+ def _report_reason(report, world: WorldState) -> str:
508
+ if report.completed:
509
+ return "goal_complete" if "goal_complete" in world.facts else "completed_without_goal"
510
+ if not report.issues:
511
+ return "unknown_failure"
512
+ issue = report.issues[0]
513
+ if issue.code in {"missing_precondition", "unsatisfied_constraint"}:
514
+ return f"{issue.code}:{issue.detail}"
515
+ return issue.code
516
+
517
+
518
+ def run_case(
519
+ case: ScenarioCase,
520
+ *,
521
+ backend_factory: Callable[[str], SimulationBackend] | None = None,
522
+ ) -> CaseResult:
523
+ started = perf_counter()
524
+ facts = set(case.initial_facts)
525
+ if case.fault_mode == "missing_object":
526
+ facts.discard("object_available")
527
+ elif case.fault_mode == "robot_not_ready":
528
+ facts.discard("robot_ready")
529
+
530
+ world = WorldState(facts=facts)
531
+ actual = Verdict.FAIL
532
+ actual_reason = "unknown_failure"
533
+ executed_tasks: tuple[str, ...] = ()
534
+ blocked_task: str | None = None
535
+
536
+ try:
537
+ resource = CATALOG[case.robot]()
538
+ action = "unsupported_action" if case.fault_mode == "unsupported_goal" else case.action
539
+ goal = Goal(
540
+ case.case_id,
541
+ action,
542
+ case.object_id,
543
+ case.source,
544
+ case.destination,
545
+ )
546
+ graph = CapabilityPlanner().plan(goal, [resource])
547
+ backend = (
548
+ backend_factory(case.fault_mode)
549
+ if backend_factory is not None
550
+ else QualificationSimulationBackend(case.fault_mode)
551
+ )
552
+ report = ExecutionSupervisor().execute(
553
+ graph,
554
+ [resource],
555
+ world,
556
+ backend,
557
+ ExecutionMode.SIMULATION,
558
+ )
559
+ actual = Verdict.PASS if report.completed else Verdict.FAIL
560
+ actual_reason = _report_reason(report, world)
561
+ executed_tasks = tuple(report.executed_tasks)
562
+ blocked_task = report.blocked_task
563
+ except PlanningError:
564
+ actual = Verdict.FAIL
565
+ actual_reason = "planning_error"
566
+ except Exception as exc:
567
+ actual = Verdict.FAIL
568
+ actual_reason = f"runner_error:{type(exc).__name__}"
569
+
570
+ verdict_correct = actual is case.expected
571
+ reason_correct = verdict_correct and actual_reason == case.expected_reason
572
+ return CaseResult(
573
+ case=case,
574
+ actual=actual,
575
+ actual_reason=actual_reason,
576
+ verdict_correct=verdict_correct,
577
+ reason_correct=reason_correct,
578
+ unsafe_false_pass=case.expected is Verdict.FAIL and actual is Verdict.PASS,
579
+ false_fail=case.expected is Verdict.PASS and actual is Verdict.FAIL,
580
+ executed_tasks=executed_tasks,
581
+ blocked_task=blocked_task,
582
+ duration_ms=(perf_counter() - started) * 1000.0,
583
+ )
584
+
585
+
586
+ @dataclass(frozen=True, slots=True)
587
+ class QualificationSummary:
588
+ run_id: str
589
+ mode: str
590
+ backend: str
591
+ physical_qualification: bool
592
+ seed: int | None
593
+ scenario_names: tuple[str, ...]
594
+ total_cases: int
595
+ unique_behavioral_cases: int
596
+ correct_verdicts: int
597
+ correct_reasons: int
598
+ unsafe_false_passes: int
599
+ false_fails: int
600
+ wrong_reasons: int
601
+ expected_passes: int
602
+ expected_failures: int
603
+ actual_passes: int
604
+ actual_failures: int
605
+ corpus_hash: str
606
+ duration_ms: float
607
+
608
+ @property
609
+ def verdict_accuracy(self) -> float:
610
+ return 1.0 if self.total_cases == 0 else self.correct_verdicts / self.total_cases
611
+
612
+ @property
613
+ def reason_accuracy(self) -> float:
614
+ return 1.0 if self.total_cases == 0 else self.correct_reasons / self.total_cases
615
+
616
+ @property
617
+ def qualified(self) -> bool:
618
+ return (
619
+ self.total_cases > 0
620
+ and self.unsafe_false_passes == 0
621
+ and self.correct_verdicts == self.total_cases
622
+ and self.correct_reasons == self.total_cases
623
+ )
624
+
625
+ def to_dict(self) -> dict:
626
+ return {
627
+ "run_id": self.run_id,
628
+ "mode": self.mode,
629
+ "backend": self.backend,
630
+ "physical_qualification": self.physical_qualification,
631
+ "seed": self.seed,
632
+ "scenario_names": list(self.scenario_names),
633
+ "total_cases": self.total_cases,
634
+ "unique_behavioral_cases": self.unique_behavioral_cases,
635
+ "duplicate_cases": self.total_cases - self.unique_behavioral_cases,
636
+ "correct_verdicts": self.correct_verdicts,
637
+ "correct_reasons": self.correct_reasons,
638
+ "unsafe_false_passes": self.unsafe_false_passes,
639
+ "false_fails": self.false_fails,
640
+ "wrong_reasons": self.wrong_reasons,
641
+ "expected_passes": self.expected_passes,
642
+ "expected_failures": self.expected_failures,
643
+ "actual_passes": self.actual_passes,
644
+ "actual_failures": self.actual_failures,
645
+ "verdict_accuracy": round(self.verdict_accuracy, 8),
646
+ "reason_accuracy": round(self.reason_accuracy, 8),
647
+ "corpus_hash": self.corpus_hash,
648
+ "duration_ms": round(self.duration_ms, 6),
649
+ "qualified": self.qualified,
650
+ }
651
+
652
+
653
+ def _corpus_hash(cases: Iterable[ScenarioCase]) -> str:
654
+ digest = hashlib.sha256()
655
+ for case in cases:
656
+ digest.update(
657
+ json.dumps(
658
+ case.to_dict(),
659
+ sort_keys=True,
660
+ separators=(",", ":"),
661
+ ).encode("utf-8")
662
+ )
663
+ digest.update(b"\n")
664
+ return digest.hexdigest()
665
+
666
+
667
+ def summarize_results(
668
+ results: Sequence[CaseResult],
669
+ *,
670
+ mode: str,
671
+ seed: int | None,
672
+ run_id: str | None = None,
673
+ duration_ms: float | None = None,
674
+ ) -> QualificationSummary:
675
+ if not results:
676
+ raise QualificationError("no_qualification_results")
677
+ cases = tuple(result.case for result in results)
678
+ return QualificationSummary(
679
+ run_id=run_id or make_run_id(mode=mode, seed=seed),
680
+ mode=mode,
681
+ backend="deterministic_model",
682
+ physical_qualification=False,
683
+ seed=seed,
684
+ scenario_names=tuple(sorted({case.scenario_name for case in cases})),
685
+ total_cases=len(results),
686
+ unique_behavioral_cases=len({case.behavior_fingerprint() for case in cases}),
687
+ correct_verdicts=sum(result.verdict_correct for result in results),
688
+ correct_reasons=sum(result.reason_correct for result in results),
689
+ unsafe_false_passes=sum(result.unsafe_false_pass for result in results),
690
+ false_fails=sum(result.false_fail for result in results),
691
+ wrong_reasons=sum(
692
+ result.verdict_correct and not result.reason_correct for result in results
693
+ ),
694
+ expected_passes=sum(result.case.expected is Verdict.PASS for result in results),
695
+ expected_failures=sum(result.case.expected is Verdict.FAIL for result in results),
696
+ actual_passes=sum(result.actual is Verdict.PASS for result in results),
697
+ actual_failures=sum(result.actual is Verdict.FAIL for result in results),
698
+ corpus_hash=_corpus_hash(cases),
699
+ duration_ms=(
700
+ duration_ms
701
+ if duration_ms is not None
702
+ else sum(result.duration_ms for result in results)
703
+ ),
704
+ )
705
+
706
+
707
+ def run_cases(
708
+ cases: Sequence[ScenarioCase],
709
+ *,
710
+ mode: str,
711
+ seed: int | None,
712
+ ) -> tuple[tuple[CaseResult, ...], QualificationSummary]:
713
+ started = perf_counter()
714
+ results = tuple(run_case(case) for case in cases)
715
+ summary = summarize_results(
716
+ results,
717
+ mode=mode,
718
+ seed=seed,
719
+ duration_ms=(perf_counter() - started) * 1000.0,
720
+ )
721
+ return results, summary
722
+
723
+
724
+ def make_run_id(*, mode: str, seed: int | None) -> str:
725
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
726
+ suffix = "noseed" if seed is None else f"seed{seed}"
727
+ return f"{mode}-{timestamp}-{suffix}"
728
+
729
+
730
+ class QualificationStore:
731
+ def __init__(self, root: Path | str = "~/.devagent/qualification") -> None:
732
+ self.root = Path(root).expanduser().resolve()
733
+
734
+ def write_run(
735
+ self,
736
+ *,
737
+ scenarios: Sequence[ScenarioSpec],
738
+ results: Sequence[CaseResult],
739
+ summary: QualificationSummary,
740
+ ) -> Path:
741
+ run_dir = self.root / summary.run_id
742
+ run_dir.mkdir(parents=True, exist_ok=False)
743
+ (run_dir / "scenarios.json").write_text(
744
+ json.dumps([scenario.to_dict() for scenario in scenarios], indent=2) + "\n",
745
+ encoding="utf-8",
746
+ )
747
+ (run_dir / "summary.json").write_text(
748
+ json.dumps(summary.to_dict(), indent=2) + "\n",
749
+ encoding="utf-8",
750
+ )
751
+ with (run_dir / "cases.jsonl").open("w", encoding="utf-8") as handle:
752
+ for result in results:
753
+ handle.write(json.dumps(result.to_dict(), sort_keys=True) + "\n")
754
+
755
+ mismatches = [
756
+ result
757
+ for result in results
758
+ if not result.verdict_correct or not result.reason_correct
759
+ ]
760
+ if mismatches:
761
+ mismatch_dir = run_dir / "mismatches"
762
+ mismatch_dir.mkdir()
763
+ for result in mismatches:
764
+ (mismatch_dir / f"{result.case.case_id}.json").write_text(
765
+ json.dumps(result.to_dict(), indent=2) + "\n",
766
+ encoding="utf-8",
767
+ )
768
+
769
+ self.root.mkdir(parents=True, exist_ok=True)
770
+ (self.root / "latest-run.json").write_text(
771
+ json.dumps({"run_dir": str(run_dir)}, indent=2) + "\n",
772
+ encoding="utf-8",
773
+ )
774
+ return run_dir
775
+
776
+ def resolve_run(self, run_ref: Path | str | None = None) -> Path:
777
+ if run_ref is None:
778
+ pointer = self.root / "latest-run.json"
779
+ if not pointer.is_file():
780
+ raise QualificationError("latest_run_not_found")
781
+ try:
782
+ payload = json.loads(pointer.read_text(encoding="utf-8"))
783
+ run_dir = Path(str(payload["run_dir"])).expanduser().resolve()
784
+ except Exception as exc:
785
+ raise QualificationError("latest_run_pointer_invalid") from exc
786
+ else:
787
+ run_dir = Path(run_ref).expanduser().resolve()
788
+ if not run_dir.is_dir() or not (run_dir / "cases.jsonl").is_file():
789
+ raise QualificationError(f"qualification_run_not_found:{run_dir}")
790
+ return run_dir
791
+
792
+ def load_case_record(
793
+ self,
794
+ case_id: str,
795
+ *,
796
+ run_ref: Path | str | None = None,
797
+ ) -> dict:
798
+ if not _CASE_ID_RE.fullmatch(case_id):
799
+ raise QualificationError("invalid_case_id")
800
+ run_dir = self.resolve_run(run_ref)
801
+ with (run_dir / "cases.jsonl").open("r", encoding="utf-8") as handle:
802
+ for line in handle:
803
+ payload = json.loads(line)
804
+ case = payload.get("case") or {}
805
+ if case.get("case_id") == case_id:
806
+ return payload
807
+ raise QualificationError(f"case_not_found:{case_id}")
808
+
809
+
810
+ def case_from_dict(payload: Mapping[str, object]) -> ScenarioCase:
811
+ goal = payload.get("goal")
812
+ if not isinstance(goal, Mapping):
813
+ raise QualificationError("case_goal_invalid")
814
+ return ScenarioCase(
815
+ case_id=str(payload["case_id"]),
816
+ scenario_name=str(payload["scenario_name"]),
817
+ index=int(payload["index"]),
818
+ seed=int(payload["seed"]),
819
+ robot=str(payload["robot"]),
820
+ action=str(goal["action"]),
821
+ object_id=str(goal["object"]),
822
+ source=str(goal["source"]),
823
+ destination=str(goal["destination"]),
824
+ initial_facts=_tuple_of_strings(
825
+ payload.get("initial_facts", ()),
826
+ field_name="case_initial_facts",
827
+ ),
828
+ fault_mode=str(payload["fault_mode"]),
829
+ expected=Verdict(str(payload["expected"])),
830
+ expected_reason=str(payload["expected_reason"]),
831
+ )
832
+
833
+
834
+ def replay_case(
835
+ case_id: str,
836
+ *,
837
+ run_ref: Path | str | None = None,
838
+ store_root: Path | str = "~/.devagent/qualification",
839
+ ) -> dict:
840
+ store = QualificationStore(store_root)
841
+ recorded = store.load_case_record(case_id, run_ref=run_ref)
842
+ case = case_from_dict(recorded["case"])
843
+ replayed = run_case(case)
844
+ recorded_actual = str(recorded.get("actual"))
845
+ recorded_reason = str(recorded.get("actual_reason"))
846
+ reproduced = (
847
+ replayed.actual.value == recorded_actual
848
+ and replayed.actual_reason == recorded_reason
849
+ )
850
+ return {
851
+ "case_id": case_id,
852
+ "reproduced": reproduced,
853
+ "recorded_actual": recorded_actual,
854
+ "recorded_reason": recorded_reason,
855
+ "replayed": replayed.to_dict(),
856
+ }
857
+
858
+
859
+ def load_summary(path: Path | str) -> dict:
860
+ target = Path(path).expanduser().resolve()
861
+ if target.is_dir():
862
+ target = target / "summary.json"
863
+ if not target.is_file():
864
+ raise QualificationError(f"summary_not_found:{target}")
865
+ try:
866
+ payload = json.loads(target.read_text(encoding="utf-8"))
867
+ except Exception as exc:
868
+ raise QualificationError(f"summary_invalid:{target}") from exc
869
+ required = {
870
+ "corpus_hash",
871
+ "total_cases",
872
+ "unsafe_false_passes",
873
+ "false_fails",
874
+ "wrong_reasons",
875
+ "verdict_accuracy",
876
+ "reason_accuracy",
877
+ }
878
+ if not isinstance(payload, dict) or not required.issubset(payload):
879
+ raise QualificationError(f"summary_schema_invalid:{target}")
880
+ return payload
881
+
882
+
883
+ def compare_summaries(baseline: Mapping[str, object], candidate: Mapping[str, object]) -> dict:
884
+ comparable = (
885
+ baseline.get("corpus_hash") == candidate.get("corpus_hash")
886
+ and baseline.get("total_cases") == candidate.get("total_cases")
887
+ )
888
+ metrics = (
889
+ "unsafe_false_passes",
890
+ "false_fails",
891
+ "wrong_reasons",
892
+ "verdict_accuracy",
893
+ "reason_accuracy",
894
+ )
895
+ deltas: dict[str, float] = {}
896
+ for metric in metrics:
897
+ deltas[metric] = float(candidate[metric]) - float(baseline[metric])
898
+
899
+ regressions: list[str] = []
900
+ if comparable:
901
+ for metric in ("unsafe_false_passes", "false_fails", "wrong_reasons"):
902
+ if float(candidate[metric]) > float(baseline[metric]):
903
+ regressions.append(metric)
904
+ for metric in ("verdict_accuracy", "reason_accuracy"):
905
+ if float(candidate[metric]) < float(baseline[metric]):
906
+ regressions.append(metric)
907
+
908
+ return {
909
+ "comparable": comparable,
910
+ "same_corpus": baseline.get("corpus_hash") == candidate.get("corpus_hash"),
911
+ "total_cases": candidate.get("total_cases"),
912
+ "baseline_run_id": baseline.get("run_id"),
913
+ "candidate_run_id": candidate.get("run_id"),
914
+ "deltas": deltas,
915
+ "regressions": regressions,
916
+ "passed": comparable and not regressions,
917
+ }