equiframe 0.12.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
equiframe/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
|
+
)
|