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
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