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,232 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import re
9
+ from types import MappingProxyType
10
+ from typing import Any, Mapping
11
+
12
+ from .errors import ExtensionManifestError
13
+ from .versioning import SemanticVersion
14
+
15
+ _ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,127}$")
16
+ _TOKEN_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
17
+ _ENTRYPOINT_RE = re.compile(
18
+ r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*:"
19
+ r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$"
20
+ )
21
+ _DEP_RE = re.compile(r"^([a-z][a-z0-9_.-]{2,127})(.*)$")
22
+
23
+
24
+ class ExtensionKind(str, Enum):
25
+ BACKEND = "backend"
26
+ BRIDGE = "bridge"
27
+ DATA_CONNECTOR = "data_connector"
28
+ FEATURE_EXTRACTOR = "feature_extractor"
29
+ EVALUATOR = "evaluator"
30
+ REPORTER = "reporter"
31
+ APPLICATION = "application"
32
+ MIXED = "mixed"
33
+
34
+
35
+ class ExtensionLifecycle(str, Enum):
36
+ EXPERIMENTAL = "experimental"
37
+ ACTIVE = "active"
38
+ DEPRECATED = "deprecated"
39
+
40
+
41
+ def _kind(value: ExtensionKind | str) -> ExtensionKind:
42
+ if isinstance(value, ExtensionKind):
43
+ return value
44
+ try:
45
+ return ExtensionKind(str(value).strip().lower())
46
+ except ValueError as exc:
47
+ raise ExtensionManifestError(f"unsupported extension kind {value!r}") from exc
48
+
49
+
50
+ def _lifecycle(value: ExtensionLifecycle | str) -> ExtensionLifecycle:
51
+ if isinstance(value, ExtensionLifecycle):
52
+ return value
53
+ try:
54
+ return ExtensionLifecycle(str(value).strip().lower())
55
+ except ValueError as exc:
56
+ raise ExtensionManifestError(f"unsupported extension lifecycle {value!r}") from exc
57
+
58
+
59
+ def _token(value: object, *, field_name: str) -> str:
60
+ item = str(value).strip().lower()
61
+ if not _TOKEN_RE.fullmatch(item):
62
+ raise ExtensionManifestError(f"invalid {field_name} token {value!r}")
63
+ return item
64
+
65
+
66
+ def _freeze_json(
67
+ value: Any,
68
+ *,
69
+ field_name: str,
70
+ _seen: set[int] | None = None,
71
+ _depth: int = 0,
72
+ ) -> Any:
73
+ if _depth > 64:
74
+ raise ExtensionManifestError(f"{field_name} nesting exceeds the supported depth")
75
+ if value is None or isinstance(value, (str, bool, int)):
76
+ return value
77
+ if isinstance(value, float):
78
+ if not math.isfinite(value):
79
+ raise ExtensionManifestError(f"{field_name} may not contain NaN or Infinity")
80
+ return value
81
+ if isinstance(value, (Mapping, list, tuple)):
82
+ seen = set() if _seen is None else _seen
83
+ marker = id(value)
84
+ if marker in seen:
85
+ raise ExtensionManifestError(f"{field_name} may not contain cyclic data")
86
+ seen.add(marker)
87
+ try:
88
+ if isinstance(value, Mapping):
89
+ frozen: dict[str, Any] = {}
90
+ for key, item in value.items():
91
+ if not isinstance(key, str):
92
+ raise ExtensionManifestError(f"{field_name} mapping keys must be strings")
93
+ frozen[key] = _freeze_json(
94
+ item, field_name=field_name, _seen=seen, _depth=_depth + 1
95
+ )
96
+ return MappingProxyType(frozen)
97
+ return tuple(
98
+ _freeze_json(item, field_name=field_name, _seen=seen, _depth=_depth + 1)
99
+ for item in value
100
+ )
101
+ finally:
102
+ seen.remove(marker)
103
+ raise ExtensionManifestError(
104
+ f"{field_name} must be finite JSON-compatible data, not {type(value).__name__}"
105
+ )
106
+
107
+
108
+ def _thaw_json(value: Any) -> Any:
109
+ if isinstance(value, Mapping):
110
+ return {str(key): _thaw_json(item) for key, item in value.items()}
111
+ if isinstance(value, tuple):
112
+ return [_thaw_json(item) for item in value]
113
+ return value
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class ExtensionDependency:
118
+ extension_id: str
119
+ version: str = "*"
120
+
121
+ def __post_init__(self) -> None:
122
+ extension_id = str(self.extension_id).strip().lower()
123
+ if not _ID_RE.fullmatch(extension_id):
124
+ raise ExtensionManifestError(f"invalid dependency extension_id {self.extension_id!r}")
125
+ spec = str(self.version).strip() or "*"
126
+ # Validate every referenced version eagerly.
127
+ if spec != "*":
128
+ from .versioning import version_satisfies
129
+
130
+ # A version that satisfies itself is not required; this call is only syntax validation.
131
+ # Use a harmless concrete version and tolerate False, but propagate malformed syntax.
132
+ version_satisfies("0.0.0", spec)
133
+ object.__setattr__(self, "extension_id", extension_id)
134
+ object.__setattr__(self, "version", spec)
135
+
136
+ @classmethod
137
+ def parse(cls, value: "ExtensionDependency | str") -> "ExtensionDependency":
138
+ if isinstance(value, cls):
139
+ return value
140
+ raw = str(value).strip()
141
+ match = _DEP_RE.fullmatch(raw)
142
+ if match is None:
143
+ raise ExtensionManifestError(f"invalid dependency declaration {value!r}")
144
+ extension_id = match.group(1)
145
+ spec = match.group(2).strip() or "*"
146
+ return cls(extension_id=extension_id, version=spec)
147
+
148
+ def to_dict(self) -> dict[str, str]:
149
+ return {"extension_id": self.extension_id, "version": self.version}
150
+
151
+
152
+ @dataclass(frozen=True, slots=True)
153
+ class ExtensionManifest:
154
+ """Immutable, content-addressed description of a trusted SolverPilot extension."""
155
+
156
+ extension_id: str
157
+ name: str
158
+ version: str
159
+ kind: ExtensionKind | str
160
+ description: str
161
+ capabilities: tuple[str, ...] = ()
162
+ dependencies: tuple[ExtensionDependency | str, ...] = ()
163
+ entrypoint: str | None = None
164
+ lifecycle: ExtensionLifecycle | str = ExtensionLifecycle.EXPERIMENTAL
165
+ metadata: Mapping[str, Any] = field(default_factory=dict)
166
+
167
+ def __post_init__(self) -> None:
168
+ extension_id = str(self.extension_id).strip().lower()
169
+ if not _ID_RE.fullmatch(extension_id):
170
+ raise ExtensionManifestError(
171
+ "extension_id must start with a lowercase letter, be 3-128 characters, "
172
+ "and contain only lowercase letters, digits, '.', '_' or '-'"
173
+ )
174
+ name = str(self.name).strip()
175
+ description = str(self.description).strip()
176
+ if not name:
177
+ raise ExtensionManifestError("name must be non-empty")
178
+ if not description:
179
+ raise ExtensionManifestError("description must be non-empty")
180
+ version = str(self.version).strip()
181
+ SemanticVersion.parse(version)
182
+ capabilities: list[str] = []
183
+ for value in self.capabilities:
184
+ item = _token(value, field_name="capability")
185
+ if item not in capabilities:
186
+ capabilities.append(item)
187
+ dependencies: list[ExtensionDependency] = []
188
+ seen: set[str] = set()
189
+ for value in self.dependencies:
190
+ dep = ExtensionDependency.parse(value)
191
+ if dep.extension_id == extension_id:
192
+ raise ExtensionManifestError("an extension may not depend on itself")
193
+ if dep.extension_id in seen:
194
+ raise ExtensionManifestError(f"duplicate dependency {dep.extension_id!r}")
195
+ seen.add(dep.extension_id)
196
+ dependencies.append(dep)
197
+ entrypoint = None if self.entrypoint is None else str(self.entrypoint).strip()
198
+ if entrypoint == "":
199
+ entrypoint = None
200
+ if entrypoint is not None and not _ENTRYPOINT_RE.fullmatch(entrypoint):
201
+ raise ExtensionManifestError("entrypoint must use the form 'package.module:Attribute'")
202
+ object.__setattr__(self, "extension_id", extension_id)
203
+ object.__setattr__(self, "name", name)
204
+ object.__setattr__(self, "version", version)
205
+ object.__setattr__(self, "kind", _kind(self.kind))
206
+ object.__setattr__(self, "description", description)
207
+ object.__setattr__(self, "capabilities", tuple(capabilities))
208
+ object.__setattr__(self, "dependencies", tuple(dependencies))
209
+ object.__setattr__(self, "entrypoint", entrypoint)
210
+ object.__setattr__(self, "lifecycle", _lifecycle(self.lifecycle))
211
+ object.__setattr__(self, "metadata", _freeze_json(self.metadata, field_name="metadata"))
212
+
213
+ def to_dict(self) -> dict[str, Any]:
214
+ return {
215
+ "extension_id": self.extension_id,
216
+ "name": self.name,
217
+ "version": self.version,
218
+ "kind": self.kind.value,
219
+ "description": self.description,
220
+ "capabilities": list(self.capabilities),
221
+ "dependencies": [dep.to_dict() for dep in self.dependencies],
222
+ "entrypoint": self.entrypoint,
223
+ "lifecycle": self.lifecycle.value,
224
+ "metadata": _thaw_json(self.metadata),
225
+ }
226
+
227
+ @property
228
+ def sha256(self) -> str:
229
+ raw = json.dumps(
230
+ self.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
231
+ ).encode("utf-8")
232
+ return hashlib.sha256(raw).hexdigest()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ import re
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ from solverpilot.backends.base import Backend
9
+ from solverpilot.bridges.registry import BridgeSpec
10
+
11
+ from .errors import ExtensionManifestError
12
+ from .manifest import ExtensionManifest
13
+
14
+ _NAME_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ExtensionSelfCheck:
19
+ ok: bool
20
+ checks: tuple[str, ...] = ()
21
+ warnings: tuple[str, ...] = ()
22
+
23
+ def __post_init__(self) -> None:
24
+ if not isinstance(self.ok, bool):
25
+ raise ExtensionManifestError("self-check ok must be a boolean")
26
+ checks = tuple(str(item).strip() for item in self.checks)
27
+ warnings = tuple(str(item).strip() for item in self.warnings)
28
+ if any(not item for item in checks):
29
+ raise ExtensionManifestError("self-check identifiers must be non-empty")
30
+ if any(not item for item in warnings):
31
+ raise ExtensionManifestError("self-check warnings must be non-empty")
32
+ object.__setattr__(self, "checks", checks)
33
+ object.__setattr__(self, "warnings", warnings)
34
+
35
+
36
+ class ExportKind(str, Enum):
37
+ DATA_CONNECTOR = "data_connector"
38
+ FEATURE_EXTRACTOR = "feature_extractor"
39
+ EVALUATOR = "evaluator"
40
+ REPORTER = "reporter"
41
+ APPLICATION = "application"
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class NamedExtensionExport:
46
+ kind: ExportKind | str
47
+ name: str
48
+ value: Any
49
+
50
+ def __post_init__(self) -> None:
51
+ try:
52
+ kind = self.kind if isinstance(self.kind, ExportKind) else ExportKind(str(self.kind).strip().lower())
53
+ except ValueError as exc:
54
+ raise ExtensionManifestError(f"unsupported export kind {self.kind!r}") from exc
55
+ name = str(self.name).strip().lower()
56
+ if not _NAME_RE.fullmatch(name):
57
+ raise ExtensionManifestError(f"invalid export name {self.name!r}")
58
+ if self.value is None:
59
+ raise ExtensionManifestError("export value may not be None")
60
+ object.__setattr__(self, "kind", kind)
61
+ object.__setattr__(self, "name", name)
62
+
63
+ @property
64
+ def key(self) -> str:
65
+ return f"{self.kind.value}:{self.name}"
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class ExtensionContributions:
70
+ backends: tuple[Backend, ...] = ()
71
+ bridges: tuple[BridgeSpec, ...] = ()
72
+ exports: tuple[NamedExtensionExport, ...] = ()
73
+
74
+
75
+ @runtime_checkable
76
+ class Extension(Protocol):
77
+ @property
78
+ def manifest(self) -> ExtensionManifest: ...
79
+
80
+ def self_check(self) -> ExtensionSelfCheck: ...
81
+
82
+ def contributions(self) -> ExtensionContributions: ...
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class StagedExtensionContribution:
87
+ extension_id: str
88
+ extension_version: str
89
+ backends: tuple[Backend, ...] = ()
90
+ bridges: tuple[BridgeSpec, ...] = ()
91
+ exports: tuple[NamedExtensionExport, ...] = ()
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class ExtensionActivationPlan:
96
+ generation: int
97
+ extension_order: tuple[str, ...]
98
+ staged: tuple[StagedExtensionContribution, ...]
99
+ target_backend_names: tuple[str, ...]
100
+ target_bridge_keys: tuple[str, ...]
101
+ plan_sha256: str
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import re
5
+
6
+ from .errors import ExtensionManifestError
7
+
8
+ _SEMVER_RE = re.compile(
9
+ r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
10
+ r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
11
+ r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
12
+ )
13
+ _COMPARATOR_RE = re.compile(r"^(==|!=|>=|<=|>|<|\^|~)?\s*(.+)$")
14
+
15
+
16
+ def _identifier_key(identifier: str) -> tuple[int, int | str]:
17
+ if identifier.isdigit():
18
+ return (0, int(identifier))
19
+ return (1, identifier)
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class SemanticVersion:
24
+ major: int
25
+ minor: int
26
+ patch: int
27
+ prerelease: tuple[str, ...] = ()
28
+ build: tuple[str, ...] = ()
29
+
30
+ @classmethod
31
+ def parse(cls, value: str) -> "SemanticVersion":
32
+ raw = str(value).strip()
33
+ match = _SEMVER_RE.fullmatch(raw)
34
+ if match is None:
35
+ raise ExtensionManifestError(
36
+ f"version {value!r} must be valid SemVer MAJOR.MINOR.PATCH with optional prerelease/build suffix"
37
+ )
38
+ pre = tuple(match.group(4).split(".")) if match.group(4) else ()
39
+ build = tuple(match.group(5).split(".")) if match.group(5) else ()
40
+ for ident in pre:
41
+ if ident.isdigit() and len(ident) > 1 and ident.startswith("0"):
42
+ raise ExtensionManifestError(f"invalid numeric prerelease identifier {ident!r}")
43
+ return cls(int(match.group(1)), int(match.group(2)), int(match.group(3)), pre, build)
44
+
45
+ def _precedence(self) -> tuple[object, ...]:
46
+ base: tuple[object, ...] = (self.major, self.minor, self.patch)
47
+ if not self.prerelease:
48
+ return base + ((2, ""),)
49
+ return base + ((1, tuple(_identifier_key(x) for x in self.prerelease)),)
50
+
51
+ def __lt__(self, other: "SemanticVersion") -> bool:
52
+ return self._precedence() < other._precedence()
53
+
54
+ def __le__(self, other: "SemanticVersion") -> bool:
55
+ return self == other or self < other
56
+
57
+ def __gt__(self, other: "SemanticVersion") -> bool:
58
+ return not self <= other
59
+
60
+ def __ge__(self, other: "SemanticVersion") -> bool:
61
+ return not self < other
62
+
63
+ def __eq__(self, other: object) -> bool:
64
+ if not isinstance(other, SemanticVersion):
65
+ return NotImplemented
66
+ return (
67
+ self.major,
68
+ self.minor,
69
+ self.patch,
70
+ self.prerelease,
71
+ ) == (
72
+ other.major,
73
+ other.minor,
74
+ other.patch,
75
+ other.prerelease,
76
+ )
77
+
78
+ def __str__(self) -> str:
79
+ text = f"{self.major}.{self.minor}.{self.patch}"
80
+ if self.prerelease:
81
+ text += "-" + ".".join(self.prerelease)
82
+ if self.build:
83
+ text += "+" + ".".join(self.build)
84
+ return text
85
+
86
+
87
+ def _caret_upper(v: SemanticVersion) -> SemanticVersion:
88
+ if v.major > 0:
89
+ return SemanticVersion(v.major + 1, 0, 0)
90
+ if v.minor > 0:
91
+ return SemanticVersion(0, v.minor + 1, 0)
92
+ return SemanticVersion(0, 0, v.patch + 1)
93
+
94
+
95
+ def _tilde_upper(v: SemanticVersion) -> SemanticVersion:
96
+ return SemanticVersion(v.major, v.minor + 1, 0)
97
+
98
+
99
+ def version_satisfies(version: str, specifier: str) -> bool:
100
+ """Return whether *version* satisfies a small deterministic SemVer constraint language.
101
+
102
+ Supported clauses are comma-separated ``== != >= <= > < ^ ~`` comparisons. An empty
103
+ specifier accepts every valid version. Build metadata does not affect precedence.
104
+ """
105
+
106
+ current = SemanticVersion.parse(version)
107
+ raw = str(specifier).strip()
108
+ if not raw or raw == "*":
109
+ return True
110
+ parts = raw.split(",")
111
+ if any(not part.strip() for part in parts):
112
+ raise ExtensionManifestError(f"invalid version constraint {specifier!r}")
113
+ clauses = [part.strip() for part in parts]
114
+ for clause in clauses:
115
+ match = _COMPARATOR_RE.fullmatch(clause)
116
+ if match is None:
117
+ raise ExtensionManifestError(f"invalid version constraint {clause!r}")
118
+ op = match.group(1) or "=="
119
+ target = SemanticVersion.parse(match.group(2))
120
+ if op == "==" and not current == target:
121
+ return False
122
+ if op == "!=" and current == target:
123
+ return False
124
+ if op == ">=" and not current >= target:
125
+ return False
126
+ if op == "<=" and not current <= target:
127
+ return False
128
+ if op == ">" and not current > target:
129
+ return False
130
+ if op == "<" and not current < target:
131
+ return False
132
+ if op == "^" and not (current >= target and current < _caret_upper(target)):
133
+ return False
134
+ if op == "~" and not (current >= target and current < _tilde_upper(target)):
135
+ return False
136
+ return True
@@ -0,0 +1,22 @@
1
+ """Optional local history storage for reproducible optimization research.
2
+
3
+ Nothing is persisted unless a caller explicitly constructs :class:`HistoryStore`
4
+ and records data. The schema intentionally excludes raw problem arrays and raw
5
+ solution vectors.
6
+ """
7
+
8
+ from .errors import HistoryDataError, HistorySchemaError, HistoryStoreError
9
+ from .model import ProblemHistoryRecord, SolveHistoryRecord
10
+ from .sqlite import HISTORY_SCHEMA_LABEL, HISTORY_SCHEMA_NAME, HISTORY_SCHEMA_VERSION, HistoryStore
11
+
12
+ __all__ = [
13
+ "HISTORY_SCHEMA_LABEL",
14
+ "HISTORY_SCHEMA_NAME",
15
+ "HISTORY_SCHEMA_VERSION",
16
+ "HistoryDataError",
17
+ "HistorySchemaError",
18
+ "HistoryStoreError",
19
+ "HistoryStore",
20
+ "ProblemHistoryRecord",
21
+ "SolveHistoryRecord",
22
+ ]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from solverpilot.exceptions import SolverPilotError
4
+
5
+
6
+ class HistoryStoreError(SolverPilotError):
7
+ """Base error for SolverPilot's optional local history store."""
8
+
9
+
10
+ class HistorySchemaError(HistoryStoreError):
11
+ """Raised when a database schema is unsupported or inconsistent."""
12
+
13
+
14
+ class HistoryDataError(HistoryStoreError):
15
+ """Raised when a record violates the history-store contract."""
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Mapping
5
+
6
+ from solverpilot.io.hashing import deep_freeze_json, deep_thaw_json
7
+
8
+ from .errors import HistoryDataError
9
+
10
+
11
+ def _text(value: str, field_name: str) -> str:
12
+ if not isinstance(value, str) or not value.strip():
13
+ raise HistoryDataError(f"{field_name} must be a non-empty string")
14
+ return value.strip()
15
+
16
+
17
+ def _sha(value: str, field_name: str) -> str:
18
+ text = _text(value, field_name).lower()
19
+ if len(text) != 64 or any(ch not in "0123456789abcdef" for ch in text):
20
+ raise HistoryDataError(f"{field_name} must be a SHA-256 digest")
21
+ return text
22
+
23
+
24
+ def _json_object(value: Mapping[str, Any], field_name: str) -> Mapping[str, Any]:
25
+ if not isinstance(value, Mapping):
26
+ raise HistoryDataError(f"{field_name} must be a mapping")
27
+ try:
28
+ frozen = deep_freeze_json(value)
29
+ except Exception as exc: # normalize lower-layer configuration errors
30
+ raise HistoryDataError(f"{field_name} must contain finite JSON-safe values") from exc
31
+ if not isinstance(frozen, Mapping):
32
+ raise HistoryDataError(f"{field_name} must form a JSON object")
33
+ return frozen
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ProblemHistoryRecord:
38
+ problem_data_hash: str
39
+ problem_structural_hash: str
40
+ problem_class: str
41
+ n_variables: int
42
+ n_constraints: int
43
+ source_provenance: Mapping[str, Any] = field(default_factory=dict)
44
+ metadata: Mapping[str, Any] = field(default_factory=dict)
45
+
46
+ def __post_init__(self) -> None:
47
+ object.__setattr__(self, "problem_data_hash", _sha(self.problem_data_hash, "problem_data_hash"))
48
+ object.__setattr__(self, "problem_structural_hash", _sha(self.problem_structural_hash, "problem_structural_hash"))
49
+ object.__setattr__(self, "problem_class", _text(self.problem_class, "problem_class"))
50
+ for name in ("n_variables", "n_constraints"):
51
+ value = getattr(self, name)
52
+ if type(value) is not int or value < 0:
53
+ raise HistoryDataError(f"{name} must be a non-negative integer")
54
+ object.__setattr__(self, "source_provenance", _json_object(self.source_provenance, "source_provenance"))
55
+ object.__setattr__(self, "metadata", _json_object(self.metadata, "metadata"))
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {
59
+ "problem_data_hash": self.problem_data_hash,
60
+ "problem_structural_hash": self.problem_structural_hash,
61
+ "problem_class": self.problem_class,
62
+ "n_variables": self.n_variables,
63
+ "n_constraints": self.n_constraints,
64
+ "source_provenance": deep_thaw_json(self.source_provenance),
65
+ "metadata": deep_thaw_json(self.metadata),
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class SolveHistoryRecord:
71
+ run_id: str
72
+ problem_data_hash: str
73
+ problem_structural_hash: str
74
+ problem_class: str
75
+ backend: str | None
76
+ backend_version: str | None
77
+ public_status: str
78
+ backend_status: str
79
+ objective: float | None
80
+ validation_valid: bool | None
81
+ independently_verified_optimal: bool
82
+ total_s: float | None
83
+ environment_id: str | None = None
84
+ trace_created_at_utc: str | None = None
85
+ metadata: Mapping[str, Any] = field(default_factory=dict)
86
+
87
+ def __post_init__(self) -> None:
88
+ import math
89
+
90
+ object.__setattr__(self, "run_id", _text(self.run_id, "run_id"))
91
+ object.__setattr__(self, "problem_data_hash", _sha(self.problem_data_hash, "problem_data_hash"))
92
+ object.__setattr__(self, "problem_structural_hash", _sha(self.problem_structural_hash, "problem_structural_hash"))
93
+ object.__setattr__(self, "problem_class", _text(self.problem_class, "problem_class"))
94
+ object.__setattr__(self, "public_status", _text(self.public_status, "public_status"))
95
+ object.__setattr__(self, "backend_status", _text(self.backend_status, "backend_status"))
96
+ for name in ("backend", "backend_version", "environment_id", "trace_created_at_utc"):
97
+ value = getattr(self, name)
98
+ if value is not None:
99
+ object.__setattr__(self, name, _text(value, name))
100
+ if self.objective is not None and (type(self.objective) not in (int, float) or not math.isfinite(float(self.objective))):
101
+ raise HistoryDataError("objective must be finite when provided")
102
+ if self.total_s is not None and (type(self.total_s) not in (int, float) or not math.isfinite(float(self.total_s)) or float(self.total_s) < 0):
103
+ raise HistoryDataError("total_s must be finite and non-negative when provided")
104
+ if self.validation_valid is not None and type(self.validation_valid) is not bool:
105
+ raise HistoryDataError("validation_valid must be boolean or None")
106
+ if type(self.independently_verified_optimal) is not bool:
107
+ raise HistoryDataError("independently_verified_optimal must be boolean")
108
+ object.__setattr__(self, "objective", None if self.objective is None else float(self.objective))
109
+ object.__setattr__(self, "total_s", None if self.total_s is None else float(self.total_s))
110
+ object.__setattr__(self, "metadata", _json_object(self.metadata, "metadata"))
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ return {
114
+ "run_id": self.run_id,
115
+ "problem_data_hash": self.problem_data_hash,
116
+ "problem_structural_hash": self.problem_structural_hash,
117
+ "problem_class": self.problem_class,
118
+ "backend": self.backend,
119
+ "backend_version": self.backend_version,
120
+ "public_status": self.public_status,
121
+ "backend_status": self.backend_status,
122
+ "objective": self.objective,
123
+ "validation_valid": self.validation_valid,
124
+ "independently_verified_optimal": self.independently_verified_optimal,
125
+ "total_s": self.total_s,
126
+ "environment_id": self.environment_id,
127
+ "trace_created_at_utc": self.trace_created_at_utc,
128
+ "metadata": deep_thaw_json(self.metadata),
129
+ }