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/execution.py ADDED
@@ -0,0 +1,355 @@
1
+ """Local execution only: no semantic validators, comparator or detector orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import time
8
+ from collections.abc import Iterator, Mapping
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from datetime import timezone as _timezone
13
+ from importlib.metadata import PackageNotFoundError, version
14
+ from pathlib import Path
15
+ from threading import Event
16
+ from typing import TYPE_CHECKING
17
+ from uuid import uuid4
18
+
19
+ from pydantic import TypeAdapter, ValidationError
20
+
21
+ from equiframe._base import Identifier, Text
22
+ from equiframe._error_reporting import report_failure
23
+ from equiframe._regular_io import local_output_directory
24
+ from equiframe.contracts import DecimalType, TimestampType
25
+ from equiframe.errors import (
26
+ CapabilityError,
27
+ EquiFrameError,
28
+ ExecutionCancelled,
29
+ ExecutionTimeout,
30
+ InvalidExecutionInput,
31
+ ResourceLimitError,
32
+ )
33
+ from equiframe.execution_types import (
34
+ EngineCapabilities,
35
+ ExecutionPlan,
36
+ ExecutionProblem,
37
+ ExecutionResult,
38
+ Parameter,
39
+ StageTiming,
40
+ TableArtifact,
41
+ VersionInfo,
42
+ )
43
+ from equiframe.local import LocalArtifactStore
44
+ from equiframe.registry import TransformationRegistry
45
+ from equiframe.serialization import contract_fingerprint
46
+ from equiframe.tables import LoadedTable, capture_artifact, require_dependency
47
+ from equiframe.types import ExecutionState
48
+ from equiframe.values import InfinityValue, NaNValue, NullValue, StringValue
49
+
50
+ UTC = _timezone.utc
51
+
52
+ if TYPE_CHECKING:
53
+ import pyarrow as pa
54
+
55
+ from equiframe.migration.backends._timestamp_transport import _TimestampCodec
56
+ from equiframe.ports import ArtifactStore, DataSource, EngineAdapter
57
+
58
+
59
+ class CancellationToken:
60
+ """A caller-owned cooperative cancellation signal; no background callback threads."""
61
+
62
+ def __init__(self) -> None:
63
+ self._event = Event()
64
+
65
+ def cancel(self) -> None:
66
+ self._event.set()
67
+
68
+ @property
69
+ def cancelled(self) -> bool:
70
+ return self._event.is_set()
71
+
72
+
73
+ @dataclass
74
+ class RunContext:
75
+ run_id: str
76
+ execution_id: str
77
+ plan: ExecutionPlan
78
+ directory: Path
79
+ store: ArtifactStore
80
+ cancellation: CancellationToken
81
+ deadline: float | None
82
+ timings: list[StageTiming] = field(default_factory=list)
83
+ effective_configuration: list[Parameter] = field(default_factory=list)
84
+ stage: str = "preflight"
85
+ captured_inputs: list[TableArtifact] = field(default_factory=list)
86
+ captured_output: TableArtifact | None = None
87
+
88
+ @property
89
+ def parameters(self) -> Mapping[str, object]:
90
+ # Temporal literals stay ISO text; trusted SQL/callbacks must choose explicit casts.
91
+ def scalar(item: Parameter) -> object:
92
+ if isinstance(item.value, NullValue):
93
+ return None
94
+ if isinstance(item.value, NaNValue):
95
+ return float("nan")
96
+ if isinstance(item.value, InfinityValue):
97
+ return float("inf") if item.value.sign == "positive" else -float("inf")
98
+ return item.value.value
99
+
100
+ return {item.name: scalar(item) for item in self.plan.parameters}
101
+
102
+ def check(self) -> None:
103
+ if self.cancellation.cancelled:
104
+ raise ExecutionCancelled("cancellation requested; execution stopped at a checkpoint")
105
+ if self.deadline is not None and time.perf_counter() >= self.deadline:
106
+ raise ExecutionTimeout(
107
+ "time budget exceeded; execution returned/stopped at a checkpoint"
108
+ )
109
+
110
+ def check_table(self, table: pa.Table, *, output: bool, enforce_rows: bool = True) -> None:
111
+ if type(output) is not bool or type(enforce_rows) is not bool:
112
+ raise InvalidExecutionInput("table budget flags require explicit booleans")
113
+ self.check()
114
+ import pyarrow as pa
115
+
116
+ if not isinstance(table, pa.Table):
117
+ raise InvalidExecutionInput("data source must provide an Arrow Table")
118
+ options = self.plan.options
119
+ rows = options.max_output_rows if output else options.max_input_rows
120
+ if enforce_rows and rows is not None and table.num_rows > rows:
121
+ raise ResourceLimitError("materialized table exceeds row budget")
122
+ if options.max_transport_bytes is not None and table.nbytes > options.max_transport_bytes:
123
+ raise ResourceLimitError("materialized table exceeds Arrow buffer byte budget")
124
+
125
+ def record_configuration(self, name: str, value: str) -> None:
126
+ self.effective_configuration.append(Parameter(name=name, value=StringValue(value=value)))
127
+
128
+ @contextmanager
129
+ def measure(self, stage: str) -> Iterator[None]:
130
+ TypeAdapter(Text).validate_python(stage, strict=True)
131
+ previous = self.stage
132
+ self.stage = stage
133
+ self.check()
134
+ start = time.perf_counter()
135
+ succeeded = False
136
+ try:
137
+ yield
138
+ self.check()
139
+ succeeded = True
140
+ finally:
141
+ self.timings.append(StageTiming(stage=stage, seconds=time.perf_counter() - start))
142
+ if succeeded:
143
+ self.stage = previous
144
+
145
+
146
+ def _versions(engine: str) -> tuple[VersionInfo, ...]:
147
+ result = [VersionInfo(name="python", version=platform.python_version())]
148
+ for name in ("equiframe", "pydantic", "tzdata", "pyarrow", engine, "polars-runtime-32"):
149
+ if name == "polars-runtime-32" and engine != "polars":
150
+ continue
151
+ try:
152
+ installed = version(name)
153
+ except PackageNotFoundError:
154
+ installed = "not-installed"
155
+ result.append(VersionInfo(name=name, version=installed))
156
+ return tuple(result)
157
+
158
+
159
+ def _contract_capabilities(
160
+ plan: ExecutionPlan,
161
+ capabilities: EngineCapabilities,
162
+ *,
163
+ timestamp_codec: _TimestampCodec | None = None,
164
+ ) -> None:
165
+ if timestamp_codec is not None and timestamp_codec != timestamp_codec.for_contract(
166
+ plan.contract
167
+ ):
168
+ raise CapabilityError("IR timestamp codec differs from the execution contract")
169
+ for table in (*plan.contract.inputs, plan.contract.output):
170
+ for column in table.schema_spec.columns:
171
+ dtype = column.logical_type
172
+ if isinstance(dtype, DecimalType) and dtype.precision > capabilities.decimal_precision:
173
+ raise CapabilityError(
174
+ f"{plan.engine} cannot execute declared decimal precision {dtype.precision}"
175
+ )
176
+ if isinstance(dtype, TimestampType):
177
+ if timestamp_codec is not None:
178
+ continue
179
+ supported = (
180
+ capabilities.zoned_timestamp_units
181
+ if dtype.timezone
182
+ else capabilities.timestamp_units
183
+ )
184
+ if dtype.unit not in supported:
185
+ raise CapabilityError(
186
+ f"{plan.engine} cannot preserve declared timestamp unit/zone {dtype.unit}"
187
+ )
188
+ if (
189
+ plan.engine == "duckdb"
190
+ and dtype.timezone
191
+ and dtype.timezone != plan.options.duckdb_timezone
192
+ ):
193
+ raise CapabilityError(
194
+ "DuckDB zoned columns require the explicitly configured session timezone"
195
+ )
196
+
197
+
198
+ def execute(
199
+ plan: ExecutionPlan,
200
+ *,
201
+ source: DataSource,
202
+ registry: TransformationRegistry,
203
+ adapter: EngineAdapter | None = None,
204
+ artifact_store: ArtifactStore | None = None,
205
+ work_dir: Path | None = None,
206
+ cancellation: CancellationToken | None = None,
207
+ ) -> ExecutionResult:
208
+ """Execute trusted native code synchronously and persist run.json in a fresh directory.
209
+
210
+ Invalid plan objects and failure to create/publish the run directory raise;
211
+ operational failures inside a run become typed results. completed is not a
212
+ statement of semantic correctness. Callback timeouts are cooperative.
213
+ """
214
+ plan = ExecutionPlan.model_validate(plan)
215
+ if adapter is None:
216
+ from equiframe.adapters import native_adapter
217
+
218
+ adapter = native_adapter(plan.engine)
219
+ capabilities = EngineCapabilities.model_validate(adapter.capabilities())
220
+ run_id, execution_id = "run_" + uuid4().hex, "exec_" + uuid4().hex
221
+ directory = (
222
+ local_output_directory(Path.cwd() / "equiframe-runs" if work_dir is None else work_dir)
223
+ / run_id
224
+ / execution_id
225
+ )
226
+ directory.mkdir(parents=True, exist_ok=False)
227
+ store = (
228
+ artifact_store
229
+ if artifact_store is not None
230
+ else LocalArtifactStore(directory / "artifacts")
231
+ )
232
+ started = datetime.now(UTC).isoformat()
233
+ start = time.perf_counter()
234
+ timeout = plan.options.timeout_seconds
235
+ context = RunContext(
236
+ run_id,
237
+ execution_id,
238
+ plan,
239
+ directory,
240
+ store,
241
+ cancellation if cancellation is not None else CancellationToken(),
242
+ start + timeout if timeout is not None else None,
243
+ )
244
+ context.record_configuration(
245
+ "control_scope", "synchronous cooperative checkpoints; no callback preemption or sandbox"
246
+ )
247
+ context.record_configuration(
248
+ "contract_timezone_source",
249
+ "locked Python tzdata package; engine timezone rules recorded separately",
250
+ )
251
+ state: ExecutionState = "completed"
252
+ problem: ExecutionProblem | None = None
253
+ implementation_version: str | None = None
254
+ try:
255
+ with context.measure("preflight"):
256
+ if adapter.engine != plan.engine or capabilities.engine != plan.engine:
257
+ raise InvalidExecutionInput("adapter engine does not match execution plan")
258
+ names = TypeAdapter(tuple[Identifier, ...]).validate_python(
259
+ source.table_names, strict=True
260
+ )
261
+ if len(set(names)) != len(names):
262
+ raise InvalidExecutionInput("source table names must be unique")
263
+ if set(names) != {item.name for item in plan.contract.inputs}:
264
+ raise InvalidExecutionInput("source tables must match declared input names")
265
+ transformation = registry.resolve(plan)
266
+ for dependency in capabilities.required_packages:
267
+ require_dependency(dependency, plan.engine)
268
+ implementation_version = transformation.code_version
269
+ from equiframe.adapters import _timestamp_codec
270
+
271
+ _contract_capabilities(
272
+ plan, capabilities, timestamp_codec=_timestamp_codec(adapter, transformation)
273
+ )
274
+ tables: dict[str, LoadedTable] = {}
275
+ with context.measure("input_load"):
276
+ for item in plan.contract.inputs:
277
+ resolved = source.resolve(item.name, context=context)
278
+ if not isinstance(resolved, LoadedTable):
279
+ raise InvalidExecutionInput("data source must return LoadedTable")
280
+ tables[item.name] = resolved
281
+ context.check_table(tables[item.name].table, output=False)
282
+ capture_artifact(item.name, tables[item.name], context)
283
+ context.stage = "adapter_dispatch"
284
+ loaded = adapter.execute(transformation, tables, context)
285
+ if not isinstance(loaded, LoadedTable):
286
+ raise InvalidExecutionInput("engine adapter must return LoadedTable")
287
+ with context.measure("output_capture"):
288
+ context.check_table(loaded.table, output=True)
289
+ capture_artifact(plan.contract.output.name, loaded, context, output=True)
290
+ except Exception as error:
291
+ if isinstance(error, ExecutionCancelled):
292
+ state = "cancelled"
293
+ elif isinstance(error, ExecutionTimeout):
294
+ state = "timeout"
295
+ elif isinstance(error, (MemoryError, ResourceLimitError)):
296
+ state = "resource_exhausted"
297
+ elif isinstance(error, (InvalidExecutionInput, ValidationError)):
298
+ state = "invalid_input"
299
+ else:
300
+ state = "failed"
301
+ public_message = {
302
+ "cancelled": "Execution was cancelled.",
303
+ "timeout": "Execution exceeded its time budget.",
304
+ "resource_exhausted": "Execution exceeded a resource budget.",
305
+ "invalid_input": "Execution input could not be validated.",
306
+ "failed": "Execution failed. Check the declared engine and configuration.",
307
+ }[state]
308
+ # Engine labels come from this closed map, never exception text or SQL.
309
+ from equiframe.errors import MissingDependencyError
310
+
311
+ if isinstance(error, MissingDependencyError):
312
+ requirements = {
313
+ "arrow": "equiframe[arrow]",
314
+ "pandas": "equiframe[pandas]",
315
+ "polars": "equiframe[polars]",
316
+ "duckdb": "equiframe[duckdb]",
317
+ }
318
+ requirement = requirements.get(plan.engine)
319
+ if requirement is not None:
320
+ public_message += " Install " + requirement + " from your reviewed wheelhouse."
321
+ problem = ExecutionProblem(
322
+ code=error.code if isinstance(error, EquiFrameError) else state,
323
+ stage=context.stage,
324
+ exception_type=type(error).__name__,
325
+ message=report_failure(error, public_message),
326
+ )
327
+ result = ExecutionResult(
328
+ run_id=run_id,
329
+ execution_id=execution_id,
330
+ plan=plan,
331
+ contract_sha256=contract_fingerprint(plan.contract),
332
+ implementation_version=implementation_version,
333
+ state=state,
334
+ started_at=started,
335
+ finished_at=datetime.now(UTC).isoformat(),
336
+ elapsed_seconds=time.perf_counter() - start,
337
+ timings=tuple(context.timings),
338
+ versions=_versions(plan.engine),
339
+ capabilities=capabilities,
340
+ effective_configuration=tuple(context.effective_configuration),
341
+ inputs=tuple(context.captured_inputs),
342
+ output=context.captured_output,
343
+ problem=problem,
344
+ )
345
+ # Directory is owned by this execution. Exclusive publication never replaces a previous run.
346
+ temporary = directory / "run.part.json"
347
+ try:
348
+ with temporary.open("x", encoding="utf-8", newline="\n") as manifest:
349
+ manifest.write(result.model_dump_json())
350
+ manifest.flush()
351
+ os.fsync(manifest.fileno())
352
+ os.link(temporary, directory / "run.json")
353
+ finally:
354
+ temporary.unlink(missing_ok=True)
355
+ return result
@@ -0,0 +1,177 @@
1
+ """Validated execution metadata. Runtime objects and trusted code live separately."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import Field, model_validator
6
+ from typing_extensions import Self
7
+
8
+ from equiframe._base import (
9
+ FrozenModel,
10
+ Identifier,
11
+ NonNegativeInt,
12
+ PositiveInt,
13
+ Text,
14
+ _ExplicitOutcome,
15
+ fail,
16
+ )
17
+ from equiframe.contracts import Contract
18
+ from equiframe.types import ArtifactRef, ExecutionState, Sha256
19
+ from equiframe.values import ScalarValue, TimeUnit, TimeZone
20
+
21
+
22
+ class Parameter(FrozenModel):
23
+ name: Identifier
24
+ value: ScalarValue
25
+
26
+
27
+ class ExecutionOptions(FrozenModel):
28
+ timeout_seconds: Annotated[float, Field(gt=0)] | None = None
29
+ max_input_rows: NonNegativeInt | None = None
30
+ max_output_rows: NonNegativeInt | None = None
31
+ max_transport_bytes: PositiveInt | None = None
32
+ duckdb_memory_mb: PositiveInt = 512
33
+ duckdb_threads: PositiveInt = 1
34
+ duckdb_timezone: TimeZone = "UTC"
35
+
36
+
37
+ class ExecutionPlan(FrozenModel):
38
+ contract: Contract
39
+ engine: Identifier
40
+ transformation_id: Identifier
41
+ transformation_revision: PositiveInt = 1
42
+ parameters: tuple[Parameter, ...] = ()
43
+ options: ExecutionOptions = Field(default_factory=ExecutionOptions)
44
+
45
+ @model_validator(mode="after")
46
+ def unique_parameters(self) -> Self:
47
+ names = tuple(item.name for item in self.parameters)
48
+ if len(set(names)) != len(names):
49
+ fail("duplicate_parameter", "parameter names must be unique", "parameters")
50
+ return self
51
+
52
+
53
+ class EngineCapabilities(FrozenModel):
54
+ engine: Identifier
55
+ decimal_precision: Annotated[int, Field(ge=1, le=76)]
56
+ timestamp_units: tuple[TimeUnit, ...]
57
+ zoned_timestamp_units: tuple[TimeUnit, ...]
58
+ lazy: bool
59
+ cancellation: Literal["cooperative", "interrupt_query"]
60
+ resource_controls: tuple[Text, ...]
61
+ required_packages: tuple[Text, ...] = ()
62
+
63
+
64
+ class PhysicalColumn(FrozenModel):
65
+ name: Annotated[str, Field(min_length=1)]
66
+ engine_type: Text
67
+ arrow_type: Text
68
+ null_count: NonNegativeInt
69
+ nan_count: NonNegativeInt
70
+
71
+
72
+ class TableArtifact(FrozenModel):
73
+ name: Text
74
+ data: ArtifactRef
75
+ origin: ArtifactRef | None = None
76
+ row_count: NonNegativeInt
77
+ columns: tuple[PhysicalColumn, ...]
78
+ index: ArtifactRef | None = None
79
+ index_columns: tuple[PhysicalColumn, ...] = ()
80
+ notes: tuple[Text, ...] = ()
81
+
82
+ @model_validator(mode="after")
83
+ def consistent_table(self) -> Self:
84
+ if self.data.format not in ("arrow", "parquet"):
85
+ fail("table_artifact_format", "table data requires Arrow/Parquet", "data")
86
+ if self.index is None and self.index_columns:
87
+ fail("missing_index_artifact", "index columns require an index artifact", "index")
88
+ if self.index is not None and self.index.format not in ("arrow", "parquet"):
89
+ fail("table_artifact_format", "index requires Arrow/Parquet", "index")
90
+ for location, columns in (("columns", self.columns), ("index_columns", self.index_columns)):
91
+ if len({c.name for c in columns}) != len(columns):
92
+ fail("duplicate_physical_column", "physical column names must be unique", location)
93
+ for position, column in enumerate(columns):
94
+ if column.null_count + column.nan_count > self.row_count:
95
+ fail(
96
+ "impossible_value_counts",
97
+ "NULL/NaN counts exceed row count",
98
+ location,
99
+ position,
100
+ )
101
+ return self
102
+
103
+
104
+ class StageTiming(FrozenModel):
105
+ stage: Text
106
+ seconds: Annotated[float, Field(ge=0)]
107
+
108
+
109
+ class VersionInfo(FrozenModel):
110
+ name: Text
111
+ version: Text
112
+
113
+
114
+ class ExecutionProblem(FrozenModel):
115
+ code: Text
116
+ stage: Text
117
+ exception_type: Text
118
+ message: Text
119
+
120
+
121
+ class ExecutionResult(_ExplicitOutcome, FrozenModel):
122
+ _truth_value_guidance = (
123
+ "inspect .state explicitly; completed execution does not establish correctness"
124
+ )
125
+
126
+ schema_version: Literal["1.0"] = "1.0"
127
+ run_id: Identifier
128
+ execution_id: Identifier
129
+ plan: ExecutionPlan
130
+ contract_sha256: Sha256
131
+ implementation_version: Text | None
132
+ state: ExecutionState
133
+ started_at: Text
134
+ finished_at: Text
135
+ elapsed_seconds: Annotated[float, Field(ge=0)]
136
+ timings: tuple[StageTiming, ...]
137
+ versions: tuple[VersionInfo, ...]
138
+ capabilities: EngineCapabilities
139
+ effective_configuration: tuple[Parameter, ...]
140
+ inputs: tuple[TableArtifact, ...]
141
+ output: TableArtifact | None
142
+ problem: ExecutionProblem | None
143
+
144
+ @model_validator(mode="after")
145
+ def consistent_result(self) -> Self:
146
+ if self.state == "completed" and (self.output is None or self.problem is not None):
147
+ fail("incomplete_result", "completed requires output and no problem", "state")
148
+ if self.state != "completed" and self.problem is None:
149
+ fail("missing_problem", "unsuccessful execution requires a diagnostic", "problem")
150
+ from equiframe.serialization import contract_fingerprint
151
+
152
+ if self.contract_sha256 != contract_fingerprint(self.plan.contract):
153
+ fail(
154
+ "contract_fingerprint_mismatch",
155
+ "fingerprint must match the embedded contract",
156
+ "contract_sha256",
157
+ )
158
+ names = tuple(table.name for table in self.inputs)
159
+ declared = {table.name for table in self.plan.contract.inputs}
160
+ if len(set(names)) != len(names) or not set(names) <= declared:
161
+ fail(
162
+ "invalid_input_evidence",
163
+ "captured inputs must be unique declared table names",
164
+ "inputs",
165
+ )
166
+ if self.state == "completed":
167
+ if set(names) != declared:
168
+ fail("missing_input_evidence", "completed requires every input artifact", "inputs")
169
+ if self.capabilities.engine != self.plan.engine:
170
+ fail(
171
+ "engine_evidence_mismatch",
172
+ "completed engine capabilities must match plan",
173
+ "capabilities",
174
+ )
175
+ if self.output is not None and self.output.name != self.plan.contract.output.name:
176
+ fail("output_evidence_mismatch", "output table name must match the plan", "output")
177
+ return self