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,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from .contracts import CandidateMetrics, PlanCandidate
|
|
7
|
+
from .evaluator import candidate_verification_violations
|
|
8
|
+
from ..models import Goal, Resource, WorldState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RawSimulationMetrics:
|
|
13
|
+
"""Metric payload produced by a real simulation/motion backend.
|
|
14
|
+
|
|
15
|
+
Optional values are intentional: missing measurements become explicit
|
|
16
|
+
uncertainty and therefore fail the optimizer's eligibility gate instead of
|
|
17
|
+
being guessed by an LLM.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
measurement_context: str
|
|
21
|
+
cycle_time_s: float | None = None
|
|
22
|
+
path_length_m: float | None = None
|
|
23
|
+
min_clearance_m: float | None = None
|
|
24
|
+
energy_proxy: float | None = None
|
|
25
|
+
recovery_risk: float | None = None
|
|
26
|
+
resource_contention: float | None = None
|
|
27
|
+
confidence: float | None = None
|
|
28
|
+
simulation_success: bool = True
|
|
29
|
+
hard_violations: tuple[str, ...] = ()
|
|
30
|
+
metrics_origin: str = "measured_simulation"
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not self.measurement_context.strip():
|
|
34
|
+
raise ValueError("measurement_context_required")
|
|
35
|
+
if not self.metrics_origin.strip():
|
|
36
|
+
raise ValueError("metrics_origin_required")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SimulationMetricsBackend(Protocol):
|
|
40
|
+
def measure(
|
|
41
|
+
self,
|
|
42
|
+
candidate: PlanCandidate,
|
|
43
|
+
*,
|
|
44
|
+
goal: Goal,
|
|
45
|
+
resources: list[Resource],
|
|
46
|
+
world: WorldState,
|
|
47
|
+
) -> RawSimulationMetrics: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MeasuredCandidateMetricEvaluator:
|
|
51
|
+
"""Verify first, then convert measured backend output without guessing.
|
|
52
|
+
|
|
53
|
+
Any missing required metric is listed in `unknown_fields`, which makes the
|
|
54
|
+
candidate ineligible until the backend can actually measure it.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, backend: SimulationMetricsBackend) -> None:
|
|
58
|
+
self.backend = backend
|
|
59
|
+
|
|
60
|
+
def evaluate(
|
|
61
|
+
self,
|
|
62
|
+
candidate: PlanCandidate,
|
|
63
|
+
*,
|
|
64
|
+
goal: Goal,
|
|
65
|
+
resources: list[Resource],
|
|
66
|
+
world: WorldState,
|
|
67
|
+
) -> CandidateMetrics:
|
|
68
|
+
verification = candidate_verification_violations(
|
|
69
|
+
candidate,
|
|
70
|
+
goal=goal,
|
|
71
|
+
resources=resources,
|
|
72
|
+
world=world,
|
|
73
|
+
)
|
|
74
|
+
if verification:
|
|
75
|
+
return CandidateMetrics(
|
|
76
|
+
cycle_time_s=0.0,
|
|
77
|
+
path_length_m=0.0,
|
|
78
|
+
min_clearance_m=0.0,
|
|
79
|
+
energy_proxy=0.0,
|
|
80
|
+
recovery_risk=1.0,
|
|
81
|
+
resource_contention=1.0,
|
|
82
|
+
confidence=0.0,
|
|
83
|
+
measurement_context="verification-failed",
|
|
84
|
+
metrics_origin="deterministic_verification",
|
|
85
|
+
simulation_success=False,
|
|
86
|
+
hard_violations=("plan_verification_failed",) + verification,
|
|
87
|
+
unknown_fields=(
|
|
88
|
+
"cycle_time_s",
|
|
89
|
+
"path_length_m",
|
|
90
|
+
"min_clearance_m",
|
|
91
|
+
"energy_proxy",
|
|
92
|
+
"recovery_risk",
|
|
93
|
+
"resource_contention",
|
|
94
|
+
"confidence",
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
raw = self.backend.measure(
|
|
99
|
+
candidate,
|
|
100
|
+
goal=goal,
|
|
101
|
+
resources=resources,
|
|
102
|
+
world=world,
|
|
103
|
+
)
|
|
104
|
+
unknown: list[str] = []
|
|
105
|
+
|
|
106
|
+
def value(name: str, item: float | None, conservative: float) -> float:
|
|
107
|
+
if item is None:
|
|
108
|
+
unknown.append(name)
|
|
109
|
+
return conservative
|
|
110
|
+
return float(item)
|
|
111
|
+
|
|
112
|
+
return CandidateMetrics(
|
|
113
|
+
cycle_time_s=value("cycle_time_s", raw.cycle_time_s, 0.0),
|
|
114
|
+
path_length_m=value("path_length_m", raw.path_length_m, 0.0),
|
|
115
|
+
min_clearance_m=value("min_clearance_m", raw.min_clearance_m, 0.0),
|
|
116
|
+
energy_proxy=value("energy_proxy", raw.energy_proxy, 0.0),
|
|
117
|
+
recovery_risk=value("recovery_risk", raw.recovery_risk, 1.0),
|
|
118
|
+
resource_contention=value("resource_contention", raw.resource_contention, 1.0),
|
|
119
|
+
confidence=value("confidence", raw.confidence, 0.0),
|
|
120
|
+
measurement_context=raw.measurement_context,
|
|
121
|
+
metrics_origin=raw.metrics_origin,
|
|
122
|
+
simulation_success=raw.simulation_success,
|
|
123
|
+
hard_violations=raw.hard_violations,
|
|
124
|
+
unknown_fields=tuple(unknown),
|
|
125
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from math import isclose
|
|
5
|
+
from typing import Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from .contracts import (
|
|
8
|
+
CandidateEvaluation,
|
|
9
|
+
CandidateMetrics,
|
|
10
|
+
ObjectiveWeights,
|
|
11
|
+
OptimizationConfig,
|
|
12
|
+
OptimizationResult,
|
|
13
|
+
PlanCandidate,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OptimizationError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _eligibility_reasons(metrics: CandidateMetrics, config: OptimizationConfig) -> tuple[str, ...]:
|
|
22
|
+
thresholds = config.thresholds
|
|
23
|
+
reasons: list[str] = []
|
|
24
|
+
if not metrics.simulation_success:
|
|
25
|
+
reasons.append("simulation_failed")
|
|
26
|
+
reasons.extend(f"hard_violation:{item}" for item in metrics.hard_violations)
|
|
27
|
+
reasons.extend(f"unknown:{item}" for item in metrics.unknown_fields)
|
|
28
|
+
if metrics.min_clearance_m < thresholds.min_clearance_m:
|
|
29
|
+
reasons.append("clearance_below_minimum")
|
|
30
|
+
if metrics.recovery_risk > thresholds.max_recovery_risk:
|
|
31
|
+
reasons.append("recovery_risk_above_maximum")
|
|
32
|
+
if metrics.resource_contention > thresholds.max_resource_contention:
|
|
33
|
+
reasons.append("resource_contention_above_maximum")
|
|
34
|
+
if metrics.confidence < thresholds.min_confidence:
|
|
35
|
+
reasons.append("confidence_below_minimum")
|
|
36
|
+
return tuple(dict.fromkeys(reasons))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _objective_vector(metrics: CandidateMetrics) -> tuple[float, ...]:
|
|
40
|
+
"""All objectives transformed so lower is better for Pareto comparison."""
|
|
41
|
+
return (
|
|
42
|
+
metrics.cycle_time_s,
|
|
43
|
+
metrics.path_length_m,
|
|
44
|
+
-metrics.min_clearance_m,
|
|
45
|
+
metrics.energy_proxy,
|
|
46
|
+
metrics.recovery_risk,
|
|
47
|
+
metrics.resource_contention,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dominates(left: CandidateMetrics, right: CandidateMetrics) -> bool:
|
|
52
|
+
lv = _objective_vector(left)
|
|
53
|
+
rv = _objective_vector(right)
|
|
54
|
+
no_worse = all(a <= b or isclose(a, b, rel_tol=1e-12, abs_tol=1e-12) for a, b in zip(lv, rv))
|
|
55
|
+
strictly_better = any(a < b and not isclose(a, b, rel_tol=1e-12, abs_tol=1e-12) for a, b in zip(lv, rv))
|
|
56
|
+
return no_worse and strictly_better
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pareto_front(evaluations: Iterable[CandidateEvaluation]) -> tuple[CandidateEvaluation, ...]:
|
|
60
|
+
eligible = tuple(item for item in evaluations if item.eligible)
|
|
61
|
+
front: list[CandidateEvaluation] = []
|
|
62
|
+
for candidate in eligible:
|
|
63
|
+
if any(
|
|
64
|
+
other.candidate.candidate_id != candidate.candidate.candidate_id
|
|
65
|
+
and _dominates(other.metrics, candidate.metrics)
|
|
66
|
+
for other in eligible
|
|
67
|
+
):
|
|
68
|
+
continue
|
|
69
|
+
front.append(candidate)
|
|
70
|
+
return tuple(front)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _utility(values: Mapping[str, float], key: str, value: float, *, higher_is_better: bool) -> float:
|
|
74
|
+
minimum = values[f"{key}:min"]
|
|
75
|
+
maximum = values[f"{key}:max"]
|
|
76
|
+
if isclose(minimum, maximum, rel_tol=1e-12, abs_tol=1e-12):
|
|
77
|
+
return 1.0
|
|
78
|
+
normalized = (value - minimum) / (maximum - minimum)
|
|
79
|
+
return normalized if higher_is_better else 1.0 - normalized
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ranges(evaluations: Iterable[CandidateEvaluation]) -> dict[str, float]:
|
|
83
|
+
eligible = tuple(evaluations)
|
|
84
|
+
metrics = {
|
|
85
|
+
"cycle_time": [item.metrics.cycle_time_s for item in eligible],
|
|
86
|
+
"path_length": [item.metrics.path_length_m for item in eligible],
|
|
87
|
+
"clearance": [item.metrics.min_clearance_m for item in eligible],
|
|
88
|
+
"energy": [item.metrics.energy_proxy for item in eligible],
|
|
89
|
+
"recovery_risk": [item.metrics.recovery_risk for item in eligible],
|
|
90
|
+
"resource_contention": [item.metrics.resource_contention for item in eligible],
|
|
91
|
+
}
|
|
92
|
+
output: dict[str, float] = {}
|
|
93
|
+
for key, values in metrics.items():
|
|
94
|
+
output[f"{key}:min"] = min(values)
|
|
95
|
+
output[f"{key}:max"] = max(values)
|
|
96
|
+
return output
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _score(
|
|
100
|
+
evaluation: CandidateEvaluation,
|
|
101
|
+
ranges: Mapping[str, float],
|
|
102
|
+
weights: ObjectiveWeights,
|
|
103
|
+
) -> float:
|
|
104
|
+
metrics = evaluation.metrics
|
|
105
|
+
score = (
|
|
106
|
+
weights.cycle_time * _utility(ranges, "cycle_time", metrics.cycle_time_s, higher_is_better=False)
|
|
107
|
+
+ weights.path_length * _utility(ranges, "path_length", metrics.path_length_m, higher_is_better=False)
|
|
108
|
+
+ weights.clearance * _utility(ranges, "clearance", metrics.min_clearance_m, higher_is_better=True)
|
|
109
|
+
+ weights.energy * _utility(ranges, "energy", metrics.energy_proxy, higher_is_better=False)
|
|
110
|
+
+ weights.recovery_risk * _utility(ranges, "recovery_risk", metrics.recovery_risk, higher_is_better=False)
|
|
111
|
+
+ weights.resource_contention
|
|
112
|
+
* _utility(ranges, "resource_contention", metrics.resource_contention, higher_is_better=False)
|
|
113
|
+
+ weights.experience * max(0.0, min(1.0, 0.5 + evaluation.experience_bonus / 0.10))
|
|
114
|
+
)
|
|
115
|
+
return max(0.0, min(1.0, score))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MultiObjectiveOptimizer:
|
|
119
|
+
"""Hard-gate candidates, build a Pareto frontier, then rank the frontier.
|
|
120
|
+
|
|
121
|
+
Weighted scoring never rescues an unsafe/ineligible candidate. It is applied
|
|
122
|
+
only after hard constraints and uncertainty gates pass.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def evaluate(
|
|
126
|
+
self,
|
|
127
|
+
candidates: Iterable[PlanCandidate],
|
|
128
|
+
metrics_by_id: Mapping[str, CandidateMetrics],
|
|
129
|
+
config: OptimizationConfig,
|
|
130
|
+
*,
|
|
131
|
+
experience_bonus_by_id: Mapping[str, float] | None = None,
|
|
132
|
+
) -> tuple[CandidateEvaluation, ...]:
|
|
133
|
+
bonuses = experience_bonus_by_id or {}
|
|
134
|
+
evaluations: list[CandidateEvaluation] = []
|
|
135
|
+
seen: set[str] = set()
|
|
136
|
+
for candidate in candidates:
|
|
137
|
+
if candidate.candidate_id in seen:
|
|
138
|
+
raise OptimizationError(f"duplicate_candidate_id:{candidate.candidate_id}")
|
|
139
|
+
seen.add(candidate.candidate_id)
|
|
140
|
+
try:
|
|
141
|
+
metrics = metrics_by_id[candidate.candidate_id]
|
|
142
|
+
except KeyError as exc:
|
|
143
|
+
raise OptimizationError(f"missing_metrics:{candidate.candidate_id}") from exc
|
|
144
|
+
reasons = _eligibility_reasons(metrics, config)
|
|
145
|
+
bonus = float(bonuses.get(candidate.candidate_id, 0.0))
|
|
146
|
+
bonus = max(-config.max_experience_bonus, min(config.max_experience_bonus, bonus))
|
|
147
|
+
evaluations.append(
|
|
148
|
+
CandidateEvaluation(
|
|
149
|
+
candidate=candidate,
|
|
150
|
+
metrics=metrics,
|
|
151
|
+
eligible=not reasons,
|
|
152
|
+
disqualifiers=reasons,
|
|
153
|
+
experience_bonus=bonus,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return tuple(evaluations)
|
|
157
|
+
|
|
158
|
+
def select(
|
|
159
|
+
self,
|
|
160
|
+
candidates: Iterable[PlanCandidate],
|
|
161
|
+
metrics_by_id: Mapping[str, CandidateMetrics],
|
|
162
|
+
config: OptimizationConfig,
|
|
163
|
+
*,
|
|
164
|
+
experience_bonus_by_id: Mapping[str, float] | None = None,
|
|
165
|
+
escalation_level: int = 0,
|
|
166
|
+
mode: str = "optimized_multi_candidate",
|
|
167
|
+
quality_warnings: tuple[str, ...] = (),
|
|
168
|
+
) -> OptimizationResult:
|
|
169
|
+
evaluations = self.evaluate(
|
|
170
|
+
candidates,
|
|
171
|
+
metrics_by_id,
|
|
172
|
+
config,
|
|
173
|
+
experience_bonus_by_id=experience_bonus_by_id,
|
|
174
|
+
)
|
|
175
|
+
eligible = tuple(item for item in evaluations if item.eligible)
|
|
176
|
+
if not eligible:
|
|
177
|
+
raise OptimizationError("no_eligible_candidate")
|
|
178
|
+
contexts = {item.metrics.measurement_context for item in eligible}
|
|
179
|
+
if len(contexts) != 1:
|
|
180
|
+
raise OptimizationError("mixed_measurement_context")
|
|
181
|
+
|
|
182
|
+
front = pareto_front(eligible)
|
|
183
|
+
ranges = _ranges(eligible)
|
|
184
|
+
weights = config.effective_weights
|
|
185
|
+
scores = {
|
|
186
|
+
item.candidate.candidate_id: _score(item, ranges, weights)
|
|
187
|
+
for item in front
|
|
188
|
+
}
|
|
189
|
+
front_ids = {item.candidate.candidate_id for item in front}
|
|
190
|
+
updated = tuple(
|
|
191
|
+
replace(
|
|
192
|
+
item,
|
|
193
|
+
pareto_member=item.candidate.candidate_id in front_ids,
|
|
194
|
+
score=scores.get(item.candidate.candidate_id),
|
|
195
|
+
)
|
|
196
|
+
for item in evaluations
|
|
197
|
+
)
|
|
198
|
+
ranked_front = sorted(
|
|
199
|
+
(item for item in updated if item.pareto_member),
|
|
200
|
+
key=lambda item: (
|
|
201
|
+
-(item.score if item.score is not None else -1.0),
|
|
202
|
+
-item.metrics.confidence,
|
|
203
|
+
item.candidate.candidate_id,
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
selected = ranked_front[0]
|
|
207
|
+
return OptimizationResult(
|
|
208
|
+
selected=selected,
|
|
209
|
+
evaluations=updated,
|
|
210
|
+
pareto_candidate_ids=tuple(item.candidate.candidate_id for item in ranked_front),
|
|
211
|
+
profile=config.profile,
|
|
212
|
+
escalation_level=escalation_level,
|
|
213
|
+
mode=mode,
|
|
214
|
+
quality_warnings=quality_warnings,
|
|
215
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
|
|
5
|
+
from .candidates import CandidateSource
|
|
6
|
+
from .contracts import CandidateMetrics, OptimizationConfig, OptimizationResult, PlanCandidate
|
|
7
|
+
from .evaluator import CandidateMetricEvaluator
|
|
8
|
+
from .experience import ExperienceStore
|
|
9
|
+
from .optimizer import MultiObjectiveOptimizer, OptimizationError
|
|
10
|
+
from ..models import Goal, Resource, WorldState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AdaptiveOptimizationError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AdaptiveOptimizationEngine:
|
|
18
|
+
"""Escalate search only when the current candidate set is not convincing.
|
|
19
|
+
|
|
20
|
+
Easy tasks may use a single verified candidate fast path. Ambiguous/risky
|
|
21
|
+
tasks escalate to comparative search. The returned result explicitly states
|
|
22
|
+
whether it was a fast path or a true multi-candidate comparison.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
source: CandidateSource,
|
|
28
|
+
evaluator: CandidateMetricEvaluator,
|
|
29
|
+
*,
|
|
30
|
+
optimizer: MultiObjectiveOptimizer | None = None,
|
|
31
|
+
experience_store: ExperienceStore | None = None,
|
|
32
|
+
config: OptimizationConfig | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.source = source
|
|
35
|
+
self.evaluator = evaluator
|
|
36
|
+
self.optimizer = optimizer or MultiObjectiveOptimizer()
|
|
37
|
+
self.experience_store = experience_store
|
|
38
|
+
self.config = config or OptimizationConfig()
|
|
39
|
+
|
|
40
|
+
def _experience_bonuses(
|
|
41
|
+
self,
|
|
42
|
+
candidates: tuple[PlanCandidate, ...],
|
|
43
|
+
*,
|
|
44
|
+
goal: Goal,
|
|
45
|
+
resources: list[Resource],
|
|
46
|
+
) -> dict[str, float]:
|
|
47
|
+
if self.experience_store is None:
|
|
48
|
+
return {}
|
|
49
|
+
resource_map = {resource.resource_id: resource for resource in resources}
|
|
50
|
+
bonuses: dict[str, float] = {}
|
|
51
|
+
for candidate in candidates:
|
|
52
|
+
ordered = candidate.graph.topological_order()
|
|
53
|
+
if not ordered:
|
|
54
|
+
continue
|
|
55
|
+
resource = resource_map.get(ordered[0].contract.resource_id)
|
|
56
|
+
if resource is None:
|
|
57
|
+
continue
|
|
58
|
+
bonuses[candidate.candidate_id] = self.experience_store.bonus(
|
|
59
|
+
goal=goal,
|
|
60
|
+
resource=resource,
|
|
61
|
+
candidate=candidate,
|
|
62
|
+
max_abs_bonus=self.config.max_experience_bonus,
|
|
63
|
+
)
|
|
64
|
+
return bonuses
|
|
65
|
+
|
|
66
|
+
def _warnings(self, metrics: CandidateMetrics) -> tuple[str, ...]:
|
|
67
|
+
thresholds = self.config.thresholds
|
|
68
|
+
warnings: list[str] = []
|
|
69
|
+
if metrics.min_clearance_m < thresholds.preferred_clearance_m:
|
|
70
|
+
warnings.append("clearance_below_preferred")
|
|
71
|
+
if metrics.recovery_risk > thresholds.preferred_max_recovery_risk:
|
|
72
|
+
warnings.append("recovery_risk_above_preferred")
|
|
73
|
+
if metrics.confidence < thresholds.preferred_confidence:
|
|
74
|
+
warnings.append("confidence_below_preferred")
|
|
75
|
+
return tuple(warnings)
|
|
76
|
+
|
|
77
|
+
def optimize(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
goal: Goal,
|
|
81
|
+
resources: list[Resource],
|
|
82
|
+
world: WorldState,
|
|
83
|
+
) -> OptimizationResult:
|
|
84
|
+
candidates: tuple[PlanCandidate, ...] = ()
|
|
85
|
+
metrics_by_id: dict[str, CandidateMetrics] = {}
|
|
86
|
+
last_error: BaseException | None = None
|
|
87
|
+
|
|
88
|
+
for level, target in enumerate(self.config.escalation_targets):
|
|
89
|
+
candidates = self.source.generate(
|
|
90
|
+
goal=goal,
|
|
91
|
+
resources=resources,
|
|
92
|
+
world=world,
|
|
93
|
+
target_count=target,
|
|
94
|
+
existing=candidates,
|
|
95
|
+
attempt_budget=self.config.max_generation_attempts_per_level,
|
|
96
|
+
)
|
|
97
|
+
for candidate in candidates:
|
|
98
|
+
if candidate.candidate_id in metrics_by_id:
|
|
99
|
+
continue
|
|
100
|
+
metrics_by_id[candidate.candidate_id] = self.evaluator.evaluate(
|
|
101
|
+
candidate,
|
|
102
|
+
goal=goal,
|
|
103
|
+
resources=resources,
|
|
104
|
+
world=world,
|
|
105
|
+
)
|
|
106
|
+
if not candidates:
|
|
107
|
+
last_error = AdaptiveOptimizationError("candidate_generation_empty")
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
bonuses = self._experience_bonuses(candidates, goal=goal, resources=resources)
|
|
111
|
+
try:
|
|
112
|
+
provisional = self.optimizer.select(
|
|
113
|
+
candidates,
|
|
114
|
+
metrics_by_id,
|
|
115
|
+
self.config,
|
|
116
|
+
experience_bonus_by_id=bonuses,
|
|
117
|
+
escalation_level=level,
|
|
118
|
+
mode="verified_fast_path" if len(candidates) == 1 else "optimized_multi_candidate",
|
|
119
|
+
)
|
|
120
|
+
except OptimizationError as exc:
|
|
121
|
+
last_error = exc
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
warnings = self._warnings(provisional.selected.metrics)
|
|
125
|
+
eligible_count = sum(1 for item in provisional.evaluations if item.eligible)
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
self.config.allow_fast_path
|
|
129
|
+
and eligible_count == 1
|
|
130
|
+
and level == 0
|
|
131
|
+
and not warnings
|
|
132
|
+
):
|
|
133
|
+
return replace(provisional, quality_warnings=())
|
|
134
|
+
|
|
135
|
+
if eligible_count >= 2 and not warnings:
|
|
136
|
+
return replace(
|
|
137
|
+
provisional,
|
|
138
|
+
mode="optimized_multi_candidate",
|
|
139
|
+
quality_warnings=(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if level == len(self.config.escalation_targets) - 1:
|
|
143
|
+
return replace(
|
|
144
|
+
provisional,
|
|
145
|
+
mode=(
|
|
146
|
+
"optimized_multi_candidate"
|
|
147
|
+
if eligible_count >= 2
|
|
148
|
+
else "verified_single_candidate"
|
|
149
|
+
),
|
|
150
|
+
quality_warnings=warnings,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
raise AdaptiveOptimizationError(
|
|
154
|
+
f"optimization_failed:{type(last_error).__name__ if last_error else 'unknown'}"
|
|
155
|
+
)
|