equiframe 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
|
@@ -0,0 +1,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
|