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
equiframe/profiles.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Profiles of one captured execution, independent of rules, labels and other engines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from equiframe._document_io import read_document as _read_document
|
|
14
|
+
from equiframe._document_io import write_document as _write_document
|
|
15
|
+
from equiframe._regular_io import local_output_directory
|
|
16
|
+
from equiframe.comparison_types import ComparisonValue
|
|
17
|
+
from equiframe.contracts import ColumnSpec, Family, TimestampPolicy
|
|
18
|
+
from equiframe.errors import (
|
|
19
|
+
ArtifactIntegrityError,
|
|
20
|
+
CapabilityError,
|
|
21
|
+
ExecutionCancelled,
|
|
22
|
+
ExecutionTimeout,
|
|
23
|
+
ResourceLimitError,
|
|
24
|
+
)
|
|
25
|
+
from equiframe.execution import CancellationToken
|
|
26
|
+
from equiframe.execution_types import ExecutionResult
|
|
27
|
+
from equiframe.local import local_path
|
|
28
|
+
from equiframe.normalization import identity, normalize, number, physical_value
|
|
29
|
+
from equiframe.profile_types import (
|
|
30
|
+
ConversionReceipt,
|
|
31
|
+
FeatureObservation,
|
|
32
|
+
FeatureSupport,
|
|
33
|
+
ProfileFeature,
|
|
34
|
+
ProfileMetadata,
|
|
35
|
+
ProfileMetric,
|
|
36
|
+
ProfileOptions,
|
|
37
|
+
ProfileResult,
|
|
38
|
+
ProfileSpec,
|
|
39
|
+
ProfileVector,
|
|
40
|
+
profile_digest,
|
|
41
|
+
profile_spec_fingerprint,
|
|
42
|
+
)
|
|
43
|
+
from equiframe.tables import read_table
|
|
44
|
+
from equiframe.values import DateValue, TimestampValue
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
import pyarrow as pa
|
|
48
|
+
|
|
49
|
+
_FAMILIES: dict[ProfileMetric, tuple[Family, ...]] = {
|
|
50
|
+
"log_filas": ("nulls_types", "joins", "duplicates_order", "aggregations", "dates"),
|
|
51
|
+
"razon_salida_entrada": ("joins", "duplicates_order", "aggregations"),
|
|
52
|
+
"tasa_nulos": ("nulls_types", "joins", "aggregations"),
|
|
53
|
+
"tasa_nan": ("nulls_types",),
|
|
54
|
+
"prop_duplicados": ("duplicates_order", "joins"),
|
|
55
|
+
"cardinalidad_rel": ("joins", "duplicates_order", "aggregations"),
|
|
56
|
+
"expansion_union": ("joins",),
|
|
57
|
+
"claves_sin_pareja": ("joins",),
|
|
58
|
+
"conversiones_fallidas": ("nulls_types", "dates"),
|
|
59
|
+
"fechas_invalidas": ("dates",),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _ProfileContext:
|
|
64
|
+
def __init__(self, options: ProfileOptions, cancellation: CancellationToken) -> None:
|
|
65
|
+
self.options, self.cancellation = options, cancellation
|
|
66
|
+
self.started = time.perf_counter()
|
|
67
|
+
self.work = 0
|
|
68
|
+
self.tables: dict[str, pa.Table] = {}
|
|
69
|
+
self.conversions: dict[str, ConversionReceipt] = {}
|
|
70
|
+
self.hashes: dict[str, str] = {}
|
|
71
|
+
self.output_sha256 = ""
|
|
72
|
+
|
|
73
|
+
def checkpoint(self, cost: int = 1) -> None:
|
|
74
|
+
self.work += cost
|
|
75
|
+
if self.cancellation.cancelled:
|
|
76
|
+
raise ExecutionCancelled("profile cancelled")
|
|
77
|
+
if self.work > self.options.max_work:
|
|
78
|
+
raise ResourceLimitError("profile max_work exhausted")
|
|
79
|
+
limit = self.options.timeout_seconds
|
|
80
|
+
if limit is not None and time.perf_counter() - self.started >= limit:
|
|
81
|
+
raise ExecutionTimeout("cooperative profile timeout")
|
|
82
|
+
|
|
83
|
+
def load(self, execution: ExecutionResult) -> None:
|
|
84
|
+
self.checkpoint()
|
|
85
|
+
if execution.state != "completed" or execution.output is None:
|
|
86
|
+
raise CapabilityError("profile requires a completed captured execution")
|
|
87
|
+
self.output_sha256 = execution.output.data.sha256
|
|
88
|
+
total_bytes = actual_bytes = decoded_bytes = rows = 0
|
|
89
|
+
for artifact in (*execution.inputs, execution.output):
|
|
90
|
+
self.checkpoint()
|
|
91
|
+
refs = (artifact.data,) + ((artifact.index,) if artifact.index else ())
|
|
92
|
+
for ref in refs:
|
|
93
|
+
total_bytes += ref.size_bytes
|
|
94
|
+
actual_bytes += local_path(ref).stat().st_size
|
|
95
|
+
if max(total_bytes, actual_bytes) > self.options.max_artifact_bytes:
|
|
96
|
+
raise ResourceLimitError("profile max_artifact_bytes exhausted")
|
|
97
|
+
table = read_table(ref)
|
|
98
|
+
decoded_bytes += table.nbytes
|
|
99
|
+
rows += table.num_rows
|
|
100
|
+
if decoded_bytes > self.options.max_artifact_bytes:
|
|
101
|
+
raise ResourceLimitError("profile decoded bytes exceeded")
|
|
102
|
+
if rows > self.options.max_rows:
|
|
103
|
+
raise ResourceLimitError("profile max_rows exhausted")
|
|
104
|
+
columns = artifact.columns if ref == artifact.data else artifact.index_columns
|
|
105
|
+
if table.num_rows != artifact.row_count or tuple(table.column_names) != tuple(
|
|
106
|
+
c.name for c in columns
|
|
107
|
+
):
|
|
108
|
+
raise ArtifactIntegrityError("captured rows/columns differ from artifact")
|
|
109
|
+
import pyarrow as pa
|
|
110
|
+
import pyarrow.compute as pc
|
|
111
|
+
|
|
112
|
+
for column in columns:
|
|
113
|
+
self.checkpoint()
|
|
114
|
+
array = table[column.name]
|
|
115
|
+
nan = (
|
|
116
|
+
int(pc.sum(pc.is_nan(array)).as_py() or 0)
|
|
117
|
+
if (pa.types.is_floating(array.type))
|
|
118
|
+
else 0
|
|
119
|
+
)
|
|
120
|
+
if (str(array.type), array.null_count, nan) != (
|
|
121
|
+
column.arrow_type,
|
|
122
|
+
column.null_count,
|
|
123
|
+
column.nan_count,
|
|
124
|
+
):
|
|
125
|
+
raise ArtifactIntegrityError("captured physical metadata mismatch")
|
|
126
|
+
if ref == artifact.data:
|
|
127
|
+
self.tables[artifact.name] = table
|
|
128
|
+
self.hashes[artifact.name] = ref.sha256
|
|
129
|
+
self.checkpoint()
|
|
130
|
+
|
|
131
|
+
def rows(self, table: str, columns: tuple[str, ...]) -> Iterator[tuple[ComparisonValue, ...]]:
|
|
132
|
+
observed = self.tables[table]
|
|
133
|
+
if not set(columns) <= set(observed.column_names):
|
|
134
|
+
raise CapabilityError("profile column missing from captured table")
|
|
135
|
+
for i in range(observed.num_rows):
|
|
136
|
+
self.checkpoint(len(columns))
|
|
137
|
+
yield tuple(physical_value(observed[c][i]) for c in columns)
|
|
138
|
+
|
|
139
|
+
def insert(self, keys: set[tuple[object, ...]], key: tuple[object, ...]) -> None:
|
|
140
|
+
keys.add(key)
|
|
141
|
+
if len(keys) > self.options.max_distinct:
|
|
142
|
+
raise ResourceLimitError("profile max_distinct exhausted")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _validate_references(execution: ExecutionResult, spec: ProfileSpec) -> None:
|
|
146
|
+
contract = execution.plan.contract
|
|
147
|
+
if contract.family != spec.family:
|
|
148
|
+
raise ValueError("profile family differs from execution contract")
|
|
149
|
+
tables = {t.name: t for t in (*contract.inputs, contract.output)}
|
|
150
|
+
inputs = {t.name for t in contract.inputs}
|
|
151
|
+
for feature in spec.features:
|
|
152
|
+
for name, columns in (
|
|
153
|
+
(feature.table, feature.columns),
|
|
154
|
+
(feature.right_table, feature.right_columns),
|
|
155
|
+
(feature.denominator_table, ()),
|
|
156
|
+
):
|
|
157
|
+
if name is not None and (
|
|
158
|
+
name not in tables
|
|
159
|
+
or not set(columns) <= {c.name for c in tables[name].schema_spec.columns}
|
|
160
|
+
):
|
|
161
|
+
raise ValueError("profile references undeclared tables/columns")
|
|
162
|
+
if (
|
|
163
|
+
feature.metric
|
|
164
|
+
in (
|
|
165
|
+
"log_filas",
|
|
166
|
+
"razon_salida_entrada",
|
|
167
|
+
"expansion_union",
|
|
168
|
+
"tasa_nulos",
|
|
169
|
+
"tasa_nan",
|
|
170
|
+
"prop_duplicados",
|
|
171
|
+
"cardinalidad_rel",
|
|
172
|
+
)
|
|
173
|
+
and feature.table != contract.output.name
|
|
174
|
+
):
|
|
175
|
+
raise ValueError("output formula must reference the captured output")
|
|
176
|
+
if feature.denominator_table is not None and feature.denominator_table not in inputs:
|
|
177
|
+
raise ValueError("row ratio denominator must be a captured input")
|
|
178
|
+
if feature.metric == "claves_sin_pareja" and (
|
|
179
|
+
feature.table not in inputs or feature.right_table not in inputs
|
|
180
|
+
):
|
|
181
|
+
raise ValueError("unmatched rows require two captured inputs")
|
|
182
|
+
if feature.metric == "conversiones_fallidas" and feature.table not in inputs:
|
|
183
|
+
raise ValueError("conversion attempts must refer to captured source values")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _ratio(
|
|
187
|
+
feature: ProfileFeature, numerator: int, denominator: int, support: FeatureSupport
|
|
188
|
+
) -> FeatureObservation:
|
|
189
|
+
return FeatureObservation(
|
|
190
|
+
name=feature.name,
|
|
191
|
+
state="available" if denominator else "undefined",
|
|
192
|
+
value=numerator / denominator if denominator else None,
|
|
193
|
+
numerator=numerator,
|
|
194
|
+
denominator=denominator,
|
|
195
|
+
reason=None if denominator else "empty_denominator",
|
|
196
|
+
support=support,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _converted(value: ComparisonValue, target: ColumnSpec) -> ComparisonValue:
|
|
201
|
+
"""Parse temporal observations without changing the captured values."""
|
|
202
|
+
kind = target.logical_type.kind
|
|
203
|
+
if value.kind == kind:
|
|
204
|
+
return value
|
|
205
|
+
text = value.value
|
|
206
|
+
if text is None:
|
|
207
|
+
raise ValueError("non-finite or absent source")
|
|
208
|
+
if kind == "date" and value.kind == "string":
|
|
209
|
+
parsed = DateValue(value=text)
|
|
210
|
+
return ComparisonValue(
|
|
211
|
+
kind="date",
|
|
212
|
+
unit="day",
|
|
213
|
+
value=str(date.fromisoformat(parsed.value).toordinal() - date(1970, 1, 1).toordinal()),
|
|
214
|
+
)
|
|
215
|
+
if kind == "timestamp" and value.kind == "string":
|
|
216
|
+
policy = target.temporal_policy
|
|
217
|
+
if not (isinstance(policy, TimestampPolicy)):
|
|
218
|
+
raise AssertionError("Internal invariant check failed.")
|
|
219
|
+
parsed_stamp = TimestampValue(value=text)
|
|
220
|
+
point = parsed_stamp.nanoseconds(instant=parsed_stamp.has_offset) - (
|
|
221
|
+
date(1970, 1, 1).toordinal() * 86400 * 10**9
|
|
222
|
+
)
|
|
223
|
+
return ComparisonValue(
|
|
224
|
+
kind="timestamp",
|
|
225
|
+
unit="ns",
|
|
226
|
+
value=str(point),
|
|
227
|
+
timezone=(policy.timezone or "UTC") if parsed_stamp.has_offset else None,
|
|
228
|
+
)
|
|
229
|
+
raise ValueError("unsupported scalar conversion")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _measure(feature: ProfileFeature, context: _ProfileContext) -> FeatureObservation:
|
|
233
|
+
table = context.tables[feature.table]
|
|
234
|
+
count = table.num_rows
|
|
235
|
+
metric = feature.metric
|
|
236
|
+
if metric == "conversiones_fallidas":
|
|
237
|
+
receipt = context.conversions.get(feature.name)
|
|
238
|
+
if receipt is None:
|
|
239
|
+
raise CapabilityError("conversion receipt required; output NULL is insufficient")
|
|
240
|
+
if (
|
|
241
|
+
receipt.source_sha256 != context.hashes[feature.table]
|
|
242
|
+
or receipt.source_column != feature.columns[0]
|
|
243
|
+
or receipt.output_sha256 != context.output_sha256
|
|
244
|
+
or receipt.target != feature.target
|
|
245
|
+
):
|
|
246
|
+
raise ArtifactIntegrityError("conversion receipt does not match captured execution")
|
|
247
|
+
missing = empty = nan = zeros = 0
|
|
248
|
+
for index in receipt.attempted_rows:
|
|
249
|
+
context.checkpoint()
|
|
250
|
+
if index >= count:
|
|
251
|
+
raise ArtifactIntegrityError("conversion attempt outside source rows")
|
|
252
|
+
value = physical_value(table[feature.columns[0]][index])
|
|
253
|
+
if value.kind == "null":
|
|
254
|
+
raise ArtifactIntegrityError("conversion denominator must exclude absent values")
|
|
255
|
+
empty += value.kind == "string" and value.value == ""
|
|
256
|
+
nan += value.kind == "nan"
|
|
257
|
+
zeros += value.kind in ("integer", "decimal", "float") and number(value) == 0
|
|
258
|
+
missing = table[feature.columns[0]].null_count
|
|
259
|
+
return _ratio(
|
|
260
|
+
feature,
|
|
261
|
+
len(receipt.failed_rows),
|
|
262
|
+
len(receipt.attempted_rows),
|
|
263
|
+
FeatureSupport(rows=count, missing=missing, empty_strings=empty, nan=nan, zeros=zeros),
|
|
264
|
+
)
|
|
265
|
+
if metric == "log_filas":
|
|
266
|
+
return FeatureObservation(
|
|
267
|
+
name=feature.name,
|
|
268
|
+
state="available",
|
|
269
|
+
value=math.log1p(count),
|
|
270
|
+
numerator=count,
|
|
271
|
+
support=FeatureSupport(rows=count),
|
|
272
|
+
)
|
|
273
|
+
if metric in ("razon_salida_entrada", "expansion_union"):
|
|
274
|
+
if not (feature.denominator_table is not None):
|
|
275
|
+
raise AssertionError("Internal invariant check failed.")
|
|
276
|
+
return _ratio(
|
|
277
|
+
feature,
|
|
278
|
+
count,
|
|
279
|
+
context.tables[feature.denominator_table].num_rows,
|
|
280
|
+
FeatureSupport(rows=count),
|
|
281
|
+
)
|
|
282
|
+
if metric == "claves_sin_pareja":
|
|
283
|
+
if not (feature.right_table is not None):
|
|
284
|
+
raise AssertionError("Internal invariant check failed.")
|
|
285
|
+
left_types = tuple(table.schema.field(c).type for c in feature.columns)
|
|
286
|
+
right_types = tuple(
|
|
287
|
+
context.tables[feature.right_table].schema.field(c).type for c in feature.right_columns
|
|
288
|
+
)
|
|
289
|
+
if left_types != right_types:
|
|
290
|
+
raise CapabilityError("join key physical types differ; no implicit conversion")
|
|
291
|
+
right_keys: set[tuple[object, ...]] = set()
|
|
292
|
+
for row in context.rows(feature.right_table, feature.right_columns):
|
|
293
|
+
if any(
|
|
294
|
+
v.kind == "nan" or (v.kind == "null" and feature.null_matches == "never")
|
|
295
|
+
for v in row
|
|
296
|
+
):
|
|
297
|
+
continue
|
|
298
|
+
context.insert(right_keys, tuple(identity(v) for v in row))
|
|
299
|
+
unmatched = incomplete = 0
|
|
300
|
+
for row in context.rows(feature.table, feature.columns):
|
|
301
|
+
incomplete += any(v.kind in ("null", "nan") for v in row)
|
|
302
|
+
unmatchable = any(
|
|
303
|
+
v.kind == "nan" or (v.kind == "null" and feature.null_matches == "never")
|
|
304
|
+
for v in row
|
|
305
|
+
)
|
|
306
|
+
unmatched += unmatchable or tuple(identity(v) for v in row) not in right_keys
|
|
307
|
+
return _ratio(
|
|
308
|
+
feature, unmatched, count, FeatureSupport(rows=count, incomplete_keys=incomplete)
|
|
309
|
+
)
|
|
310
|
+
if metric == "prop_duplicados":
|
|
311
|
+
keys: set[tuple[object, ...]] = set()
|
|
312
|
+
complete = 0
|
|
313
|
+
for row in context.rows(feature.table, feature.columns):
|
|
314
|
+
if any(v.kind in ("null", "nan") for v in row):
|
|
315
|
+
continue
|
|
316
|
+
complete += 1
|
|
317
|
+
context.insert(keys, tuple(identity(v) for v in row))
|
|
318
|
+
return _ratio(
|
|
319
|
+
feature,
|
|
320
|
+
complete - len(keys),
|
|
321
|
+
complete,
|
|
322
|
+
FeatureSupport(rows=count, incomplete_keys=count - complete),
|
|
323
|
+
)
|
|
324
|
+
if metric == "tasa_nan":
|
|
325
|
+
import pyarrow as pa
|
|
326
|
+
|
|
327
|
+
dtype = table.schema.field(feature.columns[0]).type
|
|
328
|
+
if not (
|
|
329
|
+
pa.types.is_integer(dtype) or pa.types.is_floating(dtype) or pa.types.is_decimal(dtype)
|
|
330
|
+
):
|
|
331
|
+
return FeatureObservation(
|
|
332
|
+
name=feature.name,
|
|
333
|
+
state="not_applicable",
|
|
334
|
+
reason="non_numeric_column",
|
|
335
|
+
support=FeatureSupport(rows=count),
|
|
336
|
+
)
|
|
337
|
+
missing = nan = empty = zero = failed = present = 0
|
|
338
|
+
distinct: set[tuple[object, ...]] = set()
|
|
339
|
+
for (value,) in context.rows(feature.table, feature.columns):
|
|
340
|
+
missing += value.kind == "null"
|
|
341
|
+
nan += value.kind == "nan"
|
|
342
|
+
empty += value.kind == "string" and value.value == ""
|
|
343
|
+
zero += value.kind in ("integer", "decimal", "float") and number(value) == 0
|
|
344
|
+
if metric == "cardinalidad_rel" and value.kind not in ("null", "nan"):
|
|
345
|
+
context.insert(distinct, identity(value))
|
|
346
|
+
if metric == "fechas_invalidas" and value.kind != "null":
|
|
347
|
+
present += 1
|
|
348
|
+
if not (feature.target is not None):
|
|
349
|
+
raise AssertionError("Internal invariant check failed.")
|
|
350
|
+
try:
|
|
351
|
+
converted = _converted(value, feature.target)
|
|
352
|
+
_, issues = normalize(converted, feature.target)
|
|
353
|
+
failed += bool(issues)
|
|
354
|
+
except (ValueError, OverflowError):
|
|
355
|
+
failed += 1
|
|
356
|
+
support = FeatureSupport(rows=count, missing=missing, nan=nan, empty_strings=empty, zeros=zero)
|
|
357
|
+
if metric == "tasa_nulos":
|
|
358
|
+
return _ratio(feature, missing, count, support)
|
|
359
|
+
if metric == "tasa_nan":
|
|
360
|
+
return _ratio(feature, nan, count, support)
|
|
361
|
+
if metric == "cardinalidad_rel":
|
|
362
|
+
return _ratio(feature, len(distinct), count - missing - nan, support)
|
|
363
|
+
return _ratio(feature, failed, present, support)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def profile_execution(
|
|
367
|
+
execution: ExecutionResult,
|
|
368
|
+
*,
|
|
369
|
+
spec: ProfileSpec,
|
|
370
|
+
options: ProfileOptions | None = None,
|
|
371
|
+
work_dir: Path | None = None,
|
|
372
|
+
cancellation: CancellationToken | None = None,
|
|
373
|
+
conversions: tuple[ConversionReceipt, ...] = (),
|
|
374
|
+
) -> ProfileResult:
|
|
375
|
+
"""Materialize the section 3.6 formulas with exact counts and explicit unavailable states."""
|
|
376
|
+
execution = ExecutionResult.model_validate(execution)
|
|
377
|
+
spec = ProfileSpec.model_validate(spec)
|
|
378
|
+
options = ProfileOptions.model_validate(ProfileOptions() if options is None else options)
|
|
379
|
+
_validate_references(execution, spec)
|
|
380
|
+
conversions = tuple(ConversionReceipt.model_validate(c) for c in conversions)
|
|
381
|
+
if len({c.feature for c in conversions}) != len(conversions) or not {
|
|
382
|
+
c.feature for c in conversions
|
|
383
|
+
} <= {f.name for f in spec.features if f.metric == "conversiones_fallidas"}:
|
|
384
|
+
raise ValueError("conversion receipts must uniquely reference conversion features")
|
|
385
|
+
identifier = "profile_" + uuid4().hex
|
|
386
|
+
directory = (
|
|
387
|
+
local_output_directory(Path.cwd() / "equiframe-runs" if work_dir is None else work_dir)
|
|
388
|
+
/ identifier
|
|
389
|
+
)
|
|
390
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
391
|
+
context = _ProfileContext(options, cancellation or CancellationToken())
|
|
392
|
+
context.conversions = {c.feature: c for c in conversions}
|
|
393
|
+
failure: str | None = None
|
|
394
|
+
try:
|
|
395
|
+
context.load(execution)
|
|
396
|
+
except Exception as error:
|
|
397
|
+
failure = type(error).__name__ + ": captured execution unavailable"
|
|
398
|
+
observations: list[FeatureObservation] = []
|
|
399
|
+
for feature in spec.features:
|
|
400
|
+
try:
|
|
401
|
+
if failure:
|
|
402
|
+
observation = FeatureObservation(
|
|
403
|
+
name=feature.name, state="not_evaluable", reason=failure
|
|
404
|
+
)
|
|
405
|
+
elif spec.family not in _FAMILIES[feature.metric]:
|
|
406
|
+
observation = FeatureObservation(
|
|
407
|
+
name=feature.name, state="not_applicable", reason="family_not_applicable"
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
context.checkpoint()
|
|
411
|
+
observation = _measure(feature, context)
|
|
412
|
+
except Exception as error:
|
|
413
|
+
observation = FeatureObservation(
|
|
414
|
+
name=feature.name,
|
|
415
|
+
state="not_evaluable",
|
|
416
|
+
reason=type(error).__name__ + ": measurement unavailable",
|
|
417
|
+
)
|
|
418
|
+
observations.append(observation)
|
|
419
|
+
result = ProfileResult(
|
|
420
|
+
metadata=ProfileMetadata(
|
|
421
|
+
profile_id=identifier,
|
|
422
|
+
run_id=execution.run_id,
|
|
423
|
+
execution_id=execution.execution_id,
|
|
424
|
+
contract_sha256=execution.contract_sha256,
|
|
425
|
+
execution_sha256=profile_digest(execution.model_dump(mode="json")),
|
|
426
|
+
conversion_receipts=conversions,
|
|
427
|
+
artifacts=tuple(
|
|
428
|
+
ref
|
|
429
|
+
for a in (*execution.inputs, *((execution.output,) if execution.output else ()))
|
|
430
|
+
for ref in (a.data, *((a.index,) if a.index else ()))
|
|
431
|
+
),
|
|
432
|
+
),
|
|
433
|
+
vector=ProfileVector(
|
|
434
|
+
spec=spec, spec_sha256=profile_spec_fingerprint(spec), observations=tuple(observations)
|
|
435
|
+
),
|
|
436
|
+
options=options,
|
|
437
|
+
complete=all(o.state != "not_evaluable" for o in observations),
|
|
438
|
+
elapsed_seconds=time.perf_counter() - context.started,
|
|
439
|
+
work_units=context.work,
|
|
440
|
+
)
|
|
441
|
+
_write_document(directory / "profile.json", result.model_dump_json().encode("utf-8"))
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def read_profile(path: Path) -> ProfileResult:
|
|
446
|
+
"""Validate saved measurements; artifacts remain available for independent verification."""
|
|
447
|
+
return ProfileResult.model_validate_json(_read_document(path))
|
equiframe/py.typed
ADDED
|
File without changes
|