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