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
solverpilot/io/csv.py ADDED
@@ -0,0 +1,230 @@
1
+ """Safe deterministic local CSV and explicitly declared CSV-bundle ingestion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .errors import (
11
+ DataIOConfigurationError,
12
+ DataIOMappingError,
13
+ DataIOParseError,
14
+ DataIOSourceError,
15
+ )
16
+ from .hashing import canonicalize_json_value, deep_thaw_json, sha256_bytes, sha256_json
17
+ from .mapping import apply_rules, set_path
18
+ from .model import CsvMapping, CsvTable, DataIssue, LoadPolicy
19
+ from .provenance import SourceFileProvenance, SourceProvenance
20
+ from .result import LoadedData
21
+ from .source import read_limited_bytes, safe_bundle_child, validate_local_path
22
+
23
+ _CSV_LOADER_VERSION = "1.0"
24
+
25
+
26
+ def _parse_table(
27
+ raw_bytes: bytes,
28
+ *,
29
+ table: CsvTable,
30
+ encoding: str,
31
+ delimiter: str,
32
+ policy: LoadPolicy,
33
+ ) -> tuple[list[tuple[int, dict[str, str]]], tuple[str, ...]]:
34
+ try:
35
+ text = raw_bytes.decode(encoding)
36
+ except LookupError as exc:
37
+ raise DataIOSourceError("Declared CSV encoding is not supported.") from exc
38
+ except UnicodeDecodeError as exc:
39
+ raise DataIOParseError("CSV source cannot be decoded with the declared encoding.") from exc
40
+
41
+ try:
42
+ reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter, strict=True)
43
+ try:
44
+ raw_headers = next(reader)
45
+ except StopIteration as exc:
46
+ raise DataIOParseError("CSV source is missing a header row.") from exc
47
+ headers = tuple(str(header).strip() for header in raw_headers)
48
+ if any(not header for header in headers):
49
+ raise DataIOParseError("CSV header names must be non-empty.")
50
+ if len(headers) != len(set(headers)):
51
+ raise DataIOParseError("CSV header names must be unique.")
52
+
53
+ required_headers = set(table.required_headers)
54
+ required_headers.update(
55
+ rule.source_path
56
+ for rule in table.fields
57
+ if rule.source_path is not None and rule.required and not rule.has_constant
58
+ )
59
+ missing = sorted(str(item) for item in required_headers.difference(headers))
60
+ if missing:
61
+ raise DataIOParseError(f"CSV source is missing required headers: {', '.join(missing)}.")
62
+
63
+ rows: list[tuple[int, dict[str, str]]] = []
64
+ for raw_row in reader:
65
+ row_number = reader.line_num
66
+ if not raw_row:
67
+ raise DataIOParseError(f"CSV row {row_number} is blank; blank data rows are not allowed.")
68
+ if len(raw_row) != len(headers):
69
+ raise DataIOParseError(
70
+ f"CSV row {row_number} has a different field count than the header."
71
+ )
72
+ rows.append((row_number, dict(zip(headers, map(str, raw_row)))))
73
+ return rows, headers
74
+ except DataIOParseError:
75
+ raise
76
+ except (csv.Error, ValueError) as exc:
77
+ raise DataIOParseError("CSV source or delimiter configuration is invalid.") from exc
78
+
79
+
80
+ def _resolve_table_path(root: Path, table: CsvTable, policy: LoadPolicy) -> Path:
81
+ if root.is_file():
82
+ if table.file_name not in {None, root.name}:
83
+ raise DataIOConfigurationError(
84
+ "Single-file CSV mapping file_name must be omitted or match the source name."
85
+ )
86
+ return root
87
+ if table.file_name is None:
88
+ raise DataIOConfigurationError("Every CSV table in a directory bundle must declare file_name.")
89
+ return safe_bundle_child(root, table.file_name, reject_symlinks=policy.reject_symlinks)
90
+
91
+
92
+ def load_csv(
93
+ source: str | Path,
94
+ *,
95
+ mapping: CsvMapping,
96
+ policy: LoadPolicy | None = None,
97
+ ) -> LoadedData:
98
+ """Load one CSV file or an explicitly declared local CSV directory bundle."""
99
+ policy = LoadPolicy() if policy is None else policy
100
+ if not isinstance(mapping, CsvMapping):
101
+ raise DataIOConfigurationError("mapping must be CsvMapping.")
102
+ if not isinstance(policy, LoadPolicy):
103
+ raise DataIOConfigurationError("policy must be LoadPolicy.")
104
+
105
+ root = validate_local_path(
106
+ source,
107
+ expect_directory=None,
108
+ reject_symlinks=policy.reject_symlinks,
109
+ )
110
+ if root.is_dir() and any(table.file_name is None for table in mapping.tables):
111
+ raise DataIOConfigurationError("Every CSV table in a directory bundle must declare file_name.")
112
+ if root.is_file() and len(mapping.tables) != 1:
113
+ raise DataIOConfigurationError("A single CSV file source requires exactly one table mapping.")
114
+
115
+ normalized: Any = canonicalize_json_value(deep_thaw_json(mapping.constants))
116
+ if not isinstance(normalized, dict):
117
+ raise DataIOConfigurationError("CSV constants must form an object.")
118
+
119
+ source_files: list[SourceFileProvenance] = []
120
+ issues: list[DataIssue] = []
121
+ total_size = 0
122
+ total_records = 0
123
+ table_metadata: list[dict[str, Any]] = []
124
+
125
+ for table in mapping.tables:
126
+ table_path = _resolve_table_path(root, table, policy)
127
+ source_name = table_path.name if root.is_file() else str(table.file_name)
128
+ remaining = policy.max_source_bytes - total_size
129
+ if remaining <= 0:
130
+ raise DataIOSourceError("Combined CSV bundle size exceeds the configured maximum.")
131
+ raw_bytes = read_limited_bytes(table_path, max_bytes=remaining)
132
+ total_size += len(raw_bytes)
133
+ encoding = table.encoding or policy.encoding
134
+ delimiter = table.delimiter or policy.delimiter
135
+ rows, headers = _parse_table(
136
+ raw_bytes,
137
+ table=table,
138
+ encoding=encoding,
139
+ delimiter=delimiter,
140
+ policy=policy,
141
+ )
142
+ if len(rows) < table.min_rows:
143
+ raise DataIOParseError(
144
+ f"CSV table {source_name!r} has {len(rows)} data rows; minimum required is {table.min_rows}."
145
+ )
146
+ used_headers = {rule.source_path for rule in table.fields if rule.source_path is not None}
147
+ unused = tuple(header for header in headers if header not in used_headers)
148
+ if unused:
149
+ issues.append(
150
+ DataIssue(
151
+ severity="info",
152
+ code="unused_csv_headers",
153
+ message=f"CSV table {source_name!r} contains headers not used by the explicit mapping.",
154
+ path=source_name,
155
+ )
156
+ )
157
+
158
+ records: list[dict[str, Any]] = []
159
+ for row_number, row in rows:
160
+ try:
161
+ records.append(
162
+ apply_rules(
163
+ row,
164
+ table.fields,
165
+ strict_numbers=policy.strict_numbers,
166
+ max_json_depth=policy.max_json_depth,
167
+ row_number=row_number,
168
+ )
169
+ )
170
+ except DataIOMappingError as exc:
171
+ raise DataIOMappingError(f"CSV mapping failed in {source_name!r}: {exc}") from exc
172
+
173
+ if table.target_path:
174
+ set_path(normalized, table.target_path, records)
175
+ else:
176
+ if len(mapping.tables) != 1 or mapping.constants:
177
+ raise DataIOConfigurationError(
178
+ "An empty CSV target_path is only valid for one table with no top-level constants."
179
+ )
180
+ normalized = records
181
+
182
+ file_hash = sha256_bytes(raw_bytes)
183
+ source_files.append(
184
+ SourceFileProvenance(name=source_name, sha256=file_hash, size_bytes=len(raw_bytes))
185
+ )
186
+ total_records += len(records)
187
+ table_metadata.append(
188
+ {
189
+ "source_name": source_name,
190
+ "target_path": table.target_path,
191
+ "row_count": len(records),
192
+ "encoding": encoding,
193
+ "delimiter": delimiter,
194
+ }
195
+ )
196
+
197
+ evidence = tuple(sorted(source_files, key=lambda item: item.name.casefold()))
198
+ combined_source_hash = (
199
+ evidence[0].sha256
200
+ if root.is_file() and len(evidence) == 1
201
+ else sha256_json([item.to_dict() for item in evidence])
202
+ )
203
+ provenance = SourceProvenance(
204
+ loader="solverpilot.io.csv",
205
+ loader_version=_CSV_LOADER_VERSION,
206
+ source_type="csv_bundle" if root.is_dir() else "csv_file",
207
+ source_name=root.name,
208
+ source_sha256=combined_source_hash,
209
+ source_size_bytes=total_size,
210
+ mapping_sha256=sha256_json(mapping.to_dict()),
211
+ normalized_payload_sha256=sha256_json(normalized),
212
+ policy_sha256=sha256_json(policy.to_dict()),
213
+ record_count=total_records,
214
+ encoding=policy.encoding,
215
+ delimiter=policy.delimiter,
216
+ source_files=evidence,
217
+ metadata={
218
+ "mapping_version": mapping.version,
219
+ "reject_symlinks": policy.reject_symlinks,
220
+ "strict_numbers": policy.strict_numbers,
221
+ "bundle_name_policy": "normalized_posix_casefold_unique_v1",
222
+ "tables": table_metadata,
223
+ },
224
+ )
225
+ return LoadedData(
226
+ payload=normalized,
227
+ provenance=provenance,
228
+ issues=tuple(issues),
229
+ metadata={"mapping_kind": "csv", "table_count": len(mapping.tables)},
230
+ )
@@ -0,0 +1,25 @@
1
+ """Fail-closed exceptions for local data ingestion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from solverpilot.exceptions import SolverPilotError
6
+
7
+
8
+ class DataIOError(SolverPilotError):
9
+ """Base class for deterministic SolverPilot data-I/O failures."""
10
+
11
+
12
+ class DataIOConfigurationError(DataIOError, ValueError):
13
+ """Raised when an I/O or mapping configuration is invalid."""
14
+
15
+
16
+ class DataIOSourceError(DataIOError):
17
+ """Raised when a local source cannot be inspected or read safely."""
18
+
19
+
20
+ class DataIOParseError(DataIOError, ValueError):
21
+ """Raised when source bytes cannot be parsed under the declared policy."""
22
+
23
+
24
+ class DataIOMappingError(DataIOError, ValueError):
25
+ """Raised when explicit normalization or field mapping fails."""
@@ -0,0 +1,111 @@
1
+ """Canonical JSON snapshots and SHA-256 helpers for data provenance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from collections.abc import Mapping, Sequence
9
+ from pathlib import Path
10
+ from types import MappingProxyType
11
+ from typing import Any
12
+
13
+ from .errors import DataIOConfigurationError
14
+
15
+
16
+ def _validate_text(value: str) -> str:
17
+ try:
18
+ value.encode("utf-8")
19
+ except UnicodeEncodeError as exc:
20
+ raise DataIOConfigurationError("JSON-compatible text contains an invalid Unicode surrogate.") from exc
21
+ return value
22
+
23
+
24
+ def canonicalize_json_value(value: Any) -> Any:
25
+ """Return a detached JSON-compatible value with deterministic mapping order."""
26
+ if value is None or isinstance(value, bool):
27
+ return value
28
+ if isinstance(value, str):
29
+ return _validate_text(value)
30
+ if isinstance(value, int):
31
+ return value
32
+ if isinstance(value, float):
33
+ if not math.isfinite(value):
34
+ raise DataIOConfigurationError("JSON-compatible numbers must be finite.")
35
+ return value
36
+ if isinstance(value, Mapping):
37
+ keys = tuple(value.keys())
38
+ if any(not isinstance(key, str) for key in keys):
39
+ raise DataIOConfigurationError("Canonical JSON mapping keys must be strings.")
40
+ return {
41
+ _validate_text(key): canonicalize_json_value(value[key])
42
+ for key in sorted(keys)
43
+ }
44
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
45
+ return [canonicalize_json_value(item) for item in value]
46
+ raise DataIOConfigurationError(
47
+ f"Value of type {type(value).__name__} is not JSON-compatible for provenance."
48
+ )
49
+
50
+
51
+ def _freeze(value: Any) -> Any:
52
+ if isinstance(value, dict):
53
+ return MappingProxyType({key: _freeze(item) for key, item in value.items()})
54
+ if isinstance(value, list):
55
+ return tuple(_freeze(item) for item in value)
56
+ return value
57
+
58
+
59
+ def deep_freeze_json(value: Any) -> Any:
60
+ """Create a detached, recursively immutable JSON-compatible snapshot."""
61
+ try:
62
+ return _freeze(canonicalize_json_value(value))
63
+ except DataIOConfigurationError:
64
+ raise
65
+ except RecursionError as exc:
66
+ raise DataIOConfigurationError(
67
+ "JSON-compatible configuration exceeds supported nesting or contains a cycle."
68
+ ) from exc
69
+
70
+
71
+ def deep_thaw_json(value: Any) -> Any:
72
+ """Return a detached mutable JSON-compatible copy of a frozen snapshot."""
73
+ if isinstance(value, Mapping):
74
+ return {str(key): deep_thaw_json(item) for key, item in value.items()}
75
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
76
+ return [deep_thaw_json(item) for item in value]
77
+ return canonicalize_json_value(value)
78
+
79
+
80
+ def canonical_json_bytes(value: Any) -> bytes:
81
+ try:
82
+ normalized = canonicalize_json_value(value)
83
+ return json.dumps(
84
+ normalized,
85
+ ensure_ascii=False,
86
+ sort_keys=True,
87
+ separators=(",", ":"),
88
+ allow_nan=False,
89
+ ).encode("utf-8")
90
+ except DataIOConfigurationError:
91
+ raise
92
+ except (TypeError, ValueError, UnicodeEncodeError, RecursionError) as exc:
93
+ raise DataIOConfigurationError("Value cannot be serialized as canonical JSON.") from exc
94
+
95
+
96
+ def sha256_bytes(data: bytes) -> str:
97
+ return hashlib.sha256(data).hexdigest()
98
+
99
+
100
+ def sha256_json(value: Any) -> str:
101
+ return sha256_bytes(canonical_json_bytes(value))
102
+
103
+
104
+ def sha256_file(path: str | Path, *, chunk_size: int = 1024 * 1024) -> str:
105
+ if type(chunk_size) is not int or chunk_size <= 0:
106
+ raise ValueError("chunk_size must be a positive integer")
107
+ digest = hashlib.sha256()
108
+ with Path(path).open("rb") as handle:
109
+ for chunk in iter(lambda: handle.read(chunk_size), b""):
110
+ digest.update(chunk)
111
+ return digest.hexdigest()
solverpilot/io/json.py ADDED
@@ -0,0 +1,90 @@
1
+ """Safe deterministic local JSON ingestion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .errors import DataIOParseError, DataIOSourceError
8
+ from .hashing import sha256_bytes, sha256_json
9
+ from .mapping import apply_object_mapping
10
+ from .model import LoadPolicy, ObjectMapping
11
+ from .provenance import SourceFileProvenance, SourceProvenance
12
+ from .result import LoadedData
13
+ from .source import read_limited_bytes, validate_local_path
14
+ from .strict_json import loads_strict_json
15
+
16
+ _JSON_LOADER_VERSION = "1.0"
17
+
18
+
19
+ def load_json(
20
+ source: str | Path,
21
+ *,
22
+ mapping: ObjectMapping | None = None,
23
+ policy: LoadPolicy | None = None,
24
+ ) -> LoadedData:
25
+ """Load one local JSON file with strict parsing, mapping, and provenance.
26
+
27
+ No problem type is inferred and no solver-specific object is materialized. The
28
+ returned normalized payload is immutable; use ``mutable_payload()`` when a
29
+ mutable detached copy is needed.
30
+ """
31
+ mapping = ObjectMapping.identity_mapping() if mapping is None else mapping
32
+ policy = LoadPolicy() if policy is None else policy
33
+ if not isinstance(mapping, ObjectMapping):
34
+ from .errors import DataIOConfigurationError
35
+
36
+ raise DataIOConfigurationError("mapping must be ObjectMapping.")
37
+ if not isinstance(policy, LoadPolicy):
38
+ from .errors import DataIOConfigurationError
39
+
40
+ raise DataIOConfigurationError("policy must be LoadPolicy.")
41
+
42
+ path = validate_local_path(
43
+ source,
44
+ expect_directory=False,
45
+ reject_symlinks=policy.reject_symlinks,
46
+ )
47
+ raw_bytes = read_limited_bytes(path, max_bytes=policy.max_source_bytes)
48
+ try:
49
+ text = raw_bytes.decode(policy.encoding)
50
+ except LookupError as exc:
51
+ raise DataIOSourceError("Declared JSON encoding is not supported.") from exc
52
+ except UnicodeDecodeError as exc:
53
+ raise DataIOParseError("JSON source cannot be decoded with the declared encoding.") from exc
54
+
55
+ parsed = loads_strict_json(
56
+ text,
57
+ context="JSON source",
58
+ max_depth=policy.max_json_depth,
59
+ strict_numbers=policy.strict_numbers,
60
+ )
61
+ normalized = apply_object_mapping(
62
+ parsed,
63
+ mapping,
64
+ strict_numbers=policy.strict_numbers,
65
+ max_json_depth=policy.max_json_depth,
66
+ )
67
+ source_hash = sha256_bytes(raw_bytes)
68
+ provenance = SourceProvenance(
69
+ loader="solverpilot.io.json",
70
+ loader_version=_JSON_LOADER_VERSION,
71
+ source_type="json_file",
72
+ source_name=path.name,
73
+ source_sha256=source_hash,
74
+ source_size_bytes=len(raw_bytes),
75
+ mapping_sha256=sha256_json(mapping.to_dict()),
76
+ normalized_payload_sha256=sha256_json(normalized),
77
+ policy_sha256=sha256_json(policy.to_dict()),
78
+ record_count=len(parsed) if isinstance(parsed, list) else 1,
79
+ encoding=policy.encoding,
80
+ source_files=(
81
+ SourceFileProvenance(name=path.name, sha256=source_hash, size_bytes=len(raw_bytes)),
82
+ ),
83
+ metadata={
84
+ "mapping_version": mapping.version,
85
+ "reject_symlinks": policy.reject_symlinks,
86
+ "strict_numbers": policy.strict_numbers,
87
+ "strict_json_policy": "solverpilot_strict_json_v1",
88
+ },
89
+ )
90
+ return LoadedData(payload=normalized, provenance=provenance, metadata={"mapping_kind": "object"})
@@ -0,0 +1,195 @@
1
+ """Collision-safe explicit field mapping and scalar normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from .errors import DataIOConfigurationError, DataIOMappingError
10
+ from .hashing import canonicalize_json_value, deep_thaw_json
11
+ from .model import MappingRule, ObjectMapping
12
+ from .numeric import parse_boolean, parse_integer, parse_number
13
+ from .strict_json import loads_strict_json
14
+
15
+ _MISSING = object()
16
+ _PATH_TOKEN_RE = re.compile(r"([^.[\]]+)|\[([0-9]+)\]")
17
+
18
+
19
+ def _path_tokens(path: str) -> tuple[str | int, ...]:
20
+ if path == "":
21
+ return ()
22
+ tokens: list[str | int] = []
23
+ position = 0
24
+ while position < len(path):
25
+ match = _PATH_TOKEN_RE.match(path, position)
26
+ if match is None:
27
+ raise DataIOMappingError(f"Invalid source path syntax at position {position}.")
28
+ token = match.group(1)
29
+ tokens.append(token if token is not None else int(match.group(2)))
30
+ position = match.end()
31
+ if position == len(path):
32
+ break
33
+ if path[position] == "[":
34
+ continue
35
+ if path[position] != ".":
36
+ raise DataIOMappingError(f"Invalid source path syntax at position {position}.")
37
+ position += 1
38
+ if position == len(path) or path[position] in ".[":
39
+ raise DataIOMappingError(f"Invalid source path syntax at position {position}.")
40
+ return tuple(tokens)
41
+
42
+
43
+ def get_path(source: Any, path: str) -> Any:
44
+ current = source
45
+ for token in _path_tokens(path):
46
+ if isinstance(token, int):
47
+ if not isinstance(current, Sequence) or isinstance(current, (str, bytes, bytearray)):
48
+ return _MISSING
49
+ if token >= len(current):
50
+ return _MISSING
51
+ current = current[token]
52
+ else:
53
+ if not isinstance(current, Mapping) or token not in current:
54
+ return _MISSING
55
+ current = current[token]
56
+ return current
57
+
58
+
59
+ def set_path(target: dict[str, Any], path: str, value: Any) -> None:
60
+ if not path:
61
+ raise DataIOMappingError("Target path cannot be empty for object field mapping.")
62
+ parts = path.split(".")
63
+ if any(not part or "[" in part or "]" in part for part in parts):
64
+ raise DataIOMappingError(f"Invalid target path syntax: {path!r}.")
65
+ current = target
66
+ for part in parts[:-1]:
67
+ if part not in current:
68
+ nested: dict[str, Any] = {}
69
+ current[part] = nested
70
+ current = nested
71
+ continue
72
+ existing = current[part]
73
+ if not isinstance(existing, dict):
74
+ raise DataIOMappingError(
75
+ f"Target path {path!r} collides with a non-object value at {part!r}."
76
+ )
77
+ current = existing
78
+ leaf = parts[-1]
79
+ if leaf in current:
80
+ raise DataIOMappingError(f"Target path {path!r} would overwrite an existing value.")
81
+ current[leaf] = value
82
+
83
+
84
+ def _canonical_mapped(value: Any, *, field_name: str) -> Any:
85
+ try:
86
+ return canonicalize_json_value(value)
87
+ except DataIOConfigurationError as exc:
88
+ raise DataIOMappingError(f"{field_name} is not a supported JSON-compatible value.") from exc
89
+
90
+
91
+ def normalize_value(
92
+ value: Any,
93
+ rule: MappingRule,
94
+ *,
95
+ field_name: str,
96
+ strict_numbers: bool,
97
+ max_json_depth: int,
98
+ ) -> Any:
99
+ is_null = value is None or (
100
+ isinstance(value, str) and value.strip().lower() in rule.null_values
101
+ )
102
+ if is_null:
103
+ if rule.has_default:
104
+ value = deep_thaw_json(rule.default)
105
+ elif rule.required:
106
+ raise DataIOMappingError(f"{field_name} is required and cannot be null.")
107
+ else:
108
+ return None
109
+ if rule.value_type == "any":
110
+ if isinstance(value, str) and rule.trim:
111
+ return value.strip()
112
+ return _canonical_mapped(value, field_name=field_name)
113
+ if rule.value_type == "string":
114
+ if not isinstance(value, str):
115
+ raise DataIOMappingError(f"{field_name} must be a string.")
116
+ return value.strip() if rule.trim else value
117
+ if rule.value_type == "integer":
118
+ return parse_integer(value, field_name=field_name)
119
+ if rule.value_type == "number":
120
+ return parse_number(value, field_name=field_name, strict_binary64=strict_numbers)
121
+ if rule.value_type == "boolean":
122
+ return parse_boolean(value, field_name=field_name)
123
+ if rule.value_type == "json":
124
+ if isinstance(value, str):
125
+ value = loads_strict_json(
126
+ value,
127
+ context=f"{field_name} JSON text",
128
+ max_depth=max_json_depth,
129
+ strict_numbers=strict_numbers,
130
+ )
131
+ return _canonical_mapped(value, field_name=field_name)
132
+ raise DataIOMappingError(f"Unsupported normalization type {rule.value_type!r}.")
133
+
134
+
135
+ def apply_rules(
136
+ source: Any,
137
+ rules: Sequence[MappingRule],
138
+ *,
139
+ strict_numbers: bool,
140
+ max_json_depth: int,
141
+ row_number: int | None = None,
142
+ ) -> dict[str, Any]:
143
+ target: dict[str, Any] = {}
144
+ for rule in rules:
145
+ if rule.has_constant:
146
+ raw_value = deep_thaw_json(rule.constant)
147
+ else:
148
+ if rule.source_path is None:
149
+ raise DataIOMappingError("Mapping rule without a constant requires source_path.")
150
+ raw_value = get_path(source, rule.source_path)
151
+ if raw_value is _MISSING:
152
+ if rule.has_default:
153
+ raw_value = deep_thaw_json(rule.default)
154
+ elif rule.required:
155
+ suffix = "" if row_number is None else f" at row {row_number}"
156
+ raise DataIOMappingError(
157
+ f"Required source field {rule.source_path!r} is missing{suffix}."
158
+ )
159
+ else:
160
+ raw_value = None
161
+ suffix = "" if row_number is None else f" at row {row_number}"
162
+ normalized = normalize_value(
163
+ raw_value,
164
+ rule,
165
+ field_name=f"Field {rule.source_path or rule.target_path!r}{suffix}",
166
+ strict_numbers=strict_numbers,
167
+ max_json_depth=max_json_depth,
168
+ )
169
+ set_path(target, rule.target_path, normalized)
170
+ return target
171
+
172
+
173
+ def apply_object_mapping(
174
+ source: Any,
175
+ config: ObjectMapping,
176
+ *,
177
+ strict_numbers: bool,
178
+ max_json_depth: int,
179
+ ) -> Any:
180
+ if config.identity:
181
+ return _canonical_mapped(source, field_name="Identity-mapped source")
182
+ target = _canonical_mapped(deep_thaw_json(config.constants), field_name="Object mapping constants")
183
+ if not isinstance(target, dict):
184
+ raise DataIOMappingError("Object mapping constants must form an object.")
185
+ mapped = apply_rules(
186
+ source,
187
+ config.rules,
188
+ strict_numbers=strict_numbers,
189
+ max_json_depth=max_json_depth,
190
+ )
191
+ for key, value in mapped.items():
192
+ if key in target:
193
+ raise DataIOMappingError(f"Object mapping would overwrite constant key {key!r}.")
194
+ target[key] = value
195
+ return target