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,327 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from typing import Any
5
+
6
+ from solverpilot.runtime import SolveResult
7
+ from solverpilot.validate import PublicStatus
8
+
9
+ from .model import ClaimDisposition, ClaimKind, ExplanationClaim, ExplanationReport
10
+
11
+
12
+ _STATUS_EXPLANATIONS: dict[PublicStatus, str] = {
13
+ PublicStatus.VALID_OPTIMAL: (
14
+ "The backend reported optimality and the returned primal candidate passed SolverPilot's canonical validation. "
15
+ "This status alone is not an independent proof of optimality; consult the optimality evidence in this report."
16
+ ),
17
+ PublicStatus.VALID_FEASIBLE: (
18
+ "The returned candidate passed SolverPilot's canonical feasibility validation. "
19
+ "Feasibility does not establish optimality."
20
+ ),
21
+ PublicStatus.FEASIBLE_LIMIT: (
22
+ "A candidate passed canonical validation, but the backend terminated at a configured or solver limit. "
23
+ "The result must not be presented as proven optimal."
24
+ ),
25
+ PublicStatus.INFEASIBLE: (
26
+ "The backend reported the model infeasible. Additional SolverPilot diagnostics, when present, are reported separately; "
27
+ "the public status alone is not an independently reconstructed universal infeasibility proof."
28
+ ),
29
+ PublicStatus.UNBOUNDED: (
30
+ "The backend reported the model unbounded. SolverPilot does not infer an independent unboundedness proof from this status alone."
31
+ ),
32
+ PublicStatus.INFEASIBLE_OR_UNBOUNDED: (
33
+ "The backend could not distinguish infeasibility from unboundedness. Neither conclusion should be stated separately without more evidence."
34
+ ),
35
+ PublicStatus.INVALID_SOLUTION: (
36
+ "The backend returned a candidate that failed SolverPilot's independent canonical validation. "
37
+ "The candidate must not be presented as a valid solution."
38
+ ),
39
+ PublicStatus.ERROR: (
40
+ "The solve did not produce a validated normal result. No feasibility, optimality, infeasibility, or superiority claim follows from this status."
41
+ ),
42
+ PublicStatus.UNKNOWN: (
43
+ "The outcome could not be mapped to a stronger public status. Additional evidence is required before making a solve-quality claim."
44
+ ),
45
+ }
46
+
47
+
48
+ def explain_status(status: PublicStatus | str) -> str:
49
+ if isinstance(status, str):
50
+ status = PublicStatus(status)
51
+ return _STATUS_EXPLANATIONS[status]
52
+
53
+
54
+ def _validation_payload(result: SolveResult) -> dict[str, Any] | None:
55
+ report = result.validation
56
+ if report is None:
57
+ return None
58
+ return {
59
+ "valid": report.valid,
60
+ "max_bound_violation": report.max_bound_violation,
61
+ "max_constraint_violation": report.max_constraint_violation,
62
+ "max_integrality_violation": report.max_integrality_violation,
63
+ "objective_recomputed": report.objective_recomputed,
64
+ "objective_reported": report.objective_reported,
65
+ "objective_difference": report.objective_difference,
66
+ "objective_consistent": report.objective_consistent,
67
+ "warnings": tuple(report.warnings),
68
+ }
69
+
70
+
71
+ def _optimality_payload(result: SolveResult) -> dict[str, bool]:
72
+ evidence = result.optimality_evidence
73
+ return {
74
+ "backend_reported_optimal": evidence.backend_reported_optimal,
75
+ "primal_validated": evidence.primal_validated,
76
+ "dual_verified": evidence.dual_verified,
77
+ "gap_verified": evidence.gap_verified,
78
+ "certificate_verified": evidence.certificate_verified,
79
+ "independently_verified_optimal": evidence.independently_verified_optimal,
80
+ }
81
+
82
+
83
+ def _planner_payload(result: SolveResult) -> dict[str, Any] | None:
84
+ plan = result.plan
85
+ if plan is None:
86
+ return None
87
+ return {
88
+ "selected_backend": plan.selected_backend,
89
+ "intent": plan.intent.value,
90
+ "strategy": plan.strategy,
91
+ "evidence_level": plan.evidence_level,
92
+ "health_policy": plan.health_policy.value,
93
+ "rationale": tuple(plan.rationale),
94
+ "candidate_count": len(plan.candidates),
95
+ }
96
+
97
+
98
+ def _runtime_payload(result: SolveResult) -> dict[str, Any]:
99
+ timings = result.trace.timings
100
+ return {
101
+ "reuse_applied": result.trace.reuse_applied,
102
+ "reuse_mode": result.trace.reuse_mode,
103
+ "total_s": timings.total_s,
104
+ "solve_s": timings.solve_s,
105
+ "validate_s": timings.validate_s,
106
+ "diagnose_s": timings.diagnose_s,
107
+ }
108
+
109
+
110
+ def _diagnostics_payload(result: SolveResult) -> dict[str, Any] | None:
111
+ diagnostics = result.diagnostics
112
+ if diagnostics is None:
113
+ return None
114
+ return {
115
+ "confirmed_infeasible": diagnostics.confirmed_infeasible,
116
+ "static_issue_count": len(diagnostics.static_issues),
117
+ "iis_available": diagnostics.iis is not None,
118
+ "iis_valid": bool(diagnostics.iis is not None and diagnostics.iis.valid),
119
+ "elastic_available": diagnostics.elastic is not None,
120
+ "conflict_available": diagnostics.conflict is not None,
121
+ "conflict_irreducible": None if diagnostics.conflict is None else diagnostics.conflict.irreducible,
122
+ "warnings": tuple(diagnostics.warnings),
123
+ }
124
+
125
+
126
+ def _claims(result: SolveResult) -> tuple[ExplanationClaim, ...]:
127
+ claims: list[ExplanationClaim] = []
128
+ validation_valid = bool(result.validation is not None and result.validation.valid and result.x is not None)
129
+ if validation_valid:
130
+ claims.append(
131
+ ExplanationClaim(
132
+ claim_id="solution.feasibility",
133
+ kind=ClaimKind.FEASIBILITY,
134
+ disposition=ClaimDisposition.SUPPORTED,
135
+ statement="The returned candidate passed SolverPilot's canonical validation.",
136
+ rationale="A primal candidate exists and ValidationReport.valid is true.",
137
+ evidence_refs=("result.validation",),
138
+ )
139
+ )
140
+ else:
141
+ claims.append(
142
+ ExplanationClaim(
143
+ claim_id="solution.feasibility",
144
+ kind=ClaimKind.FEASIBILITY,
145
+ disposition=ClaimDisposition.BLOCKED,
146
+ statement="A validated feasible candidate cannot be claimed from this result.",
147
+ rationale="No primal candidate passed SolverPilot's canonical validation.",
148
+ evidence_refs=("result.validation",),
149
+ )
150
+ )
151
+
152
+ evidence = result.optimality_evidence
153
+ if evidence.independently_verified_optimal:
154
+ claims.append(
155
+ ExplanationClaim(
156
+ claim_id="solution.optimality",
157
+ kind=ClaimKind.OPTIMALITY,
158
+ disposition=ClaimDisposition.SUPPORTED,
159
+ statement="Optimality satisfied SolverPilot's available independent optimality checks.",
160
+ rationale="Primal validation, dual verification, and a verified gap or certificate are all present.",
161
+ evidence_refs=("result.optimality_evidence",),
162
+ qualifiers=("The scope of verification is limited to the checks represented by OptimalityEvidence.",),
163
+ )
164
+ )
165
+ elif result.status is PublicStatus.VALID_OPTIMAL and evidence.backend_reported_optimal and evidence.primal_validated:
166
+ claims.append(
167
+ ExplanationClaim(
168
+ claim_id="solution.optimality",
169
+ kind=ClaimKind.OPTIMALITY,
170
+ disposition=ClaimDisposition.QUALIFIED,
171
+ statement="The backend reported optimality and the primal candidate passed canonical validation.",
172
+ rationale="SolverPilot did not independently reconstruct all optimality conditions required by OptimalityEvidence.",
173
+ evidence_refs=("result.optimality_evidence", "result.validation"),
174
+ qualifiers=("Do not describe this as independently proven optimal.",),
175
+ )
176
+ )
177
+ else:
178
+ claims.append(
179
+ ExplanationClaim(
180
+ claim_id="solution.optimality",
181
+ kind=ClaimKind.OPTIMALITY,
182
+ disposition=ClaimDisposition.BLOCKED,
183
+ statement="Optimality is not established by this result.",
184
+ rationale="The result does not carry sufficient optimality evidence.",
185
+ evidence_refs=("result.optimality_evidence",),
186
+ )
187
+ )
188
+
189
+ if result.status is PublicStatus.INFEASIBLE:
190
+ confirmed = bool(result.diagnostics is not None and result.diagnostics.confirmed_infeasible)
191
+ claims.append(
192
+ ExplanationClaim(
193
+ claim_id="solution.infeasibility",
194
+ kind=ClaimKind.INFEASIBILITY,
195
+ disposition=ClaimDisposition.SUPPORTED if confirmed else ClaimDisposition.QUALIFIED,
196
+ statement=(
197
+ "SolverPilot diagnostics additionally confirmed infeasibility for the supplied model."
198
+ if confirmed
199
+ else "The backend reported the supplied model infeasible."
200
+ ),
201
+ rationale=(
202
+ "InfeasibilityReport.confirmed_infeasible is true."
203
+ if confirmed
204
+ else "No independent SolverPilot diagnostic confirmation is attached to this SolveResult."
205
+ ),
206
+ evidence_refs=("result.diagnostics", "result.backend_status") if confirmed else ("result.backend_status",),
207
+ qualifiers=(
208
+ "The claim is limited to the supplied mathematical model and data; it is not a statement about an external real-world process.",
209
+ ),
210
+ )
211
+ )
212
+ else:
213
+ claims.append(
214
+ ExplanationClaim(
215
+ claim_id="solution.infeasibility",
216
+ kind=ClaimKind.INFEASIBILITY,
217
+ disposition=ClaimDisposition.BLOCKED,
218
+ statement="Infeasibility is not established by this result.",
219
+ rationale=f"Public status is {result.status.value!r}, not 'infeasible'.",
220
+ evidence_refs=("result.status",),
221
+ )
222
+ )
223
+
224
+ if result.status is PublicStatus.UNBOUNDED:
225
+ claims.append(
226
+ ExplanationClaim(
227
+ claim_id="solution.unboundedness",
228
+ kind=ClaimKind.UNBOUNDEDNESS,
229
+ disposition=ClaimDisposition.QUALIFIED,
230
+ statement="The backend reported the supplied model unbounded.",
231
+ rationale="SolverPilot currently exposes no general independent unboundedness-proof field on SolveResult.",
232
+ evidence_refs=("result.backend_status",),
233
+ qualifiers=("Do not describe this as independently proven unbounded from SolveResult alone.",),
234
+ )
235
+ )
236
+ else:
237
+ claims.append(
238
+ ExplanationClaim(
239
+ claim_id="solution.unboundedness",
240
+ kind=ClaimKind.UNBOUNDEDNESS,
241
+ disposition=ClaimDisposition.BLOCKED,
242
+ statement="Unboundedness is not established by this result.",
243
+ rationale=f"Public status is {result.status.value!r}, not 'unbounded'.",
244
+ evidence_refs=("result.status",),
245
+ )
246
+ )
247
+
248
+ if result.plan is not None:
249
+ claims.append(
250
+ ExplanationClaim(
251
+ claim_id="routing.selection",
252
+ kind=ClaimKind.PLANNER_SELECTION,
253
+ disposition=ClaimDisposition.SUPPORTED,
254
+ statement=f"SolverPilot's planner selected backend {result.plan.selected_backend!r}.",
255
+ rationale="The selection is recorded in SolvePlan.",
256
+ evidence_refs=("result.plan",),
257
+ qualifiers=("Planner selection is not, by itself, evidence that this backend is globally fastest or superior.",),
258
+ )
259
+ )
260
+
261
+ if result.trace.reuse_applied is True:
262
+ claims.append(
263
+ ExplanationClaim(
264
+ claim_id="runtime.reuse",
265
+ kind=ClaimKind.REOPTIMIZATION,
266
+ disposition=ClaimDisposition.SUPPORTED,
267
+ statement="The recorded solve reused prior solver state.",
268
+ rationale="SolveTrace.reuse_applied is true.",
269
+ evidence_refs=("result.trace",),
270
+ qualifiers=("State reuse does not by itself establish a performance improvement.",),
271
+ )
272
+ )
273
+
274
+ return tuple(claims)
275
+
276
+
277
+ def _warnings(result: SolveResult) -> tuple[str, ...]:
278
+ values: list[str] = []
279
+ if result.validation is not None:
280
+ values.extend(str(x) for x in result.validation.warnings)
281
+ values.extend(str(x) for x in result.trace.warnings)
282
+ if result.diagnostics is not None:
283
+ values.extend(str(x) for x in result.diagnostics.warnings)
284
+ # Preserve order while removing duplicates.
285
+ return tuple(dict.fromkeys(values))
286
+
287
+
288
+ def explain_result(result: SolveResult) -> ExplanationReport:
289
+ if not isinstance(result, SolveResult):
290
+ raise TypeError("explain_result expects a SolveResult")
291
+ optimality = _optimality_payload(result)
292
+ independently_verified = optimality["independently_verified_optimal"]
293
+ if result.status is PublicStatus.VALID_OPTIMAL:
294
+ summary = (
295
+ "A validated candidate was returned and optimality passed SolverPilot's available independent checks."
296
+ if independently_verified
297
+ else "A validated candidate was returned; the backend reported optimality, but complete independent optimality verification is not available."
298
+ )
299
+ elif result.status in {PublicStatus.VALID_FEASIBLE, PublicStatus.FEASIBLE_LIMIT}:
300
+ summary = "A validated feasible candidate was returned without an independent optimality claim."
301
+ elif result.status is PublicStatus.INFEASIBLE:
302
+ summary = (
303
+ "The model was reported infeasible and SolverPilot diagnostics additionally confirmed infeasibility."
304
+ if result.diagnostics is not None and result.diagnostics.confirmed_infeasible
305
+ else "The backend reported the supplied model infeasible; no additional SolverPilot confirmation is attached."
306
+ )
307
+ elif result.status is PublicStatus.INVALID_SOLUTION:
308
+ summary = "The backend returned a candidate that failed SolverPilot's canonical validation."
309
+ else:
310
+ summary = f"The solve terminated with public status {result.status.value!r}."
311
+
312
+ return ExplanationReport(
313
+ status=result.status.value,
314
+ summary=summary,
315
+ status_explanation=explain_status(result.status),
316
+ problem_class=result.trace.problem_class,
317
+ backend=result.trace.backend,
318
+ backend_version=result.trace.backend_version,
319
+ objective=result.objective,
320
+ validation=_validation_payload(result),
321
+ optimality=optimality,
322
+ planner=_planner_payload(result),
323
+ runtime=_runtime_payload(result),
324
+ diagnostics=_diagnostics_payload(result),
325
+ claims=_claims(result),
326
+ warnings=_warnings(result),
327
+ )
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from solverpilot.io.hashing import deep_freeze_json, deep_thaw_json
9
+
10
+
11
+ REPORT_SCHEMA_VERSION = "0.1"
12
+
13
+
14
+ class ClaimKind(str, Enum):
15
+ FEASIBILITY = "feasibility"
16
+ OPTIMALITY = "optimality"
17
+ INFEASIBILITY = "infeasibility"
18
+ UNBOUNDEDNESS = "unboundedness"
19
+ PLANNER_SELECTION = "planner_selection"
20
+ REOPTIMIZATION = "reoptimization"
21
+
22
+
23
+ class ClaimDisposition(str, Enum):
24
+ SUPPORTED = "supported"
25
+ QUALIFIED = "qualified"
26
+ BLOCKED = "blocked"
27
+
28
+
29
+ def _nonempty_text(value: object, field_name: str) -> str:
30
+ if not isinstance(value, str) or not value.strip():
31
+ raise ValueError(f"{field_name} must be a non-empty string")
32
+ return value.strip()
33
+
34
+
35
+ def _tuple_text(values: Sequence[str], field_name: str) -> tuple[str, ...]:
36
+ if isinstance(values, (str, bytes, bytearray)):
37
+ raise ValueError(f"{field_name} must be a sequence of strings, not a string")
38
+ try:
39
+ normalized = tuple(_nonempty_text(value, field_name) for value in values)
40
+ except TypeError as exc:
41
+ raise ValueError(f"{field_name} must be a sequence of strings") from exc
42
+ return normalized
43
+
44
+
45
+ def _freeze_mapping(value: Mapping[str, Any] | None, field_name: str) -> Mapping[str, Any] | None:
46
+ if value is None:
47
+ return None
48
+ if not isinstance(value, Mapping):
49
+ raise ValueError(f"{field_name} must be a mapping or None")
50
+ try:
51
+ frozen = deep_freeze_json(value)
52
+ except Exception as exc:
53
+ if "finite" in str(exc).lower():
54
+ raise ValueError(f"report contains NaN or infinity at $.{field_name}") from exc
55
+ raise ValueError(f"report is not JSON-safe at $.{field_name}") from exc
56
+ if not isinstance(frozen, Mapping):
57
+ raise ValueError(f"{field_name} must form an object")
58
+ return frozen
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class ExplanationClaim:
63
+ claim_id: str
64
+ kind: ClaimKind
65
+ disposition: ClaimDisposition
66
+ statement: str
67
+ rationale: str
68
+ evidence_refs: tuple[str, ...] = ()
69
+ qualifiers: tuple[str, ...] = ()
70
+
71
+ def __post_init__(self) -> None:
72
+ claim_id = _nonempty_text(self.claim_id, "claim_id")
73
+ statement = _nonempty_text(self.statement, "statement")
74
+ rationale = _nonempty_text(self.rationale, "rationale")
75
+ try:
76
+ kind = self.kind if isinstance(self.kind, ClaimKind) else ClaimKind(str(self.kind).strip().lower())
77
+ except ValueError as exc:
78
+ raise ValueError("kind must be a supported ClaimKind") from exc
79
+ try:
80
+ disposition = (
81
+ self.disposition
82
+ if isinstance(self.disposition, ClaimDisposition)
83
+ else ClaimDisposition(str(self.disposition).strip().lower())
84
+ )
85
+ except ValueError as exc:
86
+ raise ValueError("disposition must be a supported ClaimDisposition") from exc
87
+ evidence_refs = _tuple_text(self.evidence_refs, "evidence_refs")
88
+ qualifiers = _tuple_text(self.qualifiers, "qualifiers")
89
+ if len(set(evidence_refs)) != len(evidence_refs):
90
+ raise ValueError("evidence_refs must not contain duplicates")
91
+ object.__setattr__(self, "claim_id", claim_id)
92
+ object.__setattr__(self, "kind", kind)
93
+ object.__setattr__(self, "disposition", disposition)
94
+ object.__setattr__(self, "statement", statement)
95
+ object.__setattr__(self, "rationale", rationale)
96
+ object.__setattr__(self, "evidence_refs", evidence_refs)
97
+ object.__setattr__(self, "qualifiers", qualifiers)
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {
101
+ "claim_id": self.claim_id,
102
+ "kind": self.kind.value,
103
+ "disposition": self.disposition.value,
104
+ "statement": self.statement,
105
+ "rationale": self.rationale,
106
+ "evidence_refs": list(self.evidence_refs),
107
+ "qualifiers": list(self.qualifiers),
108
+ }
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class ExplanationReport:
113
+ status: str
114
+ summary: str
115
+ status_explanation: str
116
+ problem_class: str
117
+ backend: str | None
118
+ backend_version: str | None
119
+ objective: float | None
120
+ validation: Mapping[str, Any] | None
121
+ optimality: Mapping[str, Any]
122
+ planner: Mapping[str, Any] | None
123
+ runtime: Mapping[str, Any]
124
+ diagnostics: Mapping[str, Any] | None
125
+ claims: tuple[ExplanationClaim, ...]
126
+ warnings: tuple[str, ...] = ()
127
+ schema_version: str = REPORT_SCHEMA_VERSION
128
+
129
+ def __post_init__(self) -> None:
130
+ status = _nonempty_text(self.status, "status")
131
+ summary = _nonempty_text(self.summary, "summary")
132
+ status_explanation = _nonempty_text(self.status_explanation, "status_explanation")
133
+ problem_class = _nonempty_text(self.problem_class, "problem_class")
134
+ backend = None if self.backend is None else _nonempty_text(self.backend, "backend")
135
+ backend_version = None if self.backend_version is None else _nonempty_text(self.backend_version, "backend_version")
136
+ if self.schema_version != REPORT_SCHEMA_VERSION:
137
+ raise ValueError(f"schema_version must be {REPORT_SCHEMA_VERSION!r}")
138
+ if self.objective is not None:
139
+ import math
140
+
141
+ if isinstance(self.objective, bool) or type(self.objective) not in (int, float) or not math.isfinite(float(self.objective)):
142
+ raise ValueError("report contains NaN or infinity at $.objective")
143
+ objective: float | None = float(self.objective)
144
+ else:
145
+ objective = None
146
+ validation = _freeze_mapping(self.validation, "validation")
147
+ optimality = _freeze_mapping(self.optimality, "optimality")
148
+ planner = _freeze_mapping(self.planner, "planner")
149
+ runtime = _freeze_mapping(self.runtime, "runtime")
150
+ diagnostics = _freeze_mapping(self.diagnostics, "diagnostics")
151
+ if optimality is None or runtime is None:
152
+ raise ValueError("optimality and runtime must be mappings")
153
+ claims = tuple(self.claims)
154
+ if any(not isinstance(claim, ExplanationClaim) for claim in claims):
155
+ raise ValueError("claims must contain ExplanationClaim objects")
156
+ warnings = _tuple_text(self.warnings, "warnings")
157
+ ids = [claim.claim_id for claim in claims]
158
+ if len(ids) != len(set(ids)):
159
+ raise ValueError("claim IDs must be unique")
160
+ object.__setattr__(self, "status", status)
161
+ object.__setattr__(self, "summary", summary)
162
+ object.__setattr__(self, "status_explanation", status_explanation)
163
+ object.__setattr__(self, "problem_class", problem_class)
164
+ object.__setattr__(self, "backend", backend)
165
+ object.__setattr__(self, "backend_version", backend_version)
166
+ object.__setattr__(self, "objective", objective)
167
+ object.__setattr__(self, "validation", validation)
168
+ object.__setattr__(self, "optimality", optimality)
169
+ object.__setattr__(self, "planner", planner)
170
+ object.__setattr__(self, "runtime", runtime)
171
+ object.__setattr__(self, "diagnostics", diagnostics)
172
+ object.__setattr__(self, "claims", claims)
173
+ object.__setattr__(self, "warnings", warnings)
174
+ _validate_claim_evidence_refs(claims)
175
+ if not self.claim_safe:
176
+ raise ValueError("ExplanationReport contains a supported claim that is not authorized by structured evidence")
177
+ _assert_json_safe(self.to_dict())
178
+
179
+ @property
180
+ def claim_safe(self) -> bool:
181
+ for claim in self.claims:
182
+ if claim.disposition is not ClaimDisposition.SUPPORTED:
183
+ continue
184
+ if claim.kind is ClaimKind.FEASIBILITY:
185
+ if not (isinstance(self.validation, Mapping) and self.validation.get("valid") is True):
186
+ return False
187
+ elif claim.kind is ClaimKind.OPTIMALITY:
188
+ if self.optimality.get("independently_verified_optimal") is not True:
189
+ return False
190
+ elif claim.kind is ClaimKind.INFEASIBILITY:
191
+ if not (isinstance(self.diagnostics, Mapping) and self.diagnostics.get("confirmed_infeasible") is True):
192
+ return False
193
+ elif claim.kind is ClaimKind.UNBOUNDEDNESS:
194
+ # S5/S10 has no generic independent unboundedness-proof field.
195
+ return False
196
+ elif claim.kind is ClaimKind.PLANNER_SELECTION:
197
+ if self.planner is None:
198
+ return False
199
+ elif claim.kind is ClaimKind.REOPTIMIZATION:
200
+ if self.runtime.get("reuse_applied") is not True:
201
+ return False
202
+ return True
203
+
204
+ def to_dict(self) -> dict[str, Any]:
205
+ payload = {
206
+ "status": self.status,
207
+ "summary": self.summary,
208
+ "status_explanation": self.status_explanation,
209
+ "problem_class": self.problem_class,
210
+ "backend": self.backend,
211
+ "backend_version": self.backend_version,
212
+ "objective": self.objective,
213
+ "validation": None if self.validation is None else deep_thaw_json(self.validation),
214
+ "optimality": deep_thaw_json(self.optimality),
215
+ "planner": None if self.planner is None else deep_thaw_json(self.planner),
216
+ "runtime": deep_thaw_json(self.runtime),
217
+ "diagnostics": None if self.diagnostics is None else deep_thaw_json(self.diagnostics),
218
+ "claims": [claim.to_dict() for claim in self.claims],
219
+ "warnings": list(self.warnings),
220
+ "schema_version": self.schema_version,
221
+ }
222
+ _assert_json_safe(payload)
223
+ if not self.claim_safe:
224
+ raise ValueError("ExplanationReport no longer satisfies claim-safety invariants")
225
+ return payload
226
+
227
+
228
+ def _assert_json_safe(value: Any, *, path: str = "$") -> None:
229
+ import math
230
+
231
+ if value is None or isinstance(value, (str, bool, int)):
232
+ return
233
+ if isinstance(value, float):
234
+ if not math.isfinite(value):
235
+ raise ValueError(f"report contains NaN or infinity at {path}")
236
+ return
237
+ if isinstance(value, (tuple, list)):
238
+ for index, item in enumerate(value):
239
+ _assert_json_safe(item, path=f"{path}[{index}]")
240
+ return
241
+ if isinstance(value, Mapping):
242
+ for key, item in value.items():
243
+ if not isinstance(key, str):
244
+ raise ValueError(f"report mapping keys must be strings at {path}")
245
+ _assert_json_safe(item, path=f"{path}.{key}")
246
+ return
247
+ raise ValueError(f"report is not JSON-safe at {path}: {type(value).__name__}")
248
+
249
+
250
+ _KNOWN_EVIDENCE_REFS = frozenset({
251
+ "result.status",
252
+ "result.validation",
253
+ "result.optimality_evidence",
254
+ "result.backend_status",
255
+ "result.diagnostics",
256
+ "result.plan",
257
+ "result.trace",
258
+ })
259
+
260
+
261
+ def _validate_claim_evidence_refs(claims: tuple[ExplanationClaim, ...]) -> None:
262
+ unknown = sorted({ref for claim in claims for ref in claim.evidence_refs if ref not in _KNOWN_EVIDENCE_REFS})
263
+ if unknown:
264
+ raise ValueError(f"claims contain unknown evidence references: {unknown}")
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+
6
+ from .model import ExplanationReport
7
+
8
+
9
+ def render_json(report: ExplanationReport, *, indent: int | None = 2) -> str:
10
+ if not isinstance(report, ExplanationReport):
11
+ raise TypeError("render_json expects an ExplanationReport")
12
+ if not report.claim_safe:
13
+ raise ValueError("refusing to render an explanation report that violates claim-safety invariants")
14
+ return json.dumps(report.to_dict(), ensure_ascii=False, sort_keys=True, indent=indent, allow_nan=False)
15
+
16
+
17
+ def _md(value: object) -> str:
18
+ text = html.escape(str(value), quote=False)
19
+ return text.replace("|", "\\|").replace("\r", " ").replace("\n", " ")
20
+
21
+
22
+ def render_markdown(report: ExplanationReport) -> str:
23
+ if not isinstance(report, ExplanationReport):
24
+ raise TypeError("render_markdown expects an ExplanationReport")
25
+ if not report.claim_safe:
26
+ raise ValueError("refusing to render an explanation report that violates claim-safety invariants")
27
+ lines = [
28
+ "# SolverPilot explanation",
29
+ "",
30
+ f"**Status:** `{_md(report.status)}`",
31
+ f"**Problem class:** `{_md(report.problem_class)}`",
32
+ f"**Backend:** `{_md(report.backend if report.backend is not None else 'unknown')}`",
33
+ f"**Objective:** `{_md(report.objective if report.objective is not None else 'not available')}`",
34
+ "",
35
+ "## Summary",
36
+ _md(report.summary),
37
+ "",
38
+ "## Status interpretation",
39
+ _md(report.status_explanation),
40
+ "",
41
+ "## Claims",
42
+ "| Claim | Disposition | Statement |",
43
+ "| --- | --- | --- |",
44
+ ]
45
+ for claim in report.claims:
46
+ lines.append(
47
+ f"| `{_md(claim.claim_id)}` | `{_md(claim.disposition.value)}` | {_md(claim.statement)} |"
48
+ )
49
+ if claim.qualifiers:
50
+ for qualifier in claim.qualifiers:
51
+ lines.append(f" - **Qualifier:** {_md(qualifier)}")
52
+ if report.warnings:
53
+ lines.extend(["", "## Warnings"])
54
+ lines.extend(f"- {_md(warning)}" for warning in report.warnings)
55
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,19 @@
1
+ from .auto import builtin_backend_candidates, default_registry, solve, solve_production
2
+ from .budgeting import apply_budget
3
+ from .executor import execute
4
+ from .portfolio import PortfolioAttempt, PortfolioSolveResult, execute_portfolio
5
+ from .result import OptimalityEvidence, SolveResult
6
+
7
+ __all__ = [
8
+ "PortfolioAttempt",
9
+ "PortfolioSolveResult",
10
+ "SolveResult",
11
+ "OptimalityEvidence",
12
+ "apply_budget",
13
+ "builtin_backend_candidates",
14
+ "default_registry",
15
+ "execute",
16
+ "execute_portfolio",
17
+ "solve",
18
+ "solve_production",
19
+ ]