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.
- solverpilot/__init__.py +250 -0
- solverpilot/_immutability.py +27 -0
- solverpilot/_version.py +3 -0
- solverpilot/applications/__init__.py +3 -0
- solverpilot/applications/routing/__init__.py +19 -0
- solverpilot/applications/routing/compile_milp.py +321 -0
- solverpilot/applications/routing/diagnostics.py +78 -0
- solverpilot/applications/routing/errors.py +14 -0
- solverpilot/applications/routing/heuristics.py +167 -0
- solverpilot/applications/routing/model.py +259 -0
- solverpilot/applications/routing/reference.py +81 -0
- solverpilot/applications/routing/solution.py +89 -0
- solverpilot/applications/routing/timeline.py +79 -0
- solverpilot/applications/routing/validation.py +169 -0
- solverpilot/applications/tsp/__init__.py +41 -0
- solverpilot/applications/tsp/compile_cp.py +121 -0
- solverpilot/applications/tsp/compile_milp.py +139 -0
- solverpilot/applications/tsp/distance.py +60 -0
- solverpilot/applications/tsp/errors.py +17 -0
- solverpilot/applications/tsp/heuristics.py +112 -0
- solverpilot/applications/tsp/model.py +175 -0
- solverpilot/applications/tsp/reference.py +121 -0
- solverpilot/applications/tsp/solution.py +65 -0
- solverpilot/applications/tsp/validation.py +92 -0
- solverpilot/backends/__init__.py +51 -0
- solverpilot/backends/_casadi_highs_worker.py +92 -0
- solverpilot/backends/base.py +28 -0
- solverpilot/backends/bundled_capi.py +910 -0
- solverpilot/backends/casadi_conic.py +303 -0
- solverpilot/backends/health.py +240 -0
- solverpilot/backends/highspy_native.py +217 -0
- solverpilot/backends/nlopt_native.py +237 -0
- solverpilot/backends/osqp_native.py +297 -0
- solverpilot/backends/protocol_v2.py +119 -0
- solverpilot/backends/pyscipopt_native.py +200 -0
- solverpilot/backends/registry.py +38 -0
- solverpilot/backends/scipy_highs.py +138 -0
- solverpilot/backends/scipy_highs_lp.py +195 -0
- solverpilot/backends/scipy_slsqp_qp.py +133 -0
- solverpilot/backends/scipy_vendored_highs_dev.py +363 -0
- solverpilot/benchmark/__init__.py +23 -0
- solverpilot/benchmark/acquire.py +199 -0
- solverpilot/benchmark/baselines.py +70 -0
- solverpilot/benchmark/bundle.py +37 -0
- solverpilot/benchmark/campaign.py +150 -0
- solverpilot/benchmark/environment.py +115 -0
- solverpilot/benchmark/integrity.py +46 -0
- solverpilot/benchmark/model.py +146 -0
- solverpilot/benchmark/policy.py +182 -0
- solverpilot/benchmark/registry.py +38 -0
- solverpilot/benchmark/report.py +44 -0
- solverpilot/benchmark/runner.py +516 -0
- solverpilot/benchmark/slurm.py +55 -0
- solverpilot/benchmark/specs.py +75 -0
- solverpilot/benchmark/splits.py +181 -0
- solverpilot/benchmark/summary.py +384 -0
- solverpilot/benchmark/verify.py +142 -0
- solverpilot/benchmark/worker.py +71 -0
- solverpilot/bridges/__init__.py +19 -0
- solverpilot/bridges/engine.py +44 -0
- solverpilot/bridges/indicator.py +232 -0
- solverpilot/bridges/registry.py +78 -0
- solverpilot/bridges/transformation-tape.schema.json +53 -0
- solverpilot/bridges/types.py +149 -0
- solverpilot/bridges/validation.py +110 -0
- solverpilot/capabilities/__init__.py +32 -0
- solverpilot/capabilities/backend-capability-v2.schema.json +21 -0
- solverpilot/capabilities/conformance.py +151 -0
- solverpilot/capabilities/enums.py +27 -0
- solverpilot/capabilities/manifest.py +19 -0
- solverpilot/capabilities/ontology.py +73 -0
- solverpilot/capabilities/requirements.py +37 -0
- solverpilot/capabilities/resolve.py +30 -0
- solverpilot/capabilities/v2.py +711 -0
- solverpilot/cli/__init__.py +1 -0
- solverpilot/cli/backend_health.py +77 -0
- solverpilot/cli/benchmark.py +331 -0
- solverpilot/cli/benchmark_worker.py +33 -0
- solverpilot/cli/capabilities.py +65 -0
- solverpilot/conic/__init__.py +11 -0
- solverpilot/conic/backend.py +263 -0
- solverpilot/conic/compiler.py +310 -0
- solverpilot/conic/conformance.py +60 -0
- solverpilot/conic/conic-ir-v1.schema.json +24 -0
- solverpilot/conic/ir.py +299 -0
- solverpilot/conic/validation.py +149 -0
- solverpilot/cp/__init__.py +7 -0
- solverpilot/cp/conformance.py +32 -0
- solverpilot/cp/cp-ir-v1.schema.json +13 -0
- solverpilot/cp/ir.py +170 -0
- solverpilot/cp/model.py +143 -0
- solverpilot/cp/ortools_backend.py +423 -0
- solverpilot/cp/ortools_worker.py +75 -0
- solverpilot/cp/reference.py +51 -0
- solverpilot/cp/serialization.py +117 -0
- solverpilot/cp/validate.py +83 -0
- solverpilot/diagnose/__init__.py +33 -0
- solverpilot/diagnose/infeasibility.py +463 -0
- solverpilot/diagnose/model.py +87 -0
- solverpilot/evaluation/__init__.py +53 -0
- solverpilot/evaluation/metrics.py +150 -0
- solverpilot/evaluation/oracle.py +288 -0
- solverpilot/evaluation/proof.py +53 -0
- solverpilot/evaluation/references.py +73 -0
- solverpilot/evaluation/regret.py +281 -0
- solverpilot/exceptions.py +34 -0
- solverpilot/experimental/__init__.py +32 -0
- solverpilot/extensions/__init__.py +56 -0
- solverpilot/extensions/entrypoints.py +34 -0
- solverpilot/extensions/errors.py +35 -0
- solverpilot/extensions/manager.py +651 -0
- solverpilot/extensions/manifest.py +232 -0
- solverpilot/extensions/model.py +101 -0
- solverpilot/extensions/versioning.py +136 -0
- solverpilot/history/__init__.py +22 -0
- solverpilot/history/errors.py +15 -0
- solverpilot/history/model.py +129 -0
- solverpilot/history/sqlite.py +651 -0
- solverpilot/inspect/__init__.py +3 -0
- solverpilot/inspect/fingerprint.py +199 -0
- solverpilot/intelligence/__init__.py +36 -0
- solverpilot/intelligence/errors.py +16 -0
- solverpilot/intelligence/fingerprint.py +108 -0
- solverpilot/intelligence/leakage.py +69 -0
- solverpilot/intelligence/schema.py +314 -0
- solverpilot/intelligence/shift.py +208 -0
- solverpilot/io/__init__.py +41 -0
- solverpilot/io/binary64.py +49 -0
- solverpilot/io/csv.py +230 -0
- solverpilot/io/errors.py +25 -0
- solverpilot/io/hashing.py +111 -0
- solverpilot/io/json.py +90 -0
- solverpilot/io/mapping.py +195 -0
- solverpilot/io/model.py +407 -0
- solverpilot/io/numeric.py +108 -0
- solverpilot/io/provenance.py +114 -0
- solverpilot/io/result.py +45 -0
- solverpilot/io/source.py +104 -0
- solverpilot/io/strict_json.py +133 -0
- solverpilot/minlp/__init__.py +6 -0
- solverpilot/minlp/compiler.py +66 -0
- solverpilot/minlp/conformance.py +34 -0
- solverpilot/minlp/convexity.py +85 -0
- solverpilot/minlp/ir.py +35 -0
- solverpilot/minlp/minlp-ir-v1.schema.json +1 -0
- solverpilot/minlp/orchestrator.py +220 -0
- solverpilot/minlp/validation.py +28 -0
- solverpilot/model/__init__.py +27 -0
- solverpilot/model/compiler.py +1263 -0
- solverpilot/model/errors.py +22 -0
- solverpilot/model/expression.py +373 -0
- solverpilot/model/model.py +716 -0
- solverpilot/model/sets.py +60 -0
- solverpilot/model/types.py +36 -0
- solverpilot/nlp/__init__.py +7 -0
- solverpilot/nlp/ad.py +166 -0
- solverpilot/nlp/backend.py +113 -0
- solverpilot/nlp/compiler.py +116 -0
- solverpilot/nlp/conformance.py +58 -0
- solverpilot/nlp/ir.py +70 -0
- solverpilot/nlp/nlp-ir-v1.schema.json +1 -0
- solverpilot/nlp/validation.py +60 -0
- solverpilot/plan/__init__.py +33 -0
- solverpilot/plan/model.py +79 -0
- solverpilot/plan/planner.py +201 -0
- solverpilot/plan/production.py +627 -0
- solverpilot/plan/selective_lp.py +65 -0
- solverpilot/problem/__init__.py +16 -0
- solverpilot/problem/enums.py +20 -0
- solverpilot/problem/hashing.py +88 -0
- solverpilot/problem/linear.py +196 -0
- solverpilot/problem/mps.py +447 -0
- solverpilot/problem/quadratic.py +192 -0
- solverpilot/reporting/__init__.py +27 -0
- solverpilot/reporting/explain.py +327 -0
- solverpilot/reporting/model.py +264 -0
- solverpilot/reporting/render.py +55 -0
- solverpilot/runtime/__init__.py +19 -0
- solverpilot/runtime/auto.py +193 -0
- solverpilot/runtime/budgeting.py +43 -0
- solverpilot/runtime/executor.py +126 -0
- solverpilot/runtime/portfolio.py +197 -0
- solverpilot/runtime/result.py +72 -0
- solverpilot/session/__init__.py +29 -0
- solverpilot/session/conformance_v2.py +239 -0
- solverpilot/session/mutations.py +115 -0
- solverpilot/session/persistent-session-trace.schema.json +45 -0
- solverpilot/session/persistent.py +491 -0
- solverpilot/session/reuse.py +81 -0
- solverpilot/session/session.py +236 -0
- solverpilot/trace/__init__.py +3 -0
- solverpilot/trace/schema.py +47 -0
- solverpilot/validate/__init__.py +10 -0
- solverpilot/validate/core.py +179 -0
- solverpilot/validate/result.py +55 -0
- solverpilot-0.1.dist-info/METADATA +554 -0
- solverpilot-0.1.dist-info/RECORD +201 -0
- solverpilot-0.1.dist-info/WHEEL +5 -0
- solverpilot-0.1.dist-info/entry_points.txt +4 -0
- solverpilot-0.1.dist-info/licenses/LICENSE +202 -0
- 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
|