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,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
|
+
}
|