equiframe 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
equiframe/comparison.py
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
"""Full local comparison with disk indexes, exact values and bounded matching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sqlite3
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from collections import Counter, deque
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from datetime import timezone as _timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import BinaryIO, Literal
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
from typing_extensions import TypeAliasType
|
|
21
|
+
|
|
22
|
+
from equiframe._comparison_io import batches, file_ref, snapshot
|
|
23
|
+
from equiframe._diff_capture import _CapturedInput, _NativeInput
|
|
24
|
+
from equiframe._document_io import read_document
|
|
25
|
+
from equiframe._error_reporting import report_failure
|
|
26
|
+
from equiframe._regular_io import local_input_path, local_output_directory
|
|
27
|
+
from equiframe.comparison_types import (
|
|
28
|
+
AppliedPolicy,
|
|
29
|
+
ComparisonCounts,
|
|
30
|
+
ComparisonDiagnostic,
|
|
31
|
+
ComparisonDifference,
|
|
32
|
+
ComparisonOptions,
|
|
33
|
+
ComparisonPolicy,
|
|
34
|
+
ComparisonResult,
|
|
35
|
+
ComparisonSource,
|
|
36
|
+
ComparisonSpec,
|
|
37
|
+
ComparisonValue,
|
|
38
|
+
Side,
|
|
39
|
+
)
|
|
40
|
+
from equiframe.contracts import ColumnKeyScope, Contract, KeySpec, SignificantOrder
|
|
41
|
+
from equiframe.errors import (
|
|
42
|
+
ArtifactIntegrityError,
|
|
43
|
+
CapabilityError,
|
|
44
|
+
ExecutionCancelled,
|
|
45
|
+
ExecutionTimeout,
|
|
46
|
+
MissingDependencyError,
|
|
47
|
+
ResourceLimitError,
|
|
48
|
+
)
|
|
49
|
+
from equiframe.execution import CancellationToken, _versions
|
|
50
|
+
from equiframe.execution_types import ExecutionResult, PhysicalColumn, TableArtifact
|
|
51
|
+
from equiframe.local import LocalArtifactStore, local_path
|
|
52
|
+
from equiframe.normalization import (
|
|
53
|
+
equal,
|
|
54
|
+
logical_signature,
|
|
55
|
+
normalize,
|
|
56
|
+
order_value,
|
|
57
|
+
physical_value,
|
|
58
|
+
row_identity,
|
|
59
|
+
schema_compatible,
|
|
60
|
+
)
|
|
61
|
+
from equiframe.serialization import _constant, _JsonObject, _unique_objects, contract_fingerprint
|
|
62
|
+
from equiframe.tables import ArrowInput
|
|
63
|
+
from equiframe.types import ArtifactRef, ComparisonState
|
|
64
|
+
|
|
65
|
+
UTC = _timezone.utc
|
|
66
|
+
|
|
67
|
+
ComparisonInput = TypeAliasType(
|
|
68
|
+
"ComparisonInput", ArrowInput | ArtifactRef | TableArtifact | ExecutionResult | Path
|
|
69
|
+
)
|
|
70
|
+
_ComparisonInput = TypeAliasType(
|
|
71
|
+
"_ComparisonInput", ComparisonInput | _NativeInput | _CapturedInput
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def comparison_fingerprint(spec: ComparisonSpec) -> str:
|
|
76
|
+
"""SHA-256 of the complete comparison specification, including authorization."""
|
|
77
|
+
spec = ComparisonSpec.model_validate(spec)
|
|
78
|
+
return hashlib.sha256(_json(spec.model_dump(mode="json"))).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _json(value: object) -> bytes:
|
|
82
|
+
return json.dumps(
|
|
83
|
+
value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
84
|
+
).encode("ascii")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _partition(value: bytes, count: int) -> int:
|
|
88
|
+
return int.from_bytes(hashlib.sha256(value).digest()[:8], "big") % count
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _values(data: str) -> tuple[ComparisonValue, ...]:
|
|
92
|
+
return tuple(ComparisonValue.model_validate(v) for v in json.loads(data))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _encoded(values: tuple[ComparisonValue, ...]) -> str:
|
|
96
|
+
return _json([v.model_dump(mode="json") for v in values]).decode("ascii")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class _Row:
|
|
101
|
+
position: int
|
|
102
|
+
raw: tuple[ComparisonValue, ...]
|
|
103
|
+
normalized: tuple[ComparisonValue, ...]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Work:
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
spec: ComparisonSpec,
|
|
110
|
+
options: ComparisonOptions,
|
|
111
|
+
directory: Path,
|
|
112
|
+
cancellation: CancellationToken,
|
|
113
|
+
start: float,
|
|
114
|
+
evidence: BinaryIO,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.spec, self.options, self.directory = spec, options, directory
|
|
117
|
+
self.cancellation, self.start, self.evidence = cancellation, start, evidence
|
|
118
|
+
self.work = self.pairs = self.peak_disk = self.evidence_bytes = 0
|
|
119
|
+
self.rows = {"left": 0, "right": 0}
|
|
120
|
+
self.matched = self.differences = self.violations = 0
|
|
121
|
+
self.invalid: set[str] = set()
|
|
122
|
+
self.examples: list[ComparisonDifference] = []
|
|
123
|
+
self.applied: Counter[tuple[Side, str | None, str]] = Counter()
|
|
124
|
+
self.sources: dict[Side, ComparisonSource] = {}
|
|
125
|
+
self.schema = spec.contract.output.schema_spec
|
|
126
|
+
self.columns = self.schema.columns
|
|
127
|
+
self.names = tuple(c.name for c in self.columns)
|
|
128
|
+
self.key = next((key for key in self.schema.keys if key.unique), None)
|
|
129
|
+
self.key_valid = self.key is not None
|
|
130
|
+
self.strategy: Literal["pending", "sequence", "key", "multiset", "matching"] = "pending"
|
|
131
|
+
self.selected_key: str | None = None
|
|
132
|
+
self.db = sqlite3.connect(directory / "work.sqlite")
|
|
133
|
+
self.db.execute("PRAGMA journal_mode=OFF")
|
|
134
|
+
self.db.execute("PRAGMA temp_store=FILE")
|
|
135
|
+
self.db.execute(f"PRAGMA cache_size=-{options.sqlite_cache_kib}")
|
|
136
|
+
self.db.executescript("""
|
|
137
|
+
CREATE TABLE rows(side TEXT, pos INTEGER, raw TEXT, norm TEXT, exact BLOB,
|
|
138
|
+
candidate BLOB, part INTEGER, grp BLOB, PRIMARY KEY(side,pos));
|
|
139
|
+
CREATE TABLE seen(side TEXT, name TEXT, phase TEXT, value BLOB, pos INTEGER,
|
|
140
|
+
PRIMARY KEY(side,name,phase,value));
|
|
141
|
+
CREATE INDEX grouping ON rows(part,grp,side,pos);
|
|
142
|
+
""")
|
|
143
|
+
self.db.execute(f"PRAGMA max_page_count={max(1, options.max_disk_bytes // 4096)}")
|
|
144
|
+
self.db.set_progress_handler(self._progress, 1000)
|
|
145
|
+
self.sql_error: Exception | None = None
|
|
146
|
+
|
|
147
|
+
def _progress(self) -> int:
|
|
148
|
+
try:
|
|
149
|
+
self.check(1000)
|
|
150
|
+
except Exception as error:
|
|
151
|
+
self.sql_error = error
|
|
152
|
+
return 1
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
def check(self, cost: int = 1) -> None:
|
|
156
|
+
"""Check controls and disk after operations that may grow files."""
|
|
157
|
+
self.check_read(cost)
|
|
158
|
+
self.check_disk()
|
|
159
|
+
|
|
160
|
+
def check_read(self, cost: int = 1) -> None:
|
|
161
|
+
"""Check work, cancellation and time during operations without writes.
|
|
162
|
+
|
|
163
|
+
Callers may omit disk measurement only while reading immutable input or
|
|
164
|
+
calculating in memory. SQLite progress callbacks and every evidence/write
|
|
165
|
+
checkpoint still use ``check``; read phases also measure disk at their
|
|
166
|
+
boundaries. Work accounting is identical to the full checkpoint.
|
|
167
|
+
"""
|
|
168
|
+
self.work += cost
|
|
169
|
+
if self.cancellation.cancelled:
|
|
170
|
+
raise ExecutionCancelled("comparison cancelled at checkpoint")
|
|
171
|
+
if (
|
|
172
|
+
self.options.timeout_seconds is not None
|
|
173
|
+
and time.perf_counter() - self.start >= self.options.timeout_seconds
|
|
174
|
+
):
|
|
175
|
+
raise ExecutionTimeout("cooperative comparison timeout")
|
|
176
|
+
if self.work > self.options.max_work:
|
|
177
|
+
raise ResourceLimitError("max_work")
|
|
178
|
+
|
|
179
|
+
def check_disk(self) -> None:
|
|
180
|
+
"""Measure current files without consuming an additional work unit."""
|
|
181
|
+
disk = 0
|
|
182
|
+
with os.scandir(self.directory) as entries:
|
|
183
|
+
for entry in entries:
|
|
184
|
+
try:
|
|
185
|
+
is_file = entry.is_file()
|
|
186
|
+
except OSError:
|
|
187
|
+
# Preserve Path.is_file's platform-specific handling of
|
|
188
|
+
# missing/invalid targets (including symlink loops).
|
|
189
|
+
is_file = Path(entry.path).is_file()
|
|
190
|
+
if is_file:
|
|
191
|
+
disk += entry.stat().st_size
|
|
192
|
+
self.peak_disk = max(self.peak_disk, disk)
|
|
193
|
+
if disk > self.options.max_disk_bytes:
|
|
194
|
+
raise ResourceLimitError("max_disk_bytes")
|
|
195
|
+
|
|
196
|
+
def emit(self, difference: ComparisonDifference) -> None:
|
|
197
|
+
block = _json(difference.model_dump(mode="json")) + b"\n"
|
|
198
|
+
if self.evidence_bytes + len(block) > self.options.max_evidence_bytes:
|
|
199
|
+
raise ResourceLimitError("max_evidence_bytes")
|
|
200
|
+
self.evidence.write(block)
|
|
201
|
+
self.evidence.flush()
|
|
202
|
+
self.evidence_bytes += len(block)
|
|
203
|
+
self.differences += 1
|
|
204
|
+
if difference.category in ("contract", "schema"):
|
|
205
|
+
self.violations += 1
|
|
206
|
+
if difference.side:
|
|
207
|
+
self.invalid.add(difference.side)
|
|
208
|
+
if len(self.examples) < self.options.max_examples:
|
|
209
|
+
self.examples.append(difference)
|
|
210
|
+
self.check()
|
|
211
|
+
|
|
212
|
+
def violation(
|
|
213
|
+
self,
|
|
214
|
+
code: str,
|
|
215
|
+
side: Side,
|
|
216
|
+
*,
|
|
217
|
+
position: int | None = None,
|
|
218
|
+
column: str | None = None,
|
|
219
|
+
values: tuple[ComparisonValue, ...] = (),
|
|
220
|
+
category: Literal["contract", "schema"] = "contract",
|
|
221
|
+
) -> None:
|
|
222
|
+
self.emit(
|
|
223
|
+
ComparisonDifference(
|
|
224
|
+
category=category,
|
|
225
|
+
code=code,
|
|
226
|
+
side=side,
|
|
227
|
+
position=position,
|
|
228
|
+
column=column,
|
|
229
|
+
left=values if side == "left" else (),
|
|
230
|
+
right=values if side == "right" else (),
|
|
231
|
+
message=code.replace("_", " "),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def source(self, value: _ComparisonInput, side: Side) -> ComparisonSource:
|
|
236
|
+
execution = None
|
|
237
|
+
table = None
|
|
238
|
+
captured = None
|
|
239
|
+
if isinstance(value, (_NativeInput, _CapturedInput)):
|
|
240
|
+
captured = value.load() if isinstance(value, _NativeInput) else value.loaded
|
|
241
|
+
self.check()
|
|
242
|
+
value = ArrowInput(captured.table)
|
|
243
|
+
if isinstance(value, ExecutionResult):
|
|
244
|
+
execution = ExecutionResult.model_validate(value)
|
|
245
|
+
if execution.state != "completed" or execution.output is None:
|
|
246
|
+
raise ValueError("execution_not_completed")
|
|
247
|
+
if execution.contract_sha256 != contract_fingerprint(self.spec.contract):
|
|
248
|
+
raise ValueError("execution_contract_mismatch")
|
|
249
|
+
for item in execution.inputs:
|
|
250
|
+
self.verify_table(item)
|
|
251
|
+
table = execution.output
|
|
252
|
+
elif isinstance(value, TableArtifact):
|
|
253
|
+
table = TableArtifact.model_validate(value)
|
|
254
|
+
if table:
|
|
255
|
+
if table.name != self.spec.contract.output.name:
|
|
256
|
+
raise ValueError("output_table_name_mismatch")
|
|
257
|
+
value = table.data
|
|
258
|
+
if not isinstance(value, (ArrowInput, ArtifactRef, Path)):
|
|
259
|
+
raise TypeError(
|
|
260
|
+
"compare accepts ArrowInput, ArtifactRef, TableArtifact, ExecutionResult or Path"
|
|
261
|
+
)
|
|
262
|
+
data = snapshot(value, self.directory, self.options, self.check)
|
|
263
|
+
if captured is not None:
|
|
264
|
+
import pyarrow.compute as pc
|
|
265
|
+
|
|
266
|
+
def describe(
|
|
267
|
+
loaded: object, native: tuple[tuple[str, str], ...]
|
|
268
|
+
) -> tuple[PhysicalColumn, ...]:
|
|
269
|
+
import pyarrow as pa
|
|
270
|
+
|
|
271
|
+
if not (isinstance(loaded, pa.Table)):
|
|
272
|
+
raise AssertionError("Internal invariant check failed.")
|
|
273
|
+
types = dict(native)
|
|
274
|
+
return tuple(
|
|
275
|
+
PhysicalColumn(
|
|
276
|
+
name=field.name,
|
|
277
|
+
engine_type=types.get(field.name, str(field.type)),
|
|
278
|
+
arrow_type=str(field.type),
|
|
279
|
+
null_count=loaded.column(field.name).null_count,
|
|
280
|
+
nan_count=(
|
|
281
|
+
int(pc.sum(pc.is_nan(loaded.column(field.name))).as_py() or 0)
|
|
282
|
+
if pa.types.is_floating(field.type)
|
|
283
|
+
else 0
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
for field in loaded.schema
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
index = captured.index if captured.meaningful_index else None
|
|
290
|
+
index_ref = (
|
|
291
|
+
snapshot(ArrowInput(index), self.directory, self.options, self.check)
|
|
292
|
+
if index is not None
|
|
293
|
+
else None
|
|
294
|
+
)
|
|
295
|
+
table = TableArtifact(
|
|
296
|
+
name=self.spec.contract.output.name,
|
|
297
|
+
data=data,
|
|
298
|
+
row_count=captured.table.num_rows,
|
|
299
|
+
columns=describe(captured.table, captured.native_types),
|
|
300
|
+
index=index_ref,
|
|
301
|
+
index_columns=describe(index, ()) if index is not None else (),
|
|
302
|
+
notes=captured.notes,
|
|
303
|
+
)
|
|
304
|
+
result = ComparisonSource(data=data, table=table, execution=execution)
|
|
305
|
+
self.sources[side] = result
|
|
306
|
+
if table and table.index:
|
|
307
|
+
self.verify_table(table, index=True)
|
|
308
|
+
if self.spec.policy.index == "reject":
|
|
309
|
+
raise CapabilityError(
|
|
310
|
+
"separate index requires explicit ignore policy or promotion "
|
|
311
|
+
"to contract columns"
|
|
312
|
+
)
|
|
313
|
+
self.applied[side, None, "ignore_captured_index"] += table.row_count
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
def verify_table(self, table: TableArtifact, *, index: bool = False) -> None:
|
|
317
|
+
ref = table.index if index else table.data
|
|
318
|
+
if not (ref is not None):
|
|
319
|
+
raise AssertionError("Internal invariant check failed.")
|
|
320
|
+
copied = snapshot(ref, self.directory, self.options, self.check)
|
|
321
|
+
try:
|
|
322
|
+
self.check_disk()
|
|
323
|
+
# Only the private immutable snapshot is read in this phase. No
|
|
324
|
+
# SQLite operation or evidence emission occurs inside these loops.
|
|
325
|
+
with batches(copied, self.options, self.check_read) as (schema, reader):
|
|
326
|
+
observed = tuple((f.name, str(f.type)) for f in schema)
|
|
327
|
+
columns = table.index_columns if index else table.columns
|
|
328
|
+
if observed != tuple((c.name, c.arrow_type) for c in columns):
|
|
329
|
+
raise ArtifactIntegrityError("table physical schema evidence mismatch")
|
|
330
|
+
nulls, nans = [0] * len(columns), [0] * len(columns)
|
|
331
|
+
count = 0
|
|
332
|
+
for batch in reader:
|
|
333
|
+
count += batch.num_rows
|
|
334
|
+
for i, array in enumerate(batch.columns):
|
|
335
|
+
nulls[i] += array.null_count
|
|
336
|
+
for scalar in array:
|
|
337
|
+
self.check_read()
|
|
338
|
+
nans[i] += physical_value(scalar).kind == "nan"
|
|
339
|
+
if count != table.row_count or any(
|
|
340
|
+
c.null_count != nulls[i] or c.nan_count != nans[i]
|
|
341
|
+
for i, c in enumerate(columns)
|
|
342
|
+
):
|
|
343
|
+
raise ArtifactIntegrityError("table row/NULL/NaN evidence mismatch")
|
|
344
|
+
self.check_disk()
|
|
345
|
+
finally:
|
|
346
|
+
local_path(copied).unlink(missing_ok=True)
|
|
347
|
+
if not index and table.index:
|
|
348
|
+
self.verify_table(table, index=True)
|
|
349
|
+
|
|
350
|
+
def key_values(
|
|
351
|
+
self, row: tuple[ComparisonValue, ...], key: KeySpec
|
|
352
|
+
) -> tuple[ComparisonValue, ...]:
|
|
353
|
+
names = (
|
|
354
|
+
(*key.scope.columns, *key.columns)
|
|
355
|
+
if isinstance(key.scope, ColumnKeyScope)
|
|
356
|
+
else key.columns
|
|
357
|
+
)
|
|
358
|
+
return tuple(row[self.names.index(name)] for name in names)
|
|
359
|
+
|
|
360
|
+
def check_unique(
|
|
361
|
+
self, side: Side, name: str, phase: str, values: tuple[ComparisonValue, ...], pos: int
|
|
362
|
+
) -> bool:
|
|
363
|
+
try:
|
|
364
|
+
self.db.execute(
|
|
365
|
+
"INSERT INTO seen VALUES(?,?,?,?,?)", (side, name, phase, row_identity(values), pos)
|
|
366
|
+
)
|
|
367
|
+
except sqlite3.IntegrityError:
|
|
368
|
+
self.violation("duplicate_" + phase + "_" + name, side, position=pos, values=values)
|
|
369
|
+
return False
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
def ingest(self, source: ComparisonSource, side: Side) -> None:
|
|
373
|
+
previous: tuple[ComparisonValue, ...] | None = None
|
|
374
|
+
with batches(source.data, self.options, self.check) as (schema, reader):
|
|
375
|
+
observed = tuple((f.name, str(f.type)) for f in schema)
|
|
376
|
+
self.sources[side] = source.model_copy(update={"observed_schema": observed})
|
|
377
|
+
if len(set(schema.names)) != len(schema.names) or any(not n for n in schema.names):
|
|
378
|
+
raise CapabilityError("ambiguous physical column names")
|
|
379
|
+
for name in self.names:
|
|
380
|
+
if name not in schema.names:
|
|
381
|
+
self.violation("missing_column", side, column=name, category="schema")
|
|
382
|
+
for name in schema.names:
|
|
383
|
+
if name not in self.names:
|
|
384
|
+
self.violation("additional_column", side, column=name, category="schema")
|
|
385
|
+
if set(schema.names) == set(self.names) and tuple(schema.names) != self.names:
|
|
386
|
+
if self.spec.policy.column_order == "significant":
|
|
387
|
+
self.violation("column_order", side, category="schema")
|
|
388
|
+
else:
|
|
389
|
+
self.applied[side, None, "align_column_names"] += 1
|
|
390
|
+
compatible: dict[str, bool] = {}
|
|
391
|
+
for field in schema:
|
|
392
|
+
logical_signature(field.type)
|
|
393
|
+
if field.name in self.names:
|
|
394
|
+
col = self.columns[self.names.index(field.name)]
|
|
395
|
+
compatible[field.name] = schema_compatible(field.type, col, self.spec.policy)
|
|
396
|
+
if not compatible[field.name]:
|
|
397
|
+
self.violation("logical_type", side, column=field.name, category="schema")
|
|
398
|
+
elif not schema_compatible(field.type, col, ComparisonPolicy()):
|
|
399
|
+
self.applied[side, field.name, "lossless_physical_type_validation"] += 1
|
|
400
|
+
nulls, nans = Counter[str](), Counter[str]()
|
|
401
|
+
for batch in reader:
|
|
402
|
+
for row_index in range(batch.num_rows):
|
|
403
|
+
self.check(len(self.columns))
|
|
404
|
+
pos = self.rows[side]
|
|
405
|
+
if sum(self.rows.values()) >= self.options.max_rows:
|
|
406
|
+
raise ResourceLimitError("max_rows (combined inputs)")
|
|
407
|
+
raw_by_name = {
|
|
408
|
+
name: physical_value(batch.column(i)[row_index])
|
|
409
|
+
for i, name in enumerate(schema.names)
|
|
410
|
+
}
|
|
411
|
+
for name, v in raw_by_name.items():
|
|
412
|
+
nulls[name] += v.kind == "null"
|
|
413
|
+
nans[name] += v.kind == "nan"
|
|
414
|
+
raw = tuple(
|
|
415
|
+
raw_by_name.get(name, ComparisonValue(kind="null")) for name in self.names
|
|
416
|
+
)
|
|
417
|
+
norm = []
|
|
418
|
+
for col, v in zip(self.columns, raw, strict=True):
|
|
419
|
+
out, issues = normalize(v, col) if compatible.get(col.name) else (v, ())
|
|
420
|
+
norm.append(out)
|
|
421
|
+
if out != v:
|
|
422
|
+
self.applied[side, col.name, "authorized_scalar_normalization"] += 1
|
|
423
|
+
for issue in issues:
|
|
424
|
+
self.violation(issue, side, position=pos, column=col.name, values=(v,))
|
|
425
|
+
normalized = tuple(norm)
|
|
426
|
+
for key in self.schema.keys:
|
|
427
|
+
logical_incomplete = any(
|
|
428
|
+
v.kind == "null" for v in self.key_values(normalized, key)
|
|
429
|
+
)
|
|
430
|
+
for phase, row in (("raw", raw), ("normalized", normalized)):
|
|
431
|
+
values = self.key_values(row, key)
|
|
432
|
+
if logical_incomplete or any(v.kind == "null" for v in values):
|
|
433
|
+
if key.incomplete == "reject":
|
|
434
|
+
self.violation(
|
|
435
|
+
"incomplete_" + phase + "_" + key.name,
|
|
436
|
+
side,
|
|
437
|
+
position=pos,
|
|
438
|
+
values=values,
|
|
439
|
+
)
|
|
440
|
+
if key == self.key:
|
|
441
|
+
self.key_valid = False
|
|
442
|
+
elif key.unique and not self.check_unique(
|
|
443
|
+
side, "key_" + key.name, phase, values, pos
|
|
444
|
+
):
|
|
445
|
+
if key == self.key:
|
|
446
|
+
self.key_valid = False
|
|
447
|
+
if self.schema.multiplicity == "distinct":
|
|
448
|
+
self.check_unique(side, "row", "normalized", normalized, pos)
|
|
449
|
+
if isinstance(self.schema.order, SignificantOrder) and previous is not None:
|
|
450
|
+
self.check_order(previous, normalized, side, pos)
|
|
451
|
+
previous = normalized
|
|
452
|
+
raw_json, norm_json = _encoded(raw), _encoded(normalized)
|
|
453
|
+
if len(raw_json) + len(norm_json) > self.options.max_row_bytes:
|
|
454
|
+
raise ResourceLimitError("max_row_bytes")
|
|
455
|
+
candidate = (
|
|
456
|
+
row_identity(self.key_values(normalized, self.key)) if self.key else b""
|
|
457
|
+
)
|
|
458
|
+
self.db.execute(
|
|
459
|
+
"INSERT INTO rows VALUES(?,?,?,?,?,?,0,?)",
|
|
460
|
+
(side, pos, raw_json, norm_json, row_identity(normalized), candidate, b""),
|
|
461
|
+
)
|
|
462
|
+
self.rows[side] += 1
|
|
463
|
+
if source.table:
|
|
464
|
+
t = source.table
|
|
465
|
+
if (
|
|
466
|
+
observed != tuple((c.name, c.arrow_type) for c in t.columns)
|
|
467
|
+
or self.rows[side] != t.row_count
|
|
468
|
+
or any(
|
|
469
|
+
nulls[c.name] != c.null_count or nans[c.name] != c.nan_count
|
|
470
|
+
for c in t.columns
|
|
471
|
+
)
|
|
472
|
+
):
|
|
473
|
+
raise ArtifactIntegrityError(
|
|
474
|
+
"output observed schema/counts disagree with execution evidence"
|
|
475
|
+
)
|
|
476
|
+
self.db.commit()
|
|
477
|
+
|
|
478
|
+
def check_order(
|
|
479
|
+
self,
|
|
480
|
+
previous: tuple[ComparisonValue, ...],
|
|
481
|
+
current: tuple[ComparisonValue, ...],
|
|
482
|
+
side: Side,
|
|
483
|
+
pos: int,
|
|
484
|
+
) -> None:
|
|
485
|
+
order = self.schema.order
|
|
486
|
+
if not (isinstance(order, SignificantOrder)):
|
|
487
|
+
raise AssertionError("Internal invariant check failed.")
|
|
488
|
+
result = 0
|
|
489
|
+
for sort in order.by:
|
|
490
|
+
index = self.names.index(sort.column)
|
|
491
|
+
a, b = previous[index], current[index]
|
|
492
|
+
if a.kind == "null" or b.kind == "null":
|
|
493
|
+
result = (int(a.kind == "null") - int(b.kind == "null")) * (
|
|
494
|
+
-1 if sort.nulls == "first" else 1
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
x, y = order_value(a), order_value(b)
|
|
498
|
+
result = (int(x > y) - int(x < y)) * (1 if sort.direction == "ascending" else -1)
|
|
499
|
+
if result:
|
|
500
|
+
break
|
|
501
|
+
if result > 0 or (result == 0 and order.ties == "reject"):
|
|
502
|
+
self.violation(
|
|
503
|
+
"order_violation" if result > 0 else "order_tie", side, position=pos, values=current
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def prepare(self) -> None:
|
|
507
|
+
approximate = any(c.numeric_policy and c.numeric_policy.tolerance for c in self.columns)
|
|
508
|
+
if isinstance(self.schema.order, SignificantOrder):
|
|
509
|
+
self.strategy = "sequence"
|
|
510
|
+
elif self.key_valid:
|
|
511
|
+
self.strategy = "key"
|
|
512
|
+
if not (self.key is not None):
|
|
513
|
+
raise AssertionError("Internal invariant check failed.")
|
|
514
|
+
self.selected_key = self.key.name
|
|
515
|
+
else:
|
|
516
|
+
self.strategy = "matching" if approximate else "multiset"
|
|
517
|
+
for side, pos, norm, exact, candidate in self.db.execute(
|
|
518
|
+
"SELECT side,pos,norm,exact,candidate FROM rows"
|
|
519
|
+
):
|
|
520
|
+
self.check()
|
|
521
|
+
if self.strategy == "sequence":
|
|
522
|
+
group = str(pos).zfill(20).encode("ascii")
|
|
523
|
+
elif self.strategy == "key":
|
|
524
|
+
group = candidate
|
|
525
|
+
elif self.strategy == "multiset":
|
|
526
|
+
group = exact
|
|
527
|
+
else:
|
|
528
|
+
group = row_identity(
|
|
529
|
+
tuple(
|
|
530
|
+
v
|
|
531
|
+
for v, c in zip(_values(norm), self.columns, strict=True)
|
|
532
|
+
if not (c.numeric_policy and c.numeric_policy.tolerance)
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
self.db.execute(
|
|
536
|
+
"UPDATE rows SET grp=?,part=? WHERE side=? AND pos=?",
|
|
537
|
+
(group, _partition(group, self.options.partitions), side, pos),
|
|
538
|
+
)
|
|
539
|
+
self.db.commit()
|
|
540
|
+
|
|
541
|
+
def row(self, side: str, part: int, group: bytes) -> _Row | None:
|
|
542
|
+
item = self.db.execute(
|
|
543
|
+
"SELECT pos,raw,norm FROM rows WHERE part=? AND grp=? AND side=? ORDER BY pos LIMIT 1",
|
|
544
|
+
(part, group, side),
|
|
545
|
+
).fetchone()
|
|
546
|
+
return _Row(item[0], _values(item[1]), _values(item[2])) if item else None
|
|
547
|
+
|
|
548
|
+
def pair_equal(self, a: _Row, b: _Row) -> bool:
|
|
549
|
+
self.check_read(len(self.columns))
|
|
550
|
+
self.pairs += 1
|
|
551
|
+
if self.pairs > self.options.max_pair_checks:
|
|
552
|
+
raise ResourceLimitError("max_pair_checks")
|
|
553
|
+
return all(
|
|
554
|
+
equal(x, y, c) for x, y, c in zip(a.normalized, b.normalized, self.columns, strict=True)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def content(self, a: _Row | None, b: _Row | None, left_count: int, right_count: int) -> None:
|
|
558
|
+
self.emit(
|
|
559
|
+
ComparisonDifference(
|
|
560
|
+
category="content",
|
|
561
|
+
code="row_multiplicity",
|
|
562
|
+
position=a.position if a else None,
|
|
563
|
+
other_position=b.position if b else None,
|
|
564
|
+
left=a.raw if a else (),
|
|
565
|
+
right=b.raw if b else (),
|
|
566
|
+
left_count=left_count,
|
|
567
|
+
right_count=right_count,
|
|
568
|
+
message="unmatched full rows with multiplicity",
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def compare_groups(self) -> None:
|
|
573
|
+
self.check_disk()
|
|
574
|
+
# Python grouping/matching only reads the prepared index and calculates
|
|
575
|
+
# in memory. SQL progress retains full checks (including spill growth),
|
|
576
|
+
# and emit checks disk after every flushed difference.
|
|
577
|
+
for part in range(self.options.partitions):
|
|
578
|
+
groups = self.db.execute(
|
|
579
|
+
"SELECT grp,SUM(side='left'),SUM(side='right') FROM rows WHERE part=? GROUP BY grp",
|
|
580
|
+
(part,),
|
|
581
|
+
)
|
|
582
|
+
for group, nleft, nright in groups:
|
|
583
|
+
self.check_read()
|
|
584
|
+
if self.strategy == "matching":
|
|
585
|
+
self.match_group(part, group, nleft, nright)
|
|
586
|
+
continue
|
|
587
|
+
a, b = self.row("left", part, group), self.row("right", part, group)
|
|
588
|
+
if self.strategy == "multiset":
|
|
589
|
+
# The complete canonical value tuple is the SQL key, never a hash.
|
|
590
|
+
self.matched += min(nleft, nright)
|
|
591
|
+
if nleft != nright:
|
|
592
|
+
self.content(a, b, nleft, nright)
|
|
593
|
+
elif a is None or b is None:
|
|
594
|
+
self.content(a, b, nleft, nright)
|
|
595
|
+
elif self.pair_equal(a, b):
|
|
596
|
+
self.matched += 1
|
|
597
|
+
else:
|
|
598
|
+
for i, col in enumerate(self.columns):
|
|
599
|
+
if not equal(a.normalized[i], b.normalized[i], col):
|
|
600
|
+
self.emit(
|
|
601
|
+
ComparisonDifference(
|
|
602
|
+
category="content",
|
|
603
|
+
code="cell_value",
|
|
604
|
+
position=a.position,
|
|
605
|
+
other_position=b.position,
|
|
606
|
+
column=col.name,
|
|
607
|
+
key=self.key_values(a.normalized, self.key)
|
|
608
|
+
if self.strategy == "key" and self.key
|
|
609
|
+
else (),
|
|
610
|
+
left=(a.raw[i],),
|
|
611
|
+
right=(b.raw[i],),
|
|
612
|
+
left_count=1,
|
|
613
|
+
right_count=1,
|
|
614
|
+
message="aligned values differ",
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
self.check_disk()
|
|
618
|
+
|
|
619
|
+
def match_group(self, part: int, group: bytes, nleft: int, nright: int) -> None:
|
|
620
|
+
if not nleft or not nright:
|
|
621
|
+
for side, pos, raw in self.db.execute(
|
|
622
|
+
"SELECT side,pos,raw FROM rows WHERE part=? AND grp=? ORDER BY side,pos",
|
|
623
|
+
(part, group),
|
|
624
|
+
):
|
|
625
|
+
row = _Row(pos, _values(raw), ())
|
|
626
|
+
self.content(
|
|
627
|
+
row if side == "left" else None,
|
|
628
|
+
row if side == "right" else None,
|
|
629
|
+
int(side == "left"),
|
|
630
|
+
int(side == "right"),
|
|
631
|
+
)
|
|
632
|
+
return
|
|
633
|
+
if nleft + nright > self.options.max_matching_rows:
|
|
634
|
+
raise ResourceLimitError("max_matching_rows in exact-column group (possibly skewed)")
|
|
635
|
+
size = self.db.execute(
|
|
636
|
+
"SELECT SUM(length(raw)+length(norm)) FROM rows WHERE part=? AND grp=?", (part, group)
|
|
637
|
+
).fetchone()[0]
|
|
638
|
+
memory = size + (nleft + nright) * (256 + len(self.columns) * 256)
|
|
639
|
+
if memory > self.options.max_matching_bytes:
|
|
640
|
+
raise ResourceLimitError("max_matching_bytes")
|
|
641
|
+
rows: dict[str, list[_Row]] = {"left": [], "right": []}
|
|
642
|
+
for side, pos, raw, norm in self.db.execute(
|
|
643
|
+
"SELECT side,pos,raw,norm FROM rows WHERE part=? AND grp=? ORDER BY side,pos",
|
|
644
|
+
(part, group),
|
|
645
|
+
):
|
|
646
|
+
rows[side].append(_Row(pos, _values(raw), _values(norm)))
|
|
647
|
+
edges: list[list[int]] = []
|
|
648
|
+
for a in rows["left"]:
|
|
649
|
+
adjacent = []
|
|
650
|
+
for j, b in enumerate(rows["right"]):
|
|
651
|
+
if self.pair_equal(a, b):
|
|
652
|
+
memory += 64
|
|
653
|
+
if memory > self.options.max_matching_bytes:
|
|
654
|
+
raise ResourceLimitError("max_matching_bytes (edges)")
|
|
655
|
+
adjacent.append(j)
|
|
656
|
+
edges.append(adjacent)
|
|
657
|
+
# Augmenting paths find a maximum matching; no tolerance-based grouping,
|
|
658
|
+
# deduplication, transitive closure or irrevocable greedy assignment.
|
|
659
|
+
matched_right: dict[int, int] = {}
|
|
660
|
+
for start in range(nleft):
|
|
661
|
+
queue = deque([start])
|
|
662
|
+
visited = {start}
|
|
663
|
+
parents: dict[int, int] = {}
|
|
664
|
+
found = None
|
|
665
|
+
while queue and found is None:
|
|
666
|
+
node = queue.popleft()
|
|
667
|
+
for target in edges[node]:
|
|
668
|
+
self.check_read()
|
|
669
|
+
if target in parents:
|
|
670
|
+
continue
|
|
671
|
+
parents[target] = node
|
|
672
|
+
if target not in matched_right:
|
|
673
|
+
found = target
|
|
674
|
+
break
|
|
675
|
+
next_node = matched_right[target]
|
|
676
|
+
if next_node not in visited:
|
|
677
|
+
visited.add(next_node)
|
|
678
|
+
queue.append(next_node)
|
|
679
|
+
if found is not None:
|
|
680
|
+
reverse = {v: k for k, v in matched_right.items()}
|
|
681
|
+
while found is not None:
|
|
682
|
+
node = parents[found]
|
|
683
|
+
old = reverse.get(node)
|
|
684
|
+
matched_right[found] = node
|
|
685
|
+
found = old
|
|
686
|
+
self.matched += len(matched_right)
|
|
687
|
+
matched_left = set(matched_right.values())
|
|
688
|
+
for i, a in enumerate(rows["left"]):
|
|
689
|
+
if i not in matched_left:
|
|
690
|
+
self.content(a, None, 1, 0)
|
|
691
|
+
for j, b in enumerate(rows["right"]):
|
|
692
|
+
if j not in matched_right:
|
|
693
|
+
self.content(None, b, 0, 1)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def compare(
|
|
697
|
+
left: ComparisonInput,
|
|
698
|
+
right: ComparisonInput,
|
|
699
|
+
*,
|
|
700
|
+
contract: Contract,
|
|
701
|
+
policy: ComparisonPolicy | None = None,
|
|
702
|
+
options: ComparisonOptions | None = None,
|
|
703
|
+
reference: Literal["peer", "expected"] = "peer",
|
|
704
|
+
work_dir: Path | None = None,
|
|
705
|
+
cancellation: CancellationToken | None = None,
|
|
706
|
+
) -> ComparisonResult:
|
|
707
|
+
"""Compare complete local outputs. Right may be an independent expected result.
|
|
708
|
+
|
|
709
|
+
A fresh directory contains comparison.json, differences.jsonl and immutable
|
|
710
|
+
snapshots. Operational failures return not_evaluable; invalid API configuration
|
|
711
|
+
and inability to create/publish evidence raise. No transformations are executed.
|
|
712
|
+
"""
|
|
713
|
+
return _compare(
|
|
714
|
+
left,
|
|
715
|
+
right,
|
|
716
|
+
contract=contract,
|
|
717
|
+
policy=policy,
|
|
718
|
+
options=options,
|
|
719
|
+
reference=reference,
|
|
720
|
+
work_dir=work_dir,
|
|
721
|
+
cancellation=cancellation,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _compare(
|
|
726
|
+
left: _ComparisonInput,
|
|
727
|
+
right: _ComparisonInput,
|
|
728
|
+
*,
|
|
729
|
+
contract: Contract,
|
|
730
|
+
policy: ComparisonPolicy | None = None,
|
|
731
|
+
options: ComparisonOptions | None = None,
|
|
732
|
+
reference: Literal["peer", "expected"] = "peer",
|
|
733
|
+
work_dir: Path | None = None,
|
|
734
|
+
cancellation: CancellationToken | None = None,
|
|
735
|
+
) -> ComparisonResult:
|
|
736
|
+
"""One comparison kernel; the facade only adds capture and contract construction."""
|
|
737
|
+
spec = ComparisonSpec(
|
|
738
|
+
contract=contract, policy=ComparisonPolicy() if policy is None else policy
|
|
739
|
+
)
|
|
740
|
+
options = ComparisonOptions.model_validate(ComparisonOptions() if options is None else options)
|
|
741
|
+
if reference not in ("peer", "expected"):
|
|
742
|
+
raise ValueError("reference must be peer or expected")
|
|
743
|
+
identifier = "comparison-" + uuid4().hex
|
|
744
|
+
directory = (
|
|
745
|
+
local_output_directory(Path("equiframe-comparisons") if work_dir is None else work_dir)
|
|
746
|
+
/ identifier
|
|
747
|
+
)
|
|
748
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
749
|
+
started_at = datetime.now(UTC).isoformat()
|
|
750
|
+
start = time.perf_counter()
|
|
751
|
+
diagnostics: list[ComparisonDiagnostic] = []
|
|
752
|
+
complete = False
|
|
753
|
+
side: Side | None = None
|
|
754
|
+
evidence_path = directory / "differences.jsonl"
|
|
755
|
+
with evidence_path.open("xb") as evidence:
|
|
756
|
+
work = _Work(spec, options, directory, cancellation or CancellationToken(), start, evidence)
|
|
757
|
+
try:
|
|
758
|
+
for column in work.columns:
|
|
759
|
+
tolerance = column.numeric_policy.tolerance if column.numeric_policy else None
|
|
760
|
+
if tolerance:
|
|
761
|
+
for tolerance_value in (tolerance.absolute, tolerance.relative):
|
|
762
|
+
if tolerance_value is not None:
|
|
763
|
+
parts = tolerance_value.as_tuple()
|
|
764
|
+
if not (isinstance(parts.exponent, int)):
|
|
765
|
+
raise AssertionError("Internal invariant check failed.")
|
|
766
|
+
if (
|
|
767
|
+
max(abs(parts.exponent), len(parts.digits))
|
|
768
|
+
> options.max_numeric_digits
|
|
769
|
+
):
|
|
770
|
+
raise ResourceLimitError("max_numeric_digits in declared tolerance")
|
|
771
|
+
inputs: tuple[tuple[Side, _ComparisonInput], ...] = (("left", left), ("right", right))
|
|
772
|
+
for input_side, value in inputs:
|
|
773
|
+
side = input_side
|
|
774
|
+
work.check()
|
|
775
|
+
source = work.source(value, input_side)
|
|
776
|
+
work.ingest(source, input_side)
|
|
777
|
+
side = None
|
|
778
|
+
lrun, rrun = work.sources["left"].execution, work.sources["right"].execution
|
|
779
|
+
if lrun is not None and rrun is not None:
|
|
780
|
+
|
|
781
|
+
def input_identity(t: TableArtifact) -> tuple[object, ...]:
|
|
782
|
+
return (
|
|
783
|
+
t.name,
|
|
784
|
+
t.data.sha256,
|
|
785
|
+
t.data.size_bytes,
|
|
786
|
+
(t.index.sha256, t.index.size_bytes) if t.index else None,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
lhs = sorted((input_identity(t) for t in lrun.inputs), key=lambda t: str(t[0]))
|
|
790
|
+
rhs = sorted((input_identity(t) for t in rrun.inputs), key=lambda t: str(t[0]))
|
|
791
|
+
if lhs != rhs:
|
|
792
|
+
raise ValueError(
|
|
793
|
+
"execution_inputs_differ: require the same verified input artifacts"
|
|
794
|
+
)
|
|
795
|
+
if lrun.plan.parameters != rrun.plan.parameters:
|
|
796
|
+
raise ValueError("execution_parameters_differ")
|
|
797
|
+
work.prepare()
|
|
798
|
+
work.compare_groups()
|
|
799
|
+
work.check()
|
|
800
|
+
complete = True
|
|
801
|
+
except (Exception, KeyboardInterrupt) as caught:
|
|
802
|
+
error: BaseException = work.sql_error or caught
|
|
803
|
+
if isinstance(error, sqlite3.OperationalError) and (
|
|
804
|
+
getattr(error, "sqlite_errorcode", None) == getattr(sqlite3, "SQLITE_FULL", 13)
|
|
805
|
+
or (
|
|
806
|
+
# CPython 3.10 exposes no SQLite result-code attributes.
|
|
807
|
+
# Match only SQLite's exact canonical FULL diagnostic; a
|
|
808
|
+
# different available result code always takes precedence.
|
|
809
|
+
not hasattr(error, "sqlite_errorcode")
|
|
810
|
+
and error.args == ("database or disk is full",)
|
|
811
|
+
)
|
|
812
|
+
):
|
|
813
|
+
error = ResourceLimitError("SQLite disk budget exhausted")
|
|
814
|
+
code = (
|
|
815
|
+
"cancelled"
|
|
816
|
+
if isinstance(error, (ExecutionCancelled, KeyboardInterrupt))
|
|
817
|
+
else "timeout"
|
|
818
|
+
if isinstance(error, ExecutionTimeout)
|
|
819
|
+
else "resource_exhausted"
|
|
820
|
+
if isinstance(error, (ResourceLimitError, MemoryError))
|
|
821
|
+
else "invalid_artifact"
|
|
822
|
+
if isinstance(error, ArtifactIntegrityError)
|
|
823
|
+
else "unsupported_capability"
|
|
824
|
+
if isinstance(error, CapabilityError)
|
|
825
|
+
and not isinstance(error, MissingDependencyError)
|
|
826
|
+
else "missing_optional_dependency"
|
|
827
|
+
if isinstance(error, MissingDependencyError)
|
|
828
|
+
else "evaluation_failed"
|
|
829
|
+
)
|
|
830
|
+
safe_message = "Comparison could not be completed: " + code
|
|
831
|
+
if type(error) is ValueError and len(error.args) == 1 and type(error.args[0]) is str:
|
|
832
|
+
if error.args == (
|
|
833
|
+
"execution_inputs_differ: require the same verified input artifacts",
|
|
834
|
+
):
|
|
835
|
+
safe_message = "execution_inputs_differ: use identical verified input artifacts"
|
|
836
|
+
elif error.args == ("execution_parameters_differ",):
|
|
837
|
+
safe_message = "execution_parameters_differ: use identical execution parameters"
|
|
838
|
+
diagnostics.append(
|
|
839
|
+
ComparisonDiagnostic(
|
|
840
|
+
code=code,
|
|
841
|
+
message=report_failure(error, safe_message),
|
|
842
|
+
side=side,
|
|
843
|
+
exception_type=type(error).__name__,
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
finally:
|
|
847
|
+
work.db.close()
|
|
848
|
+
for name in (
|
|
849
|
+
"work.sqlite",
|
|
850
|
+
"work.sqlite-journal",
|
|
851
|
+
"work.sqlite-wal",
|
|
852
|
+
"work.sqlite-shm",
|
|
853
|
+
):
|
|
854
|
+
(directory / name).unlink(missing_ok=True)
|
|
855
|
+
evidence.flush()
|
|
856
|
+
os.fsync(evidence.fileno())
|
|
857
|
+
state: ComparisonState = "not_evaluable"
|
|
858
|
+
if complete:
|
|
859
|
+
if reference == "expected" and "right" in work.invalid:
|
|
860
|
+
diagnostics.append(
|
|
861
|
+
ComparisonDiagnostic(
|
|
862
|
+
code="invalid_expected",
|
|
863
|
+
side="right",
|
|
864
|
+
message="independent expected reference violates the declared output contract",
|
|
865
|
+
)
|
|
866
|
+
)
|
|
867
|
+
else:
|
|
868
|
+
state = "different" if work.differences else "equivalent"
|
|
869
|
+
result = ComparisonResult(
|
|
870
|
+
comparison_id=identifier,
|
|
871
|
+
spec=spec,
|
|
872
|
+
contract_sha256=contract_fingerprint(spec.contract),
|
|
873
|
+
spec_sha256=comparison_fingerprint(spec),
|
|
874
|
+
state=state,
|
|
875
|
+
complete=complete,
|
|
876
|
+
reference=reference,
|
|
877
|
+
strategy=work.strategy,
|
|
878
|
+
selected_key=work.selected_key,
|
|
879
|
+
left=work.sources.get("left"),
|
|
880
|
+
right=work.sources.get("right"),
|
|
881
|
+
counts=ComparisonCounts(
|
|
882
|
+
left_rows=work.rows["left"],
|
|
883
|
+
right_rows=work.rows["right"],
|
|
884
|
+
matched_rows=work.matched,
|
|
885
|
+
difference_records=work.differences,
|
|
886
|
+
contract_violations=work.violations,
|
|
887
|
+
final=complete,
|
|
888
|
+
),
|
|
889
|
+
options=options,
|
|
890
|
+
applied_policies=tuple(
|
|
891
|
+
AppliedPolicy(side=s, column=c, operation=o, count=n)
|
|
892
|
+
for (s, c, o), n in work.applied.items()
|
|
893
|
+
),
|
|
894
|
+
diagnostics=tuple(diagnostics),
|
|
895
|
+
examples=tuple(work.examples),
|
|
896
|
+
differences=file_ref(evidence_path, "jsonl"),
|
|
897
|
+
started_at=started_at,
|
|
898
|
+
elapsed_seconds=time.perf_counter() - start,
|
|
899
|
+
work_units=work.work,
|
|
900
|
+
pair_checks=work.pairs,
|
|
901
|
+
peak_disk_bytes=work.peak_disk,
|
|
902
|
+
versions=_versions("comparison"),
|
|
903
|
+
)
|
|
904
|
+
temporary = directory / "comparison.json.part"
|
|
905
|
+
with temporary.open("xb") as output:
|
|
906
|
+
output.write(_json(result.model_dump(mode="json")))
|
|
907
|
+
output.flush()
|
|
908
|
+
os.fsync(output.fileno())
|
|
909
|
+
os.link(temporary, directory / "comparison.json")
|
|
910
|
+
temporary.unlink()
|
|
911
|
+
return result
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def read_comparison(path: Path) -> ComparisonResult:
|
|
915
|
+
"""Read and strictly revalidate a persisted comparison manifest."""
|
|
916
|
+
raw = read_document(local_input_path(path))
|
|
917
|
+
return ComparisonResult.model_validate_json(raw)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def iter_differences(ref: ArtifactRef) -> Iterator[ComparisonDifference]:
|
|
921
|
+
"""Verify all evidence bytes before yielding typed difference records."""
|
|
922
|
+
ref = ArtifactRef.model_validate(ref)
|
|
923
|
+
if ref.format != "jsonl":
|
|
924
|
+
raise ValueError("differences require a JSONL artifact")
|
|
925
|
+
with tempfile.TemporaryDirectory(prefix="equiframe-differences-") as temporary:
|
|
926
|
+
path = Path(temporary) / "differences.jsonl"
|
|
927
|
+
LocalArtifactStore(Path(temporary)).get(ref, path)
|
|
928
|
+
with path.open("rb") as source:
|
|
929
|
+
for line in source:
|
|
930
|
+
_unique_objects(
|
|
931
|
+
json.loads(
|
|
932
|
+
line.decode("utf-8"),
|
|
933
|
+
object_pairs_hook=_JsonObject,
|
|
934
|
+
parse_constant=_constant,
|
|
935
|
+
)
|
|
936
|
+
)
|
|
937
|
+
yield ComparisonDifference.model_validate_json(line)
|