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,407 @@
1
+ """Small immutable contracts for deterministic local data ingestion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import PurePosixPath
9
+ from typing import Any
10
+
11
+ from .errors import DataIOConfigurationError
12
+ from .hashing import deep_freeze_json, deep_thaw_json
13
+
14
+ _MISSING = object()
15
+ _ALLOWED_VALUE_TYPES = frozenset({"any", "string", "integer", "number", "boolean", "json"})
16
+ _MAPPING_VERSION = "1.0"
17
+ _SOURCE_PATH_RE = re.compile(r"(?:[^.\[\]]+|\[[0-9]+\])(?:\.[^.\[\]]+|\[[0-9]+\])*")
18
+ _SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
19
+
20
+
21
+ def _require_bool(value: Any, *, field_name: str) -> bool:
22
+ if type(value) is not bool:
23
+ raise DataIOConfigurationError(f"{field_name} must be a boolean.")
24
+ return value
25
+
26
+
27
+ def _clean_target_path(value: str, *, field_name: str, allow_empty: bool = False) -> str:
28
+ if not isinstance(value, str):
29
+ raise DataIOConfigurationError(f"{field_name} must be a string.")
30
+ path = value.strip()
31
+ if not path:
32
+ if allow_empty:
33
+ return ""
34
+ raise DataIOConfigurationError(f"{field_name} must be non-empty.")
35
+ parts = path.split(".")
36
+ if any(not part or "[" in part or "]" in part for part in parts):
37
+ raise DataIOConfigurationError(f"{field_name} contains an invalid target path.")
38
+ return path
39
+
40
+
41
+ def _clean_source_path(value: str, *, field_name: str, allow_empty: bool = False) -> str:
42
+ if not isinstance(value, str):
43
+ raise DataIOConfigurationError(f"{field_name} must be a string.")
44
+ path = value.strip()
45
+ if not path:
46
+ if allow_empty:
47
+ return ""
48
+ raise DataIOConfigurationError(f"{field_name} must be non-empty.")
49
+ if _SOURCE_PATH_RE.fullmatch(path) is None:
50
+ raise DataIOConfigurationError(f"{field_name} contains an invalid source path.")
51
+ return path
52
+
53
+
54
+ def _validate_delimiter(value: str, *, field_name: str) -> str:
55
+ if not isinstance(value, str) or len(value) != 1:
56
+ raise DataIOConfigurationError(f"{field_name} must be exactly one character.")
57
+ if value in {"\r", "\n", "\x00", '"'}:
58
+ raise DataIOConfigurationError(f"{field_name} cannot be newline, NUL, or quote.")
59
+ if ord(value) < 32 and value != "\t":
60
+ raise DataIOConfigurationError(f"{field_name} cannot be an unsupported control character.")
61
+ try:
62
+ value.encode("utf-8")
63
+ except UnicodeEncodeError as exc:
64
+ raise DataIOConfigurationError(f"{field_name} contains an invalid Unicode surrogate.") from exc
65
+ return value
66
+
67
+
68
+ def _normalize_bundle_name(value: str | None) -> str | None:
69
+ if value is None:
70
+ return None
71
+ if not isinstance(value, str):
72
+ raise DataIOConfigurationError("file_name must be a string.")
73
+ raw = value.strip().replace("\\", "/")
74
+ if not raw:
75
+ return None
76
+ posix = PurePosixPath(raw)
77
+ if posix.is_absolute() or any(part in {"", ".", ".."} or ":" in part for part in posix.parts):
78
+ raise DataIOConfigurationError("file_name must be a safe relative path.")
79
+ return posix.as_posix()
80
+
81
+
82
+ def _target_parts(path: str) -> tuple[str, ...]:
83
+ return tuple(path.split(".")) if path else ()
84
+
85
+
86
+ def _paths_collide(left: tuple[str, ...], right: tuple[str, ...]) -> bool:
87
+ shortest = min(len(left), len(right))
88
+ return left[:shortest] == right[:shortest]
89
+
90
+
91
+ def _constant_paths(
92
+ value: Mapping[str, Any],
93
+ prefix: tuple[str, ...] = (),
94
+ *,
95
+ _active: set[int] | None = None,
96
+ ) -> tuple[tuple[str, ...], ...]:
97
+ active = set() if _active is None else _active
98
+ identity = id(value)
99
+ if identity in active or len(prefix) > 256:
100
+ raise DataIOConfigurationError(
101
+ "Mapping constants exceed supported nesting or contain a cycle."
102
+ )
103
+ active.add(identity)
104
+ try:
105
+ paths: list[tuple[str, ...]] = []
106
+ for key, item in value.items():
107
+ if not isinstance(key, str) or not key:
108
+ raise DataIOConfigurationError("Constant mapping keys must be non-empty strings.")
109
+ path = prefix + (key,)
110
+ paths.append(path)
111
+ if isinstance(item, Mapping):
112
+ paths.extend(_constant_paths(item, path, _active=active))
113
+ return tuple(paths)
114
+ finally:
115
+ active.remove(identity)
116
+
117
+
118
+ def _validate_target_tree(paths: Sequence[str], *, constants: Mapping[str, Any], label: str) -> None:
119
+ named = [(path, _target_parts(path)) for path in paths if path]
120
+ for i, (left_name, left) in enumerate(named):
121
+ for right_name, right in named[i + 1 :]:
122
+ if _paths_collide(left, right):
123
+ raise DataIOConfigurationError(
124
+ f"{label} target paths {left_name!r} and {right_name!r} collide."
125
+ )
126
+ constant_paths = _constant_paths(constants)
127
+ for path_name, path in named:
128
+ for constant_path in constant_paths:
129
+ if _paths_collide(path, constant_path):
130
+ raise DataIOConfigurationError(
131
+ f"{label} target path {path_name!r} collides with constant path "
132
+ f"{'.'.join(constant_path)!r}."
133
+ )
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class LoadPolicy:
138
+ """Local-source policy shared by JSON and CSV ingestion."""
139
+
140
+ encoding: str = "utf-8-sig"
141
+ max_source_bytes: int = 16 * 1024 * 1024
142
+ reject_symlinks: bool = True
143
+ strict_numbers: bool = True
144
+ max_json_depth: int = 256
145
+ delimiter: str = ","
146
+
147
+ def __post_init__(self) -> None:
148
+ if not isinstance(self.encoding, str) or not self.encoding.strip():
149
+ raise DataIOConfigurationError("encoding must be a non-empty string.")
150
+ if type(self.max_source_bytes) is not int or self.max_source_bytes <= 0:
151
+ raise DataIOConfigurationError("max_source_bytes must be a positive integer.")
152
+ if type(self.max_json_depth) is not int or self.max_json_depth < 0:
153
+ raise DataIOConfigurationError("max_json_depth must be a non-negative integer.")
154
+ object.__setattr__(self, "reject_symlinks", _require_bool(self.reject_symlinks, field_name="reject_symlinks"))
155
+ object.__setattr__(self, "strict_numbers", _require_bool(self.strict_numbers, field_name="strict_numbers"))
156
+ object.__setattr__(self, "encoding", self.encoding.strip())
157
+ object.__setattr__(self, "delimiter", _validate_delimiter(self.delimiter, field_name="delimiter"))
158
+
159
+ def to_dict(self) -> dict[str, Any]:
160
+ return {
161
+ "encoding": self.encoding,
162
+ "max_source_bytes": self.max_source_bytes,
163
+ "reject_symlinks": self.reject_symlinks,
164
+ "strict_numbers": self.strict_numbers,
165
+ "max_json_depth": self.max_json_depth,
166
+ "delimiter": self.delimiter,
167
+ }
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class MappingRule:
172
+ """One explicit source-to-target normalization rule."""
173
+
174
+ target_path: str
175
+ source_path: str | None = None
176
+ value_type: str = "any"
177
+ required: bool = True
178
+ trim: bool = True
179
+ null_values: tuple[str, ...] = ("", "null")
180
+ default: Any = field(default=_MISSING, repr=False)
181
+ constant: Any = field(default=_MISSING, repr=False)
182
+
183
+ def __post_init__(self) -> None:
184
+ target_path = _clean_target_path(self.target_path, field_name="target_path")
185
+ source_path = None if self.source_path is None else _clean_source_path(
186
+ self.source_path, field_name="source_path", allow_empty=True
187
+ )
188
+ if not isinstance(self.value_type, str):
189
+ raise DataIOConfigurationError("value_type must be a string.")
190
+ value_type = self.value_type.strip().lower() or "any"
191
+ if value_type not in _ALLOWED_VALUE_TYPES:
192
+ raise DataIOConfigurationError(f"Unsupported value_type {value_type!r}.")
193
+ if self.constant is not _MISSING and source_path is not None:
194
+ raise DataIOConfigurationError("A mapping rule cannot declare source_path and constant.")
195
+ if self.constant is _MISSING and source_path is None:
196
+ raise DataIOConfigurationError("A mapping rule must declare source_path or constant.")
197
+ if not isinstance(self.null_values, Sequence) or isinstance(self.null_values, (str, bytes, bytearray)):
198
+ raise DataIOConfigurationError("null_values must be a sequence of strings.")
199
+ if any(not isinstance(item, str) for item in self.null_values):
200
+ raise DataIOConfigurationError("null_values entries must be strings.")
201
+ object.__setattr__(self, "target_path", target_path)
202
+ object.__setattr__(self, "source_path", source_path)
203
+ object.__setattr__(self, "value_type", value_type)
204
+ object.__setattr__(self, "required", _require_bool(self.required, field_name="required"))
205
+ object.__setattr__(self, "trim", _require_bool(self.trim, field_name="trim"))
206
+ object.__setattr__(self, "null_values", tuple(item.strip().lower() for item in self.null_values))
207
+ if self.default is not _MISSING:
208
+ object.__setattr__(self, "default", deep_freeze_json(self.default))
209
+ if self.constant is not _MISSING:
210
+ object.__setattr__(self, "constant", deep_freeze_json(self.constant))
211
+
212
+ @property
213
+ def has_default(self) -> bool:
214
+ return self.default is not _MISSING
215
+
216
+ @property
217
+ def has_constant(self) -> bool:
218
+ return self.constant is not _MISSING
219
+
220
+ def to_dict(self) -> dict[str, Any]:
221
+ data: dict[str, Any] = {
222
+ "target_path": self.target_path,
223
+ "value_type": self.value_type,
224
+ "required": self.required,
225
+ "trim": self.trim,
226
+ "null_values": list(self.null_values),
227
+ }
228
+ if self.source_path is not None:
229
+ data["source_path"] = self.source_path
230
+ if self.has_default:
231
+ data["default"] = deep_thaw_json(self.default)
232
+ if self.has_constant:
233
+ data["constant"] = deep_thaw_json(self.constant)
234
+ return data
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class ObjectMapping:
239
+ """Explicit deterministic mapping for JSON-compatible input."""
240
+
241
+ identity: bool = False
242
+ rules: tuple[MappingRule, ...] = ()
243
+ constants: Mapping[str, Any] = field(default_factory=dict)
244
+ version: str = _MAPPING_VERSION
245
+
246
+ def __post_init__(self) -> None:
247
+ if type(self.identity) is not bool:
248
+ raise DataIOConfigurationError("identity must be a boolean.")
249
+ if not isinstance(self.rules, Sequence) or isinstance(self.rules, (str, bytes, bytearray)):
250
+ raise DataIOConfigurationError("rules must be a sequence.")
251
+ rules = tuple(self.rules)
252
+ if any(not isinstance(rule, MappingRule) for rule in rules):
253
+ raise DataIOConfigurationError("rules must contain MappingRule values.")
254
+ if not isinstance(self.constants, Mapping):
255
+ raise DataIOConfigurationError("constants must be a mapping.")
256
+ if self.version != _MAPPING_VERSION:
257
+ raise DataIOConfigurationError(f"Unsupported mapping version {self.version!r}.")
258
+ if self.identity and (rules or self.constants):
259
+ raise DataIOConfigurationError("Identity mapping cannot be combined with rules/constants.")
260
+ if not self.identity and not rules and not self.constants:
261
+ raise DataIOConfigurationError("Mapping must enable identity or define rules/constants.")
262
+ frozen_constants = deep_freeze_json(self.constants)
263
+ if not isinstance(frozen_constants, Mapping):
264
+ raise DataIOConfigurationError("constants must form an object.")
265
+ _validate_target_tree(
266
+ [rule.target_path for rule in rules], constants=self.constants, label="Object mapping"
267
+ )
268
+ object.__setattr__(self, "rules", rules)
269
+ object.__setattr__(self, "constants", frozen_constants)
270
+
271
+ @classmethod
272
+ def identity_mapping(cls) -> "ObjectMapping":
273
+ return cls(identity=True)
274
+
275
+ def to_dict(self) -> dict[str, Any]:
276
+ return {
277
+ "kind": "object",
278
+ "version": self.version,
279
+ "identity": self.identity,
280
+ "constants": deep_thaw_json(self.constants),
281
+ "rules": [rule.to_dict() for rule in self.rules],
282
+ }
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class CsvTable:
287
+ """One explicitly declared CSV table within a file or directory bundle."""
288
+
289
+ target_path: str
290
+ fields: tuple[MappingRule, ...]
291
+ file_name: str | None = None
292
+ delimiter: str | None = None
293
+ encoding: str | None = None
294
+ required_headers: tuple[str, ...] = ()
295
+ min_rows: int = 1
296
+
297
+ def __post_init__(self) -> None:
298
+ target_path = _clean_target_path(self.target_path, field_name="CSV target_path", allow_empty=True)
299
+ fields = tuple(self.fields)
300
+ if not fields or any(not isinstance(rule, MappingRule) for rule in fields):
301
+ raise DataIOConfigurationError("CSV fields must contain at least one MappingRule.")
302
+ for rule in fields:
303
+ if rule.has_constant:
304
+ continue
305
+ if not rule.source_path or "." in rule.source_path or "[" in rule.source_path:
306
+ raise DataIOConfigurationError("CSV source_path must be one exact header name.")
307
+ _validate_target_tree([rule.target_path for rule in fields], constants={}, label="CSV field mapping")
308
+ file_name = _normalize_bundle_name(self.file_name)
309
+ delimiter = None if self.delimiter is None else _validate_delimiter(self.delimiter, field_name="CSV delimiter")
310
+ if self.encoding is not None and (not isinstance(self.encoding, str) or not self.encoding.strip()):
311
+ raise DataIOConfigurationError("CSV encoding must be a non-empty string.")
312
+ if not isinstance(self.required_headers, Sequence) or isinstance(self.required_headers, (str, bytes, bytearray)):
313
+ raise DataIOConfigurationError("required_headers must be a sequence of strings.")
314
+ headers = tuple(item.strip() for item in self.required_headers)
315
+ if any(not item for item in headers) or len(headers) != len(set(headers)):
316
+ raise DataIOConfigurationError("required_headers must be unique non-empty strings.")
317
+ if type(self.min_rows) is not int or self.min_rows < 0:
318
+ raise DataIOConfigurationError("min_rows must be a non-negative integer.")
319
+ object.__setattr__(self, "target_path", target_path)
320
+ object.__setattr__(self, "fields", fields)
321
+ object.__setattr__(self, "file_name", file_name)
322
+ object.__setattr__(self, "delimiter", delimiter)
323
+ object.__setattr__(self, "encoding", None if self.encoding is None else self.encoding.strip())
324
+ object.__setattr__(self, "required_headers", headers)
325
+
326
+ def to_dict(self) -> dict[str, Any]:
327
+ return {
328
+ "target_path": self.target_path,
329
+ "file_name": self.file_name,
330
+ "delimiter": self.delimiter,
331
+ "encoding": self.encoding,
332
+ "required_headers": list(self.required_headers),
333
+ "min_rows": self.min_rows,
334
+ "fields": [rule.to_dict() for rule in self.fields],
335
+ }
336
+
337
+
338
+ @dataclass(frozen=True, slots=True)
339
+ class CsvMapping:
340
+ """Explicit mapping for one CSV file or a declared directory bundle."""
341
+
342
+ tables: tuple[CsvTable, ...]
343
+ constants: Mapping[str, Any] = field(default_factory=dict)
344
+ version: str = _MAPPING_VERSION
345
+
346
+ def __post_init__(self) -> None:
347
+ tables = tuple(self.tables)
348
+ if not tables or any(not isinstance(table, CsvTable) for table in tables):
349
+ raise DataIOConfigurationError("CSV mapping must contain at least one CsvTable.")
350
+ if not isinstance(self.constants, Mapping):
351
+ raise DataIOConfigurationError("CSV constants must be a mapping.")
352
+ if self.version != _MAPPING_VERSION:
353
+ raise DataIOConfigurationError(f"Unsupported mapping version {self.version!r}.")
354
+ target_paths = [table.target_path for table in tables if table.target_path]
355
+ _validate_target_tree(target_paths, constants=self.constants, label="CSV table mapping")
356
+ names = [table.file_name.casefold() for table in tables if table.file_name is not None]
357
+ if len(names) != len(set(names)):
358
+ raise DataIOConfigurationError("CSV bundle file names must be unique case-insensitively.")
359
+ frozen_constants = deep_freeze_json(self.constants)
360
+ if not isinstance(frozen_constants, Mapping):
361
+ raise DataIOConfigurationError("CSV constants must form an object.")
362
+ object.__setattr__(self, "tables", tables)
363
+ object.__setattr__(self, "constants", frozen_constants)
364
+
365
+ def to_dict(self) -> dict[str, Any]:
366
+ return {
367
+ "kind": "csv",
368
+ "version": self.version,
369
+ "constants": deep_thaw_json(self.constants),
370
+ "tables": [table.to_dict() for table in self.tables],
371
+ }
372
+
373
+
374
+ @dataclass(frozen=True, slots=True)
375
+ class DataIssue:
376
+ severity: str
377
+ code: str
378
+ message: str
379
+ path: str = "source"
380
+ row_number: int | None = None
381
+
382
+ def __post_init__(self) -> None:
383
+ if not all(isinstance(v, str) for v in (self.severity, self.code, self.message, self.path)):
384
+ raise DataIOConfigurationError("Issue severity, code, message, and path must be strings.")
385
+ severity = self.severity.strip().lower()
386
+ if severity not in {"info", "warning"}:
387
+ raise DataIOConfigurationError("Data issues may only be info or warning; failures raise exceptions.")
388
+ code = self.code.strip().lower().replace(" ", "_")
389
+ message = self.message.strip()
390
+ path = self.path.strip()
391
+ if not code or not message or not path:
392
+ raise DataIOConfigurationError("Issue code, message, and path must be non-empty.")
393
+ if self.row_number is not None and (type(self.row_number) is not int or self.row_number <= 0):
394
+ raise DataIOConfigurationError("row_number must be a positive integer when provided.")
395
+ object.__setattr__(self, "severity", severity)
396
+ object.__setattr__(self, "code", code)
397
+ object.__setattr__(self, "message", message)
398
+ object.__setattr__(self, "path", path)
399
+
400
+ def to_dict(self) -> dict[str, Any]:
401
+ return {
402
+ "severity": self.severity,
403
+ "code": self.code,
404
+ "message": self.message,
405
+ "path": self.path,
406
+ "row_number": self.row_number,
407
+ }
@@ -0,0 +1,108 @@
1
+ """Strict scalar normalization for safe JSON/CSV mappings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+ from .binary64 import MAX_SAFE_FLOAT_INTEGER, Binary64ConversionError, decimal_token_to_binary64
11
+ from .errors import DataIOMappingError
12
+
13
+ _INTEGER_RE = re.compile(r"^[+-]?(?:0|[1-9][0-9]*)$")
14
+ _NUMBER_RE = re.compile(
15
+ r"^[+-]?(?:(?:0|[1-9][0-9]*)(?:\.[0-9]+)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$"
16
+ )
17
+ _TRUE_VALUES = frozenset({"true", "1", "yes", "y"})
18
+ _FALSE_VALUES = frozenset({"false", "0", "no", "n"})
19
+ _MAX_INTEGER_DIGITS = 1000
20
+
21
+
22
+ def parse_integer(value: Any, *, field_name: str = "value") -> int:
23
+ if isinstance(value, bool):
24
+ raise DataIOMappingError(f"{field_name} must be an integer, not a boolean.")
25
+ if isinstance(value, int):
26
+ return value
27
+ if not isinstance(value, str):
28
+ raise DataIOMappingError(f"{field_name} must be an integer or integer string.")
29
+ text = value.strip()
30
+ if not _INTEGER_RE.fullmatch(text):
31
+ raise DataIOMappingError(f"{field_name} is not a valid base-10 integer.")
32
+ if len(text.lstrip("+-")) > _MAX_INTEGER_DIGITS:
33
+ raise DataIOMappingError(f"{field_name} exceeds the supported integer digit limit.")
34
+ try:
35
+ return int(text, 10)
36
+ except ValueError as exc:
37
+ raise DataIOMappingError(f"{field_name} cannot be parsed as an integer safely.") from exc
38
+
39
+
40
+ def _parse_decimal_token(value: str, *, field_name: str, strict_binary64: bool) -> float:
41
+ try:
42
+ return decimal_token_to_binary64(value, exact_decimal=strict_binary64)
43
+ except Binary64ConversionError as exc:
44
+ reason = str(exc)
45
+ if "exact binary64 range" in reason:
46
+ raise DataIOMappingError(
47
+ f"{field_name} is an integer outside exact IEEE-754 range; use integer data."
48
+ ) from exc
49
+ if "underflows" in reason:
50
+ raise DataIOMappingError(f"{field_name} underflows IEEE-754 binary64.") from exc
51
+ if "loses precision" in reason:
52
+ raise DataIOMappingError(
53
+ f"{field_name} loses decimal precision in IEEE-754 binary64 under strict policy."
54
+ ) from exc
55
+ raise DataIOMappingError(
56
+ f"{field_name} cannot be represented safely as a finite IEEE-754 binary64 number."
57
+ ) from exc
58
+
59
+
60
+ def parse_number(
61
+ value: Any,
62
+ *,
63
+ field_name: str = "value",
64
+ strict_binary64: bool = True,
65
+ ) -> float:
66
+ if type(strict_binary64) is not bool:
67
+ raise DataIOMappingError("strict_binary64 must be a boolean.")
68
+ if isinstance(value, bool):
69
+ raise DataIOMappingError(f"{field_name} must be numeric, not a boolean.")
70
+ if isinstance(value, int):
71
+ return _parse_decimal_token(str(value), field_name=field_name, strict_binary64=strict_binary64)
72
+ if isinstance(value, float):
73
+ if not math.isfinite(value):
74
+ raise DataIOMappingError(f"{field_name} must be finite.")
75
+ if value.is_integer() and abs(value) > MAX_SAFE_FLOAT_INTEGER:
76
+ raise DataIOMappingError(
77
+ f"{field_name} is an integer outside exact IEEE-754 range; use integer data."
78
+ )
79
+ return value
80
+ if isinstance(value, Decimal):
81
+ if not value.is_finite():
82
+ raise DataIOMappingError(f"{field_name} must be finite.")
83
+ return _parse_decimal_token(
84
+ str(value), field_name=field_name, strict_binary64=strict_binary64
85
+ )
86
+ if isinstance(value, str):
87
+ text = value.strip()
88
+ if not _NUMBER_RE.fullmatch(text):
89
+ raise DataIOMappingError(f"{field_name} is not a valid decimal number.")
90
+ return _parse_decimal_token(text, field_name=field_name, strict_binary64=strict_binary64)
91
+ raise DataIOMappingError(f"{field_name} must be a number or numeric string.")
92
+
93
+
94
+ def parse_boolean(value: Any, *, field_name: str = "value") -> bool:
95
+ if isinstance(value, bool):
96
+ return value
97
+ if isinstance(value, int) and value in {0, 1}:
98
+ return bool(value)
99
+ if not isinstance(value, str):
100
+ raise DataIOMappingError(f"{field_name} must be a boolean or boolean string.")
101
+ normalized = value.strip().lower()
102
+ if normalized in _TRUE_VALUES:
103
+ return True
104
+ if normalized in _FALSE_VALUES:
105
+ return False
106
+ raise DataIOMappingError(
107
+ f"{field_name} is not a supported boolean value; use true/false, yes/no, or 1/0."
108
+ )
@@ -0,0 +1,114 @@
1
+ """Immutable, content-addressed provenance records for ingested data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import PurePosixPath
9
+ from typing import Any
10
+
11
+ from .errors import DataIOConfigurationError
12
+ from .hashing import deep_freeze_json, deep_thaw_json
13
+
14
+ _SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
15
+
16
+
17
+ def _sha(value: str, *, field_name: str) -> str:
18
+ if not isinstance(value, str) or _SHA256_RE.fullmatch(value.strip()) is None:
19
+ raise DataIOConfigurationError(f"{field_name} must be a 64-character SHA-256 digest.")
20
+ return value.strip().lower()
21
+
22
+
23
+ def _text(value: str, *, field_name: str) -> str:
24
+ if not isinstance(value, str) or not value.strip():
25
+ raise DataIOConfigurationError(f"{field_name} must be a non-empty string.")
26
+ return value.strip()
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SourceFileProvenance:
31
+ name: str
32
+ sha256: str
33
+ size_bytes: int
34
+
35
+ def __post_init__(self) -> None:
36
+ if not isinstance(self.name, str):
37
+ raise DataIOConfigurationError("source file name must be a string.")
38
+ name = self.name.strip().replace("\\", "/")
39
+ posix = PurePosixPath(name)
40
+ if not name or posix.is_absolute() or any(
41
+ part in {"", ".", ".."} or ":" in part for part in posix.parts
42
+ ):
43
+ raise DataIOConfigurationError("source file name must be a safe relative name.")
44
+ if type(self.size_bytes) is not int or self.size_bytes < 0:
45
+ raise DataIOConfigurationError("size_bytes must be a non-negative integer.")
46
+ object.__setattr__(self, "name", posix.as_posix())
47
+ object.__setattr__(self, "sha256", _sha(self.sha256, field_name="source file sha256"))
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ return {"name": self.name, "sha256": self.sha256, "size_bytes": self.size_bytes}
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class SourceProvenance:
55
+ loader: str
56
+ loader_version: str
57
+ source_type: str
58
+ source_name: str
59
+ source_sha256: str
60
+ source_size_bytes: int
61
+ mapping_sha256: str
62
+ normalized_payload_sha256: str
63
+ policy_sha256: str
64
+ record_count: int
65
+ encoding: str
66
+ delimiter: str | None = None
67
+ source_files: tuple[SourceFileProvenance, ...] = ()
68
+ metadata: Mapping[str, Any] = field(default_factory=dict)
69
+ provenance_schema: str = "solverpilot.source_provenance.v1"
70
+
71
+ def __post_init__(self) -> None:
72
+ for field_name in ("loader", "loader_version", "source_type", "source_name", "encoding", "provenance_schema"):
73
+ object.__setattr__(self, field_name, _text(getattr(self, field_name), field_name=field_name))
74
+ object.__setattr__(self, "source_sha256", _sha(self.source_sha256, field_name="source_sha256"))
75
+ object.__setattr__(self, "mapping_sha256", _sha(self.mapping_sha256, field_name="mapping_sha256"))
76
+ object.__setattr__(self, "normalized_payload_sha256", _sha(self.normalized_payload_sha256, field_name="normalized_payload_sha256"))
77
+ object.__setattr__(self, "policy_sha256", _sha(self.policy_sha256, field_name="policy_sha256"))
78
+ if type(self.source_size_bytes) is not int or self.source_size_bytes < 0:
79
+ raise DataIOConfigurationError("source_size_bytes must be a non-negative integer.")
80
+ if type(self.record_count) is not int or self.record_count < 0:
81
+ raise DataIOConfigurationError("record_count must be a non-negative integer.")
82
+ if self.delimiter is not None and (not isinstance(self.delimiter, str) or len(self.delimiter) != 1):
83
+ raise DataIOConfigurationError("delimiter must be one character when provided.")
84
+ files = tuple(self.source_files)
85
+ if any(not isinstance(item, SourceFileProvenance) for item in files):
86
+ raise DataIOConfigurationError("source_files must contain SourceFileProvenance values.")
87
+ frozen = deep_freeze_json(self.metadata)
88
+ if not isinstance(frozen, Mapping):
89
+ raise DataIOConfigurationError("provenance metadata must form an object.")
90
+ object.__setattr__(self, "source_files", files)
91
+ object.__setattr__(self, "metadata", frozen)
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ return {
95
+ "provenance_schema": self.provenance_schema,
96
+ "loader": self.loader,
97
+ "loader_version": self.loader_version,
98
+ "source_type": self.source_type,
99
+ "source_name": self.source_name,
100
+ "source_sha256": self.source_sha256,
101
+ "source_size_bytes": self.source_size_bytes,
102
+ "mapping_sha256": self.mapping_sha256,
103
+ "normalized_payload_sha256": self.normalized_payload_sha256,
104
+ "policy_sha256": self.policy_sha256,
105
+ "record_count": self.record_count,
106
+ "encoding": self.encoding,
107
+ "delimiter": self.delimiter,
108
+ "source_files": [item.to_dict() for item in self.source_files],
109
+ "metadata": deep_thaw_json(self.metadata),
110
+ }
111
+
112
+ def as_problem_metadata(self) -> dict[str, object]:
113
+ """Return a detached metadata fragment suitable for Problem constructors."""
114
+ return {"source_provenance": self.to_dict()}
@@ -0,0 +1,45 @@
1
+ """Immutable result object returned by SolverPilot data loaders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from .errors import DataIOConfigurationError
10
+ from .hashing import deep_freeze_json, deep_thaw_json
11
+ from .model import DataIssue
12
+ from .provenance import SourceProvenance
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class LoadedData:
17
+ """Normalized immutable payload plus provenance and non-fatal issues."""
18
+
19
+ payload: Any
20
+ provenance: SourceProvenance
21
+ issues: tuple[DataIssue, ...] = ()
22
+ metadata: Mapping[str, Any] = field(default_factory=dict)
23
+
24
+ def __post_init__(self) -> None:
25
+ if not isinstance(self.provenance, SourceProvenance):
26
+ raise DataIOConfigurationError("provenance must be SourceProvenance.")
27
+ issues = tuple(self.issues)
28
+ if any(not isinstance(issue, DataIssue) for issue in issues):
29
+ raise DataIOConfigurationError("issues must contain DataIssue values.")
30
+ frozen_metadata = deep_freeze_json(self.metadata)
31
+ if not isinstance(frozen_metadata, Mapping):
32
+ raise DataIOConfigurationError("metadata must form an object.")
33
+ object.__setattr__(self, "payload", deep_freeze_json(self.payload))
34
+ object.__setattr__(self, "issues", issues)
35
+ object.__setattr__(self, "metadata", frozen_metadata)
36
+
37
+ def mutable_payload(self) -> Any:
38
+ return deep_thaw_json(self.payload)
39
+
40
+ def evidence_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "provenance": self.provenance.to_dict(),
43
+ "issues": [issue.to_dict() for issue in self.issues],
44
+ "metadata": deep_thaw_json(self.metadata),
45
+ }