solverpilot 0.1__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 (201) hide show
  1. solverpilot/__init__.py +250 -0
  2. solverpilot/_immutability.py +27 -0
  3. solverpilot/_version.py +3 -0
  4. solverpilot/applications/__init__.py +3 -0
  5. solverpilot/applications/routing/__init__.py +19 -0
  6. solverpilot/applications/routing/compile_milp.py +321 -0
  7. solverpilot/applications/routing/diagnostics.py +78 -0
  8. solverpilot/applications/routing/errors.py +14 -0
  9. solverpilot/applications/routing/heuristics.py +167 -0
  10. solverpilot/applications/routing/model.py +259 -0
  11. solverpilot/applications/routing/reference.py +81 -0
  12. solverpilot/applications/routing/solution.py +89 -0
  13. solverpilot/applications/routing/timeline.py +79 -0
  14. solverpilot/applications/routing/validation.py +169 -0
  15. solverpilot/applications/tsp/__init__.py +41 -0
  16. solverpilot/applications/tsp/compile_cp.py +121 -0
  17. solverpilot/applications/tsp/compile_milp.py +139 -0
  18. solverpilot/applications/tsp/distance.py +60 -0
  19. solverpilot/applications/tsp/errors.py +17 -0
  20. solverpilot/applications/tsp/heuristics.py +112 -0
  21. solverpilot/applications/tsp/model.py +175 -0
  22. solverpilot/applications/tsp/reference.py +121 -0
  23. solverpilot/applications/tsp/solution.py +65 -0
  24. solverpilot/applications/tsp/validation.py +92 -0
  25. solverpilot/backends/__init__.py +51 -0
  26. solverpilot/backends/_casadi_highs_worker.py +92 -0
  27. solverpilot/backends/base.py +28 -0
  28. solverpilot/backends/bundled_capi.py +910 -0
  29. solverpilot/backends/casadi_conic.py +303 -0
  30. solverpilot/backends/health.py +240 -0
  31. solverpilot/backends/highspy_native.py +217 -0
  32. solverpilot/backends/nlopt_native.py +237 -0
  33. solverpilot/backends/osqp_native.py +297 -0
  34. solverpilot/backends/protocol_v2.py +119 -0
  35. solverpilot/backends/pyscipopt_native.py +200 -0
  36. solverpilot/backends/registry.py +38 -0
  37. solverpilot/backends/scipy_highs.py +138 -0
  38. solverpilot/backends/scipy_highs_lp.py +195 -0
  39. solverpilot/backends/scipy_slsqp_qp.py +133 -0
  40. solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
  41. solverpilot/benchmark/__init__.py +23 -0
  42. solverpilot/benchmark/acquire.py +199 -0
  43. solverpilot/benchmark/baselines.py +70 -0
  44. solverpilot/benchmark/bundle.py +37 -0
  45. solverpilot/benchmark/campaign.py +150 -0
  46. solverpilot/benchmark/environment.py +115 -0
  47. solverpilot/benchmark/integrity.py +46 -0
  48. solverpilot/benchmark/model.py +146 -0
  49. solverpilot/benchmark/policy.py +182 -0
  50. solverpilot/benchmark/registry.py +38 -0
  51. solverpilot/benchmark/report.py +44 -0
  52. solverpilot/benchmark/runner.py +516 -0
  53. solverpilot/benchmark/slurm.py +55 -0
  54. solverpilot/benchmark/specs.py +75 -0
  55. solverpilot/benchmark/splits.py +181 -0
  56. solverpilot/benchmark/summary.py +384 -0
  57. solverpilot/benchmark/verify.py +142 -0
  58. solverpilot/benchmark/worker.py +71 -0
  59. solverpilot/bridges/__init__.py +19 -0
  60. solverpilot/bridges/engine.py +44 -0
  61. solverpilot/bridges/indicator.py +232 -0
  62. solverpilot/bridges/registry.py +78 -0
  63. solverpilot/bridges/transformation-tape.schema.json +53 -0
  64. solverpilot/bridges/types.py +149 -0
  65. solverpilot/bridges/validation.py +110 -0
  66. solverpilot/capabilities/__init__.py +32 -0
  67. solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
  68. solverpilot/capabilities/conformance.py +151 -0
  69. solverpilot/capabilities/enums.py +27 -0
  70. solverpilot/capabilities/manifest.py +19 -0
  71. solverpilot/capabilities/ontology.py +73 -0
  72. solverpilot/capabilities/requirements.py +37 -0
  73. solverpilot/capabilities/resolve.py +30 -0
  74. solverpilot/capabilities/v2.py +711 -0
  75. solverpilot/cli/__init__.py +1 -0
  76. solverpilot/cli/backend_health.py +77 -0
  77. solverpilot/cli/benchmark.py +331 -0
  78. solverpilot/cli/benchmark_worker.py +33 -0
  79. solverpilot/cli/capabilities.py +65 -0
  80. solverpilot/conic/__init__.py +11 -0
  81. solverpilot/conic/backend.py +263 -0
  82. solverpilot/conic/compiler.py +310 -0
  83. solverpilot/conic/conformance.py +60 -0
  84. solverpilot/conic/conic-ir-v1.schema.json +24 -0
  85. solverpilot/conic/ir.py +299 -0
  86. solverpilot/conic/validation.py +149 -0
  87. solverpilot/cp/__init__.py +7 -0
  88. solverpilot/cp/conformance.py +32 -0
  89. solverpilot/cp/cp-ir-v1.schema.json +13 -0
  90. solverpilot/cp/ir.py +170 -0
  91. solverpilot/cp/model.py +143 -0
  92. solverpilot/cp/ortools_backend.py +423 -0
  93. solverpilot/cp/ortools_worker.py +75 -0
  94. solverpilot/cp/reference.py +51 -0
  95. solverpilot/cp/serialization.py +117 -0
  96. solverpilot/cp/validate.py +83 -0
  97. solverpilot/diagnose/__init__.py +33 -0
  98. solverpilot/diagnose/infeasibility.py +463 -0
  99. solverpilot/diagnose/model.py +87 -0
  100. solverpilot/evaluation/__init__.py +53 -0
  101. solverpilot/evaluation/metrics.py +150 -0
  102. solverpilot/evaluation/oracle.py +288 -0
  103. solverpilot/evaluation/proof.py +53 -0
  104. solverpilot/evaluation/references.py +73 -0
  105. solverpilot/evaluation/regret.py +281 -0
  106. solverpilot/exceptions.py +34 -0
  107. solverpilot/experimental/__init__.py +32 -0
  108. solverpilot/extensions/__init__.py +56 -0
  109. solverpilot/extensions/entrypoints.py +34 -0
  110. solverpilot/extensions/errors.py +35 -0
  111. solverpilot/extensions/manager.py +651 -0
  112. solverpilot/extensions/manifest.py +232 -0
  113. solverpilot/extensions/model.py +101 -0
  114. solverpilot/extensions/versioning.py +136 -0
  115. solverpilot/history/__init__.py +22 -0
  116. solverpilot/history/errors.py +15 -0
  117. solverpilot/history/model.py +129 -0
  118. solverpilot/history/sqlite.py +651 -0
  119. solverpilot/inspect/__init__.py +3 -0
  120. solverpilot/inspect/fingerprint.py +199 -0
  121. solverpilot/intelligence/__init__.py +36 -0
  122. solverpilot/intelligence/errors.py +16 -0
  123. solverpilot/intelligence/fingerprint.py +108 -0
  124. solverpilot/intelligence/leakage.py +69 -0
  125. solverpilot/intelligence/schema.py +314 -0
  126. solverpilot/intelligence/shift.py +208 -0
  127. solverpilot/io/__init__.py +41 -0
  128. solverpilot/io/binary64.py +49 -0
  129. solverpilot/io/csv.py +230 -0
  130. solverpilot/io/errors.py +25 -0
  131. solverpilot/io/hashing.py +111 -0
  132. solverpilot/io/json.py +90 -0
  133. solverpilot/io/mapping.py +195 -0
  134. solverpilot/io/model.py +407 -0
  135. solverpilot/io/numeric.py +108 -0
  136. solverpilot/io/provenance.py +114 -0
  137. solverpilot/io/result.py +45 -0
  138. solverpilot/io/source.py +104 -0
  139. solverpilot/io/strict_json.py +133 -0
  140. solverpilot/minlp/__init__.py +6 -0
  141. solverpilot/minlp/compiler.py +66 -0
  142. solverpilot/minlp/conformance.py +34 -0
  143. solverpilot/minlp/convexity.py +85 -0
  144. solverpilot/minlp/ir.py +35 -0
  145. solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
  146. solverpilot/minlp/orchestrator.py +220 -0
  147. solverpilot/minlp/validation.py +28 -0
  148. solverpilot/model/__init__.py +27 -0
  149. solverpilot/model/compiler.py +1263 -0
  150. solverpilot/model/errors.py +22 -0
  151. solverpilot/model/expression.py +373 -0
  152. solverpilot/model/model.py +716 -0
  153. solverpilot/model/sets.py +60 -0
  154. solverpilot/model/types.py +36 -0
  155. solverpilot/nlp/__init__.py +7 -0
  156. solverpilot/nlp/ad.py +166 -0
  157. solverpilot/nlp/backend.py +113 -0
  158. solverpilot/nlp/compiler.py +116 -0
  159. solverpilot/nlp/conformance.py +58 -0
  160. solverpilot/nlp/ir.py +70 -0
  161. solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
  162. solverpilot/nlp/validation.py +60 -0
  163. solverpilot/plan/__init__.py +33 -0
  164. solverpilot/plan/model.py +79 -0
  165. solverpilot/plan/planner.py +201 -0
  166. solverpilot/plan/production.py +627 -0
  167. solverpilot/plan/selective_lp.py +65 -0
  168. solverpilot/problem/__init__.py +16 -0
  169. solverpilot/problem/enums.py +20 -0
  170. solverpilot/problem/hashing.py +88 -0
  171. solverpilot/problem/linear.py +196 -0
  172. solverpilot/problem/mps.py +447 -0
  173. solverpilot/problem/quadratic.py +192 -0
  174. solverpilot/reporting/__init__.py +27 -0
  175. solverpilot/reporting/explain.py +327 -0
  176. solverpilot/reporting/model.py +264 -0
  177. solverpilot/reporting/render.py +55 -0
  178. solverpilot/runtime/__init__.py +19 -0
  179. solverpilot/runtime/auto.py +193 -0
  180. solverpilot/runtime/budgeting.py +43 -0
  181. solverpilot/runtime/executor.py +126 -0
  182. solverpilot/runtime/portfolio.py +197 -0
  183. solverpilot/runtime/result.py +72 -0
  184. solverpilot/session/__init__.py +29 -0
  185. solverpilot/session/conformance_v2.py +239 -0
  186. solverpilot/session/mutations.py +115 -0
  187. solverpilot/session/persistent-session-trace.schema.json +45 -0
  188. solverpilot/session/persistent.py +491 -0
  189. solverpilot/session/reuse.py +81 -0
  190. solverpilot/session/session.py +236 -0
  191. solverpilot/trace/__init__.py +3 -0
  192. solverpilot/trace/schema.py +47 -0
  193. solverpilot/validate/__init__.py +10 -0
  194. solverpilot/validate/core.py +179 -0
  195. solverpilot/validate/result.py +55 -0
  196. solverpilot-0.1.dist-info/METADATA +554 -0
  197. solverpilot-0.1.dist-info/RECORD +201 -0
  198. solverpilot-0.1.dist-info/WHEEL +5 -0
  199. solverpilot-0.1.dist-info/entry_points.txt +4 -0
  200. solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
  201. solverpilot-0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,651 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import json
6
+ from threading import RLock
7
+ from enum import Enum
8
+ import math
9
+ from collections.abc import Mapping
10
+ from typing import Iterable
11
+
12
+ from solverpilot.backends import BackendRegistry
13
+ from solverpilot.backends.base import Backend
14
+ from solverpilot.bridges import BridgeRegistry, BridgeSpec
15
+ from solverpilot.capabilities import BackendManifest
16
+
17
+ from .errors import (
18
+ ExtensionActivationError,
19
+ ExtensionDependencyError,
20
+ ExtensionRegistrationError,
21
+ ExtensionRegistryFrozenError,
22
+ ExtensionValidationError,
23
+ )
24
+ from .manifest import ExtensionKind, ExtensionManifest
25
+ from .model import (
26
+ ExportKind,
27
+ Extension,
28
+ ExtensionActivationPlan,
29
+ ExtensionContributions,
30
+ ExtensionSelfCheck,
31
+ NamedExtensionExport,
32
+ StagedExtensionContribution,
33
+ )
34
+ from .versioning import version_satisfies
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _Registered:
39
+ extension: Extension
40
+ manifest: ExtensionManifest
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class _IssuedPlan:
45
+ plan: ExtensionActivationPlan
46
+ fingerprint: str
47
+ generation: int
48
+ backend_target: BackendRegistry
49
+ bridge_target: BridgeRegistry
50
+ backend_target_fingerprint: str
51
+ bridge_target_fingerprint: str
52
+
53
+
54
+ def _manifest_snapshot(extension: Extension) -> ExtensionManifest:
55
+ manifest = extension.manifest
56
+ if not isinstance(manifest, ExtensionManifest):
57
+ raise ExtensionValidationError("extension.manifest must be an ExtensionManifest")
58
+ # Reconstruct from public data so nested immutable state is detached from caller-owned objects.
59
+ data = manifest.to_dict()
60
+ data["dependencies"] = tuple(
61
+ f"{item['extension_id']}{'' if item['version'] == '*' else item['version']}"
62
+ for item in data["dependencies"]
63
+ )
64
+ return ExtensionManifest(**data)
65
+
66
+
67
+ def _run_self_check(extension: Extension) -> ExtensionSelfCheck:
68
+ try:
69
+ check = extension.self_check()
70
+ except Exception as exc:
71
+ raise ExtensionValidationError(f"extension self-check raised: {exc}") from exc
72
+ if not isinstance(check, ExtensionSelfCheck):
73
+ raise ExtensionValidationError("extension.self_check() must return ExtensionSelfCheck")
74
+ if not check.ok:
75
+ raise ExtensionValidationError("extension self-check did not pass")
76
+ return check
77
+
78
+
79
+ def _bridge_key(spec: BridgeSpec) -> str:
80
+ return f"{spec.bridge_id}@{spec.bridge_version}"
81
+
82
+
83
+ def _observable(
84
+ value: object,
85
+ *,
86
+ _seen: set[int] | None = None,
87
+ _depth: int = 0,
88
+ ) -> object:
89
+ """Create a deterministic in-process snapshot for drift detection.
90
+
91
+ The snapshot is not a public serialization format. Known containers/enums/scalars are
92
+ normalized structurally; unknown objects are bound by type, repr, and identity. Cyclic,
93
+ excessively deep, or non-finite container state fails closed instead of producing an
94
+ ambiguous activation fingerprint.
95
+ """
96
+
97
+ if _depth > 64:
98
+ raise ExtensionValidationError("backend observable state exceeds the supported depth")
99
+ if isinstance(value, Enum):
100
+ return {"enum": f"{type(value).__module__}.{type(value).__qualname__}", "value": value.value}
101
+ if value is None or isinstance(value, (str, int, bool)):
102
+ return value
103
+ if isinstance(value, float):
104
+ if not math.isfinite(value):
105
+ raise ExtensionValidationError("backend observable state may not contain NaN or Infinity")
106
+ return value
107
+ if isinstance(value, (Mapping, tuple, list, set, frozenset)):
108
+ seen = set() if _seen is None else _seen
109
+ marker = id(value)
110
+ if marker in seen:
111
+ raise ExtensionValidationError("backend observable state may not contain cyclic data")
112
+ seen.add(marker)
113
+ try:
114
+ if isinstance(value, Mapping):
115
+ pairs = [
116
+ [
117
+ _observable(key, _seen=seen, _depth=_depth + 1),
118
+ _observable(item, _seen=seen, _depth=_depth + 1),
119
+ ]
120
+ for key, item in value.items()
121
+ ]
122
+ pairs.sort(
123
+ key=lambda pair: json.dumps(
124
+ pair[0], sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
125
+ )
126
+ )
127
+ return {"mapping": pairs}
128
+ items = [_observable(item, _seen=seen, _depth=_depth + 1) for item in value]
129
+ if isinstance(value, (set, frozenset)):
130
+ items.sort(
131
+ key=lambda item: json.dumps(
132
+ item, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
133
+ )
134
+ )
135
+ return {type(value).__name__: items}
136
+ finally:
137
+ seen.remove(marker)
138
+ return {
139
+ "type": f"{type(value).__module__}.{type(value).__qualname__}",
140
+ "repr": repr(value),
141
+ "id": id(value),
142
+ }
143
+
144
+
145
+ def _backend_state(backend: Backend) -> object:
146
+ try:
147
+ manifest = backend.manifest
148
+ except Exception as exc:
149
+ raise ExtensionValidationError(f"backend contribution manifest raised: {exc}") from exc
150
+ if not isinstance(manifest, BackendManifest):
151
+ raise ExtensionValidationError("backend contribution manifest must be BackendManifest")
152
+ return {
153
+ "object_id": id(backend),
154
+ "name": manifest.name,
155
+ "version": manifest.version,
156
+ "capabilities": _observable(manifest.capabilities),
157
+ "metadata": _observable(manifest.metadata),
158
+ }
159
+
160
+
161
+ def _backend_target_fingerprint(registry: BackendRegistry) -> str:
162
+ payload = [_backend_state(backend) for backend in registry.all()]
163
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode()
164
+ return hashlib.sha256(raw).hexdigest()
165
+
166
+
167
+ def _bridge_target_fingerprint(registry: BridgeRegistry) -> str:
168
+ payload = [
169
+ (
170
+ spec.bridge_id,
171
+ spec.bridge_version,
172
+ spec.source_kind,
173
+ spec.exactness.value,
174
+ spec.priority,
175
+ spec.description,
176
+ )
177
+ for spec in registry.specs
178
+ ]
179
+ raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode()
180
+ return hashlib.sha256(raw).hexdigest()
181
+
182
+
183
+ def _plan_fingerprint(plan: ExtensionActivationPlan) -> str:
184
+ payload: dict[str, object] = {
185
+ "generation": plan.generation,
186
+ "order": list(plan.extension_order),
187
+ "targets": {
188
+ "backend_names": list(plan.target_backend_names),
189
+ "bridge_keys": list(plan.target_bridge_keys),
190
+ },
191
+ "staged": [],
192
+ }
193
+ staged_payload: list[dict[str, object]] = []
194
+ for item in plan.staged:
195
+ staged_payload.append(
196
+ {
197
+ "extension_id": item.extension_id,
198
+ "extension_version": item.extension_version,
199
+ "backends": [_backend_state(backend) for backend in item.backends],
200
+ "bridges": [
201
+ (
202
+ spec.bridge_id,
203
+ spec.bridge_version,
204
+ spec.source_kind,
205
+ spec.exactness.value,
206
+ spec.priority,
207
+ spec.description,
208
+ id(spec),
209
+ )
210
+ for spec in item.bridges
211
+ ],
212
+ "exports": [(export.key, id(export.value), id(export)) for export in item.exports],
213
+ }
214
+ )
215
+ payload["staged"] = staged_payload
216
+ raw = json.dumps(
217
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
218
+ ).encode()
219
+ return hashlib.sha256(raw).hexdigest()
220
+
221
+
222
+ def _validate_target_registries(
223
+ backend_registry: BackendRegistry, bridge_registry: BridgeRegistry
224
+ ) -> None:
225
+ if not isinstance(backend_registry, BackendRegistry):
226
+ raise ExtensionActivationError("backend_registry must be SolverPilot BackendRegistry")
227
+ if not isinstance(bridge_registry, BridgeRegistry):
228
+ raise ExtensionActivationError("bridge_registry must be SolverPilot BridgeRegistry")
229
+
230
+
231
+ class ExtensionManager:
232
+ """Explicit, freezeable manager for trusted SolverPilot extension packages.
233
+
234
+ The manager does not replace SolverPilot's canonical BackendRegistry or BridgeRegistry.
235
+ Backend/bridge contributions are staged first and are applied only to caller-supplied canonical
236
+ registries after all manager-side validation succeeds. Third-party extensions execute in-process
237
+ and are trusted code; this module does not provide a sandbox.
238
+ """
239
+
240
+ def __init__(self, extensions: Iterable[Extension] | None = None) -> None:
241
+ self._lock = RLock()
242
+ self._activation_lock = RLock()
243
+ self._registered: dict[str, _Registered] = {}
244
+ self._exports: dict[str, NamedExtensionExport] = {}
245
+ self._frozen = False
246
+ self._generation = 0
247
+ self._issued_plans: dict[int, _IssuedPlan] = {}
248
+ if extensions is not None:
249
+ self.register_many(extensions)
250
+
251
+ @property
252
+ def frozen(self) -> bool:
253
+ with self._lock:
254
+ return self._frozen
255
+
256
+ @property
257
+ def generation(self) -> int:
258
+ with self._lock:
259
+ return self._generation
260
+
261
+ def register(self, extension: Extension) -> None:
262
+ self.register_many((extension,))
263
+
264
+ def register_many(self, extensions: Iterable[Extension]) -> None:
265
+ incoming = tuple(extensions)
266
+ if not incoming:
267
+ return
268
+ prepared: list[_Registered] = []
269
+ seen: set[str] = set()
270
+ # Extension-controlled properties/hooks execute outside the manager lock.
271
+ for extension in incoming:
272
+ if not isinstance(extension, Extension):
273
+ raise ExtensionValidationError(
274
+ "extension must provide manifest, self_check(), and contributions()"
275
+ )
276
+ manifest = _manifest_snapshot(extension)
277
+ _run_self_check(extension)
278
+ # Ensure the object did not change identity during validation.
279
+ if _manifest_snapshot(extension) != manifest:
280
+ raise ExtensionValidationError(
281
+ f"extension {manifest.extension_id!r} changed its manifest during registration"
282
+ )
283
+ if manifest.extension_id in seen:
284
+ raise ExtensionRegistrationError(
285
+ f"duplicate extension id in registration batch: {manifest.extension_id}"
286
+ )
287
+ seen.add(manifest.extension_id)
288
+ prepared.append(_Registered(extension=extension, manifest=manifest))
289
+ with self._lock:
290
+ if self._frozen:
291
+ raise ExtensionRegistryFrozenError("extension manager is frozen")
292
+ collisions = sorted(seen.intersection(self._registered))
293
+ if collisions:
294
+ raise ExtensionRegistrationError(
295
+ f"extension ids already registered: {', '.join(collisions)}"
296
+ )
297
+ for item in prepared:
298
+ self._registered[item.manifest.extension_id] = item
299
+ self._generation += 1
300
+
301
+ def get(self, extension_id: str) -> Extension:
302
+ key = str(extension_id).strip().lower()
303
+ with self._lock:
304
+ try:
305
+ return self._registered[key].extension
306
+ except KeyError as exc:
307
+ raise ExtensionRegistrationError(f"unknown extension id {extension_id!r}") from exc
308
+
309
+ def list_extension_ids(self) -> tuple[str, ...]:
310
+ with self._lock:
311
+ return tuple(sorted(self._registered))
312
+
313
+ def manifests(self) -> tuple[ExtensionManifest, ...]:
314
+ with self._lock:
315
+ return tuple(self._registered[key].manifest for key in sorted(self._registered))
316
+
317
+ def _dependency_analysis(
318
+ self, entries: tuple[_Registered, ...]
319
+ ) -> tuple[dict[str, tuple[str, ...]], tuple[str, ...]]:
320
+ manifests = {item.manifest.extension_id: item.manifest for item in entries}
321
+ missing: dict[str, tuple[str, ...]] = {}
322
+ graph: dict[str, set[str]] = {}
323
+ reverse: dict[str, set[str]] = {key: set() for key in manifests}
324
+ indegree: dict[str, int] = {key: 0 for key in manifests}
325
+ for extension_id, manifest in manifests.items():
326
+ deps: set[str] = set()
327
+ unresolved: list[str] = []
328
+ for dep in manifest.dependencies:
329
+ target = manifests.get(dep.extension_id)
330
+ if target is None:
331
+ unresolved.append(f"{dep.extension_id}{'' if dep.version == '*' else dep.version}")
332
+ continue
333
+ if not version_satisfies(target.version, dep.version):
334
+ raise ExtensionDependencyError(
335
+ f"extension {extension_id!r} requires {dep.extension_id}{dep.version}, "
336
+ f"but registered version is {target.version}"
337
+ )
338
+ deps.add(dep.extension_id)
339
+ if unresolved:
340
+ missing[extension_id] = tuple(sorted(unresolved))
341
+ graph[extension_id] = deps
342
+ indegree[extension_id] = len(deps)
343
+ for dep_id in deps:
344
+ reverse[dep_id].add(extension_id)
345
+ if missing:
346
+ details = "; ".join(f"{key}: {', '.join(values)}" for key, values in sorted(missing.items()))
347
+ raise ExtensionDependencyError(f"unresolved extension dependencies: {details}")
348
+ ready = sorted(key for key, degree in indegree.items() if degree == 0)
349
+ order: list[str] = []
350
+ while ready:
351
+ current = ready.pop(0)
352
+ order.append(current)
353
+ for consumer in sorted(reverse[current]):
354
+ indegree[consumer] -= 1
355
+ if indegree[consumer] == 0:
356
+ ready.append(consumer)
357
+ ready.sort()
358
+ if len(order) != len(manifests):
359
+ cyclic = sorted(key for key, degree in indegree.items() if degree > 0)
360
+ raise ExtensionDependencyError(
361
+ f"extension dependency graph contains a cycle involving: {', '.join(cyclic)}"
362
+ )
363
+ return {key: tuple(sorted(values)) for key, values in graph.items()}, tuple(order)
364
+
365
+ def freeze(self) -> None:
366
+ while True:
367
+ with self._lock:
368
+ if self._frozen:
369
+ return
370
+ generation = self._generation
371
+ entries = tuple(self._registered.values())
372
+ # Do not invoke extension-owned properties while holding the manager lock.
373
+ for item in entries:
374
+ if _manifest_snapshot(item.extension) != item.manifest:
375
+ raise ExtensionValidationError(
376
+ f"extension {item.manifest.extension_id!r} manifest drifted after registration"
377
+ )
378
+ self._dependency_analysis(entries)
379
+ with self._lock:
380
+ if self._generation != generation:
381
+ continue
382
+ self._frozen = True
383
+ self._generation += 1
384
+ self._issued_plans.clear()
385
+ return
386
+
387
+ def dependency_order(self) -> tuple[str, ...]:
388
+ with self._lock:
389
+ entries = tuple(self._registered.values())
390
+ return self._dependency_analysis(entries)[1]
391
+
392
+ def _validate_contributions(
393
+ self, manifest: ExtensionManifest, contributions: ExtensionContributions
394
+ ) -> StagedExtensionContribution:
395
+ if not isinstance(contributions, ExtensionContributions):
396
+ raise ExtensionValidationError(
397
+ f"extension {manifest.extension_id!r} contributions() must return ExtensionContributions"
398
+ )
399
+ backend_names: set[str] = set()
400
+ for backend in contributions.backends:
401
+ if not isinstance(backend, Backend):
402
+ raise ExtensionValidationError("backend contribution does not satisfy Backend protocol")
403
+ backend_manifest = backend.manifest
404
+ if not isinstance(backend_manifest, BackendManifest):
405
+ raise ExtensionValidationError("backend contribution manifest must be BackendManifest")
406
+ name = str(backend_manifest.name).strip()
407
+ if not name:
408
+ raise ExtensionValidationError("backend contribution name must be non-empty")
409
+ if name in backend_names:
410
+ raise ExtensionValidationError(f"duplicate backend contribution {name!r}")
411
+ backend_names.add(name)
412
+ bridge_keys: set[str] = set()
413
+ for bridge in contributions.bridges:
414
+ if not isinstance(bridge, BridgeSpec):
415
+ raise ExtensionValidationError("bridge contribution must be BridgeSpec")
416
+ from solverpilot.bridges import ExactnessClass
417
+
418
+ if not bridge.bridge_id or not bridge.bridge_version or not bridge.source_kind or not bridge.description:
419
+ raise ExtensionValidationError("bridge contribution identifiers and description must be non-empty")
420
+ if not isinstance(bridge.exactness, ExactnessClass):
421
+ raise ExtensionValidationError("bridge contribution exactness must be ExactnessClass")
422
+ if isinstance(bridge.priority, bool) or not isinstance(bridge.priority, int):
423
+ raise ExtensionValidationError("bridge contribution priority must be an integer")
424
+ key = _bridge_key(bridge)
425
+ if key in bridge_keys:
426
+ raise ExtensionValidationError(f"duplicate bridge contribution {key!r}")
427
+ bridge_keys.add(key)
428
+ export_keys: set[str] = set()
429
+ for export in contributions.exports:
430
+ if not isinstance(export, NamedExtensionExport):
431
+ raise ExtensionValidationError("export contribution must be NamedExtensionExport")
432
+ if export.key in export_keys:
433
+ raise ExtensionValidationError(f"duplicate named export {export.key!r}")
434
+ export_keys.add(export.key)
435
+
436
+ if manifest.kind is not ExtensionKind.MIXED:
437
+ actual_kinds: set[ExtensionKind] = set()
438
+ if contributions.backends:
439
+ actual_kinds.add(ExtensionKind.BACKEND)
440
+ if contributions.bridges:
441
+ actual_kinds.add(ExtensionKind.BRIDGE)
442
+ export_kind_map = {
443
+ ExportKind.DATA_CONNECTOR: ExtensionKind.DATA_CONNECTOR,
444
+ ExportKind.FEATURE_EXTRACTOR: ExtensionKind.FEATURE_EXTRACTOR,
445
+ ExportKind.EVALUATOR: ExtensionKind.EVALUATOR,
446
+ ExportKind.REPORTER: ExtensionKind.REPORTER,
447
+ ExportKind.APPLICATION: ExtensionKind.APPLICATION,
448
+ }
449
+ actual_kinds.update(export_kind_map[item.kind] for item in contributions.exports)
450
+ unexpected = sorted(kind.value for kind in actual_kinds if kind is not manifest.kind)
451
+ if unexpected:
452
+ raise ExtensionValidationError(
453
+ f"extension {manifest.extension_id!r} declares kind {manifest.kind.value!r} "
454
+ f"but contributes: {', '.join(unexpected)}; use kind='mixed' for mixed contributions"
455
+ )
456
+
457
+ return StagedExtensionContribution(
458
+ extension_id=manifest.extension_id,
459
+ extension_version=manifest.version,
460
+ backends=tuple(contributions.backends),
461
+ bridges=tuple(contributions.bridges),
462
+ exports=tuple(contributions.exports),
463
+ )
464
+
465
+ def plan_activation(
466
+ self,
467
+ *,
468
+ backend_registry: BackendRegistry,
469
+ bridge_registry: BridgeRegistry,
470
+ ) -> ExtensionActivationPlan:
471
+ _validate_target_registries(backend_registry, bridge_registry)
472
+ with self._lock:
473
+ if not self._frozen:
474
+ raise ExtensionActivationError("freeze the extension manager before activation planning")
475
+ generation = self._generation
476
+ entries_by_id = dict(self._registered)
477
+ existing_export_keys = set(self._exports)
478
+ order = self._dependency_analysis(tuple(entries_by_id.values()))[1]
479
+ staged: list[StagedExtensionContribution] = []
480
+ all_backend_names: set[str] = set()
481
+ all_bridge_keys: set[str] = set()
482
+ all_export_keys: set[str] = set()
483
+ for extension_id in order:
484
+ item = entries_by_id[extension_id]
485
+ if _manifest_snapshot(item.extension) != item.manifest:
486
+ raise ExtensionValidationError(f"extension {extension_id!r} manifest drifted before staging")
487
+ _run_self_check(item.extension)
488
+ try:
489
+ contributions = item.extension.contributions()
490
+ except Exception as exc:
491
+ raise ExtensionValidationError(
492
+ f"extension {extension_id!r} contributions() raised: {exc}"
493
+ ) from exc
494
+ staged_item = self._validate_contributions(item.manifest, contributions)
495
+ if _manifest_snapshot(item.extension) != item.manifest:
496
+ raise ExtensionValidationError(f"extension {extension_id!r} manifest drifted during staging")
497
+ for backend in staged_item.backends:
498
+ name = backend.manifest.name
499
+ if name in all_backend_names:
500
+ raise ExtensionActivationError(f"duplicate staged backend name {name!r}")
501
+ all_backend_names.add(name)
502
+ for bridge in staged_item.bridges:
503
+ key = _bridge_key(bridge)
504
+ if key in all_bridge_keys:
505
+ raise ExtensionActivationError(f"duplicate staged bridge key {key!r}")
506
+ all_bridge_keys.add(key)
507
+ for export in staged_item.exports:
508
+ if export.key in all_export_keys:
509
+ raise ExtensionActivationError(f"duplicate staged export key {export.key!r}")
510
+ all_export_keys.add(export.key)
511
+ staged.append(staged_item)
512
+ target_backend_names = tuple(sorted(backend_registry.names()))
513
+ target_bridge_keys = tuple(sorted(_bridge_key(spec) for spec in bridge_registry.specs))
514
+ collisions = sorted(all_backend_names.intersection(target_backend_names))
515
+ if collisions:
516
+ raise ExtensionActivationError(f"backend target collisions: {', '.join(collisions)}")
517
+ bridge_collisions = sorted(all_bridge_keys.intersection(target_bridge_keys))
518
+ if bridge_collisions:
519
+ raise ExtensionActivationError(f"bridge target collisions: {', '.join(bridge_collisions)}")
520
+ export_collisions = sorted(all_export_keys.intersection(existing_export_keys))
521
+ if export_collisions:
522
+ raise ExtensionActivationError(f"export target collisions: {', '.join(export_collisions)}")
523
+ provisional = ExtensionActivationPlan(
524
+ generation=generation,
525
+ extension_order=order,
526
+ staged=tuple(staged),
527
+ target_backend_names=target_backend_names,
528
+ target_bridge_keys=target_bridge_keys,
529
+ plan_sha256="",
530
+ )
531
+ fingerprint = _plan_fingerprint(provisional)
532
+ plan = ExtensionActivationPlan(
533
+ generation=provisional.generation,
534
+ extension_order=provisional.extension_order,
535
+ staged=provisional.staged,
536
+ target_backend_names=provisional.target_backend_names,
537
+ target_bridge_keys=provisional.target_bridge_keys,
538
+ plan_sha256=fingerprint,
539
+ )
540
+ issued = _IssuedPlan(
541
+ plan=plan,
542
+ fingerprint=_plan_fingerprint(plan),
543
+ generation=generation,
544
+ backend_target=backend_registry,
545
+ bridge_target=bridge_registry,
546
+ backend_target_fingerprint=_backend_target_fingerprint(backend_registry),
547
+ bridge_target_fingerprint=_bridge_target_fingerprint(bridge_registry),
548
+ )
549
+ with self._lock:
550
+ if not self._frozen or self._generation != generation:
551
+ raise ExtensionActivationError("extension manager changed while activation was staged")
552
+ self._issued_plans[id(plan)] = issued
553
+ return plan
554
+
555
+ def apply_activation(
556
+ self,
557
+ plan: ExtensionActivationPlan,
558
+ *,
559
+ backend_registry: BackendRegistry,
560
+ bridge_registry: BridgeRegistry,
561
+ ) -> None:
562
+ if not isinstance(plan, ExtensionActivationPlan):
563
+ raise ExtensionActivationError("plan must be ExtensionActivationPlan")
564
+ _validate_target_registries(backend_registry, bridge_registry)
565
+
566
+ # Serialize applies without holding the central query/registration lock while touching
567
+ # extension-provided backend objects. Direct external mutation of caller-owned canonical
568
+ # registries during this method remains outside the manager's synchronization contract.
569
+ with self._activation_lock:
570
+ current_plan_fingerprint = _plan_fingerprint(plan)
571
+ backend_target_fingerprint = _backend_target_fingerprint(backend_registry)
572
+ bridge_target_fingerprint = _bridge_target_fingerprint(bridge_registry)
573
+ with self._lock:
574
+ issued = self._issued_plans.get(id(plan))
575
+ if issued is None or issued.plan is not plan:
576
+ raise ExtensionActivationError(
577
+ "activation plan was not issued by this manager or was consumed"
578
+ )
579
+ if issued.backend_target is not backend_registry or issued.bridge_target is not bridge_registry:
580
+ raise ExtensionActivationError(
581
+ "activation plan is bound to different canonical registry objects"
582
+ )
583
+ if plan.generation != self._generation or issued.generation != self._generation:
584
+ raise ExtensionActivationError("activation plan generation is stale")
585
+ if plan.plan_sha256 != current_plan_fingerprint or issued.fingerprint != current_plan_fingerprint:
586
+ raise ExtensionActivationError("activation plan changed after staging")
587
+ if backend_target_fingerprint != issued.backend_target_fingerprint:
588
+ raise ExtensionActivationError("backend registry changed after activation planning")
589
+ if bridge_target_fingerprint != issued.bridge_target_fingerprint:
590
+ raise ExtensionActivationError("bridge registry changed after activation planning")
591
+ staged_exports = tuple(export for item in plan.staged for export in item.exports)
592
+ for export in staged_exports:
593
+ if export.key in self._exports:
594
+ raise ExtensionActivationError(f"export target collision: {export.key}")
595
+ # Reserve single-use ownership before any caller-owned registry is mutated.
596
+ del self._issued_plans[id(plan)]
597
+
598
+ staged_backends = tuple(backend for item in plan.staged for backend in item.backends)
599
+ staged_bridges = tuple(bridge for item in plan.staged for bridge in item.bridges)
600
+
601
+ # Preview with canonical registry implementations. This verifies every built-in
602
+ # registration rule before mutating caller-owned targets. Under the documented
603
+ # no-concurrent-direct-mutation precondition, the subsequent canonical register calls
604
+ # cannot introduce a new duplicate/collision.
605
+ preview_backends = BackendRegistry()
606
+ for backend in backend_registry.all():
607
+ preview_backends.register(backend)
608
+ for backend in staged_backends:
609
+ preview_backends.register(backend)
610
+ preview_bridges = BridgeRegistry()
611
+ for spec in bridge_registry.specs:
612
+ preview_bridges.register(spec)
613
+ for spec in staged_bridges:
614
+ preview_bridges.register(spec)
615
+
616
+ try:
617
+ for backend in staged_backends:
618
+ backend_registry.register(backend)
619
+ for spec in staged_bridges:
620
+ bridge_registry.register(spec)
621
+ except Exception as exc:
622
+ # This path should only be reachable if caller-owned registries are concurrently
623
+ # mutated or a non-canonical registry violates the previewed registration contract.
624
+ raise ExtensionActivationError(
625
+ "canonical registry activation failed after prevalidation; the activation plan "
626
+ "is consumed and target registries must be inspected before retrying"
627
+ ) from exc
628
+
629
+ with self._lock:
630
+ for export in staged_exports:
631
+ if export.key in self._exports:
632
+ # Same-manager applies are serialized, so this is a defensive invariant.
633
+ raise ExtensionActivationError(f"export target collision: {export.key}")
634
+ self._exports[export.key] = export
635
+
636
+ def list_exports(self, *, kind: ExportKind | str | None = None) -> tuple[NamedExtensionExport, ...]:
637
+ normalized = None if kind is None else (kind if isinstance(kind, ExportKind) else ExportKind(str(kind).strip().lower()))
638
+ with self._lock:
639
+ values = tuple(self._exports[key] for key in sorted(self._exports))
640
+ if normalized is None:
641
+ return values
642
+ return tuple(item for item in values if item.kind is normalized)
643
+
644
+ def get_export(self, kind: ExportKind | str, name: str) -> object:
645
+ normalized_kind = kind if isinstance(kind, ExportKind) else ExportKind(str(kind).strip().lower())
646
+ key = f"{normalized_kind.value}:{str(name).strip().lower()}"
647
+ with self._lock:
648
+ try:
649
+ return self._exports[key].value
650
+ except KeyError as exc:
651
+ raise ExtensionRegistrationError(f"unknown extension export {key!r}") from exc