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
equiframe/r_review.py ADDED
@@ -0,0 +1,706 @@
1
+ """Optional, independent base-R comparison after the existing R/M decisions.
2
+
3
+ Here ``R`` in ``HybridDecision.r`` still means rules. The language-R reviewer
4
+ does not train, score, calibrate or consume Isolation Forest features. It reads
5
+ lossless observations and an admitted exact contract, never Python's verdict.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import math
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ import time
17
+ from collections.abc import Iterable, Iterator
18
+ from importlib.resources import as_file, files
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, Annotated, Literal
21
+
22
+ from pydantic import Field, model_validator
23
+ from typing_extensions import Self
24
+
25
+ from equiframe._base import FrozenModel, NonNegativeInt, PositiveInt, Text, _ExplicitOutcome
26
+ from equiframe._diff_capture import DiffCaptureOptions, capture_native
27
+ from equiframe.comparison import comparison_fingerprint
28
+ from equiframe.comparison_types import ComparisonResult, ComparisonSpec
29
+ from equiframe.contracts import ColumnSpec, Contract, SignificantOrder
30
+ from equiframe.decision import combine_decisions
31
+ from equiframe.detector_types import ComponentDecision, ComponentDetectionResult, HybridDecision
32
+ from equiframe.diff_contracts import DiffContract
33
+ from equiframe.errors import (
34
+ CapabilityError,
35
+ EquiFrameError,
36
+ ExecutionCancelled,
37
+ ExecutionTimeout,
38
+ ResourceLimitError,
39
+ )
40
+ from equiframe.execution import CancellationToken
41
+ from equiframe.execution_types import ExecutionResult, TableArtifact
42
+ from equiframe.profile_types import ProfileResult, profile_digest
43
+ from equiframe.rule_types import RuleEvaluation
44
+ from equiframe.tables import ArrowInput, LoadedTable, read_table
45
+ from equiframe.types import ArtifactRef, ComparisonState, Sha256
46
+
47
+ if TYPE_CHECKING:
48
+ import pyarrow as pa
49
+
50
+
51
+ class RReviewOptions(FrozenModel):
52
+ """Explicit local executable and cooperative capture/process budgets.
53
+
54
+ R must already be installed. This API never installs R or any R package.
55
+ Byte limits cover captures and the generated transport, not hard process RSS.
56
+ """
57
+
58
+ rscript: Text = "Rscript"
59
+ timeout_seconds: Annotated[float, Field(gt=0)] = 30.0
60
+ max_rows: PositiveInt = 100_000
61
+ max_bytes: PositiveInt = 64 * 1024 * 1024
62
+ max_columns: PositiveInt = 256
63
+
64
+
65
+ class RReviewBinding(FrozenModel):
66
+ """Verified local artifacts whose full observations were sent to R."""
67
+
68
+ comparison_id: Text
69
+ left_artifact_sha256: Sha256
70
+ right_artifact_sha256: Sha256
71
+
72
+
73
+ class RExecutionBinding(FrozenModel):
74
+ """Actual execution/profile/IF result associated by the bound finalizer."""
75
+
76
+ execution_id: Text
77
+ execution_sha256: Sha256
78
+ profile_id: Text
79
+ profile_sha256: Sha256
80
+ detection_sha256: Sha256
81
+ output_sha256: Sha256
82
+
83
+
84
+ class RReviewResult(_ExplicitOutcome, FrozenModel):
85
+ """Independent exact comparison, with unsupported/unavailable states explicit."""
86
+
87
+ _truth_value_guidance = "inspect .state and .complete; availability is not equivalence"
88
+ schema_version: Literal["1.0"] = "1.0"
89
+ reviewer: Literal["equiframe-base-r-exact-1.0"] = "equiframe-base-r-exact-1.0"
90
+ state: ComparisonState
91
+ complete: bool
92
+ reason: Text | None = None
93
+ spec_sha256: Sha256
94
+ reviewer_sha256: Sha256
95
+ left_transport_sha256: Sha256 | None = None
96
+ right_transport_sha256: Sha256 | None = None
97
+ binding: RReviewBinding | None = None
98
+ columns: tuple[str, ...] = ()
99
+ left_rows: NonNegativeInt = 0
100
+ right_rows: NonNegativeInt = 0
101
+ matched_rows: NonNegativeInt = 0
102
+ unmatched_rows: NonNegativeInt = 0
103
+ contract_violations: NonNegativeInt = 0
104
+ diagnostics: tuple[Text, ...] = ()
105
+ r_version: Text | None = None
106
+ returncode: int | None = None
107
+ elapsed_seconds: Annotated[float, Field(ge=0)]
108
+
109
+ @model_validator(mode="after")
110
+ def consistent(self) -> Self:
111
+ if self.complete != (self.reason is None):
112
+ raise ValueError("incomplete R review requires a reason")
113
+ if (self.state == "not_evaluable") != (not self.complete):
114
+ raise ValueError("R review requires either a complete verdict or not_evaluable")
115
+ if self.matched_rows > min(self.left_rows, self.right_rows):
116
+ raise ValueError("matched rows exceed inputs")
117
+ if self.complete:
118
+ if not self.left_transport_sha256 or not self.right_transport_sha256:
119
+ raise ValueError("completed R review requires both observed transports")
120
+ if not self.r_version or self.returncode != 0:
121
+ raise ValueError("completed R review requires a successful real R process")
122
+ expected = (
123
+ "different" if self.unmatched_rows or self.contract_violations else "equivalent"
124
+ )
125
+ if self.state != expected:
126
+ raise ValueError("R state must preserve all reported differences")
127
+ if self.unmatched_rows != self.left_rows + self.right_rows - 2 * self.matched_rows:
128
+ raise ValueError("R row accounting is inconsistent")
129
+ return self
130
+
131
+
132
+ def _disagreement(comparison: ComparisonResult | None, review: RReviewResult) -> bool:
133
+ return (
134
+ comparison is not None
135
+ and comparison.complete
136
+ and comparison.state != "not_evaluable"
137
+ and review.complete
138
+ and comparison.state != review.state
139
+ )
140
+
141
+
142
+ def _final(
143
+ hybrid: HybridDecision,
144
+ review: RReviewResult,
145
+ comparison: ComparisonResult | None = None,
146
+ ) -> ComponentDecision:
147
+ spec_mismatch = comparison is not None and comparison.spec_sha256 != review.spec_sha256
148
+ binding = review.binding
149
+ comparison_ready = comparison is None or (
150
+ comparison.complete
151
+ and comparison.state != "not_evaluable"
152
+ and not spec_mismatch
153
+ and binding is not None
154
+ and comparison.left is not None
155
+ and comparison.right is not None
156
+ and binding.comparison_id == comparison.comparison_id
157
+ and binding.left_artifact_sha256 == comparison.left.data.sha256
158
+ and binding.right_artifact_sha256 == comparison.right.data.sha256
159
+ )
160
+ complete = hybrid.result.complete and review.complete and comparison_ready
161
+ alert = (
162
+ hybrid.result.decision == "alert"
163
+ or review.state == "different"
164
+ or (comparison is not None and comparison.state == "different")
165
+ )
166
+ return ComponentDecision(
167
+ decision="alert" if alert else "no_alert" if complete else "not_evaluable",
168
+ complete=complete,
169
+ reason=(
170
+ None
171
+ if complete
172
+ else "comparison_spec_mismatch"
173
+ if spec_mismatch
174
+ else "incomplete_hybrid_comparison_or_r_review"
175
+ ),
176
+ )
177
+
178
+
179
+ class ReviewedDecision(_ExplicitOutcome, FrozenModel):
180
+ """H plus an independent exact comparison; alerts are never overwritten.
181
+
182
+ ``accepted`` means all requested operational checks completed without an
183
+ alert. It is not a scientific correctness label or approval of an IF model.
184
+ """
185
+
186
+ _truth_value_guidance = "inspect .accepted, .result and .r_review explicitly"
187
+ schema_version: Literal["1.0"] = "1.0"
188
+ hybrid: HybridDecision
189
+ r_review: RReviewResult
190
+ comparison: ComparisonResult | None = None
191
+ comparison_disagreement: bool = False
192
+ execution_binding: RExecutionBinding | None = None
193
+ result: ComponentDecision
194
+ accepted: bool
195
+
196
+ @model_validator(mode="after")
197
+ def consistent(self) -> Self:
198
+ expected = _final(self.hybrid, self.r_review, self.comparison)
199
+ if self.result != expected:
200
+ raise ValueError("reviewed decision must preserve H, comparison and R review")
201
+ if self.comparison_disagreement != _disagreement(self.comparison, self.r_review):
202
+ raise ValueError("comparison disagreement must preserve both complete verdicts")
203
+ if self.accepted != (expected.complete and expected.decision == "no_alert"):
204
+ raise ValueError("acceptance requires complete checks without alerts")
205
+ return self
206
+
207
+
208
+ def _spec(contract: Contract | DiffContract | ComparisonSpec) -> ComparisonSpec:
209
+ if isinstance(contract, DiffContract):
210
+ return DiffContract.model_validate(contract).spec
211
+ if isinstance(contract, ComparisonSpec):
212
+ return ComparisonSpec.model_validate(contract)
213
+ return ComparisonSpec(contract=Contract.model_validate(contract))
214
+
215
+
216
+ def _admit(spec: ComparisonSpec) -> None:
217
+ # Do not silently replace the original semantics with an easier R contract.
218
+ if spec.policy.physical_types != "exact":
219
+ raise CapabilityError("r_review_requires_exact_physical_types")
220
+ for column in spec.contract.output.schema_spec.columns:
221
+ if column.null_policy.representations != ("null",):
222
+ raise CapabilityError("r_review_does_not_support_null_aliases")
223
+ if column.allowed_values:
224
+ raise CapabilityError("r_review_does_not_support_allowed_values")
225
+ numeric = column.numeric_policy
226
+ if numeric is not None and (numeric.comparison != "exact" or numeric.rounding is not None):
227
+ raise CapabilityError("r_review_does_not_support_tolerance_or_rounding")
228
+ if column.nan_policy is not None and column.nan_policy.handling == "as_null":
229
+ raise CapabilityError("r_review_does_not_support_nan_as_null")
230
+ temporal = column.temporal_policy
231
+ if temporal is not None:
232
+ if temporal.lower is not None or temporal.upper is not None:
233
+ raise CapabilityError("r_review_does_not_support_temporal_bounds")
234
+ if temporal.kind == "timestamp" and not (
235
+ (temporal.naive == "preserve" and temporal.timezone is None)
236
+ or (
237
+ temporal.comparison == "instant"
238
+ and temporal.naive == "reject"
239
+ and temporal.timezone is not None
240
+ )
241
+ ):
242
+ raise CapabilityError("r_review_does_not_support_local_timezone_resolution")
243
+
244
+
245
+ def _hex(value: str) -> str:
246
+ return value.encode("utf-8").hex() or "-"
247
+
248
+
249
+ def _signature(dtype: pa.DataType) -> str:
250
+ import pyarrow as pa
251
+
252
+ if pa.types.is_boolean(dtype):
253
+ return "boolean"
254
+ if pa.types.is_integer(dtype):
255
+ return f"integer:{dtype.bit_width}:{int(pa.types.is_signed_integer(dtype))}"
256
+ if pa.types.is_floating(dtype):
257
+ return f"float:{dtype.bit_width}"
258
+ if (
259
+ pa.types.is_string(dtype)
260
+ or pa.types.is_large_string(dtype)
261
+ or pa.types.is_string_view(dtype)
262
+ ):
263
+ return "string"
264
+ if pa.types.is_decimal(dtype):
265
+ return f"decimal:{dtype.precision}:{dtype.scale}"
266
+ if pa.types.is_date32(dtype):
267
+ return "date"
268
+ if pa.types.is_timestamp(dtype):
269
+ return f"timestamp:{dtype.unit}:{_hex(dtype.tz or '')}"
270
+ # date64 requires exact day divisibility; never truncate it to date32 here.
271
+ raise CapabilityError("r_review_unsupported_physical_type")
272
+
273
+
274
+ def _expected(column: ColumnSpec) -> str:
275
+ dtype = column.logical_type
276
+ if dtype.kind == "integer":
277
+ return f"integer:{dtype.bits}:{int(dtype.signed)}"
278
+ if dtype.kind == "float":
279
+ return f"float:{dtype.bits}"
280
+ if dtype.kind == "decimal":
281
+ return f"decimal:{dtype.precision}:{dtype.scale}"
282
+ if dtype.kind == "timestamp":
283
+ return f"timestamp:{dtype.unit}:{_hex(dtype.timezone or '')}"
284
+ return dtype.kind
285
+
286
+
287
+ def _token(scalar: pa.Scalar) -> str:
288
+ import pyarrow as pa
289
+
290
+ if not scalar.is_valid:
291
+ return "N"
292
+ dtype = scalar.type
293
+ if pa.types.is_boolean(dtype):
294
+ return "B1" if scalar.as_py() else "B0"
295
+ if pa.types.is_integer(dtype):
296
+ return "I" + str(scalar.as_py())
297
+ if pa.types.is_floating(dtype):
298
+ value = float(scalar.as_py())
299
+ if math.isnan(value):
300
+ return "Q"
301
+ if math.isinf(value):
302
+ return "P" if value > 0 else "M"
303
+ return "F" + repr(value)
304
+ if pa.types.is_decimal(dtype):
305
+ return "D" + str(scalar.as_py())
306
+ if pa.types.is_date32(dtype):
307
+ return "A" + str(scalar.value)
308
+ if pa.types.is_timestamp(dtype):
309
+ return "T" + str(scalar.value)
310
+ return "S" + _hex(scalar.as_py())
311
+
312
+
313
+ def _capture(value: object, options: RReviewOptions, spec: ComparisonSpec) -> LoadedTable:
314
+ if isinstance(value, ExecutionResult):
315
+ if value.output is None:
316
+ raise CapabilityError("r_review_execution_output_unavailable")
317
+ value = value.output
318
+ if isinstance(value, TableArtifact):
319
+ if value.index is not None and spec.policy.index != "ignore":
320
+ raise CapabilityError("r_review_requires_explicit_index_policy")
321
+ value = value.data
322
+ if isinstance(value, ArtifactRef):
323
+ if value.size_bytes > options.max_bytes:
324
+ raise ResourceLimitError("r_review_artifact_max_bytes")
325
+ value = read_table(value)
326
+ loaded = capture_native(
327
+ value.table if isinstance(value, ArrowInput) else value,
328
+ DiffCaptureOptions(max_rows=options.max_rows, max_bytes=options.max_bytes),
329
+ )
330
+ if loaded.meaningful_index and spec.policy.index != "ignore":
331
+ raise CapabilityError("r_review_requires_explicit_index_policy")
332
+ if loaded.table.num_columns > options.max_columns:
333
+ raise ResourceLimitError("r_review_max_columns")
334
+ return loaded
335
+
336
+
337
+ def _table_lines(loaded: LoadedTable) -> Iterator[str]:
338
+ yield "EFR_TABLE_1"
339
+ yield f"{loaded.table.num_rows}\t{loaded.table.num_columns}"
340
+ for field in loaded.table.schema:
341
+ yield _hex(field.name) + "\t" + _signature(field.type)
342
+ for batch in loaded.table.to_batches(max_chunksize=1024):
343
+ for row in range(batch.num_rows):
344
+ yield "\t".join(_token(column[row]) for column in batch.columns)
345
+
346
+
347
+ def review_with_r(
348
+ left: object,
349
+ right: object,
350
+ *,
351
+ contract: Contract | DiffContract | ComparisonSpec,
352
+ comparison: ComparisonResult | None = None,
353
+ options: RReviewOptions | None = None,
354
+ cancellation: CancellationToken | None = None,
355
+ ) -> RReviewResult:
356
+ """Compare captured Arrow/Pandas/Polars/DuckDB observations in base R.
357
+
358
+ An explicitly supplied DuckDB relation authorizes consumption of its native
359
+ plan under row/byte budgets and the caller's session settings. SQL strings,
360
+ connections, callbacks and other lazy plans are not inferred as inputs.
361
+ Unsupported policies, missing R, process
362
+ failure, timeout and cancellation return ``not_evaluable``. Bad API options
363
+ and invalid contracts raise validation errors. Temporary inputs/results are
364
+ removed on every exit; only compact observations are returned to the caller.
365
+ """
366
+ checked = RReviewOptions.model_validate(RReviewOptions() if options is None else options)
367
+ spec = _spec(contract)
368
+ script_resource = files("equiframe").joinpath("_r_review.R")
369
+ script_hash = hashlib.sha256(script_resource.read_bytes()).hexdigest()
370
+ start = time.monotonic()
371
+ details: dict[str, object] = {
372
+ "spec_sha256": comparison_fingerprint(spec),
373
+ "reviewer_sha256": script_hash,
374
+ }
375
+
376
+ def check() -> None:
377
+ if cancellation is not None and cancellation.cancelled:
378
+ raise ExecutionCancelled("r_review_cancelled")
379
+ if time.monotonic() - start >= checked.timeout_seconds:
380
+ raise ExecutionTimeout("r_review_timeout")
381
+
382
+ try:
383
+ check()
384
+ _admit(spec)
385
+ executable = shutil.which(checked.rscript)
386
+ if executable is None:
387
+ raise CapabilityError("rscript_unavailable")
388
+ schema = spec.contract.output.schema_spec
389
+ with tempfile.TemporaryDirectory(prefix="equiframe-r-review-") as temporary:
390
+ directory = Path(temporary)
391
+ used = 0
392
+
393
+ def write(path: Path, lines: Iterable[str]) -> str:
394
+ nonlocal used
395
+ digest = hashlib.sha256()
396
+ with path.open("xb") as sink:
397
+ for line in lines:
398
+ check()
399
+ block = (line + "\n").encode("ascii")
400
+ used += len(block)
401
+ if used > checked.max_bytes:
402
+ raise ResourceLimitError("r_review_transport_max_bytes")
403
+ sink.write(block)
404
+ digest.update(block)
405
+ return digest.hexdigest()
406
+
407
+ left_loaded = _capture(left, checked, spec)
408
+ check()
409
+ right_loaded = _capture(right, checked, spec)
410
+ check()
411
+ details.update(
412
+ columns=tuple(column.name for column in schema.columns),
413
+ left_rows=left_loaded.table.num_rows,
414
+ right_rows=right_loaded.table.num_rows,
415
+ )
416
+ details["left_transport_sha256"] = write(
417
+ directory / "left.txt", _table_lines(left_loaded)
418
+ )
419
+ details["right_transport_sha256"] = write(
420
+ directory / "right.txt", _table_lines(right_loaded)
421
+ )
422
+ if comparison is not None:
423
+ comparison = ComparisonResult.model_validate(comparison)
424
+ if comparison.spec_sha256 != details["spec_sha256"]:
425
+ raise CapabilityError("r_review_comparison_spec_mismatch")
426
+ if comparison.left is None or comparison.right is None:
427
+ raise CapabilityError("r_review_comparison_sources_unavailable")
428
+ for side, source in (("left", comparison.left), ("right", comparison.right)):
429
+ if (
430
+ source.table is not None
431
+ and source.table.index is not None
432
+ and spec.policy.index != "ignore"
433
+ ):
434
+ raise CapabilityError("r_review_requires_explicit_index_policy")
435
+ expected = _capture(source.data, checked, spec)
436
+ digest = hashlib.sha256()
437
+ size = 0
438
+ for line in _table_lines(expected):
439
+ check()
440
+ block = (line + "\n").encode("ascii")
441
+ size += len(block)
442
+ if size > checked.max_bytes:
443
+ raise ResourceLimitError("r_review_transport_max_bytes")
444
+ digest.update(block)
445
+ if digest.hexdigest() != details[side + "_transport_sha256"]:
446
+ raise CapabilityError("r_review_comparison_data_mismatch")
447
+ details["binding"] = RReviewBinding(
448
+ comparison_id=comparison.comparison_id,
449
+ left_artifact_sha256=comparison.left.data.sha256,
450
+ right_artifact_sha256=comparison.right.data.sha256,
451
+ )
452
+ names = tuple(column.name for column in schema.columns)
453
+ order = schema.order
454
+ config = [
455
+ "EFR_CONTRACT_1",
456
+ f"{len(names)}\t{spec.policy.column_order}\t{schema.multiplicity}",
457
+ "sequence" if isinstance(order, SignificantOrder) else "bag",
458
+ ]
459
+ for column in schema.columns:
460
+ config.append(
461
+ "\t".join(
462
+ (
463
+ _hex(column.name),
464
+ _expected(column),
465
+ str(int(column.nullable)),
466
+ column.nan_policy.handling if column.nan_policy is not None else "none",
467
+ )
468
+ )
469
+ )
470
+ config.append(str(len(schema.keys)))
471
+ for key in schema.keys:
472
+ indices = tuple(names.index(name) + 1 for name in key.columns)
473
+ if key.scope.kind == "columns":
474
+ indices += tuple(names.index(name) + 1 for name in key.scope.columns)
475
+ config.append(
476
+ "\t".join(
477
+ (
478
+ str(int(key.unique)),
479
+ key.incomplete,
480
+ ",".join(str(index) for index in indices),
481
+ )
482
+ )
483
+ )
484
+ sorts = order.by if isinstance(order, SignificantOrder) else ()
485
+ ties = order.ties if isinstance(order, SignificantOrder) else "preserve"
486
+ config.append(f"{len(sorts)}\t{ties}")
487
+ for sort in sorts:
488
+ config.append(f"{names.index(sort.column) + 1}\t{sort.direction}\t{sort.nulls}")
489
+ write(directory / "contract.txt", config)
490
+ with (
491
+ as_file(script_resource) as script,
492
+ (directory / "process.log").open("wb") as log,
493
+ ):
494
+ # The transport is ASCII and its numeric tokens use a dot.
495
+ # Keep locale resolution deterministic in the R child only,
496
+ # including Windows hosts with a Unix-only inherited locale.
497
+ environment = dict(os.environ)
498
+ for name in tuple(environment):
499
+ if name.startswith("LC_"):
500
+ environment[name] = "C"
501
+ environment.update(LC_ALL="C", LANG="C", LANGUAGE="C")
502
+ process = subprocess.Popen(
503
+ [
504
+ executable,
505
+ "--vanilla",
506
+ "--default-packages=NULL",
507
+ str(script),
508
+ str(directory),
509
+ ],
510
+ cwd=directory,
511
+ stdin=subprocess.DEVNULL,
512
+ stdout=log,
513
+ stderr=log,
514
+ shell=False,
515
+ env=environment,
516
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
517
+ )
518
+ try:
519
+ while True:
520
+ check()
521
+ try:
522
+ process.wait(timeout=0.1)
523
+ break
524
+ except subprocess.TimeoutExpired:
525
+ continue
526
+ finally:
527
+ if process.poll() is None:
528
+ process.kill()
529
+ process.wait()
530
+ details["returncode"] = process.returncode
531
+ if process.returncode != 0:
532
+ raise CapabilityError("r_review_process_failed")
533
+ check()
534
+ result_path = directory / "result.txt"
535
+ if not result_path.is_file() or result_path.stat().st_size > 8192:
536
+ raise CapabilityError("r_review_invalid_process_result")
537
+ lines = result_path.read_text(encoding="ascii").splitlines()
538
+ if len(lines) != 8 or lines[0] != "EFR_RESULT_1":
539
+ raise CapabilityError("r_review_invalid_process_result")
540
+ matched, unmatched, violations = (int(value) for value in lines[2:5])
541
+ if lines[1] not in ("equivalent", "different"):
542
+ raise CapabilityError("r_review_invalid_process_result")
543
+ if (
544
+ int(lines[5]) != left_loaded.table.num_rows
545
+ or int(lines[6]) != right_loaded.table.num_rows
546
+ ):
547
+ raise CapabilityError("r_review_result_row_mismatch")
548
+ return RReviewResult.model_validate(
549
+ {
550
+ **details,
551
+ "state": lines[1],
552
+ "complete": True,
553
+ "matched_rows": matched,
554
+ "unmatched_rows": unmatched,
555
+ "contract_violations": violations,
556
+ "diagnostics": ("independent_contract_violation",) if violations else (),
557
+ "r_version": bytes.fromhex(lines[7]).decode("utf-8"),
558
+ "elapsed_seconds": time.monotonic() - start,
559
+ }
560
+ )
561
+ except (EquiFrameError, OSError, ValueError, TypeError) as error:
562
+ # Error messages from third-party captures may contain user values.
563
+ local_errors = (CapabilityError, ResourceLimitError, ExecutionCancelled, ExecutionTimeout)
564
+ reason = (
565
+ str(error)
566
+ if type(error) in local_errors and str(error).startswith(("r_review_", "rscript_"))
567
+ else type(error).__name__
568
+ )
569
+ return RReviewResult.model_validate(
570
+ {
571
+ **details,
572
+ "state": "not_evaluable",
573
+ "complete": False,
574
+ "reason": reason,
575
+ "elapsed_seconds": time.monotonic() - start,
576
+ }
577
+ )
578
+
579
+
580
+ def finalize_with_r(
581
+ rules: ComponentDecision | RuleEvaluation,
582
+ model: ComponentDecision,
583
+ *,
584
+ left: object,
585
+ right: object,
586
+ contract: Contract | DiffContract | ComparisonSpec,
587
+ comparison: ComparisonResult | None = None,
588
+ options: RReviewOptions | None = None,
589
+ cancellation: CancellationToken | None = None,
590
+ ) -> ReviewedDecision:
591
+ """Combine actual rule/IF decisions, then request the independent R review.
592
+
593
+ Pass the Isolation Forest decision for the corresponding execution/profile,
594
+ not a MAD decision or a made-up default. Inputs and contract remain explicit:
595
+ IF/rules receive no peer output through this finalization function. When an
596
+ existing Python comparison is supplied, its differences and incomplete state
597
+ also remain binding. It is combined only after R finishes, never sent to R.
598
+ """
599
+ hybrid = combine_decisions(rules, model)
600
+ checked_comparison = None if comparison is None else ComparisonResult.model_validate(comparison)
601
+ review = review_with_r(
602
+ left,
603
+ right,
604
+ contract=contract,
605
+ comparison=checked_comparison,
606
+ options=options,
607
+ cancellation=cancellation,
608
+ )
609
+ result = _final(hybrid, review, checked_comparison)
610
+ return ReviewedDecision(
611
+ hybrid=hybrid,
612
+ r_review=review,
613
+ comparison=checked_comparison,
614
+ comparison_disagreement=_disagreement(checked_comparison, review),
615
+ result=result,
616
+ accepted=result.complete and result.decision == "no_alert",
617
+ )
618
+
619
+
620
+ def finalize_execution_with_r(
621
+ execution: ExecutionResult,
622
+ rules: RuleEvaluation,
623
+ profile: ProfileResult,
624
+ detection: ComponentDetectionResult,
625
+ *,
626
+ comparison: ComparisonResult,
627
+ options: RReviewOptions | None = None,
628
+ cancellation: CancellationToken | None = None,
629
+ ) -> ReviewedDecision:
630
+ """Finalize one real execution using its rules, profile, frozen IF and comparison.
631
+
632
+ Reject mismatched identifiers/hashes before starting R. Detection must come
633
+ from ``detect_component(bundle, (profile.vector,), method='isolation_forest')``.
634
+ The comparator artifacts are verified and read afresh; its verdict, rules
635
+ and model scores are never included in the R input.
636
+ """
637
+ execution = ExecutionResult.model_validate(execution)
638
+ rules = RuleEvaluation.model_validate(rules)
639
+ profile = ProfileResult.model_validate(profile)
640
+ detection = ComponentDetectionResult.model_validate(detection)
641
+ comparison = ComparisonResult.model_validate(comparison)
642
+ execution_hash = profile_digest(execution.model_dump(mode="json"))
643
+ if execution.output is None or execution.state != "completed":
644
+ raise ValueError("R finalization requires a completed execution output")
645
+ if (rules.execution_id, rules.run_id, rules.contract_sha256) != (
646
+ execution.execution_id,
647
+ execution.run_id,
648
+ execution.contract_sha256,
649
+ ):
650
+ raise ValueError("rules do not belong to the execution")
651
+ metadata = profile.metadata
652
+ artifacts = tuple(
653
+ ref
654
+ for table in (*execution.inputs, execution.output)
655
+ for ref in (table.data, *((table.index,) if table.index is not None else ()))
656
+ )
657
+ if (
658
+ metadata.execution_id,
659
+ metadata.run_id,
660
+ metadata.contract_sha256,
661
+ metadata.execution_sha256,
662
+ metadata.artifacts,
663
+ ) != (
664
+ execution.execution_id,
665
+ execution.run_id,
666
+ execution.contract_sha256,
667
+ execution_hash,
668
+ artifacts,
669
+ ):
670
+ raise ValueError("profile does not belong to the execution and artifacts")
671
+ if (
672
+ detection.method != "isolation_forest"
673
+ or detection.rows != 1
674
+ or detection.profiles_sha256 != profile_digest([profile.vector.model_dump(mode="json")])
675
+ ):
676
+ raise ValueError("IF detection does not belong to this single profile")
677
+ if comparison.contract_sha256 != execution.contract_sha256 or not any(
678
+ source is not None
679
+ and source.execution == execution
680
+ and source.data.sha256 == execution.output.data.sha256
681
+ for source in (comparison.left, comparison.right)
682
+ ):
683
+ raise ValueError("comparison does not include this execution output")
684
+ if comparison.left is None or comparison.right is None:
685
+ raise ValueError("comparison must retain both captured artifacts")
686
+ reviewed = finalize_with_r(
687
+ rules,
688
+ detection.decisions[0].result,
689
+ left=comparison.left.data,
690
+ right=comparison.right.data,
691
+ contract=comparison.spec,
692
+ comparison=comparison,
693
+ options=options,
694
+ cancellation=cancellation,
695
+ )
696
+ return ReviewedDecision(
697
+ **reviewed.model_dump(exclude={"execution_binding"}),
698
+ execution_binding=RExecutionBinding(
699
+ execution_id=execution.execution_id,
700
+ execution_sha256=execution_hash,
701
+ profile_id=metadata.profile_id,
702
+ profile_sha256=profile_digest(profile.model_dump(mode="json")),
703
+ detection_sha256=profile_digest(detection.model_dump(mode="json")),
704
+ output_sha256=execution.output.data.sha256,
705
+ ),
706
+ )