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,199 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+
6
+ import numpy as np
7
+ from scipy import sparse
8
+
9
+ from solverpilot.problem import ConvexityStatus, LinearProblem, QuadraticProblem, VariableDomain
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class DistributionStats:
14
+ minimum: float
15
+ q25: float
16
+ median: float
17
+ mean: float
18
+ q75: float
19
+ maximum: float
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ProblemFingerprint:
24
+ problem_class: str
25
+ n_variables: int
26
+ n_constraints: int
27
+ nnz_a: int
28
+ density_a: float
29
+ binary_fraction: float
30
+ integer_fraction: float
31
+ equality_fraction: float
32
+ fully_bounded_variable_fraction: float
33
+ free_variable_fraction: float
34
+ objective_nnz_fraction: float
35
+ coefficient_dynamic_range_log10: float | None
36
+ objective_dynamic_range_log10: float | None
37
+ rhs_dynamic_range_log10: float | None
38
+ row_nnz: DistributionStats
39
+ col_nnz: DistributionStats
40
+ singleton_rows: int
41
+ singleton_cols: int
42
+ positive_coefficient_fraction: float
43
+ negative_coefficient_fraction: float
44
+ quadratic_nnz: int | None
45
+ quadratic_density: float | None
46
+ convexity_status: str | None
47
+ structural_hash: str
48
+ estimated_numeric_bytes: int
49
+
50
+
51
+ def _distribution(values: np.ndarray) -> DistributionStats:
52
+ values = np.asarray(values, dtype=np.float64)
53
+ if values.size == 0:
54
+ return DistributionStats(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
55
+ q25, med, q75 = np.quantile(values, [0.25, 0.5, 0.75])
56
+ return DistributionStats(
57
+ minimum=float(np.min(values)),
58
+ q25=float(q25),
59
+ median=float(med),
60
+ mean=float(np.mean(values)),
61
+ q75=float(q75),
62
+ maximum=float(np.max(values)),
63
+ )
64
+
65
+
66
+ def _dynamic_range_log10(values: np.ndarray) -> float | None:
67
+ values = np.asarray(values, dtype=np.float64)
68
+ finite_nonzero = np.abs(values[np.isfinite(values) & (values != 0.0)])
69
+ if finite_nonzero.size == 0:
70
+ return None
71
+ lo = float(np.min(finite_nonzero))
72
+ hi = float(np.max(finite_nonzero))
73
+ if lo <= 0.0 or hi <= 0.0:
74
+ return None
75
+ return float(math.log10(hi / lo))
76
+
77
+
78
+ def _estimated_bytes_linear(problem: LinearProblem) -> int:
79
+ A = problem.A
80
+ sparse_bytes = int(A.data.nbytes + A.indices.nbytes + A.indptr.nbytes)
81
+ dense_bytes = sum(
82
+ int(arr.nbytes)
83
+ for arr in (
84
+ problem.c,
85
+ problem.variable_lower,
86
+ problem.variable_upper,
87
+ problem.constraint_lower,
88
+ problem.constraint_upper,
89
+ problem.domains,
90
+ )
91
+ )
92
+ return sparse_bytes + dense_bytes
93
+
94
+
95
+ def inspect_problem(problem: LinearProblem | QuadraticProblem) -> ProblemFingerprint:
96
+ if isinstance(problem, QuadraticProblem):
97
+ if problem.convexity_status is not ConvexityStatus.CONFIRMED:
98
+ raise ValueError("unverified quadratic problem cannot be inspected/routed as convex_qp")
99
+ linear = problem.linear
100
+ problem_class = "convex_qp"
101
+ quadratic_nnz: int | None = int(problem.P.nnz)
102
+ n = problem.n_variables
103
+ quadratic_density: float | None = (
104
+ float(problem.P.nnz / (n * n)) if n > 0 else 0.0
105
+ )
106
+ convexity_status: str | None = ConvexityStatus(problem.convexity_status).value
107
+ extra_bytes = int(problem.P.data.nbytes + problem.P.indices.nbytes + problem.P.indptr.nbytes)
108
+ elif isinstance(problem, LinearProblem):
109
+ linear = problem
110
+ problem_class = "milp" if problem.has_integer_variables else "lp"
111
+ quadratic_nnz = None
112
+ quadratic_density = None
113
+ convexity_status = None
114
+ extra_bytes = 0
115
+ else:
116
+ raise TypeError(f"unsupported problem type: {type(problem)!r}")
117
+
118
+ m, n = linear.A.shape
119
+ size = m * n
120
+ density = float(linear.A.nnz / size) if size else 0.0
121
+
122
+ domains = linear.domains
123
+ if n:
124
+ binary_fraction = float(np.mean(domains == VariableDomain.BINARY.value))
125
+ integer_fraction = float(
126
+ np.mean(
127
+ np.isin(
128
+ domains,
129
+ [VariableDomain.INTEGER.value, VariableDomain.BINARY.value],
130
+ )
131
+ )
132
+ )
133
+ fully_bounded = float(
134
+ np.mean(np.isfinite(linear.variable_lower) & np.isfinite(linear.variable_upper))
135
+ )
136
+ free_fraction = float(
137
+ np.mean(np.isneginf(linear.variable_lower) & np.isposinf(linear.variable_upper))
138
+ )
139
+ objective_nnz_fraction = float(np.count_nonzero(linear.c) / n)
140
+ else:
141
+ binary_fraction = integer_fraction = fully_bounded = free_fraction = 0.0
142
+ objective_nnz_fraction = 0.0
143
+
144
+ if m:
145
+ equality_mask = (
146
+ np.isfinite(linear.constraint_lower)
147
+ & np.isfinite(linear.constraint_upper)
148
+ & (linear.constraint_lower == linear.constraint_upper)
149
+ )
150
+ equality_fraction = float(np.mean(equality_mask))
151
+ else:
152
+ equality_fraction = 0.0
153
+
154
+ row_counts = np.diff(linear.A.indptr)
155
+ col_counts = np.diff(linear.A.tocsc().indptr)
156
+
157
+ data = linear.A.data
158
+ if data.size:
159
+ positive_fraction = float(np.mean(data > 0.0))
160
+ negative_fraction = float(np.mean(data < 0.0))
161
+ else:
162
+ positive_fraction = negative_fraction = 0.0
163
+
164
+ finite_rhs = np.concatenate(
165
+ [
166
+ linear.constraint_lower[np.isfinite(linear.constraint_lower)],
167
+ linear.constraint_upper[np.isfinite(linear.constraint_upper)],
168
+ linear.variable_lower[np.isfinite(linear.variable_lower)],
169
+ linear.variable_upper[np.isfinite(linear.variable_upper)],
170
+ ]
171
+ )
172
+
173
+ return ProblemFingerprint(
174
+ problem_class=problem_class,
175
+ n_variables=n,
176
+ n_constraints=m,
177
+ nnz_a=int(linear.A.nnz),
178
+ density_a=density,
179
+ binary_fraction=binary_fraction,
180
+ integer_fraction=integer_fraction,
181
+ equality_fraction=equality_fraction,
182
+ fully_bounded_variable_fraction=fully_bounded,
183
+ free_variable_fraction=free_fraction,
184
+ objective_nnz_fraction=objective_nnz_fraction,
185
+ coefficient_dynamic_range_log10=_dynamic_range_log10(linear.A.data),
186
+ objective_dynamic_range_log10=_dynamic_range_log10(linear.c),
187
+ rhs_dynamic_range_log10=_dynamic_range_log10(finite_rhs),
188
+ row_nnz=_distribution(row_counts),
189
+ col_nnz=_distribution(col_counts),
190
+ singleton_rows=int(np.count_nonzero(row_counts == 1)),
191
+ singleton_cols=int(np.count_nonzero(col_counts == 1)),
192
+ positive_coefficient_fraction=positive_fraction,
193
+ negative_coefficient_fraction=negative_fraction,
194
+ quadratic_nnz=quadratic_nnz,
195
+ quadratic_density=quadratic_density,
196
+ convexity_status=convexity_status,
197
+ structural_hash=problem.structural_hash,
198
+ estimated_numeric_bytes=_estimated_bytes_linear(linear) + extra_bytes,
199
+ )
@@ -0,0 +1,36 @@
1
+ """Feature contracts and conservative distribution-shift diagnostics.
2
+
3
+ This package does not select a solver. It only standardizes pre-solve features,
4
+ audits leakage boundaries, and reports simple explainable shift evidence.
5
+ """
6
+ from .errors import DistributionShiftError, FeatureSchemaError, IntelligenceError, LeakageDetectedError
7
+ from .fingerprint import PROBLEM_FINGERPRINT_FEATURE_SCHEMA, feature_record_from_problem, problem_fingerprint_feature_schema
8
+ from .leakage import LeakageAuditReport, LeakageFinding, audit_feature_boundary
9
+ from .schema import (
10
+ FeatureDataType,
11
+ FeatureDefinition,
12
+ FeatureRecord,
13
+ FeatureSchema,
14
+ MissingnessPolicy,
15
+ build_feature_record,
16
+ build_feature_schema,
17
+ )
18
+ from .shift import (
19
+ DistributionShiftAssessment,
20
+ DistributionShiftProfile,
21
+ ShiftFlag,
22
+ ShiftSeverity,
23
+ assess_distribution_shift,
24
+ fit_distribution_shift_profile,
25
+ fit_training_shift_profile,
26
+ )
27
+
28
+ __all__ = [
29
+ "IntelligenceError", "FeatureSchemaError", "LeakageDetectedError", "DistributionShiftError",
30
+ "FeatureDataType", "MissingnessPolicy", "FeatureDefinition", "FeatureSchema", "FeatureRecord",
31
+ "build_feature_schema", "build_feature_record",
32
+ "LeakageFinding", "LeakageAuditReport", "audit_feature_boundary",
33
+ "ShiftSeverity", "ShiftFlag", "DistributionShiftProfile", "DistributionShiftAssessment",
34
+ "fit_distribution_shift_profile", "fit_training_shift_profile", "assess_distribution_shift",
35
+ "PROBLEM_FINGERPRINT_FEATURE_SCHEMA", "problem_fingerprint_feature_schema", "feature_record_from_problem",
36
+ ]
@@ -0,0 +1,16 @@
1
+ """Errors for versioned feature-intelligence contracts."""
2
+
3
+ class IntelligenceError(ValueError):
4
+ """Base error for SolverPilot feature-intelligence validation."""
5
+
6
+
7
+ class FeatureSchemaError(IntelligenceError):
8
+ """Raised when a feature schema or record is invalid."""
9
+
10
+
11
+ class LeakageDetectedError(IntelligenceError):
12
+ """Raised when feature inputs cross the pre-solve/outcome boundary."""
13
+
14
+
15
+ class DistributionShiftError(IntelligenceError):
16
+ """Raised when a distribution-shift profile cannot be built or compared safely."""
@@ -0,0 +1,108 @@
1
+ """Versioned feature schema for SolverPilot's existing ProblemFingerprint."""
2
+ from __future__ import annotations
3
+
4
+ from solverpilot.inspect import ProblemFingerprint, inspect_problem
5
+ from solverpilot.problem import LinearProblem, QuadraticProblem
6
+
7
+ from .schema import FeatureDefinition, FeatureRecord, MissingnessPolicy, build_feature_record, build_feature_schema
8
+
9
+ _EXTRACTOR_ID = "solverpilot.inspect.problem_fingerprint"
10
+ _EXTRACTOR_VERSION = "1.0.0"
11
+
12
+
13
+ def _d(name: str, dtype: str, unit: str, source: tuple[str, ...], *, nullable: bool = False, allowed: tuple[str, ...] = (), lower: float | None = None, upper: float | None = None) -> FeatureDefinition:
14
+ return FeatureDefinition(
15
+ name=name,
16
+ data_type=dtype,
17
+ unit=unit,
18
+ missingness=MissingnessPolicy.NULLABLE if nullable else MissingnessPolicy.FORBIDDEN,
19
+ description=f"Pre-solve structural feature {name} derived from the canonical problem representation.",
20
+ source_fields=source,
21
+ lower_bound=lower,
22
+ upper_bound=upper,
23
+ allowed_values=allowed,
24
+ )
25
+
26
+
27
+ def problem_fingerprint_feature_schema():
28
+ defs = [
29
+ _d("problem_class", "categorical", "category", ("problem.type",), allowed=("convex_qp", "lp", "milp")),
30
+ _d("n_variables", "integer", "count", ("problem.variables",), lower=0),
31
+ _d("n_constraints", "integer", "count", ("problem.constraints",), lower=0),
32
+ _d("nnz_a", "integer", "count", ("problem.A",), lower=0),
33
+ _d("density_a", "number", "ratio", ("problem.A",), lower=0, upper=1),
34
+ _d("binary_fraction", "number", "ratio", ("problem.domains",), lower=0, upper=1),
35
+ _d("integer_fraction", "number", "ratio", ("problem.domains",), lower=0, upper=1),
36
+ _d("equality_fraction", "number", "ratio", ("problem.constraint_bounds",), lower=0, upper=1),
37
+ _d("fully_bounded_variable_fraction", "number", "ratio", ("problem.variable_bounds",), lower=0, upper=1),
38
+ _d("free_variable_fraction", "number", "ratio", ("problem.variable_bounds",), lower=0, upper=1),
39
+ _d("objective_nnz_fraction", "number", "ratio", ("problem.objective_coefficients",), lower=0, upper=1),
40
+ _d("coefficient_dynamic_range_log10", "number", "log10_ratio", ("problem.A",), nullable=True, lower=0),
41
+ _d("objective_dynamic_range_log10", "number", "log10_ratio", ("problem.objective_coefficients",), nullable=True, lower=0),
42
+ _d("rhs_dynamic_range_log10", "number", "log10_ratio", ("problem.constraint_bounds", "problem.variable_bounds"), nullable=True, lower=0),
43
+ _d("singleton_rows", "integer", "count", ("problem.A",), lower=0),
44
+ _d("singleton_cols", "integer", "count", ("problem.A",), lower=0),
45
+ _d("positive_coefficient_fraction", "number", "ratio", ("problem.A",), lower=0, upper=1),
46
+ _d("negative_coefficient_fraction", "number", "ratio", ("problem.A",), lower=0, upper=1),
47
+ _d("quadratic_nnz", "integer", "count", ("problem.P",), nullable=True, lower=0),
48
+ _d("quadratic_density", "number", "ratio", ("problem.P",), nullable=True, lower=0, upper=1),
49
+ _d("convexity_status", "categorical", "category", ("problem.convexity_status",), nullable=True, allowed=("confirmed",)),
50
+ _d("estimated_numeric_bytes", "integer", "bytes", ("problem.canonical_arrays",), lower=0),
51
+ ]
52
+ for prefix in ("row_nnz", "col_nnz"):
53
+ for suffix in ("minimum", "q25", "median", "mean", "q75", "maximum"):
54
+ defs.append(_d(f"{prefix}_{suffix}", "number", "count", ("problem.A",), lower=0))
55
+ return build_feature_schema(domain="solverpilot_problem_fingerprint", version="1.0.0", definitions=defs)
56
+
57
+
58
+ PROBLEM_FINGERPRINT_FEATURE_SCHEMA = problem_fingerprint_feature_schema()
59
+
60
+
61
+ def _values(f: ProblemFingerprint) -> dict[str, object]:
62
+ values: dict[str, object] = {
63
+ "problem_class": f.problem_class,
64
+ "n_variables": f.n_variables,
65
+ "n_constraints": f.n_constraints,
66
+ "nnz_a": f.nnz_a,
67
+ "density_a": f.density_a,
68
+ "binary_fraction": f.binary_fraction,
69
+ "integer_fraction": f.integer_fraction,
70
+ "equality_fraction": f.equality_fraction,
71
+ "fully_bounded_variable_fraction": f.fully_bounded_variable_fraction,
72
+ "free_variable_fraction": f.free_variable_fraction,
73
+ "objective_nnz_fraction": f.objective_nnz_fraction,
74
+ "coefficient_dynamic_range_log10": f.coefficient_dynamic_range_log10,
75
+ "objective_dynamic_range_log10": f.objective_dynamic_range_log10,
76
+ "rhs_dynamic_range_log10": f.rhs_dynamic_range_log10,
77
+ "singleton_rows": f.singleton_rows,
78
+ "singleton_cols": f.singleton_cols,
79
+ "positive_coefficient_fraction": f.positive_coefficient_fraction,
80
+ "negative_coefficient_fraction": f.negative_coefficient_fraction,
81
+ "quadratic_nnz": f.quadratic_nnz,
82
+ "quadratic_density": f.quadratic_density,
83
+ "convexity_status": f.convexity_status,
84
+ "estimated_numeric_bytes": f.estimated_numeric_bytes,
85
+ }
86
+ for prefix, stats in (("row_nnz", f.row_nnz), ("col_nnz", f.col_nnz)):
87
+ for suffix in ("minimum", "q25", "median", "mean", "q75", "maximum"):
88
+ values[f"{prefix}_{suffix}"] = getattr(stats, suffix)
89
+ return values
90
+
91
+
92
+ def feature_record_from_problem(
93
+ problem: LinearProblem | QuadraticProblem,
94
+ *,
95
+ instance_id: str | None = None,
96
+ ) -> FeatureRecord:
97
+ fingerprint = inspect_problem(problem)
98
+ values = _values(fingerprint)
99
+ reasons = {name: "not applicable to this problem class" for name, value in values.items() if value is None}
100
+ return build_feature_record(
101
+ schema=PROBLEM_FINGERPRINT_FEATURE_SCHEMA,
102
+ instance_id=problem.data_hash if instance_id is None else instance_id,
103
+ values=values,
104
+ missingness_reasons=reasons,
105
+ source_content_sha256=problem.data_hash,
106
+ extractor_id=_EXTRACTOR_ID,
107
+ extractor_version=_EXTRACTOR_VERSION,
108
+ )
@@ -0,0 +1,69 @@
1
+ """Fail-closed auditing for predictive feature boundaries."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ import re
6
+ from typing import Iterable
7
+
8
+ from .errors import LeakageDetectedError
9
+ from .schema import FeatureRecord, FeatureSchema
10
+
11
+ _FORBIDDEN_FRAGMENTS = (
12
+ "solver", "backend_result", "solve_result", "selected_backend", "selected_solver", "winner",
13
+ "runtime", "wall_clock", "cpu_time", "regret", "target", "label", "prediction",
14
+ "benchmark_result", "oracle", "quality_score", "best_known", "timeout", "solve_status",
15
+ "optimality_status", "validation_result", "rank",
16
+ )
17
+
18
+
19
+ def _norm(value: str) -> str:
20
+ return re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class LeakageFinding:
25
+ code: str
26
+ location: str
27
+ fragment: str
28
+ message: str
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class LeakageAuditReport:
33
+ passed: bool
34
+ findings: tuple[LeakageFinding, ...]
35
+ audited_feature_count: int
36
+ audited_record_count: int
37
+
38
+ def require_pass(self) -> None:
39
+ if not self.passed:
40
+ raise LeakageDetectedError(f"feature leakage audit failed with {len(self.findings)} finding(s)")
41
+
42
+
43
+ def audit_feature_boundary(schema: FeatureSchema, records: Iterable[FeatureRecord] = ()) -> LeakageAuditReport:
44
+ rows = tuple(records)
45
+ findings: list[LeakageFinding] = []
46
+ for definition in schema.definitions:
47
+ locations = [(f"definition.{definition.name}.name", definition.name)]
48
+ locations.extend((f"definition.{definition.name}.source_fields", field) for field in definition.source_fields)
49
+ for location, raw in locations:
50
+ normalized = _norm(raw)
51
+ for fragment in _FORBIDDEN_FRAGMENTS:
52
+ if fragment in normalized:
53
+ findings.append(LeakageFinding(
54
+ "FORBIDDEN_PREDICTIVE_INPUT", location, fragment,
55
+ "feature boundary references solver outcome, runtime, target, oracle, or other post-solve evidence",
56
+ ))
57
+ for record in rows:
58
+ if record.feature_schema_id != schema.feature_schema_id:
59
+ findings.append(LeakageFinding(
60
+ "SCHEMA_MISMATCH", "record.feature_schema_id", "schema_mismatch",
61
+ "record does not belong to the audited feature schema",
62
+ ))
63
+ undeclared = sorted(set(record.values) - set(schema.names))
64
+ for name in undeclared:
65
+ findings.append(LeakageFinding(
66
+ "UNDECLARED_FEATURE", "record.values", name,
67
+ "record contains an undeclared feature",
68
+ ))
69
+ return LeakageAuditReport(not findings, tuple(findings), len(schema.definitions), len(rows))