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,314 @@
1
+ """Versioned, immutable, content-addressed feature schemas and records."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ import math
8
+ import re
9
+ from types import MappingProxyType
10
+ from typing import Any
11
+
12
+ from solverpilot.io.hashing import sha256_json
13
+
14
+ from .errors import FeatureSchemaError
15
+
16
+ _SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:[-+][0-9A-Za-z.-]+)?$")
17
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$")
18
+
19
+
20
+ class FeatureDataType(str, Enum):
21
+ INTEGER = "integer"
22
+ NUMBER = "number"
23
+ BOOLEAN = "boolean"
24
+ CATEGORICAL = "categorical"
25
+
26
+
27
+ class MissingnessPolicy(str, Enum):
28
+ FORBIDDEN = "forbidden"
29
+ NULLABLE = "nullable"
30
+ NOT_APPLICABLE = "not_applicable"
31
+
32
+
33
+ def _text(value: Any, *, field_name: str, lower: bool = False) -> str:
34
+ text = str(value).strip()
35
+ if not text:
36
+ raise FeatureSchemaError(f"{field_name} must be non-empty")
37
+ return text.lower() if lower else text
38
+
39
+
40
+ def _semver(value: str) -> str:
41
+ text = _text(value, field_name="schema_version")
42
+ if _SEMVER.fullmatch(text) is None:
43
+ raise FeatureSchemaError("feature schema version must be semantic version text")
44
+ return text
45
+
46
+
47
+ def _finite(value: Any, *, field_name: str) -> float:
48
+ if type(value) not in (int, float) or not math.isfinite(float(value)):
49
+ raise FeatureSchemaError(f"{field_name} must be finite")
50
+ return float(value)
51
+
52
+
53
+ def _sha256(value: str, *, field_name: str) -> str:
54
+ text = _text(value, field_name=field_name, lower=True)
55
+ if _SHA256.fullmatch(text) is None:
56
+ raise FeatureSchemaError(f"{field_name} must be a lowercase SHA-256 digest")
57
+ return text
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class FeatureDefinition:
62
+ name: str
63
+ data_type: FeatureDataType | str
64
+ unit: str
65
+ missingness: MissingnessPolicy | str
66
+ description: str
67
+ source_fields: tuple[str, ...] | Sequence[str]
68
+ lower_bound: float | None = None
69
+ upper_bound: float | None = None
70
+ allowed_values: tuple[str, ...] | Sequence[str] = ()
71
+
72
+ def __post_init__(self) -> None:
73
+ name = _text(self.name, field_name="feature.name", lower=True)
74
+ try:
75
+ dtype = self.data_type if isinstance(self.data_type, FeatureDataType) else FeatureDataType(str(self.data_type).strip().lower())
76
+ except ValueError as exc:
77
+ raise FeatureSchemaError(f"unsupported feature data type for {name!r}") from exc
78
+ try:
79
+ missingness = self.missingness if isinstance(self.missingness, MissingnessPolicy) else MissingnessPolicy(str(self.missingness).strip().lower())
80
+ except ValueError as exc:
81
+ raise FeatureSchemaError(f"unsupported missingness policy for {name!r}") from exc
82
+ unit = _text(self.unit, field_name=f"feature {name} unit", lower=True)
83
+ description = _text(self.description, field_name=f"feature {name} description")
84
+ fields = tuple(sorted(_text(x, field_name=f"feature {name} source field") for x in self.source_fields))
85
+ if not fields or len(fields) != len(set(fields)):
86
+ raise FeatureSchemaError(f"feature {name} source_fields must be non-empty and unique")
87
+ lower = None if self.lower_bound is None else _finite(self.lower_bound, field_name=f"feature {name} lower_bound")
88
+ upper = None if self.upper_bound is None else _finite(self.upper_bound, field_name=f"feature {name} upper_bound")
89
+ if lower is not None and upper is not None and lower > upper:
90
+ raise FeatureSchemaError(f"feature {name} bounds are inverted")
91
+ allowed = tuple(sorted(_text(x, field_name=f"feature {name} allowed value") for x in self.allowed_values))
92
+ if len(allowed) != len(set(allowed)):
93
+ raise FeatureSchemaError(f"feature {name} allowed_values contain duplicates")
94
+ if dtype is FeatureDataType.CATEGORICAL and not allowed:
95
+ raise FeatureSchemaError(f"categorical feature {name} requires allowed_values")
96
+ if dtype is not FeatureDataType.CATEGORICAL and allowed:
97
+ raise FeatureSchemaError(f"non-categorical feature {name} cannot declare allowed_values")
98
+ object.__setattr__(self, "name", name)
99
+ object.__setattr__(self, "data_type", dtype)
100
+ object.__setattr__(self, "unit", unit)
101
+ object.__setattr__(self, "missingness", missingness)
102
+ object.__setattr__(self, "description", description)
103
+ object.__setattr__(self, "source_fields", fields)
104
+ object.__setattr__(self, "lower_bound", lower)
105
+ object.__setattr__(self, "upper_bound", upper)
106
+ object.__setattr__(self, "allowed_values", allowed)
107
+
108
+ def to_dict(self) -> dict[str, Any]:
109
+ return {
110
+ "name": self.name,
111
+ "data_type": self.data_type.value,
112
+ "unit": self.unit,
113
+ "missingness": self.missingness.value,
114
+ "description": self.description,
115
+ "source_fields": list(self.source_fields),
116
+ "lower_bound": self.lower_bound,
117
+ "upper_bound": self.upper_bound,
118
+ "allowed_values": list(self.allowed_values),
119
+ }
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class FeatureSchema:
124
+ domain: str
125
+ version: str
126
+ definitions: tuple[FeatureDefinition, ...] | Sequence[FeatureDefinition]
127
+ feature_schema_id: str = field(init=False)
128
+
129
+ def __post_init__(self) -> None:
130
+ domain = _text(self.domain, field_name="domain", lower=True)
131
+ version = _semver(self.version)
132
+ definitions = tuple(sorted(self.definitions, key=lambda x: x.name))
133
+ if not definitions or any(not isinstance(x, FeatureDefinition) for x in definitions):
134
+ raise FeatureSchemaError("definitions must contain at least one FeatureDefinition")
135
+ names = tuple(x.name for x in definitions)
136
+ if len(names) != len(set(names)):
137
+ raise FeatureSchemaError("feature names must be unique")
138
+ payload = {"domain": domain, "version": version, "definitions": [x.to_dict() for x in definitions]}
139
+ object.__setattr__(self, "domain", domain)
140
+ object.__setattr__(self, "version", version)
141
+ object.__setattr__(self, "definitions", definitions)
142
+ object.__setattr__(self, "feature_schema_id", sha256_json(payload))
143
+
144
+ @property
145
+ def names(self) -> tuple[str, ...]:
146
+ return tuple(x.name for x in self.definitions)
147
+
148
+ def to_dict(self) -> dict[str, Any]:
149
+ return {
150
+ "domain": self.domain,
151
+ "version": self.version,
152
+ "feature_schema_id": self.feature_schema_id,
153
+ "definitions": [x.to_dict() for x in self.definitions],
154
+ }
155
+
156
+ @classmethod
157
+ def from_dict(cls, payload: Mapping[str, Any]) -> "FeatureSchema":
158
+ required = {"domain", "version", "feature_schema_id", "definitions"}
159
+ if set(payload) != required:
160
+ raise FeatureSchemaError("serialized FeatureSchema keys do not match contract")
161
+ definitions = tuple(FeatureDefinition(**dict(x)) for x in payload["definitions"])
162
+ rebuilt = cls(str(payload["domain"]), str(payload["version"]), definitions)
163
+ if rebuilt.feature_schema_id != str(payload["feature_schema_id"]):
164
+ raise FeatureSchemaError("serialized feature_schema_id does not match canonical content")
165
+ return rebuilt
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class FeatureRecord:
170
+ instance_id: str
171
+ feature_schema_id: str
172
+ feature_schema_version: str
173
+ values: Mapping[str, Any]
174
+ missingness_reasons: Mapping[str, str]
175
+ source_content_sha256: str
176
+ extractor_id: str
177
+ extractor_version: str
178
+ record_id: str = field(init=False)
179
+
180
+ def __post_init__(self) -> None:
181
+ instance_id = _text(self.instance_id, field_name="instance_id")
182
+ schema_id = _sha256(self.feature_schema_id, field_name="feature_schema_id")
183
+ schema_version = _semver(self.feature_schema_version)
184
+ source_hash = _sha256(self.source_content_sha256, field_name="source_content_sha256")
185
+ extractor_id = _text(self.extractor_id, field_name="extractor_id", lower=True)
186
+ extractor_version = _semver(self.extractor_version)
187
+ values = MappingProxyType(dict(self.values))
188
+ reasons = MappingProxyType({str(k): _text(v, field_name=f"missingness reason {k}") for k, v in self.missingness_reasons.items()})
189
+ payload = {
190
+ "instance_id": instance_id,
191
+ "feature_schema_id": schema_id,
192
+ "feature_schema_version": schema_version,
193
+ "values": dict(values),
194
+ "missingness_reasons": dict(reasons),
195
+ "source_content_sha256": source_hash,
196
+ "extractor_id": extractor_id,
197
+ "extractor_version": extractor_version,
198
+ }
199
+ object.__setattr__(self, "instance_id", instance_id)
200
+ object.__setattr__(self, "feature_schema_id", schema_id)
201
+ object.__setattr__(self, "feature_schema_version", schema_version)
202
+ object.__setattr__(self, "values", values)
203
+ object.__setattr__(self, "missingness_reasons", reasons)
204
+ object.__setattr__(self, "source_content_sha256", source_hash)
205
+ object.__setattr__(self, "extractor_id", extractor_id)
206
+ object.__setattr__(self, "extractor_version", extractor_version)
207
+ object.__setattr__(self, "record_id", sha256_json(payload))
208
+
209
+ def to_dict(self) -> dict[str, Any]:
210
+ return {
211
+ "record_id": self.record_id,
212
+ "instance_id": self.instance_id,
213
+ "feature_schema_id": self.feature_schema_id,
214
+ "feature_schema_version": self.feature_schema_version,
215
+ "values": dict(self.values),
216
+ "missingness_reasons": dict(self.missingness_reasons),
217
+ "source_content_sha256": self.source_content_sha256,
218
+ "extractor_id": self.extractor_id,
219
+ "extractor_version": self.extractor_version,
220
+ }
221
+
222
+ @classmethod
223
+ def from_dict(cls, payload: Mapping[str, Any], *, schema: FeatureSchema) -> "FeatureRecord":
224
+ required = {
225
+ "record_id", "instance_id", "feature_schema_id", "feature_schema_version", "values",
226
+ "missingness_reasons", "source_content_sha256", "extractor_id", "extractor_version",
227
+ }
228
+ if set(payload) != required:
229
+ raise FeatureSchemaError("serialized FeatureRecord keys do not match contract")
230
+ rebuilt = build_feature_record(
231
+ schema=schema,
232
+ instance_id=str(payload["instance_id"]),
233
+ values=dict(payload["values"]),
234
+ missingness_reasons=dict(payload["missingness_reasons"]),
235
+ source_content_sha256=str(payload["source_content_sha256"]),
236
+ extractor_id=str(payload["extractor_id"]),
237
+ extractor_version=str(payload["extractor_version"]),
238
+ )
239
+ if rebuilt.record_id != str(payload["record_id"]):
240
+ raise FeatureSchemaError("serialized record_id does not match canonical content")
241
+ return rebuilt
242
+
243
+
244
+ def build_feature_schema(*, domain: str, version: str, definitions: Sequence[FeatureDefinition]) -> FeatureSchema:
245
+ return FeatureSchema(domain=domain, version=version, definitions=tuple(definitions))
246
+
247
+
248
+ def build_feature_record(
249
+ *,
250
+ schema: FeatureSchema,
251
+ instance_id: str,
252
+ values: Mapping[str, Any],
253
+ source_content_sha256: str,
254
+ extractor_id: str,
255
+ extractor_version: str,
256
+ missingness_reasons: Mapping[str, str] | None = None,
257
+ ) -> FeatureRecord:
258
+ definitions = {x.name: x for x in schema.definitions}
259
+ if set(values) != set(definitions):
260
+ raise FeatureSchemaError(
261
+ f"feature values must exactly match schema names; missing={sorted(set(definitions)-set(values))}, extra={sorted(set(values)-set(definitions))}"
262
+ )
263
+ reasons = dict(missingness_reasons or {})
264
+ normalized: dict[str, Any] = {}
265
+ for name, definition in definitions.items():
266
+ value = values[name]
267
+ if value is None:
268
+ if definition.missingness is MissingnessPolicy.FORBIDDEN:
269
+ raise FeatureSchemaError(f"feature {name} may not be missing")
270
+ if name not in reasons:
271
+ raise FeatureSchemaError(f"missing feature {name} requires a reason")
272
+ normalized[name] = None
273
+ continue
274
+ if name in reasons:
275
+ raise FeatureSchemaError(f"present feature {name} may not have a missingness reason")
276
+ normalized[name] = _validate_value(value, definition)
277
+ missing = {k for k, v in normalized.items() if v is None}
278
+ if set(reasons) - missing:
279
+ raise FeatureSchemaError("missingness_reasons contains non-missing feature names")
280
+ return FeatureRecord(
281
+ instance_id=instance_id,
282
+ feature_schema_id=schema.feature_schema_id,
283
+ feature_schema_version=schema.version,
284
+ values=normalized,
285
+ missingness_reasons=reasons,
286
+ source_content_sha256=source_content_sha256,
287
+ extractor_id=extractor_id,
288
+ extractor_version=extractor_version,
289
+ )
290
+
291
+
292
+ def _validate_value(value: Any, definition: FeatureDefinition) -> Any:
293
+ if definition.data_type is FeatureDataType.BOOLEAN:
294
+ if type(value) is not bool:
295
+ raise FeatureSchemaError(f"{definition.name} must be boolean")
296
+ return value
297
+ if definition.data_type is FeatureDataType.INTEGER:
298
+ if type(value) is not int:
299
+ raise FeatureSchemaError(f"{definition.name} must be integer")
300
+ number = float(value)
301
+ elif definition.data_type is FeatureDataType.NUMBER:
302
+ if type(value) not in (int, float) or not math.isfinite(float(value)):
303
+ raise FeatureSchemaError(f"{definition.name} must be finite numeric")
304
+ number = float(value)
305
+ else:
306
+ text = _text(value, field_name=definition.name)
307
+ if text not in definition.allowed_values:
308
+ raise FeatureSchemaError(f"{definition.name} has unsupported categorical value {text!r}")
309
+ return text
310
+ if definition.lower_bound is not None and number < definition.lower_bound:
311
+ raise FeatureSchemaError(f"{definition.name} is below lower bound")
312
+ if definition.upper_bound is not None and number > definition.upper_bound:
313
+ raise FeatureSchemaError(f"{definition.name} is above upper bound")
314
+ return int(value) if definition.data_type is FeatureDataType.INTEGER else number
@@ -0,0 +1,208 @@
1
+ """Simple, explainable distribution-shift profiles for feature records."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ import math
8
+ from statistics import mean, pstdev
9
+ from typing import Any
10
+
11
+ from solverpilot.benchmark.splits import SplitRecord, validate_split_records
12
+ from solverpilot.io.hashing import deep_freeze_json, deep_thaw_json, sha256_json
13
+
14
+ from .errors import DistributionShiftError
15
+ from .schema import FeatureDataType, FeatureRecord, FeatureSchema
16
+
17
+
18
+ class ShiftSeverity(str, Enum):
19
+ IN_DISTRIBUTION = "in_distribution"
20
+ MILD_SHIFT = "mild_shift"
21
+ STRONG_SHIFT = "strong_shift"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class DistributionShiftProfile:
26
+ feature_schema_id: str
27
+ training_record_ids: tuple[str, ...]
28
+ training_instance_ids: tuple[str, ...]
29
+ statistics: Mapping[str, Any]
30
+ profile_id: str = field(init=False)
31
+
32
+ def __post_init__(self) -> None:
33
+ record_ids = tuple(sorted(map(str, self.training_record_ids)))
34
+ instance_ids = tuple(sorted(map(str, self.training_instance_ids)))
35
+ if not record_ids or len(record_ids) != len(set(record_ids)):
36
+ raise DistributionShiftError("training_record_ids must be non-empty and unique")
37
+ if not instance_ids or len(instance_ids) != len(set(instance_ids)):
38
+ raise DistributionShiftError("training_instance_ids must be non-empty and unique")
39
+ if len(record_ids) != len(instance_ids):
40
+ raise DistributionShiftError("training record and instance counts must match")
41
+ frozen = deep_freeze_json(self.statistics)
42
+ payload = {
43
+ "feature_schema_id": self.feature_schema_id,
44
+ "training_record_ids": list(record_ids),
45
+ "training_instance_ids": list(instance_ids),
46
+ "statistics": deep_thaw_json(frozen),
47
+ }
48
+ object.__setattr__(self, "training_record_ids", record_ids)
49
+ object.__setattr__(self, "training_instance_ids", instance_ids)
50
+ object.__setattr__(self, "statistics", frozen)
51
+ object.__setattr__(self, "profile_id", sha256_json(payload))
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "profile_id": self.profile_id,
56
+ "feature_schema_id": self.feature_schema_id,
57
+ "training_record_ids": list(self.training_record_ids),
58
+ "training_instance_ids": list(self.training_instance_ids),
59
+ "statistics": deep_thaw_json(self.statistics),
60
+ }
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ShiftFlag:
65
+ feature: str
66
+ code: str
67
+ severity: ShiftSeverity
68
+ details: Mapping[str, Any] = field(default_factory=dict)
69
+
70
+ def __post_init__(self) -> None:
71
+ object.__setattr__(self, "details", deep_freeze_json(self.details))
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return {
75
+ "feature": self.feature,
76
+ "code": self.code,
77
+ "severity": self.severity.value,
78
+ "details": deep_thaw_json(self.details),
79
+ }
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class DistributionShiftAssessment:
84
+ profile_id: str
85
+ record_id: str
86
+ severity: ShiftSeverity
87
+ flags: tuple[ShiftFlag, ...]
88
+ assessment_id: str = field(init=False)
89
+
90
+ def __post_init__(self) -> None:
91
+ flags = tuple(self.flags)
92
+ payload = {
93
+ "profile_id": self.profile_id,
94
+ "record_id": self.record_id,
95
+ "severity": self.severity.value,
96
+ "flags": [x.to_dict() for x in flags],
97
+ }
98
+ object.__setattr__(self, "flags", flags)
99
+ object.__setattr__(self, "assessment_id", sha256_json(payload))
100
+
101
+ @property
102
+ def shifted(self) -> bool:
103
+ return self.severity is not ShiftSeverity.IN_DISTRIBUTION
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ "assessment_id": self.assessment_id,
108
+ "profile_id": self.profile_id,
109
+ "record_id": self.record_id,
110
+ "severity": self.severity.value,
111
+ "flags": [x.to_dict() for x in self.flags],
112
+ }
113
+
114
+
115
+ def fit_distribution_shift_profile(schema: FeatureSchema, records: Sequence[FeatureRecord]) -> DistributionShiftProfile:
116
+ rows = tuple(records)
117
+ if not rows:
118
+ raise DistributionShiftError("at least one training record is required")
119
+ if len({x.record_id for x in rows}) != len(rows):
120
+ raise DistributionShiftError("training feature records must be unique")
121
+ if len({x.instance_id for x in rows}) != len(rows):
122
+ raise DistributionShiftError("training instance IDs must be unique")
123
+ if any(x.feature_schema_id != schema.feature_schema_id for x in rows):
124
+ raise DistributionShiftError("all training records must match the feature schema")
125
+ definitions = {x.name: x for x in schema.definitions}
126
+ statistics: dict[str, dict[str, Any]] = {}
127
+ for name, definition in definitions.items():
128
+ values = [row.values[name] for row in rows if row.values[name] is not None]
129
+ missing = len(rows) - len(values)
130
+ if definition.data_type in (FeatureDataType.INTEGER, FeatureDataType.NUMBER):
131
+ numbers = [float(v) for v in values]
132
+ statistics[name] = {
133
+ "kind": "numeric", "count": len(numbers), "missing_count": missing,
134
+ "min": min(numbers) if numbers else None,
135
+ "max": max(numbers) if numbers else None,
136
+ "mean": mean(numbers) if numbers else None,
137
+ "std": pstdev(numbers) if len(numbers) > 1 else 0.0,
138
+ }
139
+ elif definition.data_type is FeatureDataType.BOOLEAN:
140
+ statistics[name] = {
141
+ "kind": "boolean", "count": len(values), "missing_count": missing,
142
+ "true_rate": (sum(bool(v) for v in values) / len(values)) if values else None,
143
+ }
144
+ else:
145
+ counts = {value: values.count(value) for value in sorted(set(values))}
146
+ statistics[name] = {
147
+ "kind": "categorical", "count": len(values), "missing_count": missing, "counts": counts,
148
+ }
149
+ return DistributionShiftProfile(
150
+ schema.feature_schema_id,
151
+ tuple(x.record_id for x in rows),
152
+ tuple(x.instance_id for x in rows),
153
+ statistics,
154
+ )
155
+
156
+
157
+ def fit_training_shift_profile(
158
+ schema: FeatureSchema,
159
+ records: Sequence[FeatureRecord],
160
+ split_records: Sequence[SplitRecord],
161
+ ) -> DistributionShiftProfile:
162
+ rows = tuple(records)
163
+ split_rows = tuple(split_records)
164
+ report = validate_split_records(split_rows, expected_instances=[x.instance_id for x in rows])
165
+ if not report.ok:
166
+ raise DistributionShiftError("invalid benchmark split for feature profile: " + "; ".join(report.errors))
167
+ train_ids = {x.instance for x in split_rows if x.split == "train"}
168
+ training = tuple(x for x in rows if x.instance_id in train_ids)
169
+ if not training:
170
+ raise DistributionShiftError("benchmark split contains no training feature records")
171
+ return fit_distribution_shift_profile(schema, training)
172
+
173
+
174
+ def assess_distribution_shift(
175
+ profile: DistributionShiftProfile,
176
+ record: FeatureRecord,
177
+ *,
178
+ z_threshold: float = 3.0,
179
+ ) -> DistributionShiftAssessment:
180
+ if record.feature_schema_id != profile.feature_schema_id:
181
+ raise DistributionShiftError("record schema does not match shift profile")
182
+ if type(z_threshold) not in (int, float) or not math.isfinite(float(z_threshold)) or float(z_threshold) <= 0:
183
+ raise DistributionShiftError("z_threshold must be a finite positive number")
184
+ flags: list[ShiftFlag] = []
185
+ for name, stat_frozen in profile.statistics.items():
186
+ stat = deep_thaw_json(stat_frozen)
187
+ value = record.values[name]
188
+ if value is None:
189
+ if stat["missing_count"] == 0:
190
+ flags.append(ShiftFlag(name, "new_missingness", ShiftSeverity.MILD_SHIFT))
191
+ continue
192
+ if stat["kind"] == "numeric" and stat["count"]:
193
+ numeric = float(value)
194
+ if numeric < stat["min"] or numeric > stat["max"]:
195
+ flags.append(ShiftFlag(name, "outside_training_range", ShiftSeverity.MILD_SHIFT, {
196
+ "value": numeric, "min": stat["min"], "max": stat["max"],
197
+ }))
198
+ if stat["std"]:
199
+ z = abs(numeric - stat["mean"]) / stat["std"]
200
+ if z > float(z_threshold):
201
+ flags.append(ShiftFlag(name, "large_standardized_distance", ShiftSeverity.STRONG_SHIFT, {"z": z}))
202
+ elif stat["kind"] == "categorical" and value not in stat["counts"]:
203
+ flags.append(ShiftFlag(name, "unseen_category", ShiftSeverity.STRONG_SHIFT, {"value": value}))
204
+ severity = (
205
+ ShiftSeverity.STRONG_SHIFT if any(x.severity is ShiftSeverity.STRONG_SHIFT for x in flags)
206
+ else ShiftSeverity.MILD_SHIFT if flags else ShiftSeverity.IN_DISTRIBUTION
207
+ )
208
+ return DistributionShiftAssessment(profile.profile_id, record.record_id, severity, tuple(flags))
@@ -0,0 +1,41 @@
1
+ """Safe local data ingestion with deterministic mapping and provenance.
2
+
3
+ This subpackage is additive and intentionally not re-exported from ``solverpilot``
4
+ so the frozen top-level public API remains unchanged.
5
+ """
6
+
7
+ from .csv import load_csv
8
+ from .errors import (
9
+ DataIOConfigurationError,
10
+ DataIOError,
11
+ DataIOMappingError,
12
+ DataIOParseError,
13
+ DataIOSourceError,
14
+ )
15
+ from .hashing import canonical_json_bytes, sha256_file, sha256_json
16
+ from .json import load_json
17
+ from .model import CsvMapping, CsvTable, DataIssue, LoadPolicy, MappingRule, ObjectMapping
18
+ from .provenance import SourceFileProvenance, SourceProvenance
19
+ from .result import LoadedData
20
+
21
+ __all__ = [
22
+ "CsvMapping",
23
+ "CsvTable",
24
+ "DataIOConfigurationError",
25
+ "DataIOError",
26
+ "DataIOMappingError",
27
+ "DataIOParseError",
28
+ "DataIOSourceError",
29
+ "DataIssue",
30
+ "LoadPolicy",
31
+ "LoadedData",
32
+ "MappingRule",
33
+ "ObjectMapping",
34
+ "SourceFileProvenance",
35
+ "SourceProvenance",
36
+ "canonical_json_bytes",
37
+ "load_csv",
38
+ "load_json",
39
+ "sha256_file",
40
+ "sha256_json",
41
+ ]
@@ -0,0 +1,49 @@
1
+ """Deterministic decimal-to-binary64 conversion policies used by safe I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from decimal import Decimal, InvalidOperation
7
+
8
+ MAX_SAFE_FLOAT_INTEGER = 2**53 - 1
9
+
10
+
11
+ class Binary64ConversionError(ValueError):
12
+ """Raised when a decimal token violates the selected binary64 policy."""
13
+
14
+
15
+ def decimal_token_to_binary64(
16
+ value: str,
17
+ *,
18
+ exact_decimal: bool = True,
19
+ max_safe_integer: int = MAX_SAFE_FLOAT_INTEGER,
20
+ ) -> float:
21
+ """Convert a decimal token to finite IEEE-754 binary64.
22
+
23
+ ``exact_decimal=True`` preserves the strict legacy connector invariant: the
24
+ shortest decimal spelling of the resulting float must represent the same
25
+ exact decimal value. This rejects silent ingestion changes such as
26
+ ``0.10000000000000001 -> 0.1``. With ``exact_decimal=False`` only finite
27
+ representability, non-underflow, and the exact integer boundary are enforced.
28
+ """
29
+ if not isinstance(value, str) or not value:
30
+ raise Binary64ConversionError("decimal token must be a non-empty string")
31
+ try:
32
+ decimal_value = Decimal(value)
33
+ except InvalidOperation as exc:
34
+ raise Binary64ConversionError("invalid decimal token") from exc
35
+ if not decimal_value.is_finite():
36
+ raise Binary64ConversionError("non-finite decimal token")
37
+ if decimal_value == decimal_value.to_integral_value() and abs(decimal_value) > max_safe_integer:
38
+ raise Binary64ConversionError("integral decimal outside exact binary64 range")
39
+ try:
40
+ parsed = float(value)
41
+ except (ValueError, OverflowError) as exc:
42
+ raise Binary64ConversionError("decimal token cannot be represented as binary64") from exc
43
+ if not math.isfinite(parsed):
44
+ raise Binary64ConversionError("decimal token overflows binary64")
45
+ if decimal_value != 0 and parsed == 0.0:
46
+ raise Binary64ConversionError("decimal token underflows binary64")
47
+ if exact_decimal and Decimal(str(parsed)) != decimal_value:
48
+ raise Binary64ConversionError("decimal token loses precision in binary64")
49
+ return parsed