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,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)