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,405 @@
1
+ """Strict migration configuration and evidence; comparisons remain independent of R/M/B/H."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import Field, model_validator
8
+ from typing_extensions import Self
9
+
10
+ from equiframe._base import (
11
+ FrozenModel,
12
+ Identifier,
13
+ NonNegativeInt,
14
+ PositiveInt,
15
+ Text,
16
+ _ExplicitOutcome,
17
+ )
18
+ from equiframe.comparison_types import ComparisonOptions, ComparisonPolicy, ComparisonResult
19
+ from equiframe.contracts import Contract
20
+ from equiframe.execution_types import ExecutionOptions, TableArtifact
21
+ from equiframe.pipeline_types import EvidenceFile, PipelineInput, PipelineResult, StageState
22
+ from equiframe.profile_types import ProfileOptions, ProfileSpec, profile_digest
23
+ from equiframe.rule_types import RuleOptions
24
+ from equiframe.serialization import contract_fingerprint
25
+ from equiframe.types import ArtifactRef, Sha256
26
+
27
+ from .types import CompilationCheck, RelationalPlan
28
+ from .validation import ir_fingerprint
29
+
30
+ MigrationStageName = Literal[
31
+ "preflight",
32
+ "capture",
33
+ "source",
34
+ "target",
35
+ "comparison",
36
+ "verification_source",
37
+ "verification_target",
38
+ ]
39
+ MIGRATION_STAGES: tuple[MigrationStageName, ...] = (
40
+ "preflight",
41
+ "capture",
42
+ "source",
43
+ "target",
44
+ "comparison",
45
+ "verification_source",
46
+ "verification_target",
47
+ )
48
+
49
+
50
+ class MigrationOptions(FrozenModel):
51
+ timeout_seconds: Annotated[float, Field(gt=0)] | None = None
52
+ max_capture_rows: NonNegativeInt = 10_000_000
53
+ max_capture_bytes: PositiveInt = 512 * 1024 * 1024
54
+
55
+
56
+ class MigrationSpec(FrozenModel):
57
+ schema_version: Literal["1.0"] = "1.0"
58
+ ir: RelationalPlan
59
+ contract: Contract
60
+ source_engine: Identifier
61
+ target_engine: Identifier
62
+ require_rules: bool = True
63
+ execution_options: ExecutionOptions = Field(default_factory=ExecutionOptions)
64
+ comparison_policy: ComparisonPolicy = Field(default_factory=ComparisonPolicy)
65
+ comparison_options: ComparisonOptions = Field(default_factory=ComparisonOptions)
66
+ rule_options: RuleOptions = Field(default_factory=RuleOptions)
67
+ profile: ProfileSpec | None = None
68
+ profile_options: ProfileOptions = Field(default_factory=ProfileOptions)
69
+ options: MigrationOptions = Field(default_factory=MigrationOptions)
70
+
71
+ @model_validator(mode="after")
72
+ def consistent(self) -> Self:
73
+ if self.source_engine == self.target_engine:
74
+ raise ValueError("migration requires two distinct engines")
75
+ if self.profile and self.profile.family != self.contract.family:
76
+ raise ValueError("migration profile family must match the contract")
77
+ return self
78
+
79
+
80
+ class MigrationRequest(FrozenModel):
81
+ schema_version: Literal["1.0"] = "1.0"
82
+ spec: MigrationSpec
83
+ inputs: tuple[PipelineInput, ...]
84
+ expected: ArtifactRef | None = None
85
+
86
+ @model_validator(mode="after")
87
+ def consistent(self) -> Self:
88
+ names = tuple(i.name for i in self.inputs)
89
+ if len(set(names)) != len(names) or set(names) != {
90
+ t.name for t in self.spec.contract.inputs
91
+ }:
92
+ raise ValueError("migration inputs must exactly cover declared tables")
93
+ if any(i.artifact.format not in ("arrow", "parquet") for i in self.inputs):
94
+ raise ValueError("migration inputs require Arrow or Parquet")
95
+ if self.expected and self.expected.format not in ("arrow", "parquet"):
96
+ raise ValueError("migration expected requires Arrow or Parquet")
97
+ return self
98
+
99
+
100
+ class CompilerRecord(FrozenModel):
101
+ engine: Identifier
102
+ transformation_id: Identifier
103
+ revision: PositiveInt
104
+ code_version: Text
105
+ ir_sha256: Sha256
106
+ contract_sha256: Sha256
107
+ check: CompilationCheck
108
+
109
+
110
+ def pipeline_complete(spec: MigrationSpec, result: PipelineResult) -> bool:
111
+ """Completion of explicitly required components; optional R never implies no-alert."""
112
+ if spec.require_rules:
113
+ return result.complete
114
+ required = {"execution"}
115
+ if spec.profile is not None:
116
+ required.add("profile")
117
+ if result.detection_requested:
118
+ required.add("detection")
119
+ return not result.cancelled and all(
120
+ s.state == "completed" for s in result.stages if s.stage in required
121
+ )
122
+
123
+
124
+ class MigrationStage(FrozenModel):
125
+ stage: MigrationStageName
126
+ state: StageState
127
+ code: Identifier
128
+ elapsed_seconds: Annotated[float, Field(ge=0)]
129
+
130
+
131
+ def _identity(t: TableArtifact) -> tuple[object, ...]:
132
+ return (
133
+ t.name,
134
+ t.data.sha256,
135
+ t.data.size_bytes,
136
+ (t.index.sha256, t.index.size_bytes) if t.index else None,
137
+ )
138
+
139
+
140
+ class MigrationResult(_ExplicitOutcome, FrozenModel):
141
+ _truth_value_guidance = "inspect .state, .verification, .complete and .exit_code explicitly"
142
+
143
+ schema_version: Literal["1.0"] = "1.0"
144
+ migration_id: Identifier
145
+ spec: MigrationSpec
146
+ spec_sha256: Sha256
147
+ started_at: Text
148
+ finished_at: Text
149
+ elapsed_seconds: Annotated[float, Field(ge=0)]
150
+ stages: tuple[MigrationStage, ...]
151
+ compilers: tuple[CompilerRecord, ...]
152
+ inputs: tuple[TableArtifact, ...]
153
+ expected_requested: bool
154
+ expected: ArtifactRef | None
155
+ detection_requested: bool
156
+ source: PipelineResult | None
157
+ target: PipelineResult | None
158
+ comparison: ComparisonResult | None
159
+ verification_source: ComparisonResult | None
160
+ verification_target: ComparisonResult | None
161
+ cancelled: bool
162
+ evidence: tuple[EvidenceFile, ...]
163
+ event_count: Annotated[int, Field(ge=2)]
164
+ event_head_sha256: Sha256
165
+
166
+ @model_validator(mode="after")
167
+ def consistent(self) -> Self:
168
+ if self.spec_sha256 != profile_digest(self.spec.model_dump(mode="json")):
169
+ raise ValueError("migration specification fingerprint mismatch")
170
+ if tuple(s.stage for s in self.stages) != MIGRATION_STAGES:
171
+ raise ValueError("migration stages must occur once in the declared order")
172
+ if len({f.path for f in self.evidence}) != len(self.evidence):
173
+ raise ValueError("migration evidence paths must be unique")
174
+ if self.expected and (
175
+ not self.expected_requested or self.expected.format not in ("arrow", "parquet")
176
+ ):
177
+ raise ValueError("unexpected or invalid expected reference")
178
+ if self.detection_requested and self.spec.profile is None:
179
+ raise ValueError("migration detection requires an explicit profile")
180
+ names = tuple(t.name for t in self.inputs)
181
+ declared = {t.name for t in self.spec.contract.inputs}
182
+ if len(set(names)) != len(names) or not set(names) <= declared:
183
+ raise ValueError("migration captured inputs must be unique declared tables")
184
+ if self.stages[1].state == "completed" and (
185
+ self.stages[0].state != "completed"
186
+ or set(names) != declared
187
+ or (self.expected_requested and self.expected is None)
188
+ ):
189
+ raise ValueError("complete capture requires all requested data")
190
+ if self.compilers and tuple(c.engine for c in self.compilers) != (
191
+ self.spec.source_engine,
192
+ self.spec.target_engine,
193
+ ):
194
+ raise ValueError("compiler records must bind the configured engine pair")
195
+ if self.stages[0].state == "completed" and len(self.compilers) != 2:
196
+ raise ValueError("preflight requires both compiler records")
197
+ for c in self.compilers:
198
+ if (
199
+ not c.check.supported
200
+ or c.check.engine != c.engine
201
+ or c.ir_sha256 != ir_fingerprint(self.spec.ir)
202
+ or c.contract_sha256 != contract_fingerprint(self.spec.contract)
203
+ ):
204
+ raise ValueError("compiler record contradicts the plan")
205
+ children = (self.source, self.target)
206
+ for n, p in enumerate(children):
207
+ stage = self.stages[n + 2]
208
+ if p is None:
209
+ if stage.state in ("completed", "not_evaluable"):
210
+ raise ValueError("migration stage requires its pipeline")
211
+ continue
212
+ if len(self.compilers) != 2 or self.stages[1].state != "completed":
213
+ raise ValueError("pipeline requires preflight and fixed capture")
214
+ record = self.compilers[n]
215
+ e = p.spec.execution
216
+ opts = e.options.model_copy(
217
+ update={
218
+ "timeout_seconds": self.spec.execution_options.timeout_seconds,
219
+ }
220
+ )
221
+ if (
222
+ e.contract != self.spec.contract
223
+ or e.engine != record.engine
224
+ or e.transformation_id != record.transformation_id
225
+ or e.transformation_revision != record.revision
226
+ or e.parameters
227
+ or opts != self.spec.execution_options
228
+ or p.spec.profile != self.spec.profile
229
+ or p.spec.rule_options != self.spec.rule_options
230
+ or p.spec.profile_options != self.spec.profile_options
231
+ or p.detection_requested != self.detection_requested
232
+ or p.comparison_requested
233
+ or p.spec.comparison_policy != self.spec.comparison_policy
234
+ or p.spec.comparison_options != self.spec.comparison_options
235
+ ):
236
+ raise ValueError("pipeline contradicts the migration configuration")
237
+ timeout = e.options.timeout_seconds
238
+ declared_timeout = self.spec.execution_options.timeout_seconds
239
+ if timeout is None and (
240
+ declared_timeout is not None or self.spec.options.timeout_seconds is not None
241
+ ):
242
+ raise ValueError("effective execution must retain the declared timeout")
243
+ if timeout is not None and (
244
+ timeout <= 0
245
+ or (declared_timeout is not None and timeout > declared_timeout)
246
+ or (
247
+ self.spec.options.timeout_seconds is not None
248
+ and timeout > self.spec.options.timeout_seconds
249
+ )
250
+ ):
251
+ raise ValueError("effective execution timeout exceeds its declared budget")
252
+ if (stage.state == "completed") != pipeline_complete(self.spec, p):
253
+ raise ValueError("migration stage contradicts pipeline completion")
254
+ if stage.state in ("skipped", "failed"):
255
+ raise ValueError("failed or skipped stage cannot claim a pipeline")
256
+ if p.execution is not None:
257
+ if (
258
+ p.execution.implementation_version is not None
259
+ and p.execution.implementation_version != record.code_version
260
+ ):
261
+ raise ValueError("execution compiler version mismatch")
262
+ if p.execution.state == "completed" and sorted(
263
+ (_identity(t) for t in p.execution.inputs), key=lambda x: str(x[0])
264
+ ) != sorted((_identity(t) for t in self.inputs), key=lambda x: str(x[0])):
265
+ raise ValueError("pipeline inputs differ from the single fixed capture")
266
+ comparisons = (self.comparison, self.verification_source, self.verification_target)
267
+ for n, cmp in enumerate(comparisons):
268
+ stage = self.stages[n + 4]
269
+ if cmp is None:
270
+ if stage.state in ("completed", "not_evaluable"):
271
+ raise ValueError("comparison stage requires its result")
272
+ continue
273
+ if stage.state in ("skipped", "failed"):
274
+ raise ValueError("failed or skipped stage cannot claim a comparison")
275
+ if (stage.state == "completed") != (cmp.complete and cmp.state != "not_evaluable"):
276
+ raise ValueError("comparison stage contradicts evaluability")
277
+ if (
278
+ cmp.spec.contract != self.spec.contract
279
+ or cmp.spec.policy != self.spec.comparison_policy
280
+ or cmp.options.model_copy(
281
+ update={
282
+ "timeout_seconds": self.spec.comparison_options.timeout_seconds,
283
+ }
284
+ )
285
+ != self.spec.comparison_options
286
+ ):
287
+ raise ValueError("migration comparison configuration mismatch")
288
+ timeout = cmp.options.timeout_seconds
289
+ declared_timeout = self.spec.comparison_options.timeout_seconds
290
+ if timeout is None and (
291
+ declared_timeout is not None or self.spec.options.timeout_seconds is not None
292
+ ):
293
+ raise ValueError("effective comparison must retain the declared timeout")
294
+ if timeout is not None and (
295
+ (declared_timeout is not None and timeout > declared_timeout)
296
+ or (
297
+ self.spec.options.timeout_seconds is not None
298
+ and timeout > self.spec.options.timeout_seconds
299
+ )
300
+ ):
301
+ raise ValueError("effective comparison exceeds its declared timeout")
302
+ left = self.source if n != 2 else self.target
303
+ if (
304
+ left is None
305
+ or left.execution is None
306
+ or (cmp.complete and cmp.left is None)
307
+ or (cmp.left is not None and cmp.left.execution != left.execution)
308
+ ):
309
+ raise ValueError("migration comparison left execution mismatch")
310
+ if cmp.left is not None and (
311
+ cmp.left.table != left.execution.output
312
+ or left.execution.output is None
313
+ or (cmp.left.data.sha256, cmp.left.data.size_bytes, cmp.left.data.format)
314
+ != (
315
+ left.execution.output.data.sha256,
316
+ left.execution.output.data.size_bytes,
317
+ left.execution.output.data.format,
318
+ )
319
+ ):
320
+ raise ValueError("comparison snapshot differs from the native output")
321
+ if n == 0:
322
+ if (
323
+ cmp.reference != "peer"
324
+ or self.target is None
325
+ or self.target.execution is None
326
+ or (cmp.complete and cmp.right is None)
327
+ or (cmp.right is not None and cmp.right.execution != self.target.execution)
328
+ ):
329
+ raise ValueError("peer comparison must bind both native executions")
330
+ if cmp.right is not None and (
331
+ cmp.right.table != self.target.execution.output
332
+ or self.target.execution.output is None
333
+ or (cmp.right.data.sha256, cmp.right.data.size_bytes, cmp.right.data.format)
334
+ != (
335
+ self.target.execution.output.data.sha256,
336
+ self.target.execution.output.data.size_bytes,
337
+ self.target.execution.output.data.format,
338
+ )
339
+ ):
340
+ raise ValueError("peer snapshot differs from the native output")
341
+ elif (
342
+ not self.expected_requested
343
+ or self.expected is None
344
+ or cmp.reference != "expected"
345
+ or (cmp.complete and cmp.right is None)
346
+ or (
347
+ cmp.right is not None
348
+ and (cmp.right.data.sha256, cmp.right.data.size_bytes, cmp.right.data.format)
349
+ != (self.expected.sha256, self.expected.size_bytes, self.expected.format)
350
+ )
351
+ ):
352
+ raise ValueError("verification must bind the one captured expected")
353
+ return self
354
+
355
+ @property
356
+ def complete(self) -> bool:
357
+ required = set(MIGRATION_STAGES[:5])
358
+ if self.expected_requested:
359
+ required.update(MIGRATION_STAGES[5:])
360
+ return not self.cancelled and all(
361
+ s.state == "completed" for s in self.stages if s.stage in required
362
+ )
363
+
364
+ @property
365
+ def state(self) -> Literal["equivalent", "different", "not_evaluable"]:
366
+ return self.comparison.state if self.comparison is not None else "not_evaluable"
367
+
368
+ @property
369
+ def verification(self) -> Literal["correct", "divergent", "not_evaluable", "not_requested"]:
370
+ if not self.expected_requested:
371
+ return "not_requested"
372
+ checks = (self.verification_source, self.verification_target)
373
+ if any(c is None or not c.complete or c.state == "not_evaluable" for c in checks):
374
+ return "not_evaluable"
375
+ return (
376
+ "divergent"
377
+ if any(c is not None and c.state == "different" for c in checks)
378
+ else "correct"
379
+ )
380
+
381
+ @property
382
+ def exit_code(self) -> int:
383
+ if any(
384
+ s.code in ("invalid_input", "unsupported_capability", "missing_dependency")
385
+ for s in self.stages[:2]
386
+ ):
387
+ return 2
388
+ if not self.complete:
389
+ return 3
390
+ return int(
391
+ self.state == "different"
392
+ or self.verification == "divergent"
393
+ or any(
394
+ p is not None
395
+ and (
396
+ p.hybrid.r.decision == "alert"
397
+ or p.hybrid.result.decision == "alert"
398
+ or (
399
+ p.detection is not None
400
+ and any(d.result.decision == "alert" for d in p.detection.b)
401
+ )
402
+ )
403
+ for p in (self.source, self.target)
404
+ )
405
+ )