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,660 @@
|
|
|
1
|
+
"""Build and publish conservative comparison views without rerunning sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, cast
|
|
10
|
+
|
|
11
|
+
from equiframe._regular_io import local_input_path, local_output_directory, open_regular_file
|
|
12
|
+
from equiframe.comparison_types import ComparisonDifference, ComparisonResult, ComparisonValue
|
|
13
|
+
from equiframe.contracts import ColumnKeyScope
|
|
14
|
+
from equiframe.diff_contracts import DiffContract
|
|
15
|
+
from equiframe.errors import ArtifactIntegrityError, ArtifactIOError, ResourceLimitError
|
|
16
|
+
from equiframe.evidence import canonical_bytes
|
|
17
|
+
from equiframe.report_types import (
|
|
18
|
+
ComparisonReport,
|
|
19
|
+
CountPrecision,
|
|
20
|
+
ReportCount,
|
|
21
|
+
ReportEvidenceManifest,
|
|
22
|
+
ReportOptions,
|
|
23
|
+
ReportPolicy,
|
|
24
|
+
ReportPublicationManifest,
|
|
25
|
+
ReportValue,
|
|
26
|
+
ReportViewPolicy,
|
|
27
|
+
ReportWitness,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_REASONS = {
|
|
31
|
+
"cell_value": "Aligned values differ under the declared policy",
|
|
32
|
+
"row_multiplicity": "Unmatched row occurrences; multiplicity is preserved",
|
|
33
|
+
"missing_column": "A declared column is missing",
|
|
34
|
+
"additional_column": "An undeclared column is present",
|
|
35
|
+
"column_order": "Physical column order violates the declared policy",
|
|
36
|
+
"logical_type": "An observed type violates the declared type policy",
|
|
37
|
+
"order_violation": "Rows violate the declared sort order",
|
|
38
|
+
"order_tie": "A forbidden ordering tie was observed",
|
|
39
|
+
"nan_forbidden": "NaN violates the declared policy",
|
|
40
|
+
"null_forbidden": "NULL violates the declared policy",
|
|
41
|
+
"integer_range": "An integer lies outside the declared range",
|
|
42
|
+
"float_precision": "A value violates declared floating-point precision",
|
|
43
|
+
"decimal_precision": "A Decimal violates the declared precision or scale",
|
|
44
|
+
"date_granularity": "A date violates the declared granularity",
|
|
45
|
+
"timestamp_granularity": "A timestamp violates the declared granularity",
|
|
46
|
+
"temporal_interval": "A temporal value lies outside the declared interval",
|
|
47
|
+
"allowed_values": "A value is outside the declared allowed values",
|
|
48
|
+
"duplicate_key": "Duplicate key or row occurrences violate the contract",
|
|
49
|
+
"incomplete_key": "An incomplete key violates the contract",
|
|
50
|
+
"contract_violation": "A declared contract condition was violated",
|
|
51
|
+
"resource_exhausted": "Evaluation stopped at a resource budget",
|
|
52
|
+
"cancelled": "Evaluation was cancelled",
|
|
53
|
+
"timeout": "Evaluation exceeded its cooperative timeout",
|
|
54
|
+
"invalid_artifact": "An input artifact failed integrity verification",
|
|
55
|
+
"unsupported_capability": "The requested capability is not supported",
|
|
56
|
+
"missing_optional_dependency": "A required optional dependency is unavailable",
|
|
57
|
+
"evaluation_failed": "Evaluation failed; inspect the original private diagnostic",
|
|
58
|
+
"invalid_expected": "The expected reference violates the declared contract",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _code(code: str) -> str:
|
|
63
|
+
if code in _REASONS:
|
|
64
|
+
return code
|
|
65
|
+
if code.startswith("duplicate_"):
|
|
66
|
+
return "duplicate_key"
|
|
67
|
+
if code.startswith("incomplete_"):
|
|
68
|
+
return "incomplete_key"
|
|
69
|
+
return "contract_violation"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _bounded(value: str, options: ReportOptions) -> str:
|
|
73
|
+
return value if len(value) <= options.max_value_chars else "[omitted]"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _column(name: str, result: ComparisonResult, options: ReportOptions) -> str:
|
|
77
|
+
if options.include_metadata:
|
|
78
|
+
return _bounded(name, options)
|
|
79
|
+
names = [column.name for column in result.spec.contract.output.schema_spec.columns]
|
|
80
|
+
return f"column_{names.index(name) + 1}" if name in names else "[undeclared column]"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _representation(value: ComparisonValue) -> str:
|
|
84
|
+
if value.kind == "null":
|
|
85
|
+
return "NULL"
|
|
86
|
+
if value.kind == "nan":
|
|
87
|
+
return "NaN"
|
|
88
|
+
if value.kind == "decimal":
|
|
89
|
+
return f"Decimal({json.dumps(value.value)})"
|
|
90
|
+
if value.kind in ("date", "timestamp"):
|
|
91
|
+
return (
|
|
92
|
+
f"{value.kind}(epoch={value.value}, unit={value.unit}, "
|
|
93
|
+
f"timezone={json.dumps(value.timezone)})"
|
|
94
|
+
)
|
|
95
|
+
if value.kind == "string":
|
|
96
|
+
return json.dumps(value.value, ensure_ascii=False)
|
|
97
|
+
if value.kind == "infinity":
|
|
98
|
+
return f"{value.value} infinity"
|
|
99
|
+
return f"{value.kind}({value.value})"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _value(
|
|
103
|
+
value: ComparisonValue,
|
|
104
|
+
name: str | None,
|
|
105
|
+
side: Literal["left", "right"],
|
|
106
|
+
result: ComparisonResult,
|
|
107
|
+
options: ReportOptions,
|
|
108
|
+
) -> ReportValue:
|
|
109
|
+
source = result.left if side == "left" else result.right
|
|
110
|
+
native_type = physical_type = None
|
|
111
|
+
if name is not None and source is not None:
|
|
112
|
+
physical_type = dict(source.observed_schema).get(name)
|
|
113
|
+
if source.table:
|
|
114
|
+
native_type = next(
|
|
115
|
+
(column.engine_type for column in source.table.columns if column.name == name),
|
|
116
|
+
None,
|
|
117
|
+
)
|
|
118
|
+
if name is not None and source is not None and name not in dict(source.observed_schema):
|
|
119
|
+
# The kernel's aligned tuple may contain a NULL placeholder for a missing
|
|
120
|
+
# column. It is not an observed NULL scalar in the original table.
|
|
121
|
+
return ReportValue(disclosure="omitted", kind="absent_column", representation="[omitted]")
|
|
122
|
+
# Producer-supplied dtype text is metadata too; it can contain free-form text.
|
|
123
|
+
if not options.include_metadata:
|
|
124
|
+
native_type = physical_type = None
|
|
125
|
+
else:
|
|
126
|
+
native_type = _bounded(native_type, options) if native_type is not None else None
|
|
127
|
+
physical_type = _bounded(physical_type, options) if physical_type is not None else None
|
|
128
|
+
withheld = not options.include_values or name in options.redact_columns
|
|
129
|
+
if name is None and options.redact_columns:
|
|
130
|
+
withheld = True # Ambiguous contract witnesses cannot bypass column redaction.
|
|
131
|
+
representation = _representation(value)
|
|
132
|
+
disclosure: Literal["visible", "redacted", "omitted"] = (
|
|
133
|
+
"redacted"
|
|
134
|
+
if withheld
|
|
135
|
+
else "omitted"
|
|
136
|
+
if len(representation) > options.max_value_chars
|
|
137
|
+
else "visible"
|
|
138
|
+
)
|
|
139
|
+
return ReportValue(
|
|
140
|
+
disclosure=disclosure,
|
|
141
|
+
kind=value.kind,
|
|
142
|
+
representation=representation if disclosure == "visible" else f"[{disclosure}]",
|
|
143
|
+
native_type=native_type,
|
|
144
|
+
physical_type=physical_type,
|
|
145
|
+
value=value if disclosure == "visible" else None,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _witness(
|
|
150
|
+
difference: ComparisonDifference, result: ComparisonResult, options: ReportOptions
|
|
151
|
+
) -> ReportWitness:
|
|
152
|
+
schema = result.spec.contract.output.schema_spec
|
|
153
|
+
names = tuple(column.name for column in schema.columns)
|
|
154
|
+
key = next((key for key in schema.keys if key.name == result.selected_key), None)
|
|
155
|
+
|
|
156
|
+
def key_columns(key: object) -> tuple[str, ...]:
|
|
157
|
+
from equiframe.contracts import KeySpec
|
|
158
|
+
|
|
159
|
+
if not (isinstance(key, KeySpec)):
|
|
160
|
+
raise AssertionError("Internal invariant check failed.")
|
|
161
|
+
return (
|
|
162
|
+
(*key.scope.columns, *key.columns)
|
|
163
|
+
if isinstance(key.scope, ColumnKeyScope)
|
|
164
|
+
else key.columns
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
key_names = key_columns(key) if key else ()
|
|
168
|
+
violation_columns: tuple[str, ...] | None = None
|
|
169
|
+
for declared_key in schema.keys:
|
|
170
|
+
if difference.code in {
|
|
171
|
+
f"{prefix}_{phase}_{name}"
|
|
172
|
+
for prefix, name in (
|
|
173
|
+
("duplicate", "key_" + declared_key.name),
|
|
174
|
+
("incomplete", declared_key.name),
|
|
175
|
+
)
|
|
176
|
+
for phase in ("raw", "normalized")
|
|
177
|
+
}:
|
|
178
|
+
violation_columns = key_columns(declared_key)
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
def values(
|
|
182
|
+
raw: tuple[ComparisonValue, ...], side: Literal["left", "right"]
|
|
183
|
+
) -> tuple[ReportValue, ...]:
|
|
184
|
+
columns = (
|
|
185
|
+
violation_columns
|
|
186
|
+
if violation_columns is not None and len(raw) == len(violation_columns)
|
|
187
|
+
else (difference.column,)
|
|
188
|
+
if difference.column is not None and len(raw) == 1
|
|
189
|
+
else names
|
|
190
|
+
if len(raw) == len(names)
|
|
191
|
+
else ()
|
|
192
|
+
)
|
|
193
|
+
return tuple(
|
|
194
|
+
_value(value, columns[index] if columns else None, side, result, options)
|
|
195
|
+
for index, value in enumerate(raw)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
code = _code(difference.code)
|
|
199
|
+
return ReportWitness(
|
|
200
|
+
category=difference.category,
|
|
201
|
+
code=code,
|
|
202
|
+
reason=_REASONS[code],
|
|
203
|
+
position=difference.position if difference.side != "right" else None,
|
|
204
|
+
other_position=difference.position
|
|
205
|
+
if difference.side == "right"
|
|
206
|
+
else difference.other_position,
|
|
207
|
+
column=_column(difference.column, result, options)
|
|
208
|
+
if difference.column is not None
|
|
209
|
+
else None,
|
|
210
|
+
columns=tuple(_column(name, result, options) for name in (violation_columns or names)),
|
|
211
|
+
key_columns=tuple(_column(name, result, options) for name in key_names),
|
|
212
|
+
key=tuple(
|
|
213
|
+
_value(
|
|
214
|
+
value, key_names[index] if index < len(key_names) else None, "left", result, options
|
|
215
|
+
)
|
|
216
|
+
for index, value in enumerate(difference.key)
|
|
217
|
+
),
|
|
218
|
+
candidate=values(difference.left, "left"),
|
|
219
|
+
reference=values(difference.right, "right"),
|
|
220
|
+
candidate_count=difference.left_count if difference.category == "content" else None,
|
|
221
|
+
reference_count=difference.right_count if difference.category == "content" else None,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _policies(
|
|
226
|
+
result: ComparisonResult, options: ReportOptions, declaration: DiffContract | None
|
|
227
|
+
) -> tuple[ReportPolicy, ...]:
|
|
228
|
+
schema = result.spec.contract.output.schema_spec
|
|
229
|
+
policies = [
|
|
230
|
+
ReportPolicy(name="column_order", value=result.spec.policy.column_order),
|
|
231
|
+
ReportPolicy(name="physical_types", value=result.spec.policy.physical_types),
|
|
232
|
+
ReportPolicy(name="index", value=result.spec.policy.index),
|
|
233
|
+
ReportPolicy(name="multiplicity", value=schema.multiplicity),
|
|
234
|
+
ReportPolicy(name="row_order", value=schema.order.kind),
|
|
235
|
+
ReportPolicy(name="alignment", value=result.strategy),
|
|
236
|
+
ReportPolicy(
|
|
237
|
+
name="key_validation",
|
|
238
|
+
value="globally validated"
|
|
239
|
+
if result.strategy == "key"
|
|
240
|
+
else "no validated key alignment",
|
|
241
|
+
),
|
|
242
|
+
]
|
|
243
|
+
from equiframe.contracts import SignificantOrder
|
|
244
|
+
|
|
245
|
+
if isinstance(schema.order, SignificantOrder):
|
|
246
|
+
order = {
|
|
247
|
+
"ties": schema.order.ties,
|
|
248
|
+
"by": [
|
|
249
|
+
{
|
|
250
|
+
"column": _column(item.column, result, options),
|
|
251
|
+
"direction": item.direction,
|
|
252
|
+
"nulls": item.nulls,
|
|
253
|
+
}
|
|
254
|
+
for item in schema.order.by
|
|
255
|
+
],
|
|
256
|
+
}
|
|
257
|
+
policies.append(
|
|
258
|
+
ReportPolicy(name="order_policy", value=_bounded(json.dumps(order), options))
|
|
259
|
+
)
|
|
260
|
+
for index, key in enumerate(schema.keys):
|
|
261
|
+
policies.append(
|
|
262
|
+
ReportPolicy(
|
|
263
|
+
name=f"declared_key_{index + 1}",
|
|
264
|
+
value=_bounded(
|
|
265
|
+
json.dumps(
|
|
266
|
+
{
|
|
267
|
+
"columns": [_column(name, result, options) for name in key.columns],
|
|
268
|
+
"scope_columns": [
|
|
269
|
+
_column(name, result, options) for name in key.scope.columns
|
|
270
|
+
]
|
|
271
|
+
if isinstance(key.scope, ColumnKeyScope)
|
|
272
|
+
else [],
|
|
273
|
+
"unique": key.unique,
|
|
274
|
+
"incomplete": key.incomplete,
|
|
275
|
+
"selected": key.name == result.selected_key,
|
|
276
|
+
}
|
|
277
|
+
),
|
|
278
|
+
options,
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
for column in schema.columns:
|
|
283
|
+
name = _column(column.name, result, options)
|
|
284
|
+
dtype = column.logical_type.model_dump(mode="json")
|
|
285
|
+
if not options.include_metadata and "timezone" in dtype:
|
|
286
|
+
dtype["timezone"] = "[redacted]"
|
|
287
|
+
settings: dict[str, object] = {
|
|
288
|
+
"logical_type": dtype,
|
|
289
|
+
"nullable": column.nullable,
|
|
290
|
+
"null_policy": column.null_policy.model_dump(mode="json", exclude={"justification"}),
|
|
291
|
+
"allowed_values_count": len(column.allowed_values),
|
|
292
|
+
}
|
|
293
|
+
if column.nan_policy:
|
|
294
|
+
settings["nan_policy"] = column.nan_policy.model_dump(
|
|
295
|
+
mode="json", exclude={"justification"}
|
|
296
|
+
)
|
|
297
|
+
if column.numeric_policy:
|
|
298
|
+
numeric = column.numeric_policy.model_dump(
|
|
299
|
+
mode="json",
|
|
300
|
+
exclude={"rounding_justification": True, "tolerance": {"justification"}},
|
|
301
|
+
)
|
|
302
|
+
if isinstance(numeric.get("tolerance"), dict) and not options.include_metadata:
|
|
303
|
+
numeric["tolerance"]["unit"] = "[redacted]"
|
|
304
|
+
if not options.include_metadata:
|
|
305
|
+
numeric["unit"] = "[redacted]"
|
|
306
|
+
settings["numeric_policy"] = numeric
|
|
307
|
+
if column.temporal_policy:
|
|
308
|
+
temporal = column.temporal_policy.model_dump(mode="json")
|
|
309
|
+
# Bounds are contract values. Disclose them only with value opt-in.
|
|
310
|
+
if not options.include_values or column.name in options.redact_columns:
|
|
311
|
+
for field in ("lower", "upper"):
|
|
312
|
+
if field in temporal and temporal[field] is not None:
|
|
313
|
+
temporal[field] = "[redacted]"
|
|
314
|
+
if not options.include_metadata and "timezone" in temporal:
|
|
315
|
+
temporal["timezone"] = "[redacted]"
|
|
316
|
+
settings["temporal_policy"] = temporal
|
|
317
|
+
policies.append(
|
|
318
|
+
ReportPolicy(
|
|
319
|
+
name=name,
|
|
320
|
+
value=_bounded(json.dumps(settings, ensure_ascii=False, sort_keys=True), options),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
if declaration is not None:
|
|
324
|
+
origins = Counter(item.origin for item in declaration.provenance)
|
|
325
|
+
policies.append(
|
|
326
|
+
ReportPolicy(
|
|
327
|
+
name="declared_policy_origins", value=json.dumps(dict(sorted(origins.items())))
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
return tuple(policies)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _derived(differences: tuple[ComparisonDifference, ...], strategy: str) -> tuple[int, int, int]:
|
|
334
|
+
added = removed = 0
|
|
335
|
+
modified: set[tuple[int | None, int | None]] = set()
|
|
336
|
+
for difference in differences:
|
|
337
|
+
if difference.category != "content":
|
|
338
|
+
continue
|
|
339
|
+
if difference.code == "cell_value" and strategy in ("key", "sequence"):
|
|
340
|
+
modified.add((difference.position, difference.other_position))
|
|
341
|
+
if difference.code == "row_multiplicity":
|
|
342
|
+
added += max(0, difference.left_count - difference.right_count)
|
|
343
|
+
removed += max(0, difference.right_count - difference.left_count)
|
|
344
|
+
return added, removed, len(modified)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def comparison_report(
|
|
348
|
+
result: ComparisonResult,
|
|
349
|
+
*,
|
|
350
|
+
options: ReportOptions | None = None,
|
|
351
|
+
evidence_root: Path | None = None,
|
|
352
|
+
declaration: DiffContract | None = None,
|
|
353
|
+
) -> ComparisonReport:
|
|
354
|
+
"""Build one detached, privacy-filtered report of an existing comparison.
|
|
355
|
+
|
|
356
|
+
The default reads only the validated in-memory result and its examples.
|
|
357
|
+
An explicit evidence_root allows bounded inspection of the comparison's own
|
|
358
|
+
manifest and differences. It never reads original inputs or executes code.
|
|
359
|
+
Inspection failure is explicit; budget exhaustion records unmet evidence
|
|
360
|
+
requirements without changing the comparison verdict.
|
|
361
|
+
"""
|
|
362
|
+
result = ComparisonResult.model_validate(result)
|
|
363
|
+
options = ReportOptions.model_validate(ReportOptions() if options is None else options)
|
|
364
|
+
names = {column.name for column in result.spec.contract.output.schema_spec.columns}
|
|
365
|
+
if not set(options.redact_columns) <= names:
|
|
366
|
+
raise ValueError("redacted columns must exist in the declared output schema")
|
|
367
|
+
if declaration is not None:
|
|
368
|
+
declaration = DiffContract.model_validate(declaration)
|
|
369
|
+
if declaration.spec != result.spec or declaration.reference != result.reference:
|
|
370
|
+
raise ArtifactIntegrityError("policy provenance does not match the comparison")
|
|
371
|
+
differences = result.examples
|
|
372
|
+
inspected = False
|
|
373
|
+
unmet: list[str] = []
|
|
374
|
+
added, removed, modified = _derived(differences, result.strategy)
|
|
375
|
+
code_counts = Counter(_code(item.code) for item in differences)
|
|
376
|
+
if evidence_root is not None:
|
|
377
|
+
from equiframe._report_io import inspect_evidence
|
|
378
|
+
|
|
379
|
+
try:
|
|
380
|
+
inspection = inspect_evidence(
|
|
381
|
+
result,
|
|
382
|
+
evidence_root,
|
|
383
|
+
max_bytes=options.max_evidence_bytes,
|
|
384
|
+
max_records=options.max_records,
|
|
385
|
+
max_examples=options.max_examples,
|
|
386
|
+
)
|
|
387
|
+
except ResourceLimitError:
|
|
388
|
+
unmet.append("evidence_verification_budget_exceeded")
|
|
389
|
+
else:
|
|
390
|
+
inspected = True
|
|
391
|
+
differences = inspection.examples
|
|
392
|
+
added = inspection.candidate_excess
|
|
393
|
+
removed = inspection.reference_excess
|
|
394
|
+
modified = inspection.modified or 0
|
|
395
|
+
code_counts = Counter()
|
|
396
|
+
for code, count in inspection.code_counts:
|
|
397
|
+
code_counts[_code(code)] += count
|
|
398
|
+
all_records = inspected or len(differences) == result.counts.difference_records
|
|
399
|
+
precision: CountPrecision = "exact" if result.counts.final else "lower_bound"
|
|
400
|
+
counts = [
|
|
401
|
+
ReportCount(
|
|
402
|
+
name=name,
|
|
403
|
+
value=None if result.strategy == "pending" and value == 0 and unit == "rows" else value,
|
|
404
|
+
precision="unknown"
|
|
405
|
+
if result.strategy == "pending" and value == 0 and unit == "rows"
|
|
406
|
+
else precision,
|
|
407
|
+
unit=cast(Literal["rows", "records", "occurrences"], unit),
|
|
408
|
+
)
|
|
409
|
+
for name, value, unit in (
|
|
410
|
+
("candidate_rows", result.counts.left_rows, "rows"),
|
|
411
|
+
("reference_rows", result.counts.right_rows, "rows"),
|
|
412
|
+
("matched_rows", result.counts.matched_rows, "rows"),
|
|
413
|
+
("difference_records", result.counts.difference_records, "records"),
|
|
414
|
+
("contract_violations", result.counts.contract_violations, "records"),
|
|
415
|
+
)
|
|
416
|
+
]
|
|
417
|
+
for name, value, applicable, unit in (
|
|
418
|
+
("added_rows", added, result.strategy in ("key", "sequence"), "rows"),
|
|
419
|
+
("removed_rows", removed, result.strategy in ("key", "sequence"), "rows"),
|
|
420
|
+
("modified_rows", modified, result.strategy in ("key", "sequence"), "rows"),
|
|
421
|
+
(
|
|
422
|
+
"candidate_excess_occurrences",
|
|
423
|
+
added,
|
|
424
|
+
result.strategy in ("multiset", "matching"),
|
|
425
|
+
"occurrences",
|
|
426
|
+
),
|
|
427
|
+
(
|
|
428
|
+
"reference_excess_occurrences",
|
|
429
|
+
removed,
|
|
430
|
+
result.strategy in ("multiset", "matching"),
|
|
431
|
+
"occurrences",
|
|
432
|
+
),
|
|
433
|
+
):
|
|
434
|
+
known = applicable and (all_records or value > 0)
|
|
435
|
+
derived_precision: CountPrecision = (
|
|
436
|
+
"exact" if all_records and result.complete else "lower_bound"
|
|
437
|
+
)
|
|
438
|
+
counts.append(
|
|
439
|
+
ReportCount(
|
|
440
|
+
name=name,
|
|
441
|
+
value=value if known else None,
|
|
442
|
+
precision=derived_precision if known else "unknown",
|
|
443
|
+
unit=cast(Literal["rows", "records", "occurrences"], unit),
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
examples = tuple(
|
|
447
|
+
_witness(item, result, options) for item in differences[: options.max_examples]
|
|
448
|
+
)
|
|
449
|
+
if options.mode == "detail" and not inspected:
|
|
450
|
+
unmet.append("detail_evidence_not_verified")
|
|
451
|
+
if options.require_exhaustive:
|
|
452
|
+
if not result.complete:
|
|
453
|
+
unmet.append("evaluation_incomplete_original_evidence_not_exhaustive")
|
|
454
|
+
if not inspected:
|
|
455
|
+
unmet.append("exhaustive_original_evidence_not_verified")
|
|
456
|
+
if options.mode == "reproduction":
|
|
457
|
+
unmet.append("reproduction_requires_successful_verified_export")
|
|
458
|
+
reasons = tuple(
|
|
459
|
+
dict.fromkeys(
|
|
460
|
+
[f"{_code(item.code)}: {_REASONS[_code(item.code)]}" for item in result.diagnostics]
|
|
461
|
+
+ [f"{code}: {_REASONS[code]}" for code in sorted(code_counts)]
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
if result.state == "equivalent":
|
|
465
|
+
next_action = (
|
|
466
|
+
"No differences within the evaluated scope; "
|
|
467
|
+
"review the contract before accepting outside that scope"
|
|
468
|
+
)
|
|
469
|
+
elif result.state == "different":
|
|
470
|
+
next_action = (
|
|
471
|
+
"Review the witnesses and declared policies, "
|
|
472
|
+
"then correct the candidate or explicitly revise the contract"
|
|
473
|
+
)
|
|
474
|
+
else:
|
|
475
|
+
next_action = "Resolve the diagnostic and rerun the comparison before accepting equivalence"
|
|
476
|
+
withheld = any(
|
|
477
|
+
value.disclosure != "visible"
|
|
478
|
+
for example in examples
|
|
479
|
+
for value in (*example.key, *example.candidate, *example.reference)
|
|
480
|
+
)
|
|
481
|
+
policies = _policies(result, options, declaration)
|
|
482
|
+
metadata_omitted = any(
|
|
483
|
+
"[omitted]" in item.name or "[omitted]" in item.value for item in policies
|
|
484
|
+
)
|
|
485
|
+
metadata_omitted = metadata_omitted or any(
|
|
486
|
+
"[omitted]" in text
|
|
487
|
+
for example in examples
|
|
488
|
+
for text in (
|
|
489
|
+
example.column or "",
|
|
490
|
+
*example.columns,
|
|
491
|
+
*example.key_columns,
|
|
492
|
+
*(
|
|
493
|
+
value.native_type or ""
|
|
494
|
+
for value in (*example.key, *example.candidate, *example.reference)
|
|
495
|
+
),
|
|
496
|
+
*(
|
|
497
|
+
value.physical_type or ""
|
|
498
|
+
for value in (*example.key, *example.candidate, *example.reference)
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
report = ComparisonReport(
|
|
503
|
+
state=result.state,
|
|
504
|
+
complete=result.complete,
|
|
505
|
+
scope=result.scope,
|
|
506
|
+
reference=result.reference,
|
|
507
|
+
strategy=result.strategy,
|
|
508
|
+
coverage="complete" if result.complete else "partial",
|
|
509
|
+
contract_sha256=result.contract_sha256,
|
|
510
|
+
spec_sha256=result.spec_sha256,
|
|
511
|
+
counts=tuple(counts),
|
|
512
|
+
reasons=reasons,
|
|
513
|
+
next_action=next_action,
|
|
514
|
+
policies=policies,
|
|
515
|
+
examples=examples,
|
|
516
|
+
evidence=ReportEvidenceManifest(
|
|
517
|
+
mode=options.mode,
|
|
518
|
+
inspection="verified_differences" if inspected else "result_only",
|
|
519
|
+
evaluation_complete=result.complete,
|
|
520
|
+
counts_final=result.counts.final,
|
|
521
|
+
available_records=result.counts.difference_records,
|
|
522
|
+
included_examples=len(examples),
|
|
523
|
+
display_complete=len(examples) == result.counts.difference_records,
|
|
524
|
+
values_withheld=withheld or not options.include_values,
|
|
525
|
+
metadata_withheld=not options.include_metadata or metadata_omitted,
|
|
526
|
+
required_exhaustive=options.require_exhaustive,
|
|
527
|
+
acceptance="not_satisfied" if unmet else "satisfied",
|
|
528
|
+
unmet_requirements=tuple(unmet),
|
|
529
|
+
reproduction="requires_verified_export"
|
|
530
|
+
if options.mode == "reproduction"
|
|
531
|
+
else "not_requested",
|
|
532
|
+
),
|
|
533
|
+
options=ReportViewPolicy(
|
|
534
|
+
mode=options.mode,
|
|
535
|
+
include_values=options.include_values,
|
|
536
|
+
include_metadata=options.include_metadata,
|
|
537
|
+
redacted_column_count=len(options.redact_columns),
|
|
538
|
+
max_examples=options.max_examples,
|
|
539
|
+
max_value_chars=options.max_value_chars,
|
|
540
|
+
max_report_bytes=options.max_report_bytes,
|
|
541
|
+
require_exhaustive=options.require_exhaustive,
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
if len(report.model_dump_json().encode("utf-8")) > options.max_report_bytes:
|
|
545
|
+
raise ResourceLimitError("comparison report exceeds max_report_bytes")
|
|
546
|
+
return report
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def read_comparison_report(path: Path, *, max_bytes: int = 2 * 1024 * 1024) -> ComparisonReport:
|
|
550
|
+
"""Read a detached report only; never follow artifact paths or execute inputs."""
|
|
551
|
+
from equiframe.evidence import parse_json
|
|
552
|
+
|
|
553
|
+
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
|
554
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
555
|
+
try:
|
|
556
|
+
with open_regular_file(local_input_path(path)) as source:
|
|
557
|
+
raw = source.read(max_bytes + 1)
|
|
558
|
+
except ArtifactIOError as error:
|
|
559
|
+
cause = error.__cause__
|
|
560
|
+
if isinstance(cause, OSError):
|
|
561
|
+
raise type(cause)(cause.errno, "comparison report cannot be read") from None
|
|
562
|
+
raise
|
|
563
|
+
if len(raw) > max_bytes:
|
|
564
|
+
raise ResourceLimitError("comparison report exceeds reader budget")
|
|
565
|
+
parse_json(raw)
|
|
566
|
+
report = ComparisonReport.model_validate_json(raw)
|
|
567
|
+
if len(raw) > report.options.max_report_bytes:
|
|
568
|
+
raise ResourceLimitError("comparison report exceeds its declared budget")
|
|
569
|
+
return report
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _write(path: Path, content: bytes) -> None:
|
|
573
|
+
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as output:
|
|
574
|
+
output.write(content)
|
|
575
|
+
output.flush()
|
|
576
|
+
os.fsync(output.fileno())
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def write_comparison_report(
|
|
580
|
+
result: ComparisonResult,
|
|
581
|
+
directory: Path,
|
|
582
|
+
*,
|
|
583
|
+
options: ReportOptions | None = None,
|
|
584
|
+
evidence_root: Path | None = None,
|
|
585
|
+
declaration: DiffContract | None = None,
|
|
586
|
+
) -> ComparisonReport:
|
|
587
|
+
"""Publish a new private local report, writing the success manifest last.
|
|
588
|
+
|
|
589
|
+
Failures raise, leave no success manifest, and may leave partial files for
|
|
590
|
+
caller cleanup. Originals are never deleted. Reproduction exports contain
|
|
591
|
+
sensitive raw captures and require explicit values/metadata opt-ins.
|
|
592
|
+
"""
|
|
593
|
+
options = ReportOptions.model_validate(ReportOptions() if options is None else options)
|
|
594
|
+
directory = local_output_directory(directory)
|
|
595
|
+
report = comparison_report(
|
|
596
|
+
result, options=options, evidence_root=evidence_root, declaration=declaration
|
|
597
|
+
)
|
|
598
|
+
documents = {
|
|
599
|
+
"report.json": report.model_dump_json(indent=2).encode("utf-8") + b"\n",
|
|
600
|
+
"report.txt": report.to_text().encode("utf-8"),
|
|
601
|
+
"report.html": report.to_html().encode("utf-8"),
|
|
602
|
+
}
|
|
603
|
+
if any(len(content) > options.max_report_bytes for content in documents.values()):
|
|
604
|
+
raise ResourceLimitError("comparison report output exceeds max_report_bytes")
|
|
605
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
606
|
+
if options.mode == "reproduction":
|
|
607
|
+
if evidence_root is None:
|
|
608
|
+
raise ValueError("reproduction requires an explicit evidence_root")
|
|
609
|
+
from equiframe._report_io import export_reproduction
|
|
610
|
+
|
|
611
|
+
export_reproduction(
|
|
612
|
+
result,
|
|
613
|
+
evidence_root,
|
|
614
|
+
directory / "reproduction",
|
|
615
|
+
max_bytes=options.max_evidence_bytes,
|
|
616
|
+
max_records=options.max_records,
|
|
617
|
+
)
|
|
618
|
+
evidence = report.evidence.model_dump(mode="python")
|
|
619
|
+
evidence["reproduction"] = "exported_materialized_inputs"
|
|
620
|
+
evidence["unmet_requirements"] = tuple(
|
|
621
|
+
item
|
|
622
|
+
for item in report.evidence.unmet_requirements
|
|
623
|
+
if item != "reproduction_requires_successful_verified_export"
|
|
624
|
+
)
|
|
625
|
+
evidence["acceptance"] = "not_satisfied" if evidence["unmet_requirements"] else "satisfied"
|
|
626
|
+
report = ComparisonReport.model_validate(
|
|
627
|
+
{
|
|
628
|
+
**report.model_dump(mode="python"),
|
|
629
|
+
"evidence": ReportEvidenceManifest.model_validate(evidence),
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
documents = {
|
|
633
|
+
"report.json": report.model_dump_json(indent=2).encode("utf-8") + b"\n",
|
|
634
|
+
"report.txt": report.to_text().encode("utf-8"),
|
|
635
|
+
"report.html": report.to_html().encode("utf-8"),
|
|
636
|
+
}
|
|
637
|
+
if any(len(content) > options.max_report_bytes for content in documents.values()):
|
|
638
|
+
raise ResourceLimitError("comparison report output exceeds max_report_bytes")
|
|
639
|
+
for name, content in documents.items():
|
|
640
|
+
_write(directory / name, content)
|
|
641
|
+
from equiframe.evidence import inventory
|
|
642
|
+
|
|
643
|
+
manifest = ReportPublicationManifest(
|
|
644
|
+
report_evidence=report.evidence,
|
|
645
|
+
files=inventory(directory),
|
|
646
|
+
)
|
|
647
|
+
temporary = directory / ".evidence-manifest.part"
|
|
648
|
+
final = directory / "evidence-manifest.json"
|
|
649
|
+
linked = False
|
|
650
|
+
try:
|
|
651
|
+
_write(temporary, canonical_bytes(manifest.model_dump(mode="json")) + b"\n")
|
|
652
|
+
os.link(temporary, final)
|
|
653
|
+
linked = True
|
|
654
|
+
temporary.unlink()
|
|
655
|
+
except BaseException:
|
|
656
|
+
if linked:
|
|
657
|
+
final.unlink(missing_ok=True)
|
|
658
|
+
temporary.unlink(missing_ok=True)
|
|
659
|
+
raise
|
|
660
|
+
return report
|