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/adapters.py ADDED
@@ -0,0 +1,454 @@
1
+ """Three independent native engines; imports and connections are per execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from collections import Counter
8
+ from collections.abc import Mapping
9
+ from threading import Event, Thread
10
+ from typing import TYPE_CHECKING, cast
11
+
12
+ from equiframe._duckdb_transport import (
13
+ duckdb_arrow_schema,
14
+ duckdb_parameters,
15
+ duckdb_temporal_capabilities,
16
+ verify_duckdb_table,
17
+ )
18
+ from equiframe.errors import (
19
+ CapabilityError,
20
+ InvalidExecutionInput,
21
+ NativeEngineError,
22
+ ResourceLimitError,
23
+ )
24
+ from equiframe.execution import RunContext
25
+ from equiframe.execution_types import EngineCapabilities
26
+ from equiframe.registry import (
27
+ DuckDBTransformation,
28
+ PandasTransformation,
29
+ PolarsTransformation,
30
+ Transformation,
31
+ )
32
+ from equiframe.tables import (
33
+ LoadedTable,
34
+ check_arrow,
35
+ copy_pandas_frame,
36
+ pandas_capture,
37
+ require_dependency,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ import pyarrow as pa
42
+
43
+ from equiframe.migration.backends._timestamp_transport import _TimestampCodec
44
+ from equiframe.ports import EngineAdapter
45
+
46
+
47
+ def _input_capabilities(loaded: LoadedTable, engine: str, context: RunContext) -> None:
48
+ import pyarrow as pa
49
+
50
+ check_arrow(loaded.table)
51
+ if engine == "duckdb" and len({name.casefold() for name in loaded.table.column_names}) != len(
52
+ loaded.table.column_names
53
+ ):
54
+ raise CapabilityError("DuckDB would rename input columns that differ only by case")
55
+ if loaded.meaningful_index and engine != "pandas":
56
+ raise CapabilityError(
57
+ "Pandas index requires index_policy='columns' or 'discard' for other engines"
58
+ )
59
+ if loaded.table.num_columns == 0 and (
60
+ engine == "duckdb" or (engine == "pandas" and loaded.table.num_rows > 0)
61
+ ):
62
+ raise CapabilityError(f"{engine} does not support faithful zero-column table input")
63
+ for field in loaded.table.schema:
64
+ dtype = field.type
65
+ if (
66
+ engine != "pandas"
67
+ and pa.types.is_decimal(dtype)
68
+ and (pa.types.is_decimal256(dtype) or dtype.scale < 0)
69
+ ):
70
+ raise CapabilityError(f"{engine} requires decimal128 with a nonnegative scale")
71
+ if engine == "polars" and pa.types.is_timestamp(dtype) and dtype.unit == "s":
72
+ import pyarrow.compute as pc
73
+
74
+ bounds = pc.min_max(loaded.table[field.name].cast(pa.int64())).as_py()
75
+ if bounds["min"] is not None and (
76
+ bounds["min"] < -(2**63 // 1000) or bounds["max"] > (2**63 - 1) // 1000
77
+ ):
78
+ raise CapabilityError(
79
+ "Polars seconds-to-milliseconds transport would overflow int64"
80
+ )
81
+ if engine == "polars" and pa.types.is_date64(dtype):
82
+ raise CapabilityError("Polars converts date64 to timestamp; use explicit date32 input")
83
+ if engine != "pandas" and pa.types.is_decimal(dtype) and dtype.precision > 38:
84
+ raise CapabilityError(f"{engine} supports decimal precision at most 38")
85
+ if (
86
+ engine == "duckdb"
87
+ and pa.types.is_timestamp(dtype)
88
+ and dtype.tz
89
+ and (dtype.unit == "ns" or dtype.tz != context.plan.options.duckdb_timezone)
90
+ ):
91
+ raise CapabilityError(
92
+ "DuckDB rejects zoned ns and zones different from its session timezone"
93
+ )
94
+ if (
95
+ engine == "duckdb"
96
+ and pa.types.is_timestamp(dtype)
97
+ and dtype.tz
98
+ and dtype.unit in ("s", "ms")
99
+ ):
100
+ import pyarrow.compute as pc
101
+
102
+ factor = 1_000_000 if dtype.unit == "s" else 1_000
103
+ limit = (2**63 - 2) // factor
104
+ bounds = pc.min_max(loaded.table[field.name].cast(pa.int64())).as_py()
105
+ if bounds["min"] is not None and (bounds["min"] < -limit or bounds["max"] > limit):
106
+ raise CapabilityError(
107
+ "DuckDB zoned timestamp transport requires finite int64 microseconds"
108
+ )
109
+
110
+
111
+ class PandasAdapter:
112
+ engine = "pandas"
113
+
114
+ def capabilities(self) -> EngineCapabilities:
115
+ return EngineCapabilities(
116
+ engine=self.engine,
117
+ required_packages=(self.engine, "pyarrow"),
118
+ decimal_precision=76,
119
+ timestamp_units=("s", "ms", "us", "ns"),
120
+ zoned_timestamp_units=("s", "ms", "us", "ns"),
121
+ lazy=False,
122
+ cancellation="cooperative",
123
+ resource_controls=("materialized rows/buffer checkpoints",),
124
+ )
125
+
126
+ def execute(
127
+ self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
128
+ ) -> LoadedTable:
129
+ require_dependency("pandas", "pandas")
130
+ require_dependency("pyarrow", "pandas")
131
+ import pandas as pd
132
+
133
+ if not isinstance(transformation, PandasTransformation):
134
+ raise InvalidExecutionInput("Pandas adapter requires a PandasTransformation")
135
+ with context.measure("input_conversion"):
136
+ tables = {}
137
+ for name, loaded in inputs.items():
138
+ _input_capabilities(loaded, self.engine, context)
139
+ tables[name] = (
140
+ copy_pandas_frame(loaded.native)
141
+ if isinstance(loaded.native, pd.DataFrame)
142
+ else loaded.table.to_pandas(types_mapper=pd.ArrowDtype, ignore_metadata=True)
143
+ )
144
+ context.record_configuration(
145
+ "pandas_timezone_source",
146
+ "input tzinfo provider; Arrow retains zone/unit; engine rules are independent",
147
+ )
148
+ with context.measure("transformation"):
149
+ frame = transformation.callback(tables, context)
150
+ with context.measure("materialization"):
151
+ return pandas_capture(frame)
152
+
153
+
154
+ class PolarsAdapter:
155
+ engine = "polars"
156
+
157
+ def capabilities(self) -> EngineCapabilities:
158
+ return EngineCapabilities(
159
+ engine=self.engine,
160
+ required_packages=(self.engine, "pyarrow"),
161
+ decimal_precision=38,
162
+ timestamp_units=("s", "ms", "us", "ns"),
163
+ zoned_timestamp_units=("s", "ms", "us", "ns"),
164
+ lazy=True,
165
+ cancellation="cooperative",
166
+ resource_controls=("materialized rows/buffer checkpoints",),
167
+ )
168
+
169
+ def execute(
170
+ self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
171
+ ) -> LoadedTable:
172
+ require_dependency("polars", "polars")
173
+ require_dependency("pyarrow", "polars")
174
+ import polars as pl
175
+
176
+ try:
177
+ return self._execute_native(transformation, inputs, context)
178
+ except pl.exceptions.PanicException as error:
179
+ raise NativeEngineError(
180
+ "Polars native code panicked; execution did not complete"
181
+ ) from error
182
+
183
+ def _execute_native(
184
+ self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
185
+ ) -> LoadedTable:
186
+ import polars as pl
187
+
188
+ if not isinstance(transformation, PolarsTransformation):
189
+ raise InvalidExecutionInput("Polars adapter requires a PolarsTransformation")
190
+ codec = _timestamp_codec(self, transformation)
191
+ with context.measure("input_conversion"):
192
+ if codec is not None:
193
+ inputs = codec.encode_inputs(inputs)
194
+ context.record_configuration(
195
+ "ir_timestamp_transport", "unchanged int64 ticks with explicit unit/zone codec"
196
+ )
197
+ tables: dict[str, pl.DataFrame] = {}
198
+ for name, loaded in inputs.items():
199
+ _input_capabilities(loaded, self.engine, context)
200
+ tables[name] = (
201
+ loaded.native.clone()
202
+ if isinstance(loaded.native, pl.DataFrame)
203
+ else cast("pl.DataFrame", pl.from_arrow(loaded.table))
204
+ )
205
+ if tables[name].shape != loaded.table.shape:
206
+ raise CapabilityError("Polars input conversion changed table dimensions")
207
+ context.record_configuration(
208
+ "polars_timezone_source",
209
+ "chrono-tz compiled into locked polars runtime; IANA release not exposed by Python API",
210
+ )
211
+ context.record_configuration("polars_threads", str(pl.thread_pool_size()))
212
+ with context.measure("planning"):
213
+ output = transformation.callback(tables, context)
214
+ if not isinstance(output, (pl.DataFrame, pl.LazyFrame)):
215
+ raise InvalidExecutionInput("Polars transformation must return DataFrame/LazyFrame")
216
+ with context.measure("materialization"):
217
+ frame = output.collect() if isinstance(output, pl.LazyFrame) else output
218
+ table = frame.to_arrow()
219
+ if table.shape != frame.shape:
220
+ raise CapabilityError("Polars output conversion changed table dimensions")
221
+ if codec is not None:
222
+ table = codec.decode_output(table)
223
+ check_arrow(table)
224
+ return LoadedTable(table, native_types=tuple((n, str(t)) for n, t in frame.schema.items()))
225
+
226
+
227
+ class DuckDBAdapter:
228
+ engine = "duckdb"
229
+
230
+ def capabilities(self) -> EngineCapabilities:
231
+ return EngineCapabilities(
232
+ engine=self.engine,
233
+ required_packages=(self.engine, "pyarrow"),
234
+ decimal_precision=38,
235
+ timestamp_units=("s", "ms", "us", "ns"),
236
+ zoned_timestamp_units=("s", "ms", "us"),
237
+ lazy=False,
238
+ cancellation="interrupt_query",
239
+ resource_controls=(
240
+ "DuckDB memory_limit (engine-managed memory only)",
241
+ "DuckDB threads",
242
+ "materialized rows/buffer checkpoints",
243
+ ),
244
+ )
245
+
246
+ def execute(
247
+ self, transformation: Transformation, inputs: Mapping[str, LoadedTable], context: RunContext
248
+ ) -> LoadedTable:
249
+ require_dependency("duckdb", "duckdb")
250
+ require_dependency("pyarrow", "duckdb")
251
+ import duckdb
252
+
253
+ if not isinstance(transformation, DuckDBTransformation):
254
+ raise InvalidExecutionInput("DuckDB adapter requires a DuckDBTransformation")
255
+ from equiframe.migration.backends.duckdb import _DuckDBProgram
256
+
257
+ program = transformation if isinstance(transformation, _DuckDBProgram) else None
258
+ codec = _timestamp_codec(self, transformation)
259
+ with context.measure("input_capabilities"):
260
+ if codec is not None:
261
+ inputs = codec.encode_inputs(inputs)
262
+ context.record_configuration(
263
+ "ir_timestamp_transport", "unchanged int64 ticks with explicit unit/zone codec"
264
+ )
265
+ for name, loaded in inputs.items():
266
+ if re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name) is None:
267
+ raise CapabilityError(
268
+ "DuckDB input identifiers allow ASCII letters, digits and underscores"
269
+ )
270
+ _input_capabilities(loaded, self.engine, context)
271
+ duckdb_temporal_capabilities(loaded.table)
272
+ options = context.plan.options
273
+ stopped = Event()
274
+ connection: duckdb.DuckDBPyConnection | None = None
275
+
276
+ def watch() -> None:
277
+ if not (connection is not None):
278
+ raise AssertionError("Internal invariant check failed.")
279
+ while not stopped.wait(0.01):
280
+ if context.cancellation.cancelled or (
281
+ context.deadline is not None and time.perf_counter() >= context.deadline
282
+ ):
283
+ connection.interrupt()
284
+ # Keep interrupting until the active stage finishes, covering start races.
285
+
286
+ watcher = Thread(target=watch, name=context.execution_id + "_interrupt", daemon=False)
287
+ try:
288
+ with context.measure("engine_setup"):
289
+ connection = duckdb.connect(
290
+ ":memory:",
291
+ config={
292
+ "memory_limit": f"{options.duckdb_memory_mb}MB",
293
+ "threads": options.duckdb_threads,
294
+ "enable_external_access": False,
295
+ "autoinstall_known_extensions": False,
296
+ "python_enable_replacements": False,
297
+ "max_temp_directory_size": "0B",
298
+ "preserve_insertion_order": True,
299
+ },
300
+ )
301
+ connection.execute("SET TimeZone = ?", [options.duckdb_timezone])
302
+ connection.execute("SET autoload_known_extensions=false")
303
+ settings = connection.execute(
304
+ "SELECT current_setting('memory_limit'), current_setting('threads'), "
305
+ "current_setting('TimeZone'), current_setting('max_temp_directory_size')"
306
+ ).fetchone()
307
+ if not (settings is not None):
308
+ raise AssertionError("Internal invariant check failed.")
309
+ for name, value in zip(
310
+ ("memory_limit", "threads", "timezone", "max_spill"), settings, strict=True
311
+ ):
312
+ context.record_configuration("duckdb_effective_" + name, str(value))
313
+ queries = (
314
+ (*tuple(step.sql for step in program.checkpoints), program.final_sql)
315
+ if program is not None
316
+ else (transformation.sql,)
317
+ )
318
+ for query in queries:
319
+ statements = connection.extract_statements(query)
320
+ if len(statements) != 1 or statements[0].type != duckdb.StatementType.SELECT:
321
+ raise InvalidExecutionInput(
322
+ "registered DuckDB SQL must be one SELECT/WITH query"
323
+ )
324
+ if set(statements[0].named_parameters) != set(context.parameters):
325
+ raise InvalidExecutionInput(
326
+ "SQL named parameters must match execution plan parameters"
327
+ )
328
+ if len({name.casefold() for name in inputs}) != len(inputs):
329
+ raise InvalidExecutionInput("DuckDB input names must also be unique ignoring case")
330
+ context.record_configuration(
331
+ "duckdb_memory_limit", f"{options.duckdb_memory_mb}MB (not a total Python RSS cap)"
332
+ )
333
+ context.record_configuration("duckdb_threads", str(options.duckdb_threads))
334
+ context.record_configuration("duckdb_timezone", options.duckdb_timezone)
335
+ context.record_configuration(
336
+ "duckdb_timezone_source",
337
+ "bundled ICU extension in installed DuckDB wheel; independent of Python tzdata",
338
+ )
339
+ context.record_configuration(
340
+ "duckdb_iana_release", "not exposed; installed wheel version recorded"
341
+ )
342
+ context.record_configuration(
343
+ "duckdb_external_access",
344
+ "disabled; no shared database, filesystem SQL or extension downloads",
345
+ )
346
+ with context.measure("input_conversion"):
347
+ for name, loaded in inputs.items():
348
+ connection.register(name, loaded.table)
349
+ watcher.start()
350
+ with context.measure("transformation"):
351
+ if program is not None:
352
+ available = {name.casefold() for name in inputs}
353
+ remaining = Counter(
354
+ name for step in program.checkpoints for name in step.dependencies
355
+ )
356
+ remaining[program.output_relation] += 1
357
+ if not program.checkpoints or not program.final_sql:
358
+ raise InvalidExecutionInput("DuckDB IR program requires native checkpoints")
359
+ context.record_configuration(
360
+ "duckdb_ir_checkpoints", str(len(program.checkpoints))
361
+ )
362
+ for step in program.checkpoints:
363
+ context.check()
364
+ if step.name.casefold() in available or any(
365
+ name.casefold() not in available for name in step.dependencies
366
+ ):
367
+ raise InvalidExecutionInput("DuckDB IR checkpoint dependency mismatch")
368
+ connection.execute(step.sql, duckdb_parameters(context.parameters))
369
+ observed = tuple(
370
+ (str(item[0]), str(item[1])) for item in connection.description or ()
371
+ )
372
+ duckdb_arrow_schema(observed, options.duckdb_timezone)
373
+ intermediate = cast("pa.Table", connection.to_arrow_table(batch_size=2048))
374
+ verify_duckdb_table(intermediate, observed, options.duckdb_timezone)
375
+ context.check_table(intermediate, output=False, enforce_rows=False)
376
+ connection.register(step.name, intermediate)
377
+ available.add(step.name.casefold())
378
+ for name in step.dependencies:
379
+ remaining[name] -= 1
380
+ if remaining[name] == 0:
381
+ connection.unregister(name)
382
+ available.remove(name.casefold())
383
+ if program.output_relation.casefold() not in available:
384
+ raise InvalidExecutionInput("DuckDB IR output checkpoint is missing")
385
+ query = program.final_sql
386
+ else:
387
+ query = transformation.sql
388
+ connection.execute(query, duckdb_parameters(context.parameters))
389
+ native_types = tuple(
390
+ (str(item[0]), str(item[1])) for item in connection.description or ()
391
+ )
392
+ with context.measure("materialization"):
393
+ duckdb_arrow_schema(native_types, options.duckdb_timezone)
394
+ table = cast("pa.Table", connection.to_arrow_table(batch_size=2048))
395
+ verify_duckdb_table(table, native_types, options.duckdb_timezone)
396
+ if codec is not None:
397
+ table = codec.decode_output(table)
398
+ return LoadedTable(
399
+ table,
400
+ native_types=native_types,
401
+ notes=(
402
+ "Observed DuckDB order retained; SQL without ORDER BY has no order guarantee.",
403
+ ),
404
+ )
405
+ except duckdb.OutOfMemoryException as error:
406
+ raise ResourceLimitError("DuckDB engine memory limit exhausted") from error
407
+ except duckdb.InterruptException:
408
+ context.check()
409
+ raise
410
+ finally:
411
+ stopped.set()
412
+ if watcher.ident is not None:
413
+ watcher.join()
414
+ if connection is not None:
415
+ connection.close()
416
+
417
+
418
+ def _timestamp_codec(
419
+ adapter: EngineAdapter, transformation: Transformation
420
+ ) -> _TimestampCodec | None:
421
+ """Only native adapters for the matching compiler understand tick encodings."""
422
+ from equiframe.migration.backends._timestamp_transport import (
423
+ _TimestampDuckDBTransformation,
424
+ _TimestampPolarsTransformation,
425
+ )
426
+ from equiframe.migration.backends.duckdb import _DuckDBProgram
427
+
428
+ if isinstance(transformation, _DuckDBProgram) and not isinstance(adapter, DuckDBAdapter):
429
+ raise CapabilityError("DuckDB IR checkpoints require the native DuckDB adapter")
430
+
431
+ if (
432
+ isinstance(adapter, DuckDBAdapter)
433
+ and isinstance(transformation, _TimestampDuckDBTransformation)
434
+ ) or (
435
+ isinstance(adapter, PolarsAdapter)
436
+ and isinstance(transformation, _TimestampPolarsTransformation)
437
+ ):
438
+ codec = transformation.timestamp_codec
439
+ if codec is None:
440
+ raise InvalidExecutionInput("IR timestamp transformation requires its explicit codec")
441
+ return codec
442
+ return None
443
+
444
+
445
+ def native_adapter(engine: str) -> EngineAdapter:
446
+ adapters: dict[str, EngineAdapter] = {
447
+ "pandas": PandasAdapter(),
448
+ "polars": PolarsAdapter(),
449
+ "duckdb": DuckDBAdapter(),
450
+ }
451
+ try:
452
+ return adapters[engine]
453
+ except KeyError:
454
+ raise CapabilityError("unknown native engine; inject an explicit EngineAdapter") from None
@@ -0,0 +1,122 @@
1
+ """Acceptance policy and detached decisions over existing comparison results.
2
+
3
+ These models describe integration acceptance, not another comparison algorithm.
4
+ The sole guarantee is complete observed equivalence under the declared output
5
+ contract. A peer or expected reference does not establish independent correctness,
6
+ and evidence integrity does not authenticate the producer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import model_validator
14
+ from typing_extensions import Never, Self
15
+
16
+ from equiframe._base import FrozenModel, PositiveInt
17
+ from equiframe.types import ComparisonState, Sha256
18
+
19
+ EquivalenceCode = Literal[
20
+ "equivalent",
21
+ "different",
22
+ "not_evaluable",
23
+ "guarantee_mismatch",
24
+ "evidence_missing",
25
+ "evidence_corrupt",
26
+ "evidence_path_rejected",
27
+ "evidence_budget_exceeded",
28
+ "evidence_unreadable",
29
+ ]
30
+ EvidenceFailureCode = Literal[
31
+ "evidence_missing",
32
+ "evidence_corrupt",
33
+ "evidence_path_rejected",
34
+ "evidence_budget_exceeded",
35
+ "evidence_unreadable",
36
+ ]
37
+ EvidenceStatus = Literal[
38
+ "not_requested",
39
+ "verified",
40
+ "missing",
41
+ "corrupt",
42
+ "path_rejected",
43
+ "budget_exceeded",
44
+ "unreadable",
45
+ ]
46
+ GuaranteeFailure = Literal["required_contract_sha256", "required_spec_sha256", "required_reference"]
47
+
48
+
49
+ class EquivalencePolicy(FrozenModel):
50
+ """Require complete output-schema equivalence, optionally pinning its context.
51
+
52
+ No setting weakens evaluation, changes a comparison policy, permits partial
53
+ results or asserts independent correctness. Persisted inspection requires an
54
+ explicit evidence_root and verifies the current manifest and all differences
55
+ within these budgets; source captures are not opened.
56
+ """
57
+
58
+ schema_version: Literal["1.0"] = "1.0"
59
+ guarantee: Literal["observed_contract"] = "observed_contract"
60
+ required_contract_sha256: Sha256 | None = None
61
+ required_spec_sha256: Sha256 | None = None
62
+ required_reference: Literal["peer", "expected"] | None = None
63
+ require_persisted_evidence: bool = False
64
+ max_evidence_bytes: PositiveInt = 64 * 1024 * 1024
65
+ max_records: PositiveInt = 100000
66
+
67
+
68
+ class EquivalenceDecision(FrozenModel):
69
+ """Safe integration summary preserving the original comparison verdict.
70
+
71
+ Inspect accepted explicitly. Comparison state failures take precedence over
72
+ guarantee mismatches and evidence failures, which remain separately visible.
73
+ This document is not accepted as input to assert_equivalent.
74
+ """
75
+
76
+ schema_version: Literal["1.0"] = "1.0"
77
+ accepted: bool
78
+ code: EquivalenceCode
79
+ state: ComparisonState
80
+ complete: bool
81
+ counts_final: bool
82
+ scope: Literal["output_schema"] = "output_schema"
83
+ reference: Literal["peer", "expected"]
84
+ contract_sha256: Sha256
85
+ spec_sha256: Sha256
86
+ guarantee: Literal["observed_contract"] = "observed_contract"
87
+ guarantee_conforms: bool
88
+ guarantee_failures: tuple[GuaranteeFailure, ...]
89
+ evidence_status: EvidenceStatus
90
+ evidence_code: EvidenceFailureCode | None
91
+ safe_reason_code: str | None = None
92
+
93
+ @model_validator(mode="after")
94
+ def consistent(self) -> Self:
95
+ if self.complete != self.counts_final:
96
+ raise ValueError("decision count completeness must preserve result completeness")
97
+ if self.state in ("equivalent", "different") and not self.complete:
98
+ raise ValueError("a final verdict requires complete evaluation")
99
+ if self.guarantee_conforms != (not self.guarantee_failures):
100
+ raise ValueError("guarantee conformance must describe its unmet requirements")
101
+ if len(set(self.guarantee_failures)) != len(self.guarantee_failures):
102
+ raise ValueError("guarantee failures must be unique")
103
+ expected_evidence = (
104
+ None
105
+ if self.evidence_status in ("not_requested", "verified")
106
+ else "evidence_" + self.evidence_status
107
+ )
108
+ if self.evidence_code != expected_evidence:
109
+ raise ValueError("evidence status and failure code must agree")
110
+ expected_code = (
111
+ self.state
112
+ if self.state != "equivalent"
113
+ else "guarantee_mismatch"
114
+ if not self.guarantee_conforms
115
+ else self.evidence_code or "equivalent"
116
+ )
117
+ if self.code != expected_code or self.accepted != (expected_code == "equivalent"):
118
+ raise ValueError("acceptance must preserve comparison, guarantee and evidence failures")
119
+ return self
120
+
121
+ def __bool__(self) -> Never:
122
+ raise TypeError("EquivalenceDecision has no truth value; inspect .accepted explicitly")