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,296 @@
1
+ """One exact contract, six input variants and three independent native engines.
2
+
3
+ All data and the injected API response are synthetic. No remote service, paid
4
+ resource, credentials, benchmark or GitHub Actions are required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import hashlib
11
+ import io
12
+ import json
13
+ import sqlite3
14
+ from contextlib import closing
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+
18
+ import pyarrow as pa
19
+ import pyarrow.parquet as pq
20
+
21
+ import equiframe as ef
22
+
23
+ INT = ef.IntegerType(bits=64, signed=True)
24
+ MONEY = ef.DecimalType(precision=12, scale=2)
25
+
26
+
27
+ def column(
28
+ name: str, dtype: ef.LogicalType, *, nullable: bool = True, unit: str | None = None
29
+ ) -> ef.ColumnSpec:
30
+ return ef.ColumnSpec(
31
+ name=name,
32
+ logical_type=dtype,
33
+ nullable=nullable,
34
+ null_policy=ef.NullPolicy(representations=("null",)),
35
+ numeric_policy=ef.NumericPolicy(comparison="exact", unit=unit)
36
+ if unit is not None
37
+ else None,
38
+ )
39
+
40
+
41
+ def declarations() -> tuple[ef.Contract, ef.RelationalPlan, ef.IngestionSpec]:
42
+ columns = (
43
+ column("id", ef.StringType(), nullable=False),
44
+ column("sequence", INT, nullable=False, unit="sequence"),
45
+ column("amount", MONEY, unit="USD"),
46
+ )
47
+ outputs = (
48
+ *columns,
49
+ column("position", INT, nullable=False, unit="rows"),
50
+ column("previous_amount", MONEY, unit="USD"),
51
+ )
52
+
53
+ def table(name: str, fields: tuple[ef.ColumnSpec, ...]) -> ef.TableSpec:
54
+ return ef.TableSpec(
55
+ name=name,
56
+ schema_spec=ef.TableSchema(
57
+ columns=fields, keys=(), multiplicity="preserve", order=ef.IgnoredOrder()
58
+ ),
59
+ )
60
+
61
+ contract = ef.Contract(
62
+ schema_version="1.0",
63
+ contract_id="universal.synthetic",
64
+ revision=1,
65
+ family="nulls_types",
66
+ inputs=(table("input", columns),),
67
+ output=table("output", outputs),
68
+ preconditions=(),
69
+ rules=(),
70
+ )
71
+ plan = ef.RelationalPlan(
72
+ nodes=(
73
+ ef.Scan(node_id="s", table="input"),
74
+ ef.Window(
75
+ node_id="w",
76
+ source="s",
77
+ partition_by=("id",),
78
+ by=(ef.SortKey(column="sequence", direction="ascending", nulls="last"),),
79
+ measures=(
80
+ ef.WindowMeasure(
81
+ name="position", operation="row_number", logical_type=INT, unit="rows"
82
+ ),
83
+ ef.WindowMeasure(
84
+ name="previous_amount", operation="lag", column="amount", logical_type=MONEY
85
+ ),
86
+ ),
87
+ ),
88
+ ),
89
+ output="w",
90
+ )
91
+ spec = ef.IngestionSpec(
92
+ format="jsonl",
93
+ fields=tuple(ef.FieldMapping(column=field, path=(field.name,)) for field in columns),
94
+ )
95
+ return contract, plan, spec
96
+
97
+
98
+ def source_table() -> pa.Table:
99
+ return pa.table(
100
+ {
101
+ "id": ["001", "001", "002", "001"],
102
+ "sequence": [2, 1, 1, 3],
103
+ "amount": pa.array(
104
+ [None, Decimal("12.30"), Decimal("-0.01"), Decimal("2.00")],
105
+ type=pa.decimal128(12, 2),
106
+ ),
107
+ }
108
+ )
109
+
110
+
111
+ def expected_output() -> pa.Table:
112
+ """Literal expected authored independently of source loading/native execution."""
113
+ return pa.table(
114
+ {
115
+ "id": ["001", "001", "001", "002"],
116
+ "sequence": [1, 2, 3, 1],
117
+ "amount": pa.array(
118
+ [Decimal("12.30"), None, Decimal("2.00"), Decimal("-0.01")],
119
+ type=pa.decimal128(12, 2),
120
+ ),
121
+ "position": [1, 2, 3, 1],
122
+ "previous_amount": pa.array(
123
+ [None, Decimal("12.30"), None, None], type=pa.decimal128(12, 2)
124
+ ),
125
+ }
126
+ )
127
+
128
+
129
+ def inputs(root: Path, spec: ef.IngestionSpec, case: str) -> dict[str, ef.DataSource]:
130
+ root.mkdir(exist_ok=False)
131
+ csv = root / "input.csv"
132
+ csv.write_text(
133
+ "id,sequence,amount\n001,2,NULL\n001,1,12.30\n002,1,-0.01\n001,3,2.00\n", encoding="utf-8"
134
+ )
135
+ csv_spec = ef.IngestionSpec(
136
+ format="csv",
137
+ fields=tuple(
138
+ mapping.model_copy(update={"null_tokens": ("NULL",)})
139
+ if mapping.column.nullable
140
+ else mapping
141
+ for mapping in spec.fields
142
+ ),
143
+ )
144
+ database = root / "input.sqlite"
145
+ with sqlite3.connect(database) as db:
146
+ db.execute("CREATE TABLE entries (id TEXT, sequence INTEGER, amount TEXT, cohort TEXT)")
147
+ db.executemany(
148
+ "INSERT INTO entries VALUES (?, ?, ?, ?)",
149
+ [
150
+ ("001", 2, None, "demo"),
151
+ ("001", 1, "12.31" if case == "different" else "12.30", "demo"),
152
+ ("002", 1, "-0.01", "demo"),
153
+ ("001", 3, "2.00", "demo"),
154
+ ("excluded", 1, "99.99", "other"),
155
+ ],
156
+ )
157
+ records = [
158
+ {"id": "001", "sequence": 2, "amount": None},
159
+ {"id": "001", "sequence": 1, "amount": "12.30"},
160
+ {"id": "002", "sequence": 1, "amount": "-0.01"},
161
+ {"id": "001", "sequence": 3, "amount": "2.00"},
162
+ ]
163
+ json_path = root / "input.json"
164
+ json_path.write_text(json.dumps({"records": records}), encoding="utf-8")
165
+ json_spec = ef.IngestionSpec(format="json", records_path=("records", "*"), fields=spec.fields)
166
+ response = ("\n".join(json.dumps(row) for row in records) + "\n").encode()
167
+ response_sha = "0" * 64 if case == "corrupt" else hashlib.sha256(response).hexdigest()
168
+ raw = source_table()
169
+ parquet = root / "input.parquet"
170
+ pq.write_table(raw, parquet)
171
+ ref = ef.LocalArtifactStore(root / "artifacts").put(
172
+ parquet, media_type="application/vnd.apache.parquet"
173
+ )
174
+ batch_input = ef.RecordBatchInput(
175
+ lambda: closing(
176
+ pa.RecordBatchReader.from_batches(raw.schema, raw.to_batches(max_chunksize=2))
177
+ ),
178
+ raw.schema,
179
+ )
180
+ items = {
181
+ "csv": ef.FileInput(csv, csv_spec, batch_rows=2),
182
+ "sqlite": ef.SQLiteInput(
183
+ database,
184
+ "SELECT id, sequence, amount FROM entries WHERE cohort = ?",
185
+ spec,
186
+ ("demo",),
187
+ batch_rows=2,
188
+ ),
189
+ "json": ef.FileInput(json_path, json_spec),
190
+ "api_response": ef.StreamInput(lambda: closing(io.BytesIO(response)), spec, response_sha),
191
+ "parquet": ef.ArtifactInput(ref),
192
+ "arrow_batches": batch_input,
193
+ }
194
+ return {
195
+ name: ef.CompositeDataSource(
196
+ {"input": (ef.LocalDataSource({"provider_table": item}), "provider_table")}
197
+ )
198
+ for name, item in items.items()
199
+ }
200
+
201
+
202
+ def run_demo(output: Path, case: str = "good") -> int:
203
+ if case not in ("good", "different", "corrupt"):
204
+ raise ValueError("unknown synthetic case")
205
+ output = output.resolve()
206
+ output.mkdir(parents=True, exist_ok=False)
207
+ contract, plan, spec = declarations()
208
+ sources = inputs(output / "inputs", spec, case)
209
+ receipts: list[dict[str, object]] = []
210
+ exit_code = 0
211
+ for engine in ("pandas", "polars", "duckdb"):
212
+ compiled = ef.native_compiler_registry().resolve(engine).compile(plan, contract)
213
+ registry = ef.TransformationRegistry()
214
+ registry.register(compiled.transformation)
215
+ for name, source in sources.items():
216
+ directory = output / engine / name
217
+ execution = ef.execute(
218
+ ef.ExecutionPlan(
219
+ contract=contract,
220
+ engine=engine,
221
+ transformation_id=compiled.transformation.transformation_id,
222
+ options=ef.ExecutionOptions(
223
+ max_input_rows=100,
224
+ max_output_rows=100,
225
+ max_transport_bytes=1_000_000,
226
+ timeout_seconds=30,
227
+ ),
228
+ ),
229
+ source=source,
230
+ registry=registry,
231
+ work_dir=directory / "execution",
232
+ )
233
+ receipt: dict[str, object] = {
234
+ "engine": engine,
235
+ "source": name,
236
+ "execution_state": execution.state,
237
+ "comparison_state": None,
238
+ "problem": execution.problem.model_dump(mode="json") if execution.problem else None,
239
+ }
240
+ if execution.state != "completed":
241
+ exit_code = 3
242
+ else:
243
+ comparison = ef.compare(
244
+ execution,
245
+ ef.ArrowInput(expected_output()),
246
+ contract=contract,
247
+ reference="expected",
248
+ work_dir=directory / "comparison",
249
+ policy=ef.ComparisonPolicy(
250
+ index="ignore",
251
+ justification=(
252
+ "This declared relational bag has no native index identity."
253
+ ),
254
+ ),
255
+ )
256
+ ef.write_comparison_report(comparison, directory / "report")
257
+ receipt["comparison_state"] = comparison.state
258
+ receipt["comparison_complete"] = comparison.complete
259
+ try:
260
+ ef.assert_equivalent(comparison)
261
+ except ef.EquivalenceDifferentError:
262
+ exit_code = max(exit_code, 1)
263
+ except ef.EquivalenceNotEvaluableError:
264
+ exit_code = 3
265
+ receipts.append(receipt)
266
+ print(
267
+ f"{engine}/{name}: execution={execution.state}; "
268
+ f"comparison={receipt['comparison_state']}"
269
+ )
270
+ (output / "receipt.json").write_text(
271
+ json.dumps(
272
+ {
273
+ "equiframe": ef.__version__,
274
+ "synthetic": True,
275
+ "case": case,
276
+ "exit_code": exit_code,
277
+ "runs": receipts,
278
+ },
279
+ indent=2,
280
+ )
281
+ + "\n",
282
+ encoding="utf-8",
283
+ )
284
+ return exit_code
285
+
286
+
287
+ def main() -> None:
288
+ parser = argparse.ArgumentParser(description=__doc__)
289
+ parser.add_argument("--output", type=Path, required=True)
290
+ parser.add_argument("--case", choices=("good", "different", "corrupt"), default="good")
291
+ args = parser.parse_args()
292
+ raise SystemExit(run_demo(args.output, args.case))
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()
@@ -0,0 +1,31 @@
1
+ # Eight complete synthetic journeys
2
+
3
+ Read the [packaged journey guide](../../docs/journeys.md) for installation,
4
+ independent expected values, acceptance criteria, artifacts, privacy and limits.
5
+ Everything is inline synthetic data; no credentials or network data sources.
6
+
7
+ After installing an identified private wheel and the exact dependencies in
8
+ `requirements.lock`, copy this entire directory outside the checkout:
9
+
10
+ ```bash
11
+ python journeys/a_pandas_refactor.py --case good --output evidence-a
12
+ python journeys/verify_journeys.py --output all-journey-evidence
13
+ ```
14
+
15
+ The first command is an equivalence gate. The second is an **acceptance harness**:
16
+ it expects both good cases and intentional failures, and returns 0 only when all
17
+ 31 scenario exits match their declared meaning. It does not approve every input.
18
+
19
+ When these examples are installed inside the candidate wheel, the isolated route
20
+ requires no copied scripts:
21
+
22
+ ```bash
23
+ python -I -m equiframe.examples.journeys.a_pandas_refactor --case good --output evidence-a
24
+ python -I -m equiframe.examples.journeys.verify_journeys --output all-journey-evidence
25
+ ```
26
+
27
+ Use a fresh output directory every time. `private/` or `raw/` contains raw
28
+ captures. Only the `reports/` or `public_reports/` subtree is data-minimized.
29
+ A portable bundle, `summary.json`, and `environment.json` are not redacted sharing
30
+ packages. Local Parquet output is local file publication, never package/service
31
+ publication. Keep raw captures and local evidence out of public commits.
@@ -0,0 +1 @@
1
+ """Runnable synthetic product journeys; not an additional public core API."""
@@ -0,0 +1,88 @@
1
+ """A: pandas refactor, exact money, composite keys and visible duplicate failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ import pandas as pd
10
+
11
+ import equiframe as ef
12
+
13
+ if TYPE_CHECKING or __package__:
14
+ from .common import cli, column, comparison_record, new_output, write_summary
15
+ else:
16
+ from common import cli, column, comparison_record, new_output, write_summary
17
+
18
+ SCENARIOS = ("good", "different", "not_evaluable", "duplicate")
19
+
20
+
21
+ def materialize(scenario: str) -> tuple[pd.DataFrame, pd.DataFrame]:
22
+ import pandas as pd
23
+
24
+ source = pd.DataFrame(
25
+ {
26
+ "order_id": pd.Series(["001", "001", "002"], dtype="string[pyarrow]"),
27
+ "line": [1, 2, 1],
28
+ "quantity": [2, 1, 3],
29
+ "unit_cents": [125, 50, 100],
30
+ }
31
+ )
32
+ # Independent literal, not computed using old or optimized transformation.
33
+ expected = pd.DataFrame(
34
+ {
35
+ "order_id": pd.Series(["001", "001", "002"], dtype="string[pyarrow]"),
36
+ "line": [1, 2, 1],
37
+ "total_cents": [250, 50, 300],
38
+ }
39
+ )
40
+ old = source[["order_id", "line"]].copy()
41
+ old["total_cents"] = [
42
+ int(quantity) * int(price)
43
+ for quantity, price in zip(source["quantity"], source["unit_cents"], strict=True)
44
+ ]
45
+ pd.testing.assert_frame_equal(old, expected)
46
+ candidate = source[["order_id", "line"]].assign(
47
+ total_cents=source["quantity"] * source["unit_cents"]
48
+ )
49
+ if scenario == "different":
50
+ candidate["total_cents"] += candidate["order_id"].eq("002").astype("int64")
51
+ if scenario == "duplicate":
52
+ candidate = pd.concat([candidate, candidate.iloc[[0]]], ignore_index=True)
53
+ else:
54
+ candidate = candidate.iloc[[2, 0, 1]].reset_index(drop=True)
55
+ return candidate, expected
56
+
57
+
58
+ def declaration() -> ef.DiffContract:
59
+ return ef.keyed_contract(
60
+ (
61
+ column("order_id", ef.StringType()),
62
+ column("line", ef.IntegerType(bits=64, signed=True), unit="line_identifier"),
63
+ column("total_cents", ef.IntegerType(bits=64, signed=True), unit="USD_cent"),
64
+ ),
65
+ keys=["order_id", "line"],
66
+ contract_id="journey.A.refactor",
67
+ )
68
+
69
+
70
+ def run(output: Path, scenario: str = "good") -> dict[str, object]:
71
+ private = new_output(output, scenario, SCENARIOS)
72
+ candidate, expected = materialize(scenario)
73
+ result = ef.diff(
74
+ candidate,
75
+ expected,
76
+ contract=declaration(),
77
+ options=ef.ComparisonOptions(max_rows=1 if scenario == "not_evaluable" else 100),
78
+ work_dir=private,
79
+ )
80
+ record = comparison_record(result, output, "refactor")
81
+ return write_summary(
82
+ output,
83
+ {"journey": "A", "synthetic": True, "scenario": scenario, **record},
84
+ )
85
+
86
+
87
+ if __name__ == "__main__":
88
+ raise SystemExit(cli(run, __doc__ or "A", SCENARIOS))
@@ -0,0 +1,181 @@
1
+ """B: real pandas and DuckDB compilation, one input capture, independent expected."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ pass
11
+
12
+ import equiframe as ef
13
+
14
+ if TYPE_CHECKING or __package__:
15
+ from .common import cli, column, comparison_record, new_output, write_summary
16
+ else:
17
+ from common import cli, column, comparison_record, new_output, write_summary
18
+
19
+
20
+ class CountedSource:
21
+ """Observe actual source resolution without changing the production source port."""
22
+
23
+ def __init__(self, source: ef.LocalDataSource) -> None:
24
+ self.source = source
25
+ self.calls = 0
26
+
27
+ @property
28
+ def table_names(self) -> tuple[str, ...]:
29
+ return self.source.table_names
30
+
31
+ def resolve(self, name: str, *, context: ef.RunContext) -> ef.LoadedTable:
32
+ self.calls += 1
33
+ return self.source.resolve(name, context=context)
34
+
35
+
36
+ def configuration(scenario: str) -> ef.MigrationSpec:
37
+ currency = column("currency", ef.StringType())
38
+ amount = column(
39
+ "amount", ef.DecimalType(precision=18, scale=2), nullable=True, unit="currency_amount"
40
+ )
41
+ total = column(
42
+ "total", ef.DecimalType(precision=20, scale=2), nullable=True, unit="currency_amount"
43
+ )
44
+ output_schema = ef.keyed_contract(
45
+ (currency, total), keys=["currency"]
46
+ ).spec.contract.output.schema_spec
47
+ contract = ef.Contract(
48
+ schema_version="1.0",
49
+ contract_id="journey.B.native-totals",
50
+ revision=1,
51
+ family="aggregations",
52
+ inputs=(
53
+ ef.TableSpec(
54
+ name="sales",
55
+ schema_spec=ef.bag_contract((currency, amount)).spec.contract.output.schema_spec,
56
+ ),
57
+ ),
58
+ output=ef.TableSpec(name="result", schema_spec=output_schema),
59
+ preconditions=(),
60
+ rules=(
61
+ ef.RuleSpec(
62
+ rule_id="schema",
63
+ revision=1,
64
+ severity="error",
65
+ required=True,
66
+ specification=ef.SchemaCheck(table="result"),
67
+ ),
68
+ ),
69
+ )
70
+ return ef.MigrationSpec(
71
+ contract=contract,
72
+ source_engine="pandas",
73
+ target_engine="duckdb",
74
+ ir=ef.RelationalPlan(
75
+ nodes=(
76
+ ef.Scan(node_id="source", table="sales"),
77
+ ef.Aggregate(
78
+ node_id="totals",
79
+ source="source",
80
+ group_by=("currency",),
81
+ measures=(
82
+ ef.Measure(
83
+ name="total",
84
+ operation="sum",
85
+ column="amount",
86
+ logical_type=ef.DecimalType(precision=20, scale=2),
87
+ unit="currency_amount",
88
+ ),
89
+ ),
90
+ null_groups="retain",
91
+ empty_sum="zero" if scenario == "different" else "null",
92
+ ),
93
+ ),
94
+ output="totals",
95
+ ),
96
+ comparison_policy=ef.ComparisonPolicy(
97
+ index="ignore", justification="Native generated RangeIndex is not row identity."
98
+ ),
99
+ comparison_options=ef.ComparisonOptions(max_rows=1 if scenario == "not_evaluable" else 100),
100
+ execution_options=ef.ExecutionOptions(
101
+ duckdb_threads=1, duckdb_timezone="UTC", max_input_rows=100, max_output_rows=100
102
+ ),
103
+ )
104
+
105
+
106
+ def run(output: Path, scenario: str = "good") -> dict[str, object]:
107
+ import pyarrow as pa
108
+
109
+ private = new_output(output, scenario)
110
+ source = CountedSource(
111
+ ef.LocalDataSource(
112
+ {
113
+ "sales": ef.ArrowInput(
114
+ pa.table(
115
+ {
116
+ "currency": ["USD", "USD", "EUR", "EUR"],
117
+ "amount": pa.array(
118
+ [Decimal("1.10"), Decimal("2.20"), None, None],
119
+ type=pa.decimal128(18, 2),
120
+ ),
121
+ }
122
+ )
123
+ )
124
+ }
125
+ )
126
+ )
127
+ # This literal is independent of both native implementations and their IR.
128
+ expected = pa.table(
129
+ {
130
+ "currency": ["USD", "EUR"],
131
+ "total": pa.array([Decimal("3.30"), None], type=pa.decimal128(20, 2)),
132
+ }
133
+ )
134
+ spec = configuration(scenario)
135
+ checks = ef.check_migration(spec)
136
+ if source.calls != 0 or not all(check.supported for check in checks):
137
+ raise RuntimeError("Preflight unexpectedly read input or refused the documented plan")
138
+ result = ef.migrate(spec, source=source, expected=ef.ArrowInput(expected), work_dir=private)
139
+ if ef.read_migration(private / result.migration_id) != result:
140
+ raise RuntimeError("Migration readback failed")
141
+ comparisons = {}
142
+ for label, comparison in (
143
+ ("peers", result.comparison),
144
+ ("pandas-expected", result.verification_source),
145
+ ("duckdb-expected", result.verification_target),
146
+ ):
147
+ if comparison is not None:
148
+ comparisons[label] = comparison_record(comparison, output, label)
149
+ executions = [
150
+ pipeline.execution
151
+ for pipeline in (result.source, result.target)
152
+ if pipeline is not None and pipeline.execution is not None
153
+ ]
154
+ same_input = (
155
+ len(executions) == 2
156
+ and executions[0].inputs[0].data.sha256 == executions[1].inputs[0].data.sha256
157
+ )
158
+ accepted = result.exit_code == 0 and source.calls == 1 and same_input
159
+ return write_summary(
160
+ output,
161
+ {
162
+ "journey": "B",
163
+ "synthetic": True,
164
+ "scenario": scenario,
165
+ "preflight": [check.model_dump(mode="json") for check in checks],
166
+ "source_resolutions": source.calls,
167
+ "same_input_sha256": same_input,
168
+ "native_engines": [execution.plan.engine for execution in executions],
169
+ "execution_states": [execution.state for execution in executions],
170
+ "state": result.state,
171
+ "verification": result.verification,
172
+ "complete": result.complete,
173
+ "accepted": accepted,
174
+ "exit_code": result.exit_code if not (result.exit_code == 0 and not accepted) else 3,
175
+ "comparisons": comparisons,
176
+ },
177
+ )
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(cli(run, __doc__ or "B"))