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,460 @@
1
+ """C: synthetic CSV/JSON retail ETL, explicit quarantine and gated local Parquet.
2
+
3
+ Requires the installed EquiFrame wheel with pandas, DuckDB and Arrow extras.
4
+ Run with a NEW directory: python c_retail_etl.py --output ./retail-good --case good
5
+ The two source formats represent the same sales, not two batches to concatenate.
6
+ Exit 0 accepts the explicitly selected valid subset; 1 rejects a discrepancy;
7
+ 2 rejects invalid input. Raw evidence contains original values and local paths.
8
+ Only public_reports/ contains the default data-minimized comparison reports.
9
+ This example does not publish a package, deploy a service or claim scientific O.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ import pyarrow as pa
21
+
22
+ import equiframe as ef
23
+
24
+ if TYPE_CHECKING or __package__:
25
+ from .common import cli, write_summary
26
+ else:
27
+ from common import cli, write_summary
28
+
29
+
30
+ def _write_json(path: Path, value: object) -> None:
31
+ with path.open("x", encoding="utf-8") as stream:
32
+ json.dump(value, stream, indent=2, ensure_ascii=False)
33
+ stream.write("\n")
34
+
35
+
36
+ def _text(name: str) -> ef.ColumnSpec:
37
+ return ef.ColumnSpec(
38
+ name=name,
39
+ logical_type=ef.StringType(),
40
+ nullable=False,
41
+ null_policy=ef.NullPolicy(representations=("null",)),
42
+ )
43
+
44
+
45
+ def _money(name: str, precision: int, *, nullable: bool = False) -> ef.ColumnSpec:
46
+ return ef.ColumnSpec(
47
+ name=name,
48
+ logical_type=ef.DecimalType(precision=precision, scale=2),
49
+ nullable=nullable,
50
+ null_policy=ef.NullPolicy(representations=("null",)),
51
+ numeric_policy=ef.NumericPolicy(comparison="exact", unit="currency_amount"),
52
+ )
53
+
54
+
55
+ def _table(name: str, columns: tuple[ef.ColumnSpec, ...]) -> ef.TableSpec:
56
+ return ef.TableSpec(
57
+ name=name,
58
+ schema_spec=ef.TableSchema(
59
+ columns=columns, keys=(), multiplicity="preserve", order=ef.IgnoredOrder()
60
+ ),
61
+ )
62
+
63
+
64
+ def _contract(
65
+ identifier: str, inputs: tuple[ef.ColumnSpec, ...], output: tuple[ef.ColumnSpec, ...]
66
+ ) -> ef.Contract:
67
+ return ef.Contract(
68
+ schema_version="1.0",
69
+ contract_id=identifier,
70
+ revision=1,
71
+ family="aggregations",
72
+ inputs=(_table("sales", inputs),),
73
+ output=_table("result", output),
74
+ preconditions=(),
75
+ rules=(
76
+ ef.RuleSpec(
77
+ rule_id="schema",
78
+ revision=1,
79
+ severity="error",
80
+ required=True,
81
+ specification=ef.SchemaCheck(table="result"),
82
+ ),
83
+ ),
84
+ )
85
+
86
+
87
+ def _artifact(value: pa.Table, path: Path) -> ef.ArtifactRef:
88
+ import pyarrow as pa
89
+
90
+ with pa.OSFile(str(path), "wb") as sink, pa.ipc.new_file(sink, value.schema) as writer:
91
+ writer.write_table(value)
92
+ return ef.LocalArtifactStore(path.parent / "expected-artifacts").put(
93
+ path, media_type="application/vnd.apache.arrow.file"
94
+ )
95
+
96
+
97
+ def _plans() -> tuple[ef.RelationalPlan, ef.RelationalPlan]:
98
+ net = ef.RelationalPlan(
99
+ nodes=(
100
+ ef.Scan(node_id="scan", table="sales"),
101
+ ef.Project(
102
+ node_id="net",
103
+ source="scan",
104
+ columns=(
105
+ ef.NamedExpression(name="sku", expression=ef.Column(name="sku")),
106
+ ef.NamedExpression(name="currency", expression=ef.Column(name="currency")),
107
+ ef.NamedExpression(
108
+ name="net",
109
+ expression=ef.Arithmetic(
110
+ operation="subtract",
111
+ left=ef.Column(name="gross"),
112
+ right=ef.Coalesce(
113
+ arguments=(
114
+ ef.Column(name="discount"),
115
+ ef.LiteralExpression(
116
+ logical_type=ef.DecimalType(precision=18, scale=2),
117
+ value=ef.DecimalValue(value=Decimal("0.00")),
118
+ unit="currency_amount",
119
+ ),
120
+ )
121
+ ),
122
+ logical_type=ef.DecimalType(precision=19, scale=2),
123
+ unit="currency_amount",
124
+ ),
125
+ ),
126
+ ),
127
+ ),
128
+ ),
129
+ output="net",
130
+ )
131
+ totals = ef.RelationalPlan(
132
+ nodes=(
133
+ ef.Scan(node_id="scan", table="sales"),
134
+ ef.Aggregate(
135
+ node_id="totals",
136
+ source="scan",
137
+ group_by=("currency",),
138
+ measures=(
139
+ ef.Measure(
140
+ name="total",
141
+ operation="sum",
142
+ column="net",
143
+ logical_type=ef.DecimalType(precision=20, scale=2),
144
+ unit="currency_amount",
145
+ ),
146
+ ef.Measure(
147
+ name="rows",
148
+ operation="count_rows",
149
+ logical_type=ef.IntegerType(bits=64, signed=True),
150
+ unit="rows",
151
+ ),
152
+ ),
153
+ null_groups="retain",
154
+ empty_sum="null",
155
+ ),
156
+ ),
157
+ output="totals",
158
+ )
159
+ return net, totals
160
+
161
+
162
+ def run(output: Path, scenario: str = "good") -> dict[str, object]:
163
+ """Run a complete example; existing output directories are never reused."""
164
+ import pyarrow as pa
165
+
166
+ if scenario not in ("good", "different", "error"):
167
+ raise ValueError("scenario must be good, different or error")
168
+ output.mkdir(parents=True, exist_ok=False)
169
+ output = output.resolve()
170
+ raw = output / "raw"
171
+ raw.mkdir()
172
+ sku, currency = _text("sku"), _text("currency")
173
+ gross, discount = _money("gross", 18), _money("discount", 18, nullable=True)
174
+ net, total = _money("net", 19), _money("total", 20, nullable=True)
175
+ rows = ef.ColumnSpec(
176
+ name="rows",
177
+ logical_type=ef.IntegerType(bits=64, signed=True),
178
+ nullable=False,
179
+ null_policy=ef.NullPolicy(representations=("null",)),
180
+ numeric_policy=ef.NumericPolicy(comparison="exact", unit="rows"),
181
+ )
182
+ fields = tuple(
183
+ ef.FieldMapping(
184
+ column=column,
185
+ path=(column.name,),
186
+ null_tokens=("NULL",) if column.name == "discount" else (),
187
+ )
188
+ for column in (sku, currency, gross, discount)
189
+ )
190
+ # A one-cent upstream CSV regression changes both native engines' answer.
191
+ # JSON remains correct, demonstrating that an independent chain can finish.
192
+ first_gross = "12.51" if scenario == "different" else "12.50"
193
+ (raw / "sales.csv").write_text(
194
+ "sku,currency,gross,discount\n"
195
+ f"0007,USD,{first_gross},0.50\n"
196
+ "0008,USD,2.00,NULL\n0009,EUR,10.00,1.00\n"
197
+ "0010,USD,not-money,0.00\n",
198
+ encoding="utf-8",
199
+ )
200
+ _write_json(
201
+ raw / "sales.json",
202
+ {
203
+ "sales": [
204
+ {"sku": "0007", "currency": "USD", "gross": "12.50", "discount": "0.50"},
205
+ {"sku": "0008", "currency": "USD", "gross": "2.00", "discount": None},
206
+ {"sku": "0009", "currency": "EUR", "gross": "10.00", "discount": "1.00"},
207
+ {"sku": "0010", "currency": "USD", "gross": "not-money", "discount": "0.00"},
208
+ ]
209
+ },
210
+ )
211
+ ingestions = {}
212
+ for source_format in ("csv", "json"):
213
+ spec = ef.IngestionSpec(
214
+ format=source_format,
215
+ fields=fields,
216
+ records_path=("sales", "*") if source_format == "json" else (),
217
+ invalid_records="reject" if scenario == "error" else "quarantine",
218
+ max_source_bytes=16_384,
219
+ max_records=10,
220
+ )
221
+ ingestions[source_format] = ef.ingest_file(
222
+ raw / ("sales." + source_format), spec=spec, work_dir=raw / "ingestion"
223
+ )
224
+
225
+ # This is an explicit example-level disposition, not automatic approval of
226
+ # any quarantined dataset. FileInput deliberately refuses quarantine.
227
+ # Exactly the known fourth synthetic record may be excluded in this demo.
228
+ adopt_subset = scenario != "error" and all(
229
+ result.state == "quarantined"
230
+ and result.inspected_records == 4
231
+ and result.accepted_records == 3
232
+ and len(result.quarantined) == 1
233
+ and result.quarantined[0].record == 3
234
+ and result.quarantined[0].code == "invalid_decimal"
235
+ and result.output is not None
236
+ for result in ingestions.values()
237
+ )
238
+ disposition = {
239
+ "synthetic": True,
240
+ "choice": "adopt_explicit_valid_subset" if adopt_subset else "reject_entire_input",
241
+ "all_source_rows_valid": False,
242
+ "accepted_subset_adopted": adopt_subset,
243
+ "allowed_exclusion": "Only zero-based record 3 with invalid_decimal in each source",
244
+ "reason": (
245
+ "The example explicitly selects three valid records per format and retains the "
246
+ "invalid originals. Real input requires its owner's separate disposition."
247
+ if adopt_subset
248
+ else "Invalid records reject each complete input; no partial table is used."
249
+ ),
250
+ "sources": {
251
+ name: {
252
+ "state": result.state,
253
+ "inspected_records": result.inspected_records,
254
+ "parsed_records": result.accepted_records,
255
+ "quarantined_records": len(result.quarantined),
256
+ "output_available": result.output is not None,
257
+ "diagnostics": [
258
+ {"record": item.record, "path": item.path, "code": item.code}
259
+ for item in result.quarantined
260
+ ],
261
+ "raw_receipt": f"raw/ingestion/{result.ingestion_id}/ingestion.json",
262
+ }
263
+ for name, result in ingestions.items()
264
+ },
265
+ }
266
+ _write_json(raw / "quarantine-disposition.json", disposition)
267
+ summary: dict[str, object] = {
268
+ "synthetic": True,
269
+ "journey": "C",
270
+ "scenario": scenario,
271
+ "exit_code": 2,
272
+ "accepted": False,
273
+ "state": "invalid_input",
274
+ "workflow_complete": False,
275
+ "workflow_exit_code": None,
276
+ "first_divergent_stage": None,
277
+ "checkpoints": {},
278
+ "verifications": {},
279
+ "published": False,
280
+ "publication": None,
281
+ "all_source_rows_valid": False,
282
+ "accepted_subset_adopted": adopt_subset,
283
+ "ingestion": disposition["sources"],
284
+ "expected_method": "Literal Decimal rows enumerated separately from the IR and engines",
285
+ "acceptance": {
286
+ "requires": "Explicit subset disposition, all checkpoints, Verify and local readback",
287
+ "expected_required": True,
288
+ "rules_required": True,
289
+ "scientific_review": "not_requested",
290
+ },
291
+ "artifacts": {
292
+ "environment": "environment.json",
293
+ "raw_evidence": "raw/",
294
+ "quarantine_disposition": "raw/quarantine-disposition.json",
295
+ "data_minimized_reports": [],
296
+ "publication": None,
297
+ },
298
+ "privacy": (
299
+ "raw/ includes source values, quarantine originals and local paths; "
300
+ "public_reports/ contains only default redacted comparison views"
301
+ ),
302
+ "limits": (
303
+ "Synthetic local files; pandas and DuckDB execute separately. No real DW, "
304
+ "business approval, scientific O, distributed transaction or hard resource quota."
305
+ ),
306
+ }
307
+ if not adopt_subset:
308
+ return write_summary(output, summary)
309
+
310
+ normalize = _contract(
311
+ "retail.journey.net", (sku, currency, gross, discount), (sku, currency, net)
312
+ )
313
+ aggregate = _contract("retail.journey.totals", (sku, currency, net), (currency, total, rows))
314
+ net_ir, totals_ir = _plans()
315
+ policy = ef.ComparisonPolicy(
316
+ index="ignore", justification="Generated RangeIndex has no business identity"
317
+ )
318
+ stages = []
319
+ for name in ingestions:
320
+ for suffix, contract, plan in (
321
+ ("net", normalize, net_ir),
322
+ ("totals", aggregate, totals_ir),
323
+ ):
324
+ stages.append(
325
+ ef.WorkflowStage(
326
+ stage_id=f"{name}_{suffix}",
327
+ migration=ef.MigrationSpec(
328
+ contract=contract,
329
+ ir=plan,
330
+ source_engine="pandas",
331
+ target_engine="duckdb",
332
+ comparison_policy=policy,
333
+ ),
334
+ inputs=(
335
+ ef.WorkflowInputBinding(name="sales", source=name)
336
+ if suffix == "net"
337
+ else ef.WorkflowInputBinding(
338
+ name="sales", source="result", stage=f"{name}_net"
339
+ ),
340
+ ),
341
+ )
342
+ )
343
+ expected_net = _artifact(
344
+ pa.table(
345
+ {
346
+ "sku": ["0007", "0008", "0009"],
347
+ "currency": ["USD", "USD", "EUR"],
348
+ "net": pa.array(
349
+ [Decimal("12.00"), Decimal("2.00"), Decimal("9.00")],
350
+ type=pa.decimal128(19, 2),
351
+ ),
352
+ }
353
+ ),
354
+ raw / "expected-net.arrow",
355
+ )
356
+ expected_totals = _artifact(
357
+ pa.table(
358
+ {
359
+ "currency": ["USD", "EUR"],
360
+ "total": pa.array([Decimal("14.00"), Decimal("9.00")], type=pa.decimal128(20, 2)),
361
+ "rows": pa.array([2, 1], type=pa.int64()),
362
+ }
363
+ ),
364
+ raw / "expected-totals.arrow",
365
+ )
366
+ workflow = ef.run_workflow(
367
+ ef.WorkflowSpec(workflow_id="retail.journey.C", stages=tuple(stages)),
368
+ source=ef.LocalDataSource(
369
+ {
370
+ name: result.output
371
+ for name, result in ingestions.items()
372
+ if result.output is not None
373
+ }
374
+ ),
375
+ expected={
376
+ stage.stage_id: expected_net if stage.stage_id.endswith("net") else expected_totals
377
+ for stage in stages
378
+ },
379
+ work_dir=raw / "workflows",
380
+ )
381
+ workflow_path = raw / "workflows" / workflow.run_id
382
+ if ef.read_workflow(workflow_path) != workflow:
383
+ raise RuntimeError("Persisted workflow differs from its returned result")
384
+ reports = {}
385
+ public_reports = []
386
+ for checkpoint in workflow.checkpoints:
387
+ migration = checkpoint.migration
388
+ if migration is None or migration.source is None or migration.target is None:
389
+ continue
390
+ report = ef.verify(
391
+ {"source": migration.source, "target": migration.target},
392
+ contract=migration.spec.contract,
393
+ expected=migration.expected,
394
+ require_expected=True,
395
+ require_rules=True,
396
+ snapshots={table.name: table.data for table in migration.inputs},
397
+ plan_sha256=ef.ir_fingerprint(migration.spec.ir),
398
+ policy=policy,
399
+ work_dir=raw / "verify",
400
+ )
401
+ if ef.read_verification(raw / "verify" / report.verification_id) != report:
402
+ raise RuntimeError("Persisted verification differs from its returned result")
403
+ reports[checkpoint.stage_id] = report
404
+ for number, comparison in enumerate(report.comparisons):
405
+ directory = output / "public_reports" / checkpoint.stage_id / str(number)
406
+ ef.write_comparison_report(comparison, directory)
407
+ public_reports.append(str((directory / "report.html").relative_to(output)))
408
+
409
+ gate = (
410
+ workflow.complete
411
+ and len(reports) == len(stages)
412
+ and all(report.publishable and report.coverage.complete for report in reports.values())
413
+ )
414
+ publication = None
415
+ if gate:
416
+ destination = ef.ParquetDestination(output / "published")
417
+ receipt = destination.publish(
418
+ workflow.outputs, run_id="retail-journey-C", verifications=reports
419
+ )
420
+ if destination.current() != receipt:
421
+ raise RuntimeError("Local Parquet publication failed verified readback")
422
+ publication = receipt.model_dump(mode="json")
423
+ summary.update(
424
+ exit_code=0 if gate else 1 if workflow.exit_code == 1 else 3,
425
+ accepted=gate,
426
+ state="accepted" if gate else "different" if workflow.exit_code == 1 else "not_evaluable",
427
+ workflow_complete=workflow.complete,
428
+ workflow_exit_code=workflow.exit_code,
429
+ published=publication is not None,
430
+ first_divergent_stage=workflow.first_divergent_stage,
431
+ checkpoints={item.stage_id: item.state for item in workflow.checkpoints},
432
+ verifications={
433
+ name: {
434
+ "operational_state": report.operational_state,
435
+ "publishable": report.publishable,
436
+ "technical": report.technical.state,
437
+ "agreement": report.agreement.state,
438
+ "expected": [check.state for check in report.expected],
439
+ "rules": [check.state for check in report.rules],
440
+ "coverage_complete": report.coverage.complete,
441
+ "independent_review": report.independent_review.state,
442
+ "raw_manifest": f"raw/verify/{report.verification_id}/verification.json",
443
+ }
444
+ for name, report in reports.items()
445
+ },
446
+ publication=publication,
447
+ artifacts={
448
+ "environment": "environment.json",
449
+ "raw_evidence": "raw/",
450
+ "quarantine_disposition": "raw/quarantine-disposition.json",
451
+ "workflow": str((workflow_path / "workflow.json").relative_to(output)),
452
+ "data_minimized_reports": public_reports,
453
+ "publication": "published/current.json" if gate else None,
454
+ },
455
+ )
456
+ return write_summary(output, summary)
457
+
458
+
459
+ if __name__ == "__main__":
460
+ raise SystemExit(cli(run, __doc__ or "C", ("good", "different", "error")))
@@ -0,0 +1,136 @@
1
+ """Small example-only presentation helpers; all decisions use public EquiFrame APIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import importlib.metadata
8
+ import json
9
+ import platform
10
+ import sys
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ import equiframe as ef
15
+
16
+ CASES = ("good", "different", "not_evaluable")
17
+
18
+
19
+ def column(
20
+ name: str,
21
+ kind: ef.LogicalType,
22
+ *,
23
+ nullable: bool = False,
24
+ unit: str = "count",
25
+ ) -> ef.ColumnSpec:
26
+ numeric = isinstance(kind, (ef.IntegerType, ef.FloatType, ef.DecimalType))
27
+ temporal = None
28
+ if isinstance(kind, ef.TimestampType):
29
+ temporal = ef.TimestampPolicy(
30
+ granularity=kind.unit,
31
+ timezone=kind.timezone,
32
+ naive="reject" if kind.timezone else "preserve",
33
+ comparison="instant" if kind.timezone else "wall_time",
34
+ ambiguous="reject",
35
+ closed="both",
36
+ )
37
+ return ef.ColumnSpec(
38
+ name=name,
39
+ logical_type=kind,
40
+ nullable=nullable,
41
+ null_policy=ef.NullPolicy(representations=("null",)),
42
+ numeric_policy=ef.NumericPolicy(comparison="exact", unit=unit) if numeric else None,
43
+ nan_policy=ef.NaNPolicy(handling="distinct") if isinstance(kind, ef.FloatType) else None,
44
+ temporal_policy=temporal,
45
+ )
46
+
47
+
48
+ def new_output(output: Path, scenario: str, choices: tuple[str, ...] = CASES) -> Path:
49
+ if scenario not in choices:
50
+ raise ValueError("Unknown synthetic scenario")
51
+ output.mkdir(parents=True, exist_ok=False)
52
+ (output / "private").mkdir()
53
+ return output / "private"
54
+
55
+
56
+ def comparison_record(result: ef.ComparisonResult, output: Path, label: str) -> dict[str, object]:
57
+ """Write a minimized view before enforcing the actual assertion gate."""
58
+ ef.write_comparison_report(result, output / "reports" / label)
59
+ try:
60
+ ef.assert_equivalent(result)
61
+ except ef.EquivalenceDifferentError:
62
+ exit_code = 1
63
+ except ef.EquivalenceAssertionError:
64
+ exit_code = 3
65
+ else:
66
+ exit_code = 0
67
+ return {
68
+ "state": result.state,
69
+ "complete": result.complete,
70
+ "counts_final": result.counts.final,
71
+ "accepted": exit_code == 0,
72
+ "exit_code": exit_code,
73
+ "difference_records": result.counts.difference_records,
74
+ "diagnostic_codes": [diagnostic.code for diagnostic in result.diagnostics],
75
+ "report": f"reports/{label}/report.html",
76
+ }
77
+
78
+
79
+ def write_summary(output: Path, summary: dict[str, object]) -> dict[str, object]:
80
+ """Bind fresh example evidence to the observed interpreter/dependencies/scripts."""
81
+ distributions = {
82
+ distribution.metadata["Name"]: distribution.version
83
+ for distribution in importlib.metadata.distributions()
84
+ if distribution.metadata["Name"]
85
+ }
86
+ manifest = {
87
+ "synthetic": True,
88
+ "python": platform.python_version(),
89
+ "implementation": platform.python_implementation(),
90
+ "system": platform.system(),
91
+ "machine": platform.machine(),
92
+ "dependencies": dict(sorted(distributions.items())),
93
+ "journey_scripts_sha256": {
94
+ path.name: hashlib.sha256(path.read_bytes()).hexdigest()
95
+ for path in sorted(Path(__file__).parent.glob("*.py"))
96
+ },
97
+ "privacy": summary.get(
98
+ "privacy", "private/ contains raw synthetic data; reports/ contains minimized views"
99
+ ),
100
+ }
101
+ (output / "environment.json").write_text(
102
+ json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
103
+ )
104
+ (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
105
+ return summary
106
+
107
+
108
+ def cli(
109
+ run: Callable[[Path, str], dict[str, object]],
110
+ description: str,
111
+ choices: tuple[str, ...] = CASES,
112
+ ) -> int:
113
+ parser = argparse.ArgumentParser(description=description)
114
+ parser.add_argument("--output", type=Path, required=True)
115
+ parser.add_argument("--case", choices=choices, default="good")
116
+ args = parser.parse_args()
117
+ try:
118
+ summary = run(args.output, args.case)
119
+ except ModuleNotFoundError:
120
+ print(
121
+ "Optional dependency missing; install journey requirements.lock in this interpreter.",
122
+ file=sys.stderr,
123
+ )
124
+ return 2
125
+ except Exception:
126
+ # Avoid disclosing arbitrary client errors, values or paths when adapted.
127
+ print(
128
+ "Journey failed before acceptance; check dependencies and use a new output.",
129
+ file=sys.stderr,
130
+ )
131
+ return 2
132
+ print(json.dumps(summary, sort_keys=True))
133
+ exit_code = summary["exit_code"]
134
+ if not isinstance(exit_code, int):
135
+ raise TypeError("Journey exit code must be an integer")
136
+ return exit_code
@@ -0,0 +1,96 @@
1
+ """D: exact Decimal reconciliation catches offsetting per-record money errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal, localcontext
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ import pyarrow as pa
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
+ def fixture(scenario: str) -> tuple[pa.Table, pa.Table]:
21
+ import pyarrow as pa
22
+
23
+ schema = pa.schema(
24
+ [("account", pa.string()), ("currency", pa.string()), ("amount", pa.decimal128(20, 2))]
25
+ )
26
+ # Authored from the tiny synthetic ledger specification, not from totals.
27
+ expected = pa.Table.from_pylist(
28
+ [
29
+ {"account": "A", "currency": "USD", "amount": Decimal("1000000000000000.01")},
30
+ {"account": "B", "currency": "USD", "amount": Decimal("0.09")},
31
+ {"account": "A", "currency": "EUR", "amount": Decimal("12.50")},
32
+ ],
33
+ schema=schema,
34
+ )
35
+ candidate = pa.Table.from_pylist(
36
+ [
37
+ {
38
+ "account": "A",
39
+ "currency": "USD",
40
+ "amount": Decimal(
41
+ "1000000000000000.02" if scenario == "different" else "1000000000000000.01"
42
+ ),
43
+ },
44
+ {
45
+ "account": "B",
46
+ "currency": "USD",
47
+ "amount": Decimal("0.08" if scenario == "different" else "0.09"),
48
+ },
49
+ {"account": "A", "currency": "EUR", "amount": Decimal("12.50")},
50
+ ],
51
+ schema=schema,
52
+ )
53
+ return candidate, expected
54
+
55
+
56
+ def run(output: Path, scenario: str = "good") -> dict[str, object]:
57
+ private = new_output(output, scenario)
58
+ candidate, expected = fixture(scenario)
59
+ with localcontext() as context:
60
+ context.prec = 40 # Enough for exact sums of this explicitly bounded fixture.
61
+ totals_match = all(
62
+ sum(row["amount"] for row in candidate.to_pylist() if row["currency"] == currency)
63
+ == sum(row["amount"] for row in expected.to_pylist() if row["currency"] == currency)
64
+ for currency in ("USD", "EUR")
65
+ )
66
+ declaration = ef.keyed_contract(
67
+ (
68
+ column("account", ef.StringType()),
69
+ column("currency", ef.StringType()),
70
+ column("amount", ef.DecimalType(precision=20, scale=2), unit="currency_amount"),
71
+ ),
72
+ keys=["account", "currency"],
73
+ contract_id="journey.D.exact-ledger",
74
+ )
75
+ result = ef.diff(
76
+ candidate,
77
+ expected,
78
+ contract=declaration,
79
+ options=ef.ComparisonOptions(max_rows=1 if scenario == "not_evaluable" else 100),
80
+ work_dir=private,
81
+ )
82
+ return write_summary(
83
+ output,
84
+ {
85
+ "journey": "D",
86
+ "synthetic": True,
87
+ "scenario": scenario,
88
+ "per_currency_totals_match": totals_match,
89
+ "currency_conversion": "not_requested",
90
+ **comparison_record(result, output, "ledger"),
91
+ },
92
+ )
93
+
94
+
95
+ if __name__ == "__main__":
96
+ raise SystemExit(cli(run, __doc__ or "D"))