devagent-physical-engine 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. devagent_physical_engine/__init__.py +44 -0
  2. devagent_physical_engine/agent/__init__.py +40 -0
  3. devagent_physical_engine/agent/compiler.py +285 -0
  4. devagent_physical_engine/agent/contracts.py +129 -0
  5. devagent_physical_engine/agent/coordinator.py +108 -0
  6. devagent_physical_engine/agent/critic.py +72 -0
  7. devagent_physical_engine/agent/evidence.py +34 -0
  8. devagent_physical_engine/agent/interpreter.py +179 -0
  9. devagent_physical_engine/agent/planner.py +105 -0
  10. devagent_physical_engine/agent/recovery.py +54 -0
  11. devagent_physical_engine/agent/routing.py +76 -0
  12. devagent_physical_engine/agent/runtime.py +270 -0
  13. devagent_physical_engine/agent/semantic.py +304 -0
  14. devagent_physical_engine/agent/structured.py +423 -0
  15. devagent_physical_engine/ai_cli.py +226 -0
  16. devagent_physical_engine/cli.py +392 -0
  17. devagent_physical_engine/doctor.py +20 -0
  18. devagent_physical_engine/engineering_agent.py +243 -0
  19. devagent_physical_engine/engineering_request.py +630 -0
  20. devagent_physical_engine/execution.py +90 -0
  21. devagent_physical_engine/models.py +143 -0
  22. devagent_physical_engine/operating_envelope.py +120 -0
  23. devagent_physical_engine/optimization/__init__.py +50 -0
  24. devagent_physical_engine/optimization/benchmark.py +122 -0
  25. devagent_physical_engine/optimization/candidates.py +198 -0
  26. devagent_physical_engine/optimization/contracts.py +235 -0
  27. devagent_physical_engine/optimization/evaluator.py +107 -0
  28. devagent_physical_engine/optimization/evidence.py +53 -0
  29. devagent_physical_engine/optimization/experience.py +105 -0
  30. devagent_physical_engine/optimization/measured.py +125 -0
  31. devagent_physical_engine/optimization/optimizer.py +215 -0
  32. devagent_physical_engine/optimization/orchestrator.py +155 -0
  33. devagent_physical_engine/physical_campaign.py +413 -0
  34. devagent_physical_engine/physical_evidence.py +214 -0
  35. devagent_physical_engine/physical_motion.py +196 -0
  36. devagent_physical_engine/planning.py +80 -0
  37. devagent_physical_engine/preexecution_contract.py +65 -0
  38. devagent_physical_engine/provider_adapters/__init__.py +22 -0
  39. devagent_physical_engine/provider_adapters/anthropic.py +112 -0
  40. devagent_physical_engine/provider_adapters/common.py +187 -0
  41. devagent_physical_engine/provider_adapters/factory.py +20 -0
  42. devagent_physical_engine/provider_adapters/gemini.py +126 -0
  43. devagent_physical_engine/provider_adapters/openai.py +95 -0
  44. devagent_physical_engine/provider_qualification.py +268 -0
  45. devagent_physical_engine/providers.py +94 -0
  46. devagent_physical_engine/qualification.py +44 -0
  47. devagent_physical_engine/qualification_cli.py +195 -0
  48. devagent_physical_engine/qualification_harness.py +917 -0
  49. devagent_physical_engine/robot_platform.py +411 -0
  50. devagent_physical_engine/robots.py +76 -0
  51. devagent_physical_engine/ros2/__init__.py +35 -0
  52. devagent_physical_engine/ros2/acceptance.py +324 -0
  53. devagent_physical_engine/ros2/commands.py +175 -0
  54. devagent_physical_engine/ros2/doctor.py +116 -0
  55. devagent_physical_engine/ros2/fk_probe.py +83 -0
  56. devagent_physical_engine/ros2/frame_alignment.py +61 -0
  57. devagent_physical_engine/ros2/gazebo_world.py +125 -0
  58. devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
  59. devagent_physical_engine/ros2/measured_motion.py +233 -0
  60. devagent_physical_engine/ros2/moveit_scene.py +121 -0
  61. devagent_physical_engine/ros2/preexecution.py +113 -0
  62. devagent_physical_engine/ros2/qualification.py +81 -0
  63. devagent_physical_engine/ros2/qualification_v10.py +252 -0
  64. devagent_physical_engine/ros2/scene_probe.py +219 -0
  65. devagent_physical_engine/ros2/state_validity_probe.py +125 -0
  66. devagent_physical_engine/ros2/tf_probe.py +51 -0
  67. devagent_physical_engine/ros2/trajectory.py +188 -0
  68. devagent_physical_engine/ros2/ur5e.py +59 -0
  69. devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
  70. devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
  71. devagent_physical_engine/setup_profile.py +356 -0
  72. devagent_physical_engine/simulation.py +32 -0
  73. devagent_physical_engine/simulation_platform.py +269 -0
  74. devagent_physical_engine/trajectory_qualification.py +201 -0
  75. devagent_physical_engine/twin.py +939 -0
  76. devagent_physical_engine/twin_builder.py +309 -0
  77. devagent_physical_engine/twin_materialization.py +404 -0
  78. devagent_physical_engine/verification.py +46 -0
  79. devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
  80. devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
  81. devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
  82. devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
  83. devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
  84. devagent_physical_engine-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )