equiframe 0.12.4__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.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Versioned, closed profile formulas and numeric-only preprocessing formats."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, model_validator
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from equiframe._base import (
|
|
12
|
+
FrozenModel,
|
|
13
|
+
Identifier,
|
|
14
|
+
NonNegativeInt,
|
|
15
|
+
PositiveInt,
|
|
16
|
+
Text,
|
|
17
|
+
_ExplicitOutcome,
|
|
18
|
+
unique,
|
|
19
|
+
)
|
|
20
|
+
from equiframe.contracts import ColumnSpec, Family
|
|
21
|
+
from equiframe.types import ArtifactRef, Sha256
|
|
22
|
+
|
|
23
|
+
ProfileMetric = Literal[
|
|
24
|
+
"log_filas",
|
|
25
|
+
"razon_salida_entrada",
|
|
26
|
+
"tasa_nulos",
|
|
27
|
+
"tasa_nan",
|
|
28
|
+
"prop_duplicados",
|
|
29
|
+
"cardinalidad_rel",
|
|
30
|
+
"expansion_union",
|
|
31
|
+
"claves_sin_pareja",
|
|
32
|
+
"conversiones_fallidas",
|
|
33
|
+
"fechas_invalidas",
|
|
34
|
+
]
|
|
35
|
+
FeatureState = Literal["available", "undefined", "not_applicable", "not_evaluable"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def profile_digest(value: object) -> str:
|
|
39
|
+
return hashlib.sha256(
|
|
40
|
+
json.dumps(
|
|
41
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
|
|
42
|
+
).encode("ascii")
|
|
43
|
+
).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProfileFeature(FrozenModel):
|
|
47
|
+
"""A semantic slot, never an arbitrary numeric value or metadata expression."""
|
|
48
|
+
|
|
49
|
+
name: Identifier
|
|
50
|
+
metric: ProfileMetric
|
|
51
|
+
table: Identifier
|
|
52
|
+
columns: tuple[Text, ...] = ()
|
|
53
|
+
denominator_table: Identifier | None = None
|
|
54
|
+
right_table: Identifier | None = None
|
|
55
|
+
right_columns: tuple[Text, ...] = ()
|
|
56
|
+
null_matches: Literal["never"] | None = None
|
|
57
|
+
target: ColumnSpec | None = None
|
|
58
|
+
|
|
59
|
+
@model_validator(mode="after")
|
|
60
|
+
def consistent(self) -> Self:
|
|
61
|
+
unique(self.columns, "columns")
|
|
62
|
+
unique(self.right_columns, "right_columns")
|
|
63
|
+
rows = self.metric in ("log_filas", "razon_salida_entrada", "expansion_union")
|
|
64
|
+
multiple = self.metric in ("prop_duplicados", "claves_sin_pareja")
|
|
65
|
+
if (rows and self.columns) or (not rows and not self.columns):
|
|
66
|
+
raise ValueError("formula has an invalid column selection")
|
|
67
|
+
if not rows and not multiple and len(self.columns) != 1:
|
|
68
|
+
raise ValueError("column rates require exactly one column")
|
|
69
|
+
ratio = self.metric in ("razon_salida_entrada", "expansion_union")
|
|
70
|
+
if ratio != (self.denominator_table is not None):
|
|
71
|
+
raise ValueError("only row ratios require a denominator table")
|
|
72
|
+
join = self.metric == "claves_sin_pareja"
|
|
73
|
+
if join:
|
|
74
|
+
if (
|
|
75
|
+
not self.right_table
|
|
76
|
+
or len(self.columns) != len(self.right_columns)
|
|
77
|
+
or self.null_matches is None
|
|
78
|
+
):
|
|
79
|
+
raise ValueError("join requires paired columns and an explicit NULL policy")
|
|
80
|
+
elif self.right_table or self.right_columns or self.null_matches is not None:
|
|
81
|
+
raise ValueError("right side and NULL matching are only valid for joins")
|
|
82
|
+
conversion = self.metric in ("conversiones_fallidas", "fechas_invalidas")
|
|
83
|
+
if conversion != (self.target is not None):
|
|
84
|
+
raise ValueError("conversion/date formulas require an explicit target column policy")
|
|
85
|
+
if (
|
|
86
|
+
self.metric == "fechas_invalidas"
|
|
87
|
+
and self.target is not None
|
|
88
|
+
and self.target.logical_type.kind not in ("date", "timestamp")
|
|
89
|
+
):
|
|
90
|
+
raise ValueError("date validity requires a temporal target")
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ProfileSpec(FrozenModel):
|
|
95
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
96
|
+
feature_version: Literal["1.0"] = "1.0"
|
|
97
|
+
family: Family
|
|
98
|
+
features: Annotated[tuple[ProfileFeature, ...], Field(min_length=1)]
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="after")
|
|
101
|
+
def consistent(self) -> Self:
|
|
102
|
+
unique(tuple(f.name for f in self.features), "features")
|
|
103
|
+
unique(
|
|
104
|
+
tuple(
|
|
105
|
+
name
|
|
106
|
+
for f in self.features
|
|
107
|
+
for name in (f.name, f.name + ".undefined", f.name + ".not_applicable")
|
|
108
|
+
),
|
|
109
|
+
"matrix_columns",
|
|
110
|
+
)
|
|
111
|
+
formulas: set[str] = set()
|
|
112
|
+
for feature in self.features:
|
|
113
|
+
formula = feature.model_dump(mode="json", exclude={"name"})
|
|
114
|
+
if feature.metric == "expansion_union":
|
|
115
|
+
formula["metric"] = "razon_salida_entrada"
|
|
116
|
+
fingerprint = profile_digest(formula)
|
|
117
|
+
if fingerprint in formulas:
|
|
118
|
+
raise ValueError("duplicate formula (including expansion/row ratio alias)")
|
|
119
|
+
formulas.add(fingerprint)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def profile_spec_fingerprint(spec: ProfileSpec) -> str:
|
|
124
|
+
return profile_digest(ProfileSpec.model_validate(spec).model_dump(mode="json"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ProfileOptions(FrozenModel):
|
|
128
|
+
max_rows: PositiveInt = 1_000_000
|
|
129
|
+
max_artifact_bytes: PositiveInt = 128 * 1024 * 1024
|
|
130
|
+
max_work: PositiveInt = 10_000_000
|
|
131
|
+
max_distinct: PositiveInt = 1_000_000
|
|
132
|
+
timeout_seconds: Annotated[float, Field(gt=0)] | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ConversionReceipt(FrozenModel):
|
|
136
|
+
"""Instrumented attempts from the same transformation; NULL output is not a receipt."""
|
|
137
|
+
|
|
138
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
139
|
+
feature: Identifier
|
|
140
|
+
source_sha256: Sha256
|
|
141
|
+
source_column: Text
|
|
142
|
+
output_sha256: Sha256
|
|
143
|
+
target: ColumnSpec
|
|
144
|
+
attempted_rows: tuple[NonNegativeInt, ...]
|
|
145
|
+
failed_rows: tuple[NonNegativeInt, ...]
|
|
146
|
+
producer_version: Text
|
|
147
|
+
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def consistent(self) -> Self:
|
|
150
|
+
if len(set(self.attempted_rows)) != len(self.attempted_rows):
|
|
151
|
+
raise ValueError("conversion attempts must identify unique source rows")
|
|
152
|
+
if len(set(self.failed_rows)) != len(self.failed_rows):
|
|
153
|
+
raise ValueError("conversion failures must identify unique source rows")
|
|
154
|
+
if not set(self.failed_rows) <= set(self.attempted_rows):
|
|
155
|
+
raise ValueError("failed rows must be attempted rows")
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FeatureSupport(FrozenModel):
|
|
160
|
+
rows: NonNegativeInt = 0
|
|
161
|
+
missing: NonNegativeInt = 0
|
|
162
|
+
nan: NonNegativeInt = 0
|
|
163
|
+
empty_strings: NonNegativeInt = 0
|
|
164
|
+
zeros: NonNegativeInt = 0
|
|
165
|
+
incomplete_keys: NonNegativeInt = 0
|
|
166
|
+
|
|
167
|
+
@model_validator(mode="after")
|
|
168
|
+
def consistent(self) -> Self:
|
|
169
|
+
if self.missing + self.nan + self.empty_strings + self.zeros > self.rows:
|
|
170
|
+
raise ValueError("disjoint scalar support counts exceed rows")
|
|
171
|
+
if self.incomplete_keys > self.rows:
|
|
172
|
+
raise ValueError("incomplete keys exceed rows")
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class FeatureObservation(FrozenModel):
|
|
177
|
+
name: Identifier
|
|
178
|
+
state: FeatureState
|
|
179
|
+
value: Annotated[float, Field(ge=0)] | None = None
|
|
180
|
+
numerator: NonNegativeInt | None = None
|
|
181
|
+
denominator: NonNegativeInt | None = None
|
|
182
|
+
reason: Text | None = None
|
|
183
|
+
support: FeatureSupport = FeatureSupport()
|
|
184
|
+
|
|
185
|
+
@model_validator(mode="after")
|
|
186
|
+
def consistent(self) -> Self:
|
|
187
|
+
if self.state == "available":
|
|
188
|
+
if self.value is None or self.reason is not None or self.numerator is None:
|
|
189
|
+
raise ValueError("available measurement requires value and numerator, no reason")
|
|
190
|
+
if self.denominator is not None and (
|
|
191
|
+
self.denominator == 0 or self.value != self.numerator / self.denominator
|
|
192
|
+
):
|
|
193
|
+
raise ValueError("value must equal its documented ratio")
|
|
194
|
+
elif self.value is not None or self.reason is None:
|
|
195
|
+
raise ValueError("unavailable measurement requires reason and no value")
|
|
196
|
+
if self.state == "undefined" and (
|
|
197
|
+
self.denominator != 0 or self.numerator is None or self.reason != "empty_denominator"
|
|
198
|
+
):
|
|
199
|
+
raise ValueError("undefined formula must preserve its empty denominator")
|
|
200
|
+
if self.state in ("not_applicable", "not_evaluable") and (
|
|
201
|
+
self.numerator is not None or self.denominator is not None
|
|
202
|
+
):
|
|
203
|
+
raise ValueError("no partial counts for unavailable formulas")
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ProfileVector(FrozenModel):
|
|
208
|
+
"""Statistical input: formulas and measurements, no run metadata or labels."""
|
|
209
|
+
|
|
210
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
211
|
+
spec: ProfileSpec
|
|
212
|
+
spec_sha256: Sha256
|
|
213
|
+
observations: tuple[FeatureObservation, ...]
|
|
214
|
+
|
|
215
|
+
@model_validator(mode="after")
|
|
216
|
+
def consistent(self) -> Self:
|
|
217
|
+
if self.spec_sha256 != profile_spec_fingerprint(self.spec):
|
|
218
|
+
raise ValueError("profile feature fingerprint mismatch")
|
|
219
|
+
if tuple(o.name for o in self.observations) != tuple(f.name for f in self.spec.features):
|
|
220
|
+
raise ValueError("profile observations must match the fixed feature order")
|
|
221
|
+
for feature, observation in zip(self.spec.features, self.observations, strict=True):
|
|
222
|
+
if observation.state == "available":
|
|
223
|
+
if feature.metric == "log_filas":
|
|
224
|
+
if observation.denominator is not None or observation.value != math.log1p(
|
|
225
|
+
observation.numerator or 0
|
|
226
|
+
):
|
|
227
|
+
raise ValueError("invalid log_filas measurement")
|
|
228
|
+
elif observation.denominator is None:
|
|
229
|
+
raise ValueError("rate must retain its denominator")
|
|
230
|
+
elif (
|
|
231
|
+
feature.metric not in ("razon_salida_entrada", "expansion_union")
|
|
232
|
+
and (observation.numerator or 0) > observation.denominator
|
|
233
|
+
):
|
|
234
|
+
raise ValueError("proportion cannot exceed one")
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class ProfileMetadata(FrozenModel):
|
|
239
|
+
profile_id: Identifier
|
|
240
|
+
run_id: Identifier
|
|
241
|
+
execution_id: Identifier
|
|
242
|
+
contract_sha256: Sha256
|
|
243
|
+
execution_sha256: Sha256
|
|
244
|
+
artifacts: tuple[ArtifactRef, ...]
|
|
245
|
+
conversion_receipts: tuple[ConversionReceipt, ...] = ()
|
|
246
|
+
implementation_version: Literal["equiframe-profiles-1.0"] = "equiframe-profiles-1.0"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class ProfileResult(_ExplicitOutcome, FrozenModel):
|
|
250
|
+
_truth_value_guidance = (
|
|
251
|
+
"inspect .complete and feature availability explicitly; a profile is not a decision"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
255
|
+
metadata: ProfileMetadata
|
|
256
|
+
vector: ProfileVector
|
|
257
|
+
options: ProfileOptions
|
|
258
|
+
complete: bool
|
|
259
|
+
elapsed_seconds: Annotated[float, Field(ge=0)]
|
|
260
|
+
work_units: NonNegativeInt
|
|
261
|
+
|
|
262
|
+
@model_validator(mode="after")
|
|
263
|
+
def consistent(self) -> Self:
|
|
264
|
+
if self.complete != all(o.state != "not_evaluable" for o in self.vector.observations):
|
|
265
|
+
raise ValueError("profile completeness contradicts observations")
|
|
266
|
+
return self
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class NormalTrainingProfiles(FrozenModel):
|
|
270
|
+
"""Caller selection boundary; the external manifest proves eligibility, not this type."""
|
|
271
|
+
|
|
272
|
+
purpose: Literal["normal_training"]
|
|
273
|
+
selection_manifest_sha256: Sha256
|
|
274
|
+
profiles: tuple[ProfileVector, ...]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class FeatureFit(FrozenModel):
|
|
278
|
+
name: Identifier
|
|
279
|
+
valid_count: NonNegativeInt
|
|
280
|
+
median: Annotated[float, Field(ge=0)] | None
|
|
281
|
+
reason: Literal["no_valid_training_values", "not_applicable"] | None = None
|
|
282
|
+
|
|
283
|
+
@model_validator(mode="after")
|
|
284
|
+
def consistent(self) -> Self:
|
|
285
|
+
if self.valid_count == 0:
|
|
286
|
+
if self.median is not None or self.reason is None:
|
|
287
|
+
raise ValueError("unsupported feature needs a documented removal")
|
|
288
|
+
elif self.median is None or self.reason is not None:
|
|
289
|
+
raise ValueError("supported feature requires its fitted median")
|
|
290
|
+
return self
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class PreprocessorBundle(FrozenModel):
|
|
294
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
295
|
+
algorithm: Literal["family-median-with-indicators-1.0"] = "family-median-with-indicators-1.0"
|
|
296
|
+
spec: ProfileSpec
|
|
297
|
+
spec_sha256: Sha256
|
|
298
|
+
selection_manifest_sha256: Sha256
|
|
299
|
+
training_sha256: Sha256
|
|
300
|
+
training_rows: NonNegativeInt
|
|
301
|
+
empty_policy: Literal["drop", "not_estimable"]
|
|
302
|
+
features: tuple[FeatureFit, ...]
|
|
303
|
+
state: Literal["ready", "not_estimable"]
|
|
304
|
+
|
|
305
|
+
@model_validator(mode="after")
|
|
306
|
+
def consistent(self) -> Self:
|
|
307
|
+
if self.spec_sha256 != profile_spec_fingerprint(self.spec):
|
|
308
|
+
raise ValueError("bundle feature fingerprint mismatch")
|
|
309
|
+
if tuple(f.name for f in self.features) != tuple(f.name for f in self.spec.features):
|
|
310
|
+
raise ValueError("bundle order mismatch")
|
|
311
|
+
if any(f.valid_count > self.training_rows for f in self.features):
|
|
312
|
+
raise ValueError("feature support exceeds training rows")
|
|
313
|
+
usable = any(f.median is not None for f in self.features)
|
|
314
|
+
unsupported = any(f.reason == "no_valid_training_values" for f in self.features)
|
|
315
|
+
ready = usable and not (unsupported and self.empty_policy == "not_estimable")
|
|
316
|
+
if (self.state == "ready") != ready:
|
|
317
|
+
raise ValueError("bundle state contradicts training support")
|
|
318
|
+
return self
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def columns(self) -> tuple[str, ...]:
|
|
322
|
+
return tuple(
|
|
323
|
+
name
|
|
324
|
+
for f in self.features
|
|
325
|
+
if f.median is not None
|
|
326
|
+
for name in (f.name, f.name + ".undefined", f.name + ".not_applicable")
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class FeatureMatrix(FrozenModel):
|
|
331
|
+
"""The sole matrix for both M and B. Row metadata is deliberately absent."""
|
|
332
|
+
|
|
333
|
+
schema_version: Literal["1.0"] = "1.0"
|
|
334
|
+
preprocessor_sha256: Sha256
|
|
335
|
+
columns: tuple[Text, ...]
|
|
336
|
+
values: tuple[tuple[float, ...], ...]
|
|
337
|
+
|
|
338
|
+
@model_validator(mode="after")
|
|
339
|
+
def consistent(self) -> Self:
|
|
340
|
+
unique(self.columns, "columns")
|
|
341
|
+
if not self.columns or any(len(row) != len(self.columns) for row in self.values):
|
|
342
|
+
raise ValueError("matrix requires a nonempty schema and equal-width numeric rows")
|
|
343
|
+
return self
|