equiframe 0.12.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
@@ -0,0 +1,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