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,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Protocol, Sequence
|
|
6
|
+
|
|
7
|
+
from .contracts import PlanCandidate
|
|
8
|
+
from ..agent.contracts import RoutingPolicy
|
|
9
|
+
from ..agent.evidence import task_graph_hash
|
|
10
|
+
from ..agent.planner import PlannerAgent
|
|
11
|
+
from ..models import Goal, Resource, WorldState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CandidateGenerationError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CandidateSource(Protocol):
|
|
19
|
+
def generate(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
goal: Goal,
|
|
23
|
+
resources: list[Resource],
|
|
24
|
+
world: WorldState,
|
|
25
|
+
target_count: int,
|
|
26
|
+
existing: tuple[PlanCandidate, ...] = (),
|
|
27
|
+
attempt_budget: int = 12,
|
|
28
|
+
) -> tuple[PlanCandidate, ...]: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class StaticCandidateSource:
|
|
33
|
+
candidates: tuple[PlanCandidate, ...]
|
|
34
|
+
|
|
35
|
+
def generate(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
goal: Goal,
|
|
39
|
+
resources: list[Resource],
|
|
40
|
+
world: WorldState,
|
|
41
|
+
target_count: int,
|
|
42
|
+
existing: tuple[PlanCandidate, ...] = (),
|
|
43
|
+
attempt_budget: int = 12,
|
|
44
|
+
) -> tuple[PlanCandidate, ...]:
|
|
45
|
+
del goal, resources, world, attempt_budget
|
|
46
|
+
existing_fingerprints = {item.fingerprint for item in existing}
|
|
47
|
+
output = list(existing)
|
|
48
|
+
for candidate in self.candidates:
|
|
49
|
+
if candidate.fingerprint in existing_fingerprints:
|
|
50
|
+
continue
|
|
51
|
+
output.append(candidate)
|
|
52
|
+
existing_fingerprints.add(candidate.fingerprint)
|
|
53
|
+
if len(output) >= target_count:
|
|
54
|
+
break
|
|
55
|
+
return tuple(output)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AgentCandidateGenerator:
|
|
59
|
+
"""Generate unique verified task plans through one or more model policies.
|
|
60
|
+
|
|
61
|
+
Parallelism is optional and bounded. Results are merged in attempt order for
|
|
62
|
+
deterministic evidence. A failed model call consumes budget but does not
|
|
63
|
+
poison other candidates or expose provider exception text.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
planner: PlannerAgent,
|
|
69
|
+
policies: Sequence[RoutingPolicy],
|
|
70
|
+
*,
|
|
71
|
+
max_parallelism: int = 1,
|
|
72
|
+
) -> None:
|
|
73
|
+
if not policies:
|
|
74
|
+
raise ValueError("candidate_policies_required")
|
|
75
|
+
if not 1 <= max_parallelism <= 8:
|
|
76
|
+
raise ValueError("candidate_parallelism_out_of_range")
|
|
77
|
+
self.planner = planner
|
|
78
|
+
self.policies = tuple(policies)
|
|
79
|
+
self.max_parallelism = max_parallelism
|
|
80
|
+
|
|
81
|
+
def _plan_once(
|
|
82
|
+
self,
|
|
83
|
+
attempt_index: int,
|
|
84
|
+
*,
|
|
85
|
+
goal: Goal,
|
|
86
|
+
resources: list[Resource],
|
|
87
|
+
world: WorldState,
|
|
88
|
+
existing_hashes: tuple[str, ...],
|
|
89
|
+
) -> tuple[int, object | None]:
|
|
90
|
+
policy = self.policies[attempt_index % len(self.policies)]
|
|
91
|
+
feedback = (
|
|
92
|
+
f"candidate_generation_attempt:{attempt_index + 1}",
|
|
93
|
+
"Seek a meaningfully different valid resource/task decomposition when alternatives exist.",
|
|
94
|
+
"Do not invent preconditions, effects, safety claims, or motion constraints.",
|
|
95
|
+
(
|
|
96
|
+
f"existing_plan_hashes:{','.join(existing_hashes)}"
|
|
97
|
+
if existing_hashes
|
|
98
|
+
else "existing_plan_hashes:none"
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
planned = self.planner.plan(
|
|
103
|
+
goal,
|
|
104
|
+
resources,
|
|
105
|
+
world,
|
|
106
|
+
policy,
|
|
107
|
+
feedback=feedback,
|
|
108
|
+
)
|
|
109
|
+
except Exception:
|
|
110
|
+
return attempt_index, None
|
|
111
|
+
return attempt_index, planned
|
|
112
|
+
|
|
113
|
+
def generate(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
goal: Goal,
|
|
117
|
+
resources: list[Resource],
|
|
118
|
+
world: WorldState,
|
|
119
|
+
target_count: int,
|
|
120
|
+
existing: tuple[PlanCandidate, ...] = (),
|
|
121
|
+
attempt_budget: int = 12,
|
|
122
|
+
) -> tuple[PlanCandidate, ...]:
|
|
123
|
+
if target_count <= 0:
|
|
124
|
+
raise ValueError("target_count_invalid")
|
|
125
|
+
if attempt_budget <= 0:
|
|
126
|
+
raise ValueError("attempt_budget_invalid")
|
|
127
|
+
|
|
128
|
+
output = list(existing)
|
|
129
|
+
hashes = {item.graph_hash for item in output}
|
|
130
|
+
attempt_index = 0
|
|
131
|
+
successful_calls = 0
|
|
132
|
+
|
|
133
|
+
while len(output) < target_count and attempt_index < attempt_budget:
|
|
134
|
+
remaining_attempts = attempt_budget - attempt_index
|
|
135
|
+
batch_size = min(self.max_parallelism, remaining_attempts)
|
|
136
|
+
batch_indices = tuple(range(attempt_index, attempt_index + batch_size))
|
|
137
|
+
snapshot_hashes = tuple(sorted(hashes))
|
|
138
|
+
batch_results: list[tuple[int, object | None]] = []
|
|
139
|
+
|
|
140
|
+
if batch_size == 1:
|
|
141
|
+
batch_results.append(
|
|
142
|
+
self._plan_once(
|
|
143
|
+
batch_indices[0],
|
|
144
|
+
goal=goal,
|
|
145
|
+
resources=resources,
|
|
146
|
+
world=world,
|
|
147
|
+
existing_hashes=snapshot_hashes,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
with ThreadPoolExecutor(
|
|
152
|
+
max_workers=batch_size,
|
|
153
|
+
thread_name_prefix="devagent-candidate",
|
|
154
|
+
) as executor:
|
|
155
|
+
futures = {
|
|
156
|
+
executor.submit(
|
|
157
|
+
self._plan_once,
|
|
158
|
+
index,
|
|
159
|
+
goal=goal,
|
|
160
|
+
resources=resources,
|
|
161
|
+
world=world,
|
|
162
|
+
existing_hashes=snapshot_hashes,
|
|
163
|
+
): index
|
|
164
|
+
for index in batch_indices
|
|
165
|
+
}
|
|
166
|
+
for future in as_completed(futures):
|
|
167
|
+
batch_results.append(future.result())
|
|
168
|
+
|
|
169
|
+
for _, planned in sorted(batch_results, key=lambda item: item[0]):
|
|
170
|
+
if planned is None:
|
|
171
|
+
continue
|
|
172
|
+
successful_calls += 1
|
|
173
|
+
graph_hash = task_graph_hash(planned.graph)
|
|
174
|
+
if graph_hash in hashes:
|
|
175
|
+
continue
|
|
176
|
+
provider = planned.evidence.selected_provider or "unknown"
|
|
177
|
+
model = planned.evidence.selected_model or "unknown"
|
|
178
|
+
output.append(
|
|
179
|
+
PlanCandidate(
|
|
180
|
+
candidate_id=f"C{len(output) + 1}",
|
|
181
|
+
graph=planned.graph,
|
|
182
|
+
source=f"{provider}:{model}",
|
|
183
|
+
metadata={
|
|
184
|
+
"trace_id": planned.evidence.trace_id,
|
|
185
|
+
"provider": provider,
|
|
186
|
+
"model": model,
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
hashes.add(graph_hash)
|
|
191
|
+
if len(output) >= target_count:
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
attempt_index += batch_size
|
|
195
|
+
|
|
196
|
+
if not output and successful_calls == 0:
|
|
197
|
+
raise CandidateGenerationError("candidate_generation_exhausted")
|
|
198
|
+
return tuple(output)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
from hashlib import sha256
|
|
8
|
+
|
|
9
|
+
from ..agent.evidence import task_graph_hash
|
|
10
|
+
from ..models import TaskGraph
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OptimizationProfileName(str, Enum):
|
|
14
|
+
BALANCED = "balanced"
|
|
15
|
+
THROUGHPUT = "throughput"
|
|
16
|
+
ROBUSTNESS = "robustness"
|
|
17
|
+
ENERGY = "energy"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ObjectiveWeights:
|
|
22
|
+
cycle_time: float
|
|
23
|
+
path_length: float
|
|
24
|
+
clearance: float
|
|
25
|
+
energy: float
|
|
26
|
+
recovery_risk: float
|
|
27
|
+
resource_contention: float
|
|
28
|
+
experience: float = 0.0
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
values = (
|
|
32
|
+
self.cycle_time,
|
|
33
|
+
self.path_length,
|
|
34
|
+
self.clearance,
|
|
35
|
+
self.energy,
|
|
36
|
+
self.recovery_risk,
|
|
37
|
+
self.resource_contention,
|
|
38
|
+
self.experience,
|
|
39
|
+
)
|
|
40
|
+
if any(not isfinite(value) or value < 0 for value in values):
|
|
41
|
+
raise ValueError("objective_weight_invalid")
|
|
42
|
+
if sum(values) <= 0:
|
|
43
|
+
raise ValueError("objective_weights_empty")
|
|
44
|
+
|
|
45
|
+
def normalized(self) -> "ObjectiveWeights":
|
|
46
|
+
total = sum(
|
|
47
|
+
(
|
|
48
|
+
self.cycle_time,
|
|
49
|
+
self.path_length,
|
|
50
|
+
self.clearance,
|
|
51
|
+
self.energy,
|
|
52
|
+
self.recovery_risk,
|
|
53
|
+
self.resource_contention,
|
|
54
|
+
self.experience,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return ObjectiveWeights(
|
|
58
|
+
cycle_time=self.cycle_time / total,
|
|
59
|
+
path_length=self.path_length / total,
|
|
60
|
+
clearance=self.clearance / total,
|
|
61
|
+
energy=self.energy / total,
|
|
62
|
+
recovery_risk=self.recovery_risk / total,
|
|
63
|
+
resource_contention=self.resource_contention / total,
|
|
64
|
+
experience=self.experience / total,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def for_profile(cls, profile: OptimizationProfileName) -> "ObjectiveWeights":
|
|
69
|
+
if profile is OptimizationProfileName.THROUGHPUT:
|
|
70
|
+
return cls(0.50, 0.15, 0.10, 0.08, 0.08, 0.04, 0.05)
|
|
71
|
+
if profile is OptimizationProfileName.ROBUSTNESS:
|
|
72
|
+
return cls(0.16, 0.08, 0.32, 0.05, 0.22, 0.07, 0.10)
|
|
73
|
+
if profile is OptimizationProfileName.ENERGY:
|
|
74
|
+
return cls(0.18, 0.12, 0.12, 0.35, 0.10, 0.05, 0.08)
|
|
75
|
+
return cls(0.30, 0.12, 0.24, 0.10, 0.12, 0.05, 0.07)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class OptimizationThresholds:
|
|
80
|
+
min_clearance_m: float = 0.0
|
|
81
|
+
max_recovery_risk: float = 0.40
|
|
82
|
+
max_resource_contention: float = 0.80
|
|
83
|
+
min_confidence: float = 0.70
|
|
84
|
+
preferred_clearance_m: float = 0.02
|
|
85
|
+
preferred_max_recovery_risk: float = 0.20
|
|
86
|
+
preferred_confidence: float = 0.90
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
for name, value in (
|
|
90
|
+
("min_clearance_m", self.min_clearance_m),
|
|
91
|
+
("max_recovery_risk", self.max_recovery_risk),
|
|
92
|
+
("max_resource_contention", self.max_resource_contention),
|
|
93
|
+
("min_confidence", self.min_confidence),
|
|
94
|
+
("preferred_clearance_m", self.preferred_clearance_m),
|
|
95
|
+
("preferred_max_recovery_risk", self.preferred_max_recovery_risk),
|
|
96
|
+
("preferred_confidence", self.preferred_confidence),
|
|
97
|
+
):
|
|
98
|
+
if not isfinite(value) or value < 0:
|
|
99
|
+
raise ValueError(f"{name}_invalid")
|
|
100
|
+
for value in (
|
|
101
|
+
self.max_recovery_risk,
|
|
102
|
+
self.max_resource_contention,
|
|
103
|
+
self.min_confidence,
|
|
104
|
+
self.preferred_max_recovery_risk,
|
|
105
|
+
self.preferred_confidence,
|
|
106
|
+
):
|
|
107
|
+
if value > 1.0:
|
|
108
|
+
raise ValueError("normalized_threshold_out_of_range")
|
|
109
|
+
if self.preferred_clearance_m < self.min_clearance_m:
|
|
110
|
+
raise ValueError("preferred_clearance_below_minimum")
|
|
111
|
+
if self.preferred_max_recovery_risk > self.max_recovery_risk:
|
|
112
|
+
raise ValueError("preferred_risk_above_maximum")
|
|
113
|
+
if self.preferred_confidence < self.min_confidence:
|
|
114
|
+
raise ValueError("preferred_confidence_below_minimum")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class CandidateMetrics:
|
|
119
|
+
cycle_time_s: float
|
|
120
|
+
path_length_m: float
|
|
121
|
+
min_clearance_m: float
|
|
122
|
+
energy_proxy: float
|
|
123
|
+
recovery_risk: float
|
|
124
|
+
resource_contention: float
|
|
125
|
+
confidence: float
|
|
126
|
+
measurement_context: str = "default"
|
|
127
|
+
metrics_origin: str = "unspecified"
|
|
128
|
+
simulation_success: bool = True
|
|
129
|
+
hard_violations: tuple[str, ...] = ()
|
|
130
|
+
unknown_fields: tuple[str, ...] = ()
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
nonnegative = (
|
|
134
|
+
("cycle_time_s", self.cycle_time_s),
|
|
135
|
+
("path_length_m", self.path_length_m),
|
|
136
|
+
("min_clearance_m", self.min_clearance_m),
|
|
137
|
+
("energy_proxy", self.energy_proxy),
|
|
138
|
+
)
|
|
139
|
+
for name, value in nonnegative:
|
|
140
|
+
if not isfinite(value) or value < 0:
|
|
141
|
+
raise ValueError(f"{name}_invalid")
|
|
142
|
+
for name, value in (
|
|
143
|
+
("recovery_risk", self.recovery_risk),
|
|
144
|
+
("resource_contention", self.resource_contention),
|
|
145
|
+
("confidence", self.confidence),
|
|
146
|
+
):
|
|
147
|
+
if not isfinite(value) or not 0.0 <= value <= 1.0:
|
|
148
|
+
raise ValueError(f"{name}_out_of_range")
|
|
149
|
+
if not self.measurement_context.strip():
|
|
150
|
+
raise ValueError("measurement_context_required")
|
|
151
|
+
if not self.metrics_origin.strip():
|
|
152
|
+
raise ValueError("metrics_origin_required")
|
|
153
|
+
if any(not item.strip() for item in self.hard_violations):
|
|
154
|
+
raise ValueError("empty_hard_violation")
|
|
155
|
+
if any(not item.strip() for item in self.unknown_fields):
|
|
156
|
+
raise ValueError("empty_unknown_field")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class PlanCandidate:
|
|
161
|
+
candidate_id: str
|
|
162
|
+
graph: TaskGraph
|
|
163
|
+
source: str = "unknown"
|
|
164
|
+
variant_key: str = "task"
|
|
165
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
166
|
+
|
|
167
|
+
def __post_init__(self) -> None:
|
|
168
|
+
if not self.candidate_id.strip():
|
|
169
|
+
raise ValueError("candidate_id_required")
|
|
170
|
+
if not self.source.strip():
|
|
171
|
+
raise ValueError("candidate_source_required")
|
|
172
|
+
if not self.variant_key.strip():
|
|
173
|
+
raise ValueError("candidate_variant_key_required")
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def graph_hash(self) -> str:
|
|
177
|
+
return task_graph_hash(self.graph)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def fingerprint(self) -> str:
|
|
181
|
+
raw = f"{self.graph_hash}:{self.variant_key.strip()}".encode("utf-8")
|
|
182
|
+
return sha256(raw).hexdigest()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class CandidateEvaluation:
|
|
187
|
+
candidate: PlanCandidate
|
|
188
|
+
metrics: CandidateMetrics
|
|
189
|
+
eligible: bool
|
|
190
|
+
disqualifiers: tuple[str, ...] = ()
|
|
191
|
+
score: float | None = None
|
|
192
|
+
pareto_member: bool = False
|
|
193
|
+
experience_bonus: float = 0.0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class OptimizationResult:
|
|
198
|
+
selected: CandidateEvaluation
|
|
199
|
+
evaluations: tuple[CandidateEvaluation, ...]
|
|
200
|
+
pareto_candidate_ids: tuple[str, ...]
|
|
201
|
+
profile: OptimizationProfileName
|
|
202
|
+
escalation_level: int
|
|
203
|
+
mode: str
|
|
204
|
+
quality_warnings: tuple[str, ...] = ()
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def comparatively_optimized(self) -> bool:
|
|
208
|
+
return len([item for item in self.evaluations if item.eligible]) >= 2
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, slots=True)
|
|
212
|
+
class OptimizationConfig:
|
|
213
|
+
profile: OptimizationProfileName = OptimizationProfileName.BALANCED
|
|
214
|
+
weights: ObjectiveWeights | None = None
|
|
215
|
+
thresholds: OptimizationThresholds = field(default_factory=OptimizationThresholds)
|
|
216
|
+
escalation_targets: tuple[int, ...] = (1, 3, 5)
|
|
217
|
+
max_generation_attempts_per_level: int = 12
|
|
218
|
+
allow_fast_path: bool = True
|
|
219
|
+
max_experience_bonus: float = 0.05
|
|
220
|
+
|
|
221
|
+
def __post_init__(self) -> None:
|
|
222
|
+
if not self.escalation_targets:
|
|
223
|
+
raise ValueError("escalation_targets_required")
|
|
224
|
+
if any(target <= 0 for target in self.escalation_targets):
|
|
225
|
+
raise ValueError("escalation_target_invalid")
|
|
226
|
+
if tuple(sorted(set(self.escalation_targets))) != self.escalation_targets:
|
|
227
|
+
raise ValueError("escalation_targets_must_be_unique_sorted")
|
|
228
|
+
if not 1 <= self.max_generation_attempts_per_level <= 64:
|
|
229
|
+
raise ValueError("generation_attempt_budget_out_of_range")
|
|
230
|
+
if not 0.0 <= self.max_experience_bonus <= 0.20:
|
|
231
|
+
raise ValueError("max_experience_bonus_out_of_range")
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def effective_weights(self) -> ObjectiveWeights:
|
|
235
|
+
return (self.weights or ObjectiveWeights.for_profile(self.profile)).normalized()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from typing import Callable, Mapping, Protocol
|
|
5
|
+
|
|
6
|
+
from .contracts import CandidateMetrics, PlanCandidate
|
|
7
|
+
from ..agent.semantic import AgentPlanSemanticPolicy
|
|
8
|
+
from ..execution import ExecutionMode, ExecutionSupervisor
|
|
9
|
+
from ..models import Goal, Resource, WorldState
|
|
10
|
+
from ..simulation import DeterministicSimulationBackend, SimulationBackend
|
|
11
|
+
from ..verification import DeterministicVerifier
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CandidateMetricEvaluator(Protocol):
|
|
15
|
+
def evaluate(
|
|
16
|
+
self,
|
|
17
|
+
candidate: PlanCandidate,
|
|
18
|
+
*,
|
|
19
|
+
goal: Goal,
|
|
20
|
+
resources: list[Resource],
|
|
21
|
+
world: WorldState,
|
|
22
|
+
) -> CandidateMetrics: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def candidate_verification_violations(
|
|
26
|
+
candidate: PlanCandidate,
|
|
27
|
+
*,
|
|
28
|
+
goal: Goal,
|
|
29
|
+
resources: list[Resource],
|
|
30
|
+
world: WorldState,
|
|
31
|
+
) -> tuple[str, ...]:
|
|
32
|
+
"""Run structural/capability and semantic verification before optimization."""
|
|
33
|
+
issues: list[str] = []
|
|
34
|
+
structural = DeterministicVerifier().verify_graph(candidate.graph, resources)
|
|
35
|
+
issues.extend(f"structural:{issue.code}" for issue in structural.issues)
|
|
36
|
+
semantic = AgentPlanSemanticPolicy().verify(goal, candidate.graph, resources, world)
|
|
37
|
+
issues.extend(f"semantic:{issue.code}" for issue in semantic.issues)
|
|
38
|
+
return tuple(dict.fromkeys(issues))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DeterministicMetricEvaluator:
|
|
42
|
+
"""Qualification evaluator with deterministic metric fixtures.
|
|
43
|
+
|
|
44
|
+
It still verifies and executes the candidate through DevAgent's deterministic
|
|
45
|
+
boundaries; fixture metrics cannot hide plan or simulation failure. Real
|
|
46
|
+
ROS/Gazebo metric extraction implements `CandidateMetricEvaluator`
|
|
47
|
+
separately.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
metrics_by_id: Mapping[str, CandidateMetrics],
|
|
53
|
+
*,
|
|
54
|
+
backend_factory: Callable[[PlanCandidate], SimulationBackend] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._metrics_by_id = dict(metrics_by_id)
|
|
57
|
+
self._backend_factory = backend_factory or (lambda _: DeterministicSimulationBackend())
|
|
58
|
+
|
|
59
|
+
def evaluate(
|
|
60
|
+
self,
|
|
61
|
+
candidate: PlanCandidate,
|
|
62
|
+
*,
|
|
63
|
+
goal: Goal,
|
|
64
|
+
resources: list[Resource],
|
|
65
|
+
world: WorldState,
|
|
66
|
+
) -> CandidateMetrics:
|
|
67
|
+
try:
|
|
68
|
+
metrics = self._metrics_by_id[candidate.candidate_id]
|
|
69
|
+
except KeyError as exc:
|
|
70
|
+
raise KeyError(f"missing_metric_fixture:{candidate.candidate_id}") from exc
|
|
71
|
+
|
|
72
|
+
verification = candidate_verification_violations(
|
|
73
|
+
candidate,
|
|
74
|
+
goal=goal,
|
|
75
|
+
resources=resources,
|
|
76
|
+
world=world,
|
|
77
|
+
)
|
|
78
|
+
if verification:
|
|
79
|
+
return replace(
|
|
80
|
+
metrics,
|
|
81
|
+
simulation_success=False,
|
|
82
|
+
hard_violations=tuple(
|
|
83
|
+
dict.fromkeys(metrics.hard_violations + ("plan_verification_failed",) + verification)
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
simulation_world = WorldState(facts=set(world.facts), values=dict(world.values))
|
|
88
|
+
report = ExecutionSupervisor().execute(
|
|
89
|
+
candidate.graph,
|
|
90
|
+
resources,
|
|
91
|
+
simulation_world,
|
|
92
|
+
self._backend_factory(candidate),
|
|
93
|
+
ExecutionMode.SIMULATION,
|
|
94
|
+
)
|
|
95
|
+
if report.completed:
|
|
96
|
+
return metrics
|
|
97
|
+
violation_codes = tuple(
|
|
98
|
+
dict.fromkeys(
|
|
99
|
+
("deterministic_simulation_failed",)
|
|
100
|
+
+ tuple(issue.code for issue in report.issues)
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return replace(
|
|
104
|
+
metrics,
|
|
105
|
+
simulation_success=False,
|
|
106
|
+
hard_violations=tuple(dict.fromkeys(metrics.hard_violations + violation_codes)),
|
|
107
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .contracts import OptimizationResult
|
|
7
|
+
from ..agent.structured import canonical_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def optimization_evidence(result: OptimizationResult) -> dict[str, Any]:
|
|
11
|
+
return {
|
|
12
|
+
"profile": result.profile.value,
|
|
13
|
+
"mode": result.mode,
|
|
14
|
+
"escalation_level": result.escalation_level,
|
|
15
|
+
"selected_candidate_id": result.selected.candidate.candidate_id,
|
|
16
|
+
"selected_graph_hash": result.selected.candidate.graph_hash,
|
|
17
|
+
"selected_candidate_fingerprint": result.selected.candidate.fingerprint,
|
|
18
|
+
"pareto_candidate_ids": list(result.pareto_candidate_ids),
|
|
19
|
+
"quality_warnings": list(result.quality_warnings),
|
|
20
|
+
"candidates": [
|
|
21
|
+
{
|
|
22
|
+
"candidate_id": evaluation.candidate.candidate_id,
|
|
23
|
+
"graph_hash": evaluation.candidate.graph_hash,
|
|
24
|
+
"candidate_fingerprint": evaluation.candidate.fingerprint,
|
|
25
|
+
"variant_key": evaluation.candidate.variant_key,
|
|
26
|
+
"source": evaluation.candidate.source,
|
|
27
|
+
"eligible": evaluation.eligible,
|
|
28
|
+
"disqualifiers": list(evaluation.disqualifiers),
|
|
29
|
+
"pareto_member": evaluation.pareto_member,
|
|
30
|
+
"score": evaluation.score,
|
|
31
|
+
"experience_bonus": evaluation.experience_bonus,
|
|
32
|
+
"metrics": {
|
|
33
|
+
"cycle_time_s": evaluation.metrics.cycle_time_s,
|
|
34
|
+
"path_length_m": evaluation.metrics.path_length_m,
|
|
35
|
+
"min_clearance_m": evaluation.metrics.min_clearance_m,
|
|
36
|
+
"energy_proxy": evaluation.metrics.energy_proxy,
|
|
37
|
+
"recovery_risk": evaluation.metrics.recovery_risk,
|
|
38
|
+
"resource_contention": evaluation.metrics.resource_contention,
|
|
39
|
+
"confidence": evaluation.metrics.confidence,
|
|
40
|
+
"measurement_context": evaluation.metrics.measurement_context,
|
|
41
|
+
"metrics_origin": evaluation.metrics.metrics_origin,
|
|
42
|
+
"simulation_success": evaluation.metrics.simulation_success,
|
|
43
|
+
"hard_violations": list(evaluation.metrics.hard_violations),
|
|
44
|
+
"unknown_fields": list(evaluation.metrics.unknown_fields),
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
for evaluation in result.evaluations
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def optimization_evidence_hash(result: OptimizationResult) -> str:
|
|
53
|
+
return sha256(canonical_json(optimization_evidence(result)).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from threading import Lock
|
|
6
|
+
|
|
7
|
+
from .contracts import CandidateMetrics, PlanCandidate
|
|
8
|
+
from ..models import Goal, Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ExperienceKey:
|
|
13
|
+
goal_action: str
|
|
14
|
+
vendor: str
|
|
15
|
+
model: str
|
|
16
|
+
destination: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ExperienceRecord:
|
|
21
|
+
key: ExperienceKey
|
|
22
|
+
candidate_fingerprint: str
|
|
23
|
+
success: bool
|
|
24
|
+
verified: bool
|
|
25
|
+
metrics: CandidateMetrics
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExperienceStore:
|
|
29
|
+
"""Bounded verified-outcome memory used only as a small ranking prior.
|
|
30
|
+
|
|
31
|
+
Experience can never make an ineligible candidate eligible and never changes
|
|
32
|
+
deterministic safety/semantic verification.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, max_records: int = 2048) -> None:
|
|
36
|
+
if not 32 <= max_records <= 100_000:
|
|
37
|
+
raise ValueError("experience_max_records_out_of_range")
|
|
38
|
+
self._records: deque[ExperienceRecord] = deque(maxlen=max_records)
|
|
39
|
+
self._lock = Lock()
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def key(goal: Goal, resource: Resource) -> ExperienceKey:
|
|
43
|
+
return ExperienceKey(
|
|
44
|
+
goal_action=goal.action.strip().lower(),
|
|
45
|
+
vendor=str(resource.metadata.get("vendor", "unknown")).strip().lower(),
|
|
46
|
+
model=str(resource.metadata.get("model", "unknown")).strip().lower(),
|
|
47
|
+
destination=goal.destination.strip().lower(),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def record(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
goal: Goal,
|
|
54
|
+
resource: Resource,
|
|
55
|
+
candidate: PlanCandidate,
|
|
56
|
+
metrics: CandidateMetrics,
|
|
57
|
+
success: bool,
|
|
58
|
+
verified: bool,
|
|
59
|
+
) -> None:
|
|
60
|
+
if not verified:
|
|
61
|
+
return
|
|
62
|
+
record = ExperienceRecord(
|
|
63
|
+
key=self.key(goal, resource),
|
|
64
|
+
candidate_fingerprint=candidate.fingerprint,
|
|
65
|
+
success=bool(success),
|
|
66
|
+
verified=True,
|
|
67
|
+
metrics=metrics,
|
|
68
|
+
)
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._records.append(record)
|
|
71
|
+
|
|
72
|
+
def prior(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
goal: Goal,
|
|
76
|
+
resource: Resource,
|
|
77
|
+
candidate: PlanCandidate,
|
|
78
|
+
) -> float:
|
|
79
|
+
key = self.key(goal, resource)
|
|
80
|
+
with self._lock:
|
|
81
|
+
matches = tuple(
|
|
82
|
+
record
|
|
83
|
+
for record in self._records
|
|
84
|
+
if record.key == key and record.candidate_fingerprint == candidate.fingerprint
|
|
85
|
+
)
|
|
86
|
+
successes = sum(1 for record in matches if record.success)
|
|
87
|
+
failures = len(matches) - successes
|
|
88
|
+
return (successes + 1.0) / (successes + failures + 2.0)
|
|
89
|
+
|
|
90
|
+
def bonus(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
goal: Goal,
|
|
94
|
+
resource: Resource,
|
|
95
|
+
candidate: PlanCandidate,
|
|
96
|
+
max_abs_bonus: float = 0.05,
|
|
97
|
+
) -> float:
|
|
98
|
+
if not 0.0 <= max_abs_bonus <= 0.20:
|
|
99
|
+
raise ValueError("experience_bonus_out_of_range")
|
|
100
|
+
prior = self.prior(goal=goal, resource=resource, candidate=candidate)
|
|
101
|
+
return (prior - 0.5) * 2.0 * max_abs_bonus
|
|
102
|
+
|
|
103
|
+
def __len__(self) -> int:
|
|
104
|
+
with self._lock:
|
|
105
|
+
return len(self._records)
|