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.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
@@ -0,0 +1,247 @@
1
+ """Versioned orchestration contracts; technical completion is distinct from alerts."""
2
+
3
+ from pathlib import PurePosixPath
4
+ from typing import Annotated, Literal
5
+
6
+ from pydantic import Field, model_validator
7
+ from typing_extensions import Self
8
+
9
+ from equiframe._base import FrozenModel, Identifier, NonNegativeInt, Text, _ExplicitOutcome
10
+ from equiframe.comparison_types import ComparisonOptions, ComparisonPolicy, ComparisonResult
11
+ from equiframe.decision import combine_decisions
12
+ from equiframe.detector_types import ComponentDecision, DetectionResult, HybridDecision
13
+ from equiframe.execution_types import ExecutionPlan, ExecutionResult
14
+ from equiframe.profile_types import ProfileOptions, ProfileResult, ProfileSpec, profile_digest
15
+ from equiframe.rule_types import RuleEvaluation, RuleOptions
16
+ from equiframe.types import ArtifactRef, Sha256
17
+
18
+ StageName = Literal["execution", "rules", "profile", "detection", "comparison", "hybrid"]
19
+ StageState = Literal["completed", "not_evaluable", "failed", "skipped"]
20
+ STAGES: tuple[StageName, ...] = (
21
+ "execution",
22
+ "rules",
23
+ "profile",
24
+ "detection",
25
+ "comparison",
26
+ "hybrid",
27
+ )
28
+
29
+
30
+ class PipelineSpec(FrozenModel):
31
+ schema_version: Literal["1.0"] = "1.0"
32
+ execution: ExecutionPlan
33
+ profile: ProfileSpec | None = None
34
+ rule_options: RuleOptions = RuleOptions()
35
+ profile_options: ProfileOptions = ProfileOptions()
36
+ comparison_policy: ComparisonPolicy = ComparisonPolicy()
37
+ comparison_options: ComparisonOptions = ComparisonOptions()
38
+ comparison_reference: Literal["peer", "expected"] = "peer"
39
+
40
+ @model_validator(mode="after")
41
+ def consistent(self) -> Self:
42
+ if self.profile and self.profile.family != self.execution.contract.family:
43
+ raise ValueError("profile and execution families must agree")
44
+ # Authentication belongs in the injected provider, never in persisted ETL parameters.
45
+ forbidden = {"password", "passwd", "secret", "token", "authorization", "credentials"}
46
+ for parameter in self.execution.parameters:
47
+ name = parameter.name.lower().replace("-", "_").replace(".", "_")
48
+ if forbidden.intersection(name.split("_")) or name in {"api_key", "access_key"}:
49
+ raise ValueError("credential parameters must be supplied outside the pipeline plan")
50
+ return self
51
+
52
+
53
+ class PipelineInput(FrozenModel):
54
+ name: Identifier
55
+ artifact: ArtifactRef
56
+
57
+
58
+ class PipelineRequest(FrozenModel):
59
+ """CLI data/configuration only. Executable registrations are supplied separately."""
60
+
61
+ schema_version: Literal["1.0"] = "1.0"
62
+ spec: PipelineSpec
63
+ inputs: tuple[PipelineInput, ...]
64
+ comparison: ArtifactRef | None = None
65
+
66
+ @model_validator(mode="after")
67
+ def consistent(self) -> Self:
68
+ names = tuple(item.name for item in self.inputs)
69
+ if len(set(names)) != len(names) or set(names) != {
70
+ item.name for item in self.spec.execution.contract.inputs
71
+ }:
72
+ raise ValueError("input bindings must cover exactly the declared tables")
73
+ if any(item.artifact.format not in ("arrow", "parquet") for item in self.inputs):
74
+ raise ValueError("pipeline inputs require Arrow or Parquet")
75
+ if self.comparison and self.comparison.format not in ("arrow", "parquet"):
76
+ raise ValueError("comparison requires Arrow or Parquet")
77
+ return self
78
+
79
+
80
+ class StageRecord(FrozenModel):
81
+ stage: StageName
82
+ state: StageState
83
+ elapsed_seconds: Annotated[float, Field(ge=0)]
84
+ code: Identifier
85
+
86
+
87
+ class EvidenceFile(FrozenModel):
88
+ path: Text
89
+ sha256: Sha256
90
+ size_bytes: NonNegativeInt
91
+
92
+ @model_validator(mode="after")
93
+ def local_relative(self) -> Self:
94
+ path = PurePosixPath(self.path)
95
+ if (
96
+ path.is_absolute()
97
+ or ".." in path.parts
98
+ or ":" in self.path
99
+ or "\\" in self.path
100
+ or path.as_posix() != self.path
101
+ or self.path == "."
102
+ ):
103
+ raise ValueError("evidence path must be a normalized relative file path")
104
+ return self
105
+
106
+
107
+ class PipelineResult(_ExplicitOutcome, FrozenModel):
108
+ _truth_value_guidance = (
109
+ "inspect .complete, .exit_code and the requested component decisions explicitly"
110
+ )
111
+
112
+ schema_version: Literal["1.0"] = "1.0"
113
+ pipeline_id: Identifier
114
+ spec: PipelineSpec
115
+ spec_sha256: Sha256
116
+ started_at: Text
117
+ finished_at: Text
118
+ elapsed_seconds: Annotated[float, Field(ge=0)]
119
+ detection_requested: bool
120
+ comparison_requested: bool
121
+ cancelled: bool
122
+ stages: tuple[StageRecord, ...]
123
+ execution: ExecutionResult | None
124
+ rules: RuleEvaluation | None
125
+ profile: ProfileResult | None
126
+ detection: DetectionResult | None
127
+ comparison: ComparisonResult | None
128
+ hybrid: HybridDecision
129
+ evidence: tuple[EvidenceFile, ...]
130
+ event_count: Annotated[int, Field(ge=2)]
131
+ event_head_sha256: Sha256
132
+
133
+ @model_validator(mode="after")
134
+ def consistent(self) -> Self:
135
+ if self.spec_sha256 != profile_digest(self.spec.model_dump(mode="json")):
136
+ raise ValueError("pipeline specification fingerprint mismatch")
137
+ if tuple(stage.stage for stage in self.stages) != STAGES:
138
+ raise ValueError("pipeline must record each stage exactly once in order")
139
+ if len({item.path for item in self.evidence}) != len(self.evidence):
140
+ raise ValueError("duplicate evidence paths")
141
+ if self.execution is not None and self.execution.plan != self.spec.execution:
142
+ raise ValueError("execution does not belong to this pipeline plan")
143
+ if self.rules is not None:
144
+ if self.execution is None or (
145
+ self.rules.execution_id != self.execution.execution_id
146
+ or self.rules.contract_sha256 != self.execution.contract_sha256
147
+ or self.rules.run_id != self.execution.run_id
148
+ or self.rules.options != self.spec.rule_options
149
+ ):
150
+ raise ValueError("rules do not belong to this execution")
151
+ if self.hybrid.r != combine_decisions(self.rules, self.hybrid.m).r:
152
+ raise ValueError("hybrid must preserve the rule decision")
153
+ elif self.hybrid.r.decision != "not_evaluable":
154
+ raise ValueError("missing rules cannot produce a rule decision")
155
+ if self.profile is not None and (
156
+ self.execution is None
157
+ or self.profile.vector.spec != self.spec.profile
158
+ or self.profile.metadata.execution_id != self.execution.execution_id
159
+ or self.profile.metadata.run_id != self.execution.run_id
160
+ or self.profile.metadata.contract_sha256 != self.execution.contract_sha256
161
+ or self.profile.metadata.artifacts
162
+ != tuple(
163
+ reference
164
+ for table in (
165
+ *self.execution.inputs,
166
+ *((self.execution.output,) if self.execution.output else ()),
167
+ )
168
+ for reference in (table.data, *((table.index,) if table.index else ()))
169
+ )
170
+ or self.profile.metadata.execution_sha256
171
+ != profile_digest(self.execution.model_dump(mode="json"))
172
+ or self.profile.options != self.spec.profile_options
173
+ ):
174
+ raise ValueError("profile does not belong to the declared execution/features")
175
+ if self.detection is not None:
176
+ if not self.detection_requested or self.profile is None or not self.profile.complete:
177
+ raise ValueError("detection requires a requested, complete profile")
178
+ if len(self.detection.m) != 1 or self.hybrid.m != self.detection.m[0].result:
179
+ raise ValueError("detection and hybrid must describe one identical profile")
180
+ elif self.hybrid.m.decision != "not_evaluable":
181
+ raise ValueError("missing detector cannot produce an M decision")
182
+ if self.comparison is not None and not self.comparison_requested:
183
+ raise ValueError("unexpected comparison")
184
+ if self.comparison is not None and (
185
+ self.comparison.spec.contract != self.spec.execution.contract
186
+ or self.comparison.spec.policy != self.spec.comparison_policy
187
+ or self.comparison.options != self.spec.comparison_options
188
+ or self.comparison.reference != self.spec.comparison_reference
189
+ or (
190
+ self.comparison.left is not None
191
+ and self.comparison.left.execution != self.execution
192
+ )
193
+ ):
194
+ raise ValueError("comparison does not belong to the configured execution/policy")
195
+ values = (self.execution, self.rules, self.profile, self.detection, self.comparison)
196
+ for stage, value in zip(self.stages[:5], values, strict=True):
197
+ if stage.state == "completed" and value is None:
198
+ raise ValueError("completed stage requires its result")
199
+ if stage.state in ("failed", "skipped") and value is not None:
200
+ raise ValueError("failed/skipped stage cannot claim a result")
201
+ if self.execution is not None and (self.stages[0].state == "completed") != (
202
+ self.execution.state == "completed"
203
+ ):
204
+ raise ValueError("execution stage contradicts its result")
205
+ for stage, value in ((self.stages[1], self.rules), (self.stages[2], self.profile)):
206
+ if value is not None and (stage.state == "completed") != value.complete:
207
+ raise ValueError("stage completeness contradicts its result")
208
+ if self.detection is not None and (self.stages[3].state == "completed") != all(
209
+ d.result.complete for d in (*self.detection.m, *self.detection.b)
210
+ ):
211
+ raise ValueError("detector stage completeness contradicts its result")
212
+ if (
213
+ self.comparison is not None
214
+ and (self.stages[4].state == "completed") != self.comparison.complete
215
+ ):
216
+ raise ValueError("comparison stage completeness contradicts its result")
217
+ if (self.stages[5].state == "completed") != self.hybrid.result.complete:
218
+ raise ValueError("hybrid stage completeness contradicts its result")
219
+ return self
220
+
221
+ @property
222
+ def complete(self) -> bool:
223
+ required: set[StageName] = {"execution", "rules"}
224
+ if self.spec.profile:
225
+ required.add("profile")
226
+ if self.detection_requested:
227
+ required.update(("detection", "hybrid"))
228
+ if self.comparison_requested:
229
+ required.add("comparison")
230
+ return not self.cancelled and all(
231
+ stage.state == "completed" for stage in self.stages if stage.stage in required
232
+ )
233
+
234
+ @property
235
+ def exit_code(self) -> int:
236
+ if self.execution is not None and self.execution.state == "invalid_input":
237
+ return 2
238
+ if not self.complete:
239
+ return 3
240
+ alert = self.hybrid.r.decision == "alert" or self.hybrid.result.decision == "alert"
241
+ if self.detection is not None:
242
+ alert |= any(d.result.decision == "alert" for d in self.detection.b)
243
+ return int(alert or (self.comparison is not None and self.comparison.state == "different"))
244
+
245
+
246
+ def unavailable(reason: str) -> ComponentDecision:
247
+ return ComponentDecision(decision="not_evaluable", complete=False, reason=reason)
equiframe/ports.py ADDED
@@ -0,0 +1,39 @@
1
+ """Complete structural integration port; implementation belongs to later steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+ from equiframe.types import ArtifactRef
10
+
11
+ if TYPE_CHECKING:
12
+ from equiframe.execution import RunContext
13
+ from equiframe.execution_types import EngineCapabilities
14
+ from equiframe.registry import Transformation
15
+ from equiframe.tables import LoadedTable
16
+
17
+
18
+ class ArtifactStore(Protocol):
19
+ def put(self, path: Path, *, media_type: str) -> ArtifactRef: ...
20
+
21
+ def get(self, ref: ArtifactRef, destination: Path) -> Path: ...
22
+
23
+
24
+ class DataSource(Protocol):
25
+ @property
26
+ def table_names(self) -> tuple[str, ...]: ...
27
+
28
+ def resolve(self, name: str, *, context: RunContext) -> LoadedTable: ...
29
+
30
+
31
+ class EngineAdapter(Protocol):
32
+ @property
33
+ def engine(self) -> str: ...
34
+
35
+ def capabilities(self) -> EngineCapabilities: ...
36
+
37
+ def execute(
38
+ self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
39
+ ) -> LoadedTable: ...
@@ -0,0 +1,110 @@
1
+ """Fit once on explicitly selected normal training profiles; one matrix for M and B."""
2
+
3
+ from pathlib import Path
4
+ from statistics import median
5
+ from typing import Literal
6
+
7
+ from equiframe._document_io import read_document as _read_document
8
+ from equiframe._document_io import write_document as _write_document
9
+ from equiframe.profile_types import (
10
+ FeatureFit,
11
+ FeatureMatrix,
12
+ NormalTrainingProfiles,
13
+ PreprocessorBundle,
14
+ ProfileSpec,
15
+ ProfileVector,
16
+ profile_digest,
17
+ profile_spec_fingerprint,
18
+ )
19
+
20
+
21
+ def _compatible(profile: ProfileVector, spec: ProfileSpec) -> ProfileVector:
22
+ profile = ProfileVector.model_validate(profile)
23
+ if profile.spec_sha256 != profile_spec_fingerprint(spec):
24
+ raise ValueError("incompatible family, feature version, definitions or order")
25
+ if any(o.state == "not_evaluable" for o in profile.observations):
26
+ raise ValueError("failed measurements cannot be imputed as undefined values")
27
+ return profile
28
+
29
+
30
+ def fit_preprocessor(
31
+ training: NormalTrainingProfiles,
32
+ *,
33
+ spec: ProfileSpec,
34
+ empty_policy: Literal["drop", "not_estimable"] = "drop",
35
+ ) -> PreprocessorBundle:
36
+ """The caller must supply normal TRAINING selection, backed by its external manifest."""
37
+ training = NormalTrainingProfiles.model_validate(training)
38
+ spec = ProfileSpec.model_validate(spec)
39
+ profiles = tuple(_compatible(p, spec) for p in training.profiles)
40
+ fits: list[FeatureFit] = []
41
+ for index, feature in enumerate(spec.features):
42
+ observations = [p.observations[index] for p in profiles]
43
+ valid = [o.value for o in observations if o.value is not None]
44
+ reason: Literal["no_valid_training_values", "not_applicable"] | None = None
45
+ if not valid:
46
+ reason = (
47
+ "not_applicable"
48
+ if observations and all(o.state == "not_applicable" for o in observations)
49
+ else "no_valid_training_values"
50
+ )
51
+ fits.append(
52
+ FeatureFit(
53
+ name=feature.name,
54
+ valid_count=len(valid),
55
+ median=float(median(valid)) if valid else None,
56
+ reason=reason,
57
+ )
58
+ )
59
+ ready = any(f.median is not None for f in fits) and not (
60
+ empty_policy == "not_estimable"
61
+ and any(f.reason == "no_valid_training_values" for f in fits)
62
+ )
63
+ return PreprocessorBundle(
64
+ spec=spec,
65
+ spec_sha256=profile_spec_fingerprint(spec),
66
+ selection_manifest_sha256=training.selection_manifest_sha256,
67
+ training_sha256=profile_digest([p.model_dump(mode="json") for p in profiles]),
68
+ training_rows=len(profiles),
69
+ empty_policy=empty_policy,
70
+ features=tuple(fits),
71
+ state="ready" if ready else "not_estimable",
72
+ )
73
+
74
+
75
+ def transform_profiles(
76
+ bundle: PreprocessorBundle, profiles: tuple[ProfileVector, ...]
77
+ ) -> FeatureMatrix:
78
+ """No fit, labels, partitions or engine metadata enter this numeric matrix."""
79
+ bundle = PreprocessorBundle.model_validate(bundle)
80
+ if bundle.state != "ready":
81
+ raise ValueError("preprocessor is not estimable")
82
+ rows: list[tuple[float, ...]] = []
83
+ for profile in profiles:
84
+ profile = _compatible(profile, bundle.spec)
85
+ row: list[float] = []
86
+ for fitted, observed in zip(bundle.features, profile.observations, strict=True):
87
+ if fitted.median is None:
88
+ continue
89
+ row.extend(
90
+ (
91
+ fitted.median if observed.value is None else observed.value,
92
+ float(observed.state == "undefined"),
93
+ float(observed.state == "not_applicable"),
94
+ )
95
+ )
96
+ rows.append(tuple(row))
97
+ return FeatureMatrix(
98
+ preprocessor_sha256=profile_digest(bundle.model_dump(mode="json")),
99
+ columns=bundle.columns,
100
+ values=tuple(rows),
101
+ )
102
+
103
+
104
+ def write_preprocessor(bundle: PreprocessorBundle, path: Path) -> None:
105
+ bundle = PreprocessorBundle.model_validate(bundle)
106
+ _write_document(path, bundle.model_dump_json().encode("utf-8"))
107
+
108
+
109
+ def read_preprocessor(path: Path) -> PreprocessorBundle:
110
+ return PreprocessorBundle.model_validate_json(_read_document(path))