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/_report_io.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""Bounded inspection/export of explicitly authorized comparison evidence.
|
|
2
|
+
|
|
3
|
+
Integrity means internal consistency with a supplied manifest, not independent
|
|
4
|
+
semantic evaluation or authentication. Inspection opens only the manifest and
|
|
5
|
+
JSONL; raw snapshots are read only by the separately requested export helper.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from contextlib import contextmanager, suppress
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import BinaryIO, TypeVar
|
|
19
|
+
from urllib.parse import unquote, urlsplit
|
|
20
|
+
from urllib.request import url2pathname
|
|
21
|
+
|
|
22
|
+
from equiframe._base import FrozenModel
|
|
23
|
+
from equiframe._regular_io import absolute_path, open_regular_file, regular_directory
|
|
24
|
+
from equiframe.comparison_types import ComparisonDifference, ComparisonResult
|
|
25
|
+
from equiframe.contracts import ColumnKeyScope, SignificantOrder
|
|
26
|
+
from equiframe.errors import ArtifactIntegrityError, ResourceLimitError
|
|
27
|
+
from equiframe.types import ArtifactRef
|
|
28
|
+
|
|
29
|
+
_ModelT = TypeVar("_ModelT", bound=FrozenModel)
|
|
30
|
+
|
|
31
|
+
_MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
|
|
32
|
+
_MAX_LINE_BYTES = 8 * 1024 * 1024
|
|
33
|
+
_SCALAR_CODES = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"nan_forbidden",
|
|
36
|
+
"null_forbidden",
|
|
37
|
+
"integer_range",
|
|
38
|
+
"float_precision",
|
|
39
|
+
"decimal_precision",
|
|
40
|
+
"date_granularity",
|
|
41
|
+
"temporal_interval",
|
|
42
|
+
"naive_timestamp",
|
|
43
|
+
"nonexistent_time",
|
|
44
|
+
"ambiguous_time",
|
|
45
|
+
"timestamp_granularity",
|
|
46
|
+
"allowed_values",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
_SCHEMA_CODES = frozenset({"missing_column", "additional_column", "column_order", "logical_type"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class EvidenceInspection:
|
|
54
|
+
"""Verified counts; caller retains counts.final for exact/lower-bound meaning.
|
|
55
|
+
|
|
56
|
+
Added/removed refer to left candidate versus right reference. These and
|
|
57
|
+
modified remain unknown for bags and before alignment.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
examples: tuple[ComparisonDifference, ...]
|
|
61
|
+
records: int
|
|
62
|
+
violations: int
|
|
63
|
+
added: int | None
|
|
64
|
+
removed: int | None
|
|
65
|
+
modified: int | None
|
|
66
|
+
code_counts: tuple[tuple[str, int], ...]
|
|
67
|
+
candidate_excess: int
|
|
68
|
+
reference_excess: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EvidencePathError(ArtifactIntegrityError):
|
|
72
|
+
"""A comparison evidence path or file type violates confinement requirements."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _require(condition: bool, message: str) -> None:
|
|
76
|
+
if not condition:
|
|
77
|
+
raise ArtifactIntegrityError(message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _path_require(condition: bool, message: str) -> None:
|
|
81
|
+
if not condition:
|
|
82
|
+
raise EvidencePathError(message)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _limits(max_bytes: int, max_records: int, max_examples: int) -> None:
|
|
86
|
+
for value, name, minimum in (
|
|
87
|
+
(max_bytes, "max_bytes", 1),
|
|
88
|
+
(max_records, "max_records", 1),
|
|
89
|
+
(max_examples, "max_examples", 0),
|
|
90
|
+
):
|
|
91
|
+
if type(value) is not int or value < minimum:
|
|
92
|
+
raise ValueError(f"{name} must be an integer >= {minimum}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _absolute(path: Path) -> Path:
|
|
96
|
+
return absolute_path(path, path_error=EvidencePathError)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _directory(path: Path) -> Path:
|
|
100
|
+
return regular_directory(path, path_error=EvidencePathError)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _reference_path(ref: ArtifactRef, root: Path) -> Path:
|
|
104
|
+
try:
|
|
105
|
+
parts = urlsplit(ref.uri)
|
|
106
|
+
except ValueError:
|
|
107
|
+
raise EvidencePathError("evidence URI is invalid") from None
|
|
108
|
+
_path_require(
|
|
109
|
+
parts.scheme == "file" and not parts.netloc and not parts.query and not parts.fragment,
|
|
110
|
+
"evidence requires a local file URI without a host",
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
decoded = unquote(parts.path, errors="strict")
|
|
114
|
+
except UnicodeError:
|
|
115
|
+
raise EvidencePathError("evidence path encoding is invalid") from None
|
|
116
|
+
_path_require(
|
|
117
|
+
not decoded.startswith("//") and "\\" not in decoded and "\x00" not in decoded,
|
|
118
|
+
"network or malformed evidence paths are not permitted",
|
|
119
|
+
)
|
|
120
|
+
path = Path(url2pathname(parts.path))
|
|
121
|
+
_path_require(path.is_absolute(), "evidence paths must be absolute")
|
|
122
|
+
path = _absolute(path)
|
|
123
|
+
_path_require(path.is_relative_to(root), "evidence reference is outside the authorized root")
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@contextmanager
|
|
128
|
+
def _open_regular(path: Path) -> Iterator[BinaryIO]:
|
|
129
|
+
"""Check every ancestor, reject special files, and pin POSIX directories.
|
|
130
|
+
|
|
131
|
+
On systems without openat/O_NOFOLLOW, identities are checked around open.
|
|
132
|
+
These cooperative checks do not sandbox a hostile concurrent filesystem.
|
|
133
|
+
"""
|
|
134
|
+
with open_regular_file(path, path_error=EvidencePathError) as stream:
|
|
135
|
+
yield stream
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
139
|
+
result: dict[str, object] = {}
|
|
140
|
+
for key, value in pairs:
|
|
141
|
+
_require(key not in result, "duplicate JSON object member in evidence")
|
|
142
|
+
result[key] = value
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _constant(value: str) -> object:
|
|
147
|
+
raise ArtifactIntegrityError("nonstandard JSON number in evidence")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _document(raw: bytes, model: type[_ModelT]) -> _ModelT:
|
|
151
|
+
try:
|
|
152
|
+
text = raw.decode("utf-8")
|
|
153
|
+
parsed = json.loads(text, object_pairs_hook=_object, parse_constant=_constant)
|
|
154
|
+
_require(isinstance(parsed, dict), "evidence JSON must be an object")
|
|
155
|
+
return model.model_validate_json(text)
|
|
156
|
+
except (UnicodeError, ValueError, RecursionError) as error:
|
|
157
|
+
raise ArtifactIntegrityError("invalid typed evidence JSON") from error
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read_document(path: Path, model: type[_ModelT], budget: int) -> tuple[_ModelT, int]:
|
|
161
|
+
with _open_regular(path) as source:
|
|
162
|
+
limit = min(budget, _MAX_DOCUMENT_BYTES)
|
|
163
|
+
if os.fstat(source.fileno()).st_size > limit:
|
|
164
|
+
raise ResourceLimitError("manifest exceeds report byte budget")
|
|
165
|
+
raw = source.read(limit + 1)
|
|
166
|
+
if len(raw) > limit:
|
|
167
|
+
raise ResourceLimitError("manifest exceeds report byte budget")
|
|
168
|
+
return _document(raw, model), len(raw)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _manifest(result: ComparisonResult, root: Path, max_bytes: int) -> tuple[Path, int]:
|
|
172
|
+
root = _directory(root)
|
|
173
|
+
path = _reference_path(result.differences, root)
|
|
174
|
+
_path_require(
|
|
175
|
+
path.name == "differences.jsonl" and path.parent.name == result.comparison_id,
|
|
176
|
+
"evidence must use the comparison identifier and conventional filenames",
|
|
177
|
+
)
|
|
178
|
+
persisted, size = _read_document(path.with_name("comparison.json"), ComparisonResult, max_bytes)
|
|
179
|
+
_require(persisted == result, "persisted comparison manifest differs from supplied result")
|
|
180
|
+
return path, size
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _validate_difference(difference: ComparisonDifference, result: ComparisonResult) -> None:
|
|
184
|
+
schema = result.spec.contract.output.schema_spec
|
|
185
|
+
names = {column.name for column in schema.columns}
|
|
186
|
+
width = len(names)
|
|
187
|
+
rows = {"left": result.counts.left_rows, "right": result.counts.right_rows}
|
|
188
|
+
d = difference
|
|
189
|
+
if d.category == "content":
|
|
190
|
+
_require(result.strategy != "pending", "content evidence requires an alignment strategy")
|
|
191
|
+
_require(d.side is None, "content differences must not carry a violation side")
|
|
192
|
+
for position, count, values, side in (
|
|
193
|
+
(d.position, d.left_count, d.left, "left"),
|
|
194
|
+
(d.other_position, d.right_count, d.right, "right"),
|
|
195
|
+
):
|
|
196
|
+
_require((position is not None) == (count > 0), "content count/position mismatch")
|
|
197
|
+
_require(bool(values) == (count > 0), "content count/value mismatch")
|
|
198
|
+
_require(count <= rows[side], "content count exceeds observed rows")
|
|
199
|
+
if position is not None:
|
|
200
|
+
_require(position < rows[side], "content position exceeds observed rows")
|
|
201
|
+
if d.code == "cell_value":
|
|
202
|
+
_require(result.strategy in ("key", "sequence"), "cell differences require alignment")
|
|
203
|
+
_require(d.column in names, "cell column is not declared")
|
|
204
|
+
_require(
|
|
205
|
+
d.left_count == d.right_count == len(d.left) == len(d.right) == 1,
|
|
206
|
+
"cell difference must contain two single values",
|
|
207
|
+
)
|
|
208
|
+
if result.strategy == "key":
|
|
209
|
+
key = next(key for key in schema.keys if key.name == result.selected_key)
|
|
210
|
+
key_width = len(key.columns)
|
|
211
|
+
if isinstance(key.scope, ColumnKeyScope):
|
|
212
|
+
key_width += len(key.scope.columns)
|
|
213
|
+
_require(len(d.key) == key_width, "cell key dimensions do not match selected key")
|
|
214
|
+
else:
|
|
215
|
+
_require(not d.key, "sequence differences must not claim a key")
|
|
216
|
+
_require(d.position == d.other_position, "sequence cell positions must align")
|
|
217
|
+
elif d.code == "row_multiplicity":
|
|
218
|
+
_require(d.column is None and not d.key, "row differences have no column or key")
|
|
219
|
+
_require(d.left_count != d.right_count, "row difference must change multiplicity")
|
|
220
|
+
_require(
|
|
221
|
+
(not d.left or len(d.left) == width) and (not d.right or len(d.right) == width),
|
|
222
|
+
"row value dimensions do not match declared columns",
|
|
223
|
+
)
|
|
224
|
+
if result.strategy != "multiset":
|
|
225
|
+
_require(
|
|
226
|
+
(d.left_count, d.right_count) in ((1, 0), (0, 1)),
|
|
227
|
+
"aligned/matching unmatched rows must be single-sided occurrences",
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
raise ArtifactIntegrityError("unknown content difference code")
|
|
231
|
+
return
|
|
232
|
+
_require(d.side is not None, "contract/schema evidence requires a side")
|
|
233
|
+
_require(
|
|
234
|
+
not d.key and not d.left_count and not d.right_count and d.other_position is None,
|
|
235
|
+
"violation must not claim aligned row counts or keys",
|
|
236
|
+
)
|
|
237
|
+
_require(
|
|
238
|
+
not (d.right if d.side == "left" else d.left),
|
|
239
|
+
"violation values must belong to their declared side",
|
|
240
|
+
)
|
|
241
|
+
values = d.left if d.side == "left" else d.right
|
|
242
|
+
if d.category == "schema":
|
|
243
|
+
_require(d.code in _SCHEMA_CODES, "unknown schema difference code")
|
|
244
|
+
_require(d.position is None and not values, "schema evidence must not claim row values")
|
|
245
|
+
if d.code == "column_order":
|
|
246
|
+
_require(d.column is None, "column order evidence must not select one column")
|
|
247
|
+
elif d.code == "additional_column":
|
|
248
|
+
_require(d.column is not None and d.column not in names, "invalid additional column")
|
|
249
|
+
else:
|
|
250
|
+
_require(d.column in names, "schema column is not declared")
|
|
251
|
+
return
|
|
252
|
+
if not (d.side is not None):
|
|
253
|
+
raise AssertionError("Internal invariant check failed.")
|
|
254
|
+
_require(d.position is not None, "contract evidence requires a row position")
|
|
255
|
+
if not (d.position is not None):
|
|
256
|
+
raise AssertionError("Internal invariant check failed.")
|
|
257
|
+
# Ingest emits violations before incrementing its row count. Interruption
|
|
258
|
+
# can retain a violation at exactly the next observed position.
|
|
259
|
+
_require(
|
|
260
|
+
d.position < rows[d.side] or (not result.complete and d.position == rows[d.side]),
|
|
261
|
+
"violation position exceeds observed rows",
|
|
262
|
+
)
|
|
263
|
+
if d.code in _SCALAR_CODES:
|
|
264
|
+
_require(d.column in names and len(values) == 1, "scalar violation dimensions mismatch")
|
|
265
|
+
return
|
|
266
|
+
_require(d.column is None, "row/key violations must not select one column")
|
|
267
|
+
if d.code in ("order_violation", "order_tie"):
|
|
268
|
+
_require(isinstance(schema.order, SignificantOrder), "order violation needs ordered policy")
|
|
269
|
+
_require(len(values) == width, "order violation row dimensions mismatch")
|
|
270
|
+
return
|
|
271
|
+
if d.code == "duplicate_normalized_row":
|
|
272
|
+
_require(schema.multiplicity == "distinct", "duplicate row needs distinct policy")
|
|
273
|
+
_require(len(values) == width, "duplicate row dimensions mismatch")
|
|
274
|
+
return
|
|
275
|
+
for key in schema.keys:
|
|
276
|
+
key_width = len(key.columns)
|
|
277
|
+
if isinstance(key.scope, ColumnKeyScope):
|
|
278
|
+
key_width += len(key.scope.columns)
|
|
279
|
+
incomplete = {f"incomplete_{phase}_{key.name}" for phase in ("raw", "normalized")}
|
|
280
|
+
duplicate = {f"duplicate_{phase}_key_{key.name}" for phase in ("raw", "normalized")}
|
|
281
|
+
if d.code in incomplete | duplicate:
|
|
282
|
+
_require(len(values) == key_width, "key violation dimensions mismatch")
|
|
283
|
+
_require(
|
|
284
|
+
key.incomplete == "reject" if d.code in incomplete else key.unique,
|
|
285
|
+
"key violation requires the corresponding declared policy",
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
raise ArtifactIntegrityError("unknown contract difference code")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def inspect_evidence(
|
|
292
|
+
result: ComparisonResult,
|
|
293
|
+
root: Path,
|
|
294
|
+
*,
|
|
295
|
+
max_bytes: int,
|
|
296
|
+
max_records: int,
|
|
297
|
+
max_examples: int,
|
|
298
|
+
) -> EvidenceInspection:
|
|
299
|
+
"""Verify all bounded persisted records without opening source/execution paths.
|
|
300
|
+
|
|
301
|
+
The aggregate byte budget includes the manifest and JSONL. Oversized lines
|
|
302
|
+
or manifests fail instead of returning truncated verification. Examples are
|
|
303
|
+
the requested prefix, independent of how many persisted records are checked.
|
|
304
|
+
"""
|
|
305
|
+
_limits(max_bytes, max_records, max_examples)
|
|
306
|
+
result = ComparisonResult.model_validate(result)
|
|
307
|
+
path, manifest_size = _manifest(result, root, max_bytes)
|
|
308
|
+
ref = result.differences
|
|
309
|
+
remaining = max_bytes - manifest_size
|
|
310
|
+
if ref.size_bytes > remaining or result.counts.difference_records > max_records:
|
|
311
|
+
raise ResourceLimitError("comparison evidence exceeds report inspection budget")
|
|
312
|
+
digest = hashlib.sha256()
|
|
313
|
+
size = records = violations = added = removed = 0
|
|
314
|
+
codes: Counter[str] = Counter()
|
|
315
|
+
examples: list[ComparisonDifference] = []
|
|
316
|
+
modified: set[tuple[int, int]] = set()
|
|
317
|
+
cells: set[tuple[int, int, str]] = set()
|
|
318
|
+
left_positions: dict[int, int | None] = {}
|
|
319
|
+
right_positions: dict[int, int | None] = {}
|
|
320
|
+
with _open_regular(path) as source:
|
|
321
|
+
_require(os.fstat(source.fileno()).st_size == ref.size_bytes, "difference size mismatch")
|
|
322
|
+
while True:
|
|
323
|
+
line = source.readline(min(_MAX_LINE_BYTES, remaining - size) + 1)
|
|
324
|
+
if not line:
|
|
325
|
+
break
|
|
326
|
+
size += len(line)
|
|
327
|
+
if size > remaining or len(line) > _MAX_LINE_BYTES:
|
|
328
|
+
raise ResourceLimitError("difference line/total exceeds report byte budget")
|
|
329
|
+
records += 1
|
|
330
|
+
if records > max_records:
|
|
331
|
+
raise ResourceLimitError("difference records exceed report record budget")
|
|
332
|
+
digest.update(line)
|
|
333
|
+
difference = _document(line, ComparisonDifference)
|
|
334
|
+
_validate_difference(difference, result)
|
|
335
|
+
if records <= len(result.examples):
|
|
336
|
+
_require(
|
|
337
|
+
difference == result.examples[records - 1],
|
|
338
|
+
"manifest examples do not match the persisted prefix",
|
|
339
|
+
)
|
|
340
|
+
if records <= max_examples:
|
|
341
|
+
examples.append(difference)
|
|
342
|
+
codes[difference.code] += 1
|
|
343
|
+
violations += difference.category in ("contract", "schema")
|
|
344
|
+
if difference.category != "content":
|
|
345
|
+
continue
|
|
346
|
+
a, b = difference.position, difference.other_position
|
|
347
|
+
if difference.code == "cell_value":
|
|
348
|
+
if not (a is not None and b is not None and difference.column is not None):
|
|
349
|
+
raise AssertionError("Internal invariant check failed.")
|
|
350
|
+
cell = (a, b, difference.column)
|
|
351
|
+
_require(cell not in cells, "duplicate aligned cell evidence")
|
|
352
|
+
cells.add(cell)
|
|
353
|
+
_require(
|
|
354
|
+
left_positions.get(a, b) == b and right_positions.get(b, a) == a,
|
|
355
|
+
"content positions do not define a one-to-one alignment",
|
|
356
|
+
)
|
|
357
|
+
left_positions[a], right_positions[b] = b, a
|
|
358
|
+
modified.add((a, b))
|
|
359
|
+
else:
|
|
360
|
+
for position, positions in ((a, left_positions), (b, right_positions)):
|
|
361
|
+
if position is not None:
|
|
362
|
+
_require(position not in positions, "repeated unmatched row position")
|
|
363
|
+
positions[position] = None
|
|
364
|
+
added += max(0, difference.left_count - difference.right_count)
|
|
365
|
+
removed += max(0, difference.right_count - difference.left_count)
|
|
366
|
+
_require(
|
|
367
|
+
size == ref.size_bytes and digest.hexdigest() == ref.sha256, "difference hash/size mismatch"
|
|
368
|
+
)
|
|
369
|
+
_require(records == result.counts.difference_records, "difference record count mismatch")
|
|
370
|
+
_require(violations == result.counts.contract_violations, "contract violation count mismatch")
|
|
371
|
+
_require(
|
|
372
|
+
result.counts.matched_rows + len(modified) + added <= result.counts.left_rows
|
|
373
|
+
and result.counts.matched_rows + len(modified) + removed <= result.counts.right_rows,
|
|
374
|
+
"content evidence exceeds observed row counts",
|
|
375
|
+
)
|
|
376
|
+
if result.complete:
|
|
377
|
+
_require(
|
|
378
|
+
result.counts.matched_rows + len(modified) + added == result.counts.left_rows
|
|
379
|
+
and result.counts.matched_rows + len(modified) + removed == result.counts.right_rows,
|
|
380
|
+
"content evidence does not account for completed row counts",
|
|
381
|
+
)
|
|
382
|
+
aligned = result.strategy in ("key", "sequence")
|
|
383
|
+
return EvidenceInspection(
|
|
384
|
+
examples=tuple(examples),
|
|
385
|
+
records=records,
|
|
386
|
+
violations=violations,
|
|
387
|
+
added=added if aligned else None,
|
|
388
|
+
removed=removed if aligned else None,
|
|
389
|
+
modified=len(modified) if aligned else None,
|
|
390
|
+
code_counts=tuple(sorted(codes.items())),
|
|
391
|
+
candidate_excess=added,
|
|
392
|
+
reference_excess=removed,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _json_bytes(value: object) -> bytes:
|
|
397
|
+
return json.dumps(
|
|
398
|
+
value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
399
|
+
).encode("utf-8")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _inventory(path: Path, digest: str, size: int) -> dict[str, object]:
|
|
403
|
+
return {"path": path.name, "sha256": digest, "size_bytes": size}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _write_private(path: Path, data: bytes) -> dict[str, object]:
|
|
407
|
+
descriptor = os.open(
|
|
408
|
+
path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600
|
|
409
|
+
)
|
|
410
|
+
with os.fdopen(descriptor, "wb") as target:
|
|
411
|
+
target.write(data)
|
|
412
|
+
target.flush()
|
|
413
|
+
os.fsync(target.fileno())
|
|
414
|
+
return _inventory(path, hashlib.sha256(data).hexdigest(), len(data))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _copy_capture(
|
|
418
|
+
ref: ArtifactRef, root: Path, destination: Path, budget: int
|
|
419
|
+
) -> dict[str, object]:
|
|
420
|
+
_require(ref.format in ("arrow", "parquet"), "reproduction captures require Arrow/Parquet")
|
|
421
|
+
if ref.size_bytes > budget:
|
|
422
|
+
raise ResourceLimitError("reproduction captures exceed export byte budget")
|
|
423
|
+
source_path = _reference_path(ref, root)
|
|
424
|
+
digest = hashlib.sha256()
|
|
425
|
+
size = 0
|
|
426
|
+
with _open_regular(source_path) as source:
|
|
427
|
+
_require(os.fstat(source.fileno()).st_size == ref.size_bytes, "capture size mismatch")
|
|
428
|
+
descriptor = os.open(
|
|
429
|
+
destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600
|
|
430
|
+
)
|
|
431
|
+
with os.fdopen(descriptor, "wb") as target:
|
|
432
|
+
while block := source.read(min(1024 * 1024, budget - size + 1)):
|
|
433
|
+
size += len(block)
|
|
434
|
+
if size > budget:
|
|
435
|
+
raise ResourceLimitError("reproduction captures exceed export byte budget")
|
|
436
|
+
digest.update(block)
|
|
437
|
+
target.write(block)
|
|
438
|
+
target.flush()
|
|
439
|
+
os.fsync(target.fileno())
|
|
440
|
+
_require(
|
|
441
|
+
size == ref.size_bytes and digest.hexdigest() == ref.sha256, "capture hash/size mismatch"
|
|
442
|
+
)
|
|
443
|
+
return _inventory(destination, digest.hexdigest(), size)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def export_reproduction(
|
|
447
|
+
result: ComparisonResult,
|
|
448
|
+
root: Path,
|
|
449
|
+
destination: Path,
|
|
450
|
+
*,
|
|
451
|
+
max_bytes: int,
|
|
452
|
+
max_records: int,
|
|
453
|
+
) -> dict[str, object]:
|
|
454
|
+
"""Export confined captured outputs and context, never execute transformations.
|
|
455
|
+
|
|
456
|
+
The caller must separately authorize full data/metadata disclosure. The new
|
|
457
|
+
destination is private on POSIX and must not exist. Only a final
|
|
458
|
+
reproduction.json marks successful publication; failures remove files this
|
|
459
|
+
helper created. This is materialized-comparison context, not original code,
|
|
460
|
+
execution inputs, a promised deterministic replay, or an authenticated proof.
|
|
461
|
+
"""
|
|
462
|
+
from equiframe.diff import _DiffReceipt
|
|
463
|
+
|
|
464
|
+
_limits(max_bytes, max_records, 0)
|
|
465
|
+
result = ComparisonResult.model_validate(result)
|
|
466
|
+
_require(result.complete, "reproduction requires a complete comparison")
|
|
467
|
+
_require(
|
|
468
|
+
result.left is not None and result.right is not None,
|
|
469
|
+
"reproduction requires both captured outputs",
|
|
470
|
+
)
|
|
471
|
+
if not (result.left is not None and result.right is not None):
|
|
472
|
+
raise AssertionError("Internal invariant check failed.")
|
|
473
|
+
_require(
|
|
474
|
+
result.left.execution is None and result.right.execution is None,
|
|
475
|
+
"execution-backed sources require their independent execution context; export unsupported",
|
|
476
|
+
)
|
|
477
|
+
inspect_evidence(result, root, max_bytes=max_bytes, max_records=max_records, max_examples=0)
|
|
478
|
+
root = _directory(root)
|
|
479
|
+
evidence_path, consumed = _manifest(result, root, max_bytes)
|
|
480
|
+
consumed += result.differences.size_bytes
|
|
481
|
+
context: dict[str, object] = {
|
|
482
|
+
"schema_version": "1.0",
|
|
483
|
+
"scope": "materialized_comparison_only_no_transform_reexecution",
|
|
484
|
+
"spec": result.spec.model_dump(mode="json"),
|
|
485
|
+
"options": result.options.model_dump(mode="json"),
|
|
486
|
+
"reference": result.reference,
|
|
487
|
+
"versions": [item.model_dump(mode="json") for item in result.versions],
|
|
488
|
+
}
|
|
489
|
+
companion = evidence_path.with_name("diff-contract.json")
|
|
490
|
+
try:
|
|
491
|
+
companion.lstat()
|
|
492
|
+
except FileNotFoundError:
|
|
493
|
+
pass
|
|
494
|
+
else:
|
|
495
|
+
receipt, size = _read_document(companion, _DiffReceipt, max_bytes - consumed)
|
|
496
|
+
consumed += size
|
|
497
|
+
_require(
|
|
498
|
+
receipt.comparison_id == result.comparison_id
|
|
499
|
+
and receipt.spec_sha256 == result.spec_sha256
|
|
500
|
+
and receipt.declaration.spec == result.spec
|
|
501
|
+
and receipt.declaration.reference == result.reference,
|
|
502
|
+
"diff declaration does not match the comparison",
|
|
503
|
+
)
|
|
504
|
+
context["diff_declaration"] = receipt.declaration.model_dump(mode="json")
|
|
505
|
+
context["capture_options"] = receipt.capture_options.model_dump(mode="json")
|
|
506
|
+
destination = _absolute(destination)
|
|
507
|
+
_directory(destination.parent)
|
|
508
|
+
files: list[dict[str, object]] = []
|
|
509
|
+
created: list[Path] = []
|
|
510
|
+
made_directory = False
|
|
511
|
+
try:
|
|
512
|
+
destination.mkdir(mode=0o700)
|
|
513
|
+
made_directory = True
|
|
514
|
+
for side, source in (("left", result.left), ("right", result.right)):
|
|
515
|
+
captured = destination / f"{side}.{source.data.format}"
|
|
516
|
+
created.append(captured)
|
|
517
|
+
item = _copy_capture(source.data, evidence_path.parent, captured, max_bytes - consumed)
|
|
518
|
+
consumed += source.data.size_bytes
|
|
519
|
+
files.append(item)
|
|
520
|
+
capture: dict[str, object] = {"data": item | {"format": source.data.format}}
|
|
521
|
+
if source.table is not None:
|
|
522
|
+
table = source.table
|
|
523
|
+
_require(
|
|
524
|
+
(table.data.sha256, table.data.size_bytes, table.data.format)
|
|
525
|
+
== (source.data.sha256, source.data.size_bytes, source.data.format),
|
|
526
|
+
"table context does not identify the captured output",
|
|
527
|
+
)
|
|
528
|
+
_require(
|
|
529
|
+
table.name == result.spec.contract.output.name
|
|
530
|
+
and table.row_count
|
|
531
|
+
== (result.counts.left_rows if side == "left" else result.counts.right_rows)
|
|
532
|
+
and tuple((column.name, column.arrow_type) for column in table.columns)
|
|
533
|
+
== source.observed_schema,
|
|
534
|
+
"table context does not match observed schema/rows",
|
|
535
|
+
)
|
|
536
|
+
capture["table"] = {
|
|
537
|
+
"name": table.name,
|
|
538
|
+
"row_count": table.row_count,
|
|
539
|
+
"columns": [column.model_dump(mode="json") for column in table.columns],
|
|
540
|
+
"index_columns": [
|
|
541
|
+
column.model_dump(mode="json") for column in table.index_columns
|
|
542
|
+
],
|
|
543
|
+
}
|
|
544
|
+
if table.index is not None:
|
|
545
|
+
index_path = destination / f"{side}-index.{table.index.format}"
|
|
546
|
+
created.append(index_path)
|
|
547
|
+
index_item = _copy_capture(
|
|
548
|
+
table.index, evidence_path.parent, index_path, max_bytes - consumed
|
|
549
|
+
)
|
|
550
|
+
consumed += table.index.size_bytes
|
|
551
|
+
files.append(index_item)
|
|
552
|
+
capture["index"] = index_item | {"format": table.index.format}
|
|
553
|
+
context[side] = capture
|
|
554
|
+
data = _json_bytes(context)
|
|
555
|
+
if consumed + len(data) > max_bytes or len(data) > _MAX_DOCUMENT_BYTES:
|
|
556
|
+
raise ResourceLimitError("reproduction context exceeds export byte budget")
|
|
557
|
+
context_path = destination / "context.json"
|
|
558
|
+
created.append(context_path)
|
|
559
|
+
files.append(_write_private(context_path, data))
|
|
560
|
+
consumed += len(data)
|
|
561
|
+
manifest: dict[str, object] = {
|
|
562
|
+
"schema_version": "1.0",
|
|
563
|
+
"scope": "materialized_comparison_only_no_transform_reexecution",
|
|
564
|
+
"complete": True,
|
|
565
|
+
"files": files,
|
|
566
|
+
"context": "context.json",
|
|
567
|
+
"source_state": result.state,
|
|
568
|
+
"source_complete": result.complete,
|
|
569
|
+
"source_spec_sha256": result.spec_sha256,
|
|
570
|
+
"source_contract_sha256": result.contract_sha256,
|
|
571
|
+
"integrity": "hashes_identify_bytes_not_authentication_or_semantic_proof",
|
|
572
|
+
}
|
|
573
|
+
manifest_bytes = _json_bytes(manifest)
|
|
574
|
+
if consumed + len(manifest_bytes) > max_bytes:
|
|
575
|
+
raise ResourceLimitError("reproduction manifest exceeds export byte budget")
|
|
576
|
+
_require(
|
|
577
|
+
{path.name for path in destination.iterdir()} == {item["path"] for item in files},
|
|
578
|
+
"reproduction destination contains files outside the closed inventory",
|
|
579
|
+
)
|
|
580
|
+
final_path = destination / "reproduction.json"
|
|
581
|
+
created.append(final_path)
|
|
582
|
+
_write_private(final_path, manifest_bytes)
|
|
583
|
+
return manifest
|
|
584
|
+
except BaseException:
|
|
585
|
+
for path in reversed(created):
|
|
586
|
+
with suppress(OSError):
|
|
587
|
+
path.unlink(missing_ok=True)
|
|
588
|
+
if made_directory:
|
|
589
|
+
with suppress(OSError):
|
|
590
|
+
destination.rmdir()
|
|
591
|
+
raise
|