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,358 @@
1
+ """Authorized scalar normalization, exact arithmetic and physical/logical checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import struct
8
+ from datetime import date, datetime, timedelta
9
+ from datetime import timezone as _timezone
10
+ from decimal import Decimal, localcontext
11
+ from fractions import Fraction
12
+ from typing import TYPE_CHECKING
13
+
14
+ from equiframe._timezones import timezone
15
+ from equiframe.comparison_types import ComparisonPolicy, ComparisonValue
16
+ from equiframe.contracts import ColumnSpec, DatePolicy, TimestampPolicy
17
+ from equiframe.errors import CapabilityError
18
+ from equiframe.values import ScalarValue, offset_nanoseconds
19
+
20
+ UTC = _timezone.utc
21
+
22
+ if TYPE_CHECKING:
23
+ import pyarrow as pa
24
+
25
+ NS = {"s": 10**9, "ms": 10**6, "us": 1000, "ns": 1}
26
+ EPOCH = date(1970, 1, 1).toordinal()
27
+
28
+
29
+ def logical_signature(dtype: pa.DataType) -> tuple[object, ...]:
30
+ import pyarrow as pa
31
+
32
+ if pa.types.is_null(dtype):
33
+ return ("null",)
34
+ if pa.types.is_boolean(dtype):
35
+ return ("boolean",)
36
+ if pa.types.is_integer(dtype):
37
+ return ("integer", dtype.bit_width, pa.types.is_signed_integer(dtype))
38
+ if pa.types.is_floating(dtype) and dtype.bit_width in (32, 64):
39
+ return ("float", dtype.bit_width)
40
+ if any(
41
+ f(dtype) for f in (pa.types.is_string, pa.types.is_large_string, pa.types.is_string_view)
42
+ ):
43
+ return ("string",)
44
+ if pa.types.is_decimal(dtype):
45
+ return ("decimal", dtype.precision, dtype.scale)
46
+ if pa.types.is_date(dtype):
47
+ return ("date",)
48
+ if pa.types.is_timestamp(dtype):
49
+ if dtype.tz is not None:
50
+ try:
51
+ timezone(dtype.tz)
52
+ except (ValueError, KeyError) as error:
53
+ raise CapabilityError("timestamp requires a supported IANA zone") from error
54
+ return ("timestamp", dtype.unit, dtype.tz)
55
+ raise CapabilityError(f"unsupported comparison type: {dtype}")
56
+
57
+
58
+ def schema_compatible(dtype: pa.DataType, column: ColumnSpec, policy: ComparisonPolicy) -> bool:
59
+ observed = logical_signature(dtype)
60
+ expected = tuple(column.logical_type.model_dump().values())
61
+ if observed == expected:
62
+ return True
63
+ if policy.physical_types == "exact":
64
+ return False
65
+ if observed[0] == "null":
66
+ return column.nullable
67
+ if observed[0] != expected[0]:
68
+ return False
69
+ if observed[0] == "timestamp":
70
+ temporal = column.temporal_policy
71
+ if not (isinstance(temporal, TimestampPolicy)):
72
+ raise AssertionError("Internal invariant check failed.")
73
+ # Zone changes only preserve meaning for instant comparison. A naive
74
+ # observation needs the separately authorized assume_zone policy.
75
+ if observed[2] is None:
76
+ return temporal.naive != "reject"
77
+ return temporal.comparison == "instant" or observed[2] == expected[2]
78
+ return observed[0] in ("integer", "float", "decimal")
79
+
80
+
81
+ def physical_value(scalar: pa.Scalar) -> ComparisonValue:
82
+ dtype = scalar.type
83
+ kind = logical_signature(dtype)[0]
84
+ if not scalar.is_valid:
85
+ return ComparisonValue(kind="null")
86
+ if kind == "timestamp":
87
+ return ComparisonValue(
88
+ kind="timestamp", value=str(scalar.value), unit=dtype.unit, timezone=dtype.tz
89
+ )
90
+ if kind == "date":
91
+ import pyarrow as pa
92
+
93
+ return ComparisonValue(
94
+ kind="date", value=str(scalar.value), unit="day" if pa.types.is_date32(dtype) else "ms"
95
+ )
96
+ value = scalar.as_py()
97
+ if kind == "float":
98
+ if math.isnan(value):
99
+ return ComparisonValue(kind="nan")
100
+ if math.isinf(value):
101
+ return ComparisonValue(kind="infinity", value="positive" if value > 0 else "negative")
102
+ return ComparisonValue(kind="float", value=repr(value))
103
+ if kind == "boolean":
104
+ return ComparisonValue(kind="boolean", value="true" if value else "false")
105
+ if kind == "decimal":
106
+ with localcontext() as decimal_context:
107
+ decimal_context.capitals = 1
108
+ return ComparisonValue(kind="decimal", value=str(value))
109
+ if kind == "integer":
110
+ return ComparisonValue(kind="integer", value=str(value))
111
+ return ComparisonValue(kind="string", value=value)
112
+
113
+
114
+ def number(value: ComparisonValue) -> Fraction:
115
+ if not (value.value is not None):
116
+ raise AssertionError("Internal invariant check failed.")
117
+ if value.kind == "float":
118
+ return Fraction.from_float(float(value.value))
119
+ return Fraction(Decimal(value.value))
120
+
121
+
122
+ def identity(value: ComparisonValue) -> tuple[object, ...]:
123
+ if value.kind in ("integer", "decimal", "float"):
124
+ n = number(value)
125
+ return value.kind, str(n.numerator), str(n.denominator)
126
+ return value.kind, value.value, value.unit, value.timezone
127
+
128
+
129
+ def row_identity(row: tuple[ComparisonValue, ...]) -> bytes:
130
+ return json.dumps([identity(v) for v in row], ensure_ascii=True, separators=(",", ":")).encode(
131
+ "ascii"
132
+ )
133
+
134
+
135
+ def temporal_point(value: ComparisonValue, policy: TimestampPolicy) -> int:
136
+ if not (value.value is not None and value.unit in NS):
137
+ raise AssertionError("Internal invariant check failed.")
138
+ ns = int(value.value) * NS[value.unit]
139
+ if value.timezone is None:
140
+ if policy.naive == "reject":
141
+ raise ValueError("naive_timestamp")
142
+ if policy.naive == "preserve":
143
+ return ns
144
+ if not (policy.timezone is not None):
145
+ raise AssertionError("Internal invariant check failed.")
146
+ wall = _datetime(ns)
147
+ choices = [wall.replace(tzinfo=timezone(policy.timezone), fold=fold) for fold in (0, 1)]
148
+ valid = [
149
+ v
150
+ for v in choices
151
+ if v.astimezone(UTC).astimezone(v.tzinfo).replace(tzinfo=None) == wall
152
+ ]
153
+ if not valid:
154
+ raise ValueError("nonexistent_time")
155
+ offsets = {offset_nanoseconds(v.utcoffset()) for v in valid}
156
+ if len(offsets) > 1 and policy.ambiguous == "reject":
157
+ raise ValueError("ambiguous_time")
158
+ if policy.comparison == "wall_time":
159
+ return ns
160
+ choices_ns = [ns - offset for offset in offsets]
161
+ return max(choices_ns) if policy.ambiguous == "later" else min(choices_ns)
162
+ if policy.comparison == "instant":
163
+ return ns
164
+ try:
165
+ local = _datetime(ns).replace(tzinfo=UTC).astimezone(timezone(value.timezone))
166
+ except (OverflowError, ValueError) as error:
167
+ raise CapabilityError("zoned timestamp exceeds supported calendar range") from error
168
+ return ns + offset_nanoseconds(local.utcoffset())
169
+
170
+
171
+ def _datetime(ns: int) -> datetime:
172
+ try:
173
+ return datetime(1970, 1, 1) + timedelta(seconds=ns // 10**9)
174
+ except OverflowError as error:
175
+ raise CapabilityError("zone/DST computation requires calendar years 1..9999") from error
176
+
177
+
178
+ def normalize(
179
+ value: ComparisonValue, column: ColumnSpec
180
+ ) -> tuple[ComparisonValue, tuple[str, ...]]:
181
+ """Return normalized value and observable violations; never repair a value."""
182
+ issues: list[str] = []
183
+ out = value
184
+ if value.kind == "nan":
185
+ if column.nan_policy and column.nan_policy.handling == "forbid":
186
+ issues.append("nan_forbidden")
187
+ if column.nan_policy and column.nan_policy.handling == "as_null":
188
+ out = ComparisonValue(kind="null")
189
+ if (
190
+ value.kind == "string"
191
+ and value.value == ""
192
+ and "empty_string" in column.null_policy.representations
193
+ ):
194
+ out = ComparisonValue(kind="null")
195
+ if (
196
+ value.kind in ("integer", "decimal", "float")
197
+ and "zero" in column.null_policy.representations
198
+ and number(value) == 0
199
+ ):
200
+ out = ComparisonValue(kind="null")
201
+ if out.kind == "null":
202
+ if not column.nullable:
203
+ issues.append("null_forbidden")
204
+ elif value.kind == column.logical_type.kind:
205
+ dtype = column.logical_type
206
+ if dtype.kind == "integer":
207
+ n = number(value)
208
+ if (
209
+ not -(2 ** (dtype.bits - 1)) * int(dtype.signed)
210
+ <= n
211
+ < 2 ** (dtype.bits - int(dtype.signed))
212
+ ):
213
+ issues.append("integer_range")
214
+ elif dtype.kind == "float" and dtype.bits == 32:
215
+ try:
216
+ represented = struct.unpack("!f", struct.pack("!f", float(value.value or "0")))[0]
217
+ if Fraction.from_float(represented) != number(value):
218
+ issues.append("float_precision")
219
+ except (OverflowError, ValueError):
220
+ issues.append("float_precision")
221
+ elif dtype.kind == "decimal":
222
+ n = number(value)
223
+ scaled = n * 10**dtype.scale
224
+ if scaled.denominator != 1 or abs(scaled) >= 10**dtype.precision:
225
+ issues.append("decimal_precision")
226
+ elif dtype.kind == "date":
227
+ if not (value.value is not None):
228
+ raise AssertionError("Internal invariant check failed.")
229
+ days = int(value.value)
230
+ if value.unit == "ms":
231
+ if days % 86400000:
232
+ issues.append("date_granularity")
233
+ days //= 86400000
234
+ out = ComparisonValue(kind="date", value=str(days), unit="day")
235
+ policy = column.temporal_policy
236
+ if not (isinstance(policy, DatePolicy)):
237
+ raise AssertionError("Internal invariant check failed.")
238
+ lower = (
239
+ date.fromisoformat(policy.lower.value).toordinal() - EPOCH if policy.lower else None
240
+ )
241
+ upper = (
242
+ date.fromisoformat(policy.upper.value).toordinal() - EPOCH if policy.upper else None
243
+ )
244
+ if not in_interval(days, lower, upper, policy.closed):
245
+ issues.append("temporal_interval")
246
+ elif dtype.kind == "timestamp":
247
+ stamp = column.temporal_policy
248
+ if not (isinstance(stamp, TimestampPolicy)):
249
+ raise AssertionError("Internal invariant check failed.")
250
+ try:
251
+ point = temporal_point(value, stamp)
252
+ except ValueError as error:
253
+ issues.append(str(error))
254
+ else:
255
+ if point % NS[stamp.granularity]:
256
+ issues.append("timestamp_granularity")
257
+ out = ComparisonValue(
258
+ kind="timestamp",
259
+ value=str(point),
260
+ unit="ns",
261
+ timezone="UTC" if stamp.comparison == "instant" else None,
262
+ )
263
+ lower = (
264
+ stamp._bound(stamp.lower, "lower") - EPOCH * 86400 * 10**9
265
+ if stamp.lower
266
+ else None
267
+ )
268
+ upper = (
269
+ stamp._bound(stamp.upper, "upper") - EPOCH * 86400 * 10**9
270
+ if stamp.upper
271
+ else None
272
+ )
273
+ if not in_interval(point, lower, upper, stamp.closed):
274
+ issues.append("temporal_interval")
275
+ if column.allowed_values:
276
+ allowed_column = column.model_copy(update={"allowed_values": ()})
277
+ allowed = (allowed_value(v, allowed_column) for v in column.allowed_values)
278
+ if not any(identity(out) == identity(v) for v in allowed):
279
+ issues.append("allowed_values")
280
+ return out, tuple(issues)
281
+
282
+
283
+ def allowed_value(value: ScalarValue, column: ColumnSpec) -> ComparisonValue:
284
+ policy = column.temporal_policy
285
+ if value.kind == "timestamp" and isinstance(policy, TimestampPolicy):
286
+ return ComparisonValue(
287
+ kind="timestamp",
288
+ unit="ns",
289
+ timezone="UTC" if policy.comparison == "instant" else None,
290
+ value=str(policy._bound(value, "allowed_values") - EPOCH * 86400 * 10**9),
291
+ )
292
+ return normalize(literal(value), column)[0]
293
+
294
+
295
+ def in_interval(point: int, lower: int | None, upper: int | None, closed: str) -> bool:
296
+ return not (
297
+ (
298
+ lower is not None
299
+ and (point < lower or (point == lower and closed not in ("both", "left")))
300
+ )
301
+ or (
302
+ upper is not None
303
+ and (point > upper or (point == upper and closed not in ("both", "right")))
304
+ )
305
+ )
306
+
307
+
308
+ def literal(value: ScalarValue) -> ComparisonValue:
309
+ if value.kind == "null" or value.kind == "nan":
310
+ return ComparisonValue(kind=value.kind)
311
+ if value.kind == "infinity":
312
+ return ComparisonValue(kind="infinity", value=value.sign)
313
+ if value.kind == "date":
314
+ return ComparisonValue(
315
+ kind="date", value=str(date.fromisoformat(value.value).toordinal() - EPOCH), unit="day"
316
+ )
317
+ if value.kind == "timestamp":
318
+ return ComparisonValue(
319
+ kind="timestamp",
320
+ unit="ns",
321
+ timezone="UTC" if value.has_offset else None,
322
+ value=str(value.nanoseconds(instant=value.has_offset) - EPOCH * 86400 * 10**9),
323
+ )
324
+ if value.kind == "boolean":
325
+ return ComparisonValue(kind="boolean", value="true" if value.value else "false")
326
+ with localcontext() as decimal_context:
327
+ decimal_context.capitals = 1
328
+ return ComparisonValue(kind=value.kind, value=str(value.value))
329
+
330
+
331
+ def equal(left: ComparisonValue, right: ComparisonValue, column: ColumnSpec) -> bool:
332
+ if identity(left) == identity(right):
333
+ return True
334
+ policy = column.numeric_policy
335
+ if (
336
+ not policy
337
+ or not policy.tolerance
338
+ or left.kind != right.kind
339
+ or left.kind not in ("integer", "float")
340
+ ):
341
+ return False
342
+ a, b = number(left), number(right)
343
+ tolerance = policy.tolerance
344
+ return abs(a - b) <= max(
345
+ Fraction(tolerance.absolute or 0), Fraction(tolerance.relative or 0) * max(abs(a), abs(b))
346
+ )
347
+
348
+
349
+ def order_value(value: ComparisonValue) -> tuple[int, Fraction | str]:
350
+ if value.kind == "nan":
351
+ return 3, ""
352
+ if value.kind == "infinity":
353
+ return (0 if value.value == "negative" else 2), ""
354
+ if value.kind in ("integer", "float", "decimal"):
355
+ return 1, number(value)
356
+ if value.kind in ("date", "timestamp"):
357
+ return 1, Fraction(int(value.value or "0"))
358
+ return 1, value.value or ""
equiframe/pipeline.py ADDED
@@ -0,0 +1,274 @@
1
+ """Compose existing independent components without re-execution, fitting or oracle access."""
2
+
3
+ import platform
4
+ import time
5
+ from collections.abc import Callable
6
+ from datetime import datetime
7
+ from datetime import timezone as _timezone
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+ from typing import TypeVar, cast
11
+ from uuid import uuid4
12
+
13
+ from pydantic import BaseModel
14
+
15
+ from equiframe._document_io import write_document as _write_document
16
+ from equiframe._regular_io import local_output_directory
17
+ from equiframe.comparison import ComparisonInput, compare
18
+ from equiframe.decision import combine_decisions
19
+ from equiframe.detector_types import DetectorBundle
20
+ from equiframe.detectors import detect
21
+ from equiframe.evidence import EventJournal, inventory, publish_json
22
+ from equiframe.execution import CancellationToken, execute
23
+ from equiframe.execution_types import ExecutionResult
24
+ from equiframe.pipeline_types import (
25
+ PipelineResult,
26
+ PipelineSpec,
27
+ StageName,
28
+ StageRecord,
29
+ unavailable,
30
+ )
31
+ from equiframe.ports import DataSource, EngineAdapter
32
+ from equiframe.profile_types import ConversionReceipt, profile_digest
33
+ from equiframe.profiles import profile_execution
34
+ from equiframe.registry import TransformationRegistry
35
+ from equiframe.reporting import pipeline_summary, render_report
36
+ from equiframe.rules import RuleRegistry, evaluate_rules
37
+
38
+ UTC = _timezone.utc
39
+ _ModelT = TypeVar("_ModelT", bound=BaseModel)
40
+ _T = TypeVar("_T")
41
+
42
+
43
+ def _environment() -> dict[str, object]:
44
+ packages: dict[str, str] = {}
45
+ for name in ("equiframe", "pydantic", "pyarrow", "pandas", "polars", "duckdb", "scikit-learn"):
46
+ try:
47
+ packages[name] = version(name)
48
+ except PackageNotFoundError:
49
+ packages[name] = "not-installed"
50
+ # No environment variables, argv, host/user names, directories or authentication data.
51
+ return {
52
+ "schema_version": "1.0",
53
+ "python": platform.python_version(),
54
+ "system": platform.system(),
55
+ "machine": platform.machine(),
56
+ "packages": packages,
57
+ }
58
+
59
+
60
+ class _Stages:
61
+ def __init__(self, journal: EventJournal, cancellation: CancellationToken) -> None:
62
+ self.journal = journal
63
+ self.cancellation = cancellation
64
+ self.records: list[StageRecord] = []
65
+
66
+ def run(
67
+ self,
68
+ name: StageName,
69
+ action: Callable[[], _ModelT],
70
+ complete: Callable[[_ModelT], bool],
71
+ *,
72
+ skip: str | None = None,
73
+ always: bool = False,
74
+ ) -> _ModelT | None:
75
+ if self.cancellation.cancelled and not always:
76
+ skip = "cancelled"
77
+ start = time.perf_counter()
78
+ result: _ModelT | None = None
79
+ if skip:
80
+ record = StageRecord(stage=name, state="skipped", code=skip, elapsed_seconds=0.0)
81
+ else:
82
+ self.journal.emit(name, "started", "stage_started")
83
+ try:
84
+ result = action()
85
+ succeeded = complete(result)
86
+ record = StageRecord(
87
+ stage=name,
88
+ state="completed" if succeeded else "not_evaluable",
89
+ code="stage_completed" if succeeded else "component_unavailable",
90
+ elapsed_seconds=time.perf_counter() - start,
91
+ )
92
+ except Exception:
93
+ # Exception text/type can contain SQL, values, tokens or paths from plugins.
94
+ # Other independent stages can still produce evidence.
95
+ result = None
96
+ record = StageRecord(
97
+ stage=name,
98
+ state="failed",
99
+ code="operation_failed",
100
+ elapsed_seconds=time.perf_counter() - start,
101
+ )
102
+ self.records.append(record)
103
+ self.journal.emit(name, record.state, record.code)
104
+ return result
105
+
106
+
107
+ def run_pipeline(
108
+ spec: PipelineSpec,
109
+ *,
110
+ source: DataSource,
111
+ registry: TransformationRegistry,
112
+ work_dir: Path | None = None,
113
+ adapter: EngineAdapter | None = None,
114
+ rule_registry: RuleRegistry | None = None,
115
+ detector_bundle: DetectorBundle | None = None,
116
+ comparison: ComparisonInput | None = None,
117
+ cancellation: CancellationToken | None = None,
118
+ conversion_provider: Callable[[ExecutionResult], tuple[ConversionReceipt, ...]] | None = None,
119
+ ) -> PipelineResult:
120
+ """Persist one isolated operation; failures/incompleteness never become no-alert.
121
+
122
+ A manifest is published only after reports and journal are durable. Invalid
123
+ top-level specs and publication failures raise; partial evidence remains.
124
+ Callbacks are trusted, synchronous and cooperatively cancellable. No retries.
125
+ """
126
+ spec = PipelineSpec.model_validate(spec)
127
+ if detector_bundle is not None:
128
+ detector_bundle = DetectorBundle.model_validate(detector_bundle)
129
+ if spec.profile is None:
130
+ raise ValueError("detectors require an explicit profile specification")
131
+ identifier = "pipeline_" + uuid4().hex
132
+ directory = (
133
+ local_output_directory(Path.cwd() / "equiframe-runs" if work_dir is None else work_dir)
134
+ / identifier
135
+ )
136
+ directory.mkdir(parents=True, exist_ok=False)
137
+ token = cancellation if cancellation is not None else CancellationToken()
138
+ started_at = datetime.now(UTC).isoformat()
139
+ start = time.perf_counter()
140
+ journal = EventJournal(directory, identifier)
141
+ journal.emit("pipeline", "started", "pipeline_started")
142
+ publish_json(spec, directory / "pipeline.json")
143
+ publish_json(_environment(), directory / "environment.json")
144
+ if detector_bundle is not None:
145
+ publish_json(detector_bundle, directory / "detector-bundle.json")
146
+ stages = _Stages(journal, token)
147
+ execution = stages.run(
148
+ "execution",
149
+ lambda: execute(
150
+ spec.execution,
151
+ source=source,
152
+ registry=registry,
153
+ adapter=adapter,
154
+ work_dir=directory,
155
+ cancellation=token,
156
+ ),
157
+ lambda value: value.state == "completed",
158
+ )
159
+ rules = stages.run(
160
+ "rules",
161
+ lambda: evaluate_rules(
162
+ _present(execution),
163
+ registry=rule_registry,
164
+ options=spec.rule_options,
165
+ work_dir=directory,
166
+ cancellation=token,
167
+ ),
168
+ lambda value: value.complete,
169
+ skip="execution_unavailable" if execution is None else None,
170
+ )
171
+ profile = stages.run(
172
+ "profile",
173
+ lambda: profile_execution(
174
+ _present(execution),
175
+ spec=_present(spec.profile),
176
+ options=spec.profile_options,
177
+ work_dir=directory,
178
+ cancellation=token,
179
+ conversions=conversion_provider(_present(execution)) if conversion_provider else (),
180
+ ),
181
+ lambda value: value.complete,
182
+ skip="not_requested"
183
+ if spec.profile is None
184
+ else "execution_unavailable"
185
+ if execution is None
186
+ else None,
187
+ )
188
+ detection = stages.run(
189
+ "detection",
190
+ lambda: detect(_present(detector_bundle), (_present(profile).vector,)),
191
+ lambda value: all(d.result.complete for d in (*value.m, *value.b)),
192
+ skip="not_requested"
193
+ if detector_bundle is None
194
+ else "profile_unavailable"
195
+ if profile is None or not profile.complete
196
+ else None,
197
+ )
198
+ compared = stages.run(
199
+ "comparison",
200
+ lambda: compare(
201
+ _present(execution),
202
+ cast(ComparisonInput, comparison),
203
+ contract=spec.execution.contract,
204
+ policy=spec.comparison_policy,
205
+ options=spec.comparison_options,
206
+ reference=spec.comparison_reference,
207
+ work_dir=directory,
208
+ cancellation=token,
209
+ ),
210
+ lambda value: value.complete,
211
+ skip="not_requested"
212
+ if comparison is None
213
+ else "execution_unavailable"
214
+ if execution is None
215
+ else None,
216
+ )
217
+ hybrid = stages.run(
218
+ "hybrid",
219
+ lambda: combine_decisions(
220
+ rules if rules is not None else unavailable("rules_unavailable"),
221
+ detection.m[0].result if detection is not None else unavailable("detector_unavailable"),
222
+ ),
223
+ lambda value: value.result.complete,
224
+ always=True,
225
+ )
226
+ if hybrid is None:
227
+ raise RuntimeError("cannot persist a pipeline without a valid hybrid decision")
228
+ # Self-contained typed results are independently addressable in addition to child manifests.
229
+ for name, value in (
230
+ ("execution", execution),
231
+ ("rules", rules),
232
+ ("profile", profile),
233
+ ("detection", detection),
234
+ ("comparison", compared),
235
+ ("hybrid", hybrid),
236
+ ):
237
+ if value is not None:
238
+ publish_json(value, directory / (name + ".json"))
239
+ journal.emit("pipeline", "finished", "pipeline_finished")
240
+ result = PipelineResult(
241
+ pipeline_id=identifier,
242
+ spec=spec,
243
+ spec_sha256=profile_digest(spec.model_dump(mode="json")),
244
+ started_at=started_at,
245
+ finished_at=datetime.now(UTC).isoformat(),
246
+ elapsed_seconds=time.perf_counter() - start,
247
+ detection_requested=detector_bundle is not None,
248
+ comparison_requested=comparison is not None,
249
+ cancelled=token.cancelled,
250
+ stages=tuple(stages.records),
251
+ execution=execution,
252
+ rules=rules,
253
+ profile=profile,
254
+ detection=detection,
255
+ comparison=compared,
256
+ hybrid=hybrid,
257
+ evidence=(),
258
+ event_count=journal.count,
259
+ event_head_sha256=journal.head,
260
+ )
261
+ summary = pipeline_summary(result)
262
+ publish_json(summary, directory / "report.json")
263
+ _write_document(directory / "report.html", render_report(summary).encode("utf-8"))
264
+ result = PipelineResult.model_validate(
265
+ {**result.model_dump(), "evidence": inventory(directory)}
266
+ )
267
+ publish_json(result, directory / "manifest.json")
268
+ return result
269
+
270
+
271
+ def _present(value: _T | None) -> _T:
272
+ if value is None:
273
+ raise ValueError("required component unavailable")
274
+ return value