dataforge-07 0.1.0__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 (150) hide show
  1. dataforge/__init__.py +204 -0
  2. dataforge/__main__.py +5 -0
  3. dataforge/agent/__init__.py +16 -0
  4. dataforge/agent/providers.py +259 -0
  5. dataforge/agent/scratchpad.py +183 -0
  6. dataforge/agent/tool_actions.py +343 -0
  7. dataforge/bench/__init__.py +31 -0
  8. dataforge/bench/core.py +426 -0
  9. dataforge/bench/groq_client.py +386 -0
  10. dataforge/bench/methods.py +443 -0
  11. dataforge/bench/report.py +309 -0
  12. dataforge/bench/runner.py +247 -0
  13. dataforge/causal/__init__.py +21 -0
  14. dataforge/causal/dag.py +174 -0
  15. dataforge/causal/pc.py +232 -0
  16. dataforge/causal/root_cause.py +193 -0
  17. dataforge/cli/__init__.py +50 -0
  18. dataforge/cli/audit.py +70 -0
  19. dataforge/cli/bench.py +154 -0
  20. dataforge/cli/common.py +267 -0
  21. dataforge/cli/constraints.py +407 -0
  22. dataforge/cli/profile.py +147 -0
  23. dataforge/cli/release.py +166 -0
  24. dataforge/cli/repair.py +407 -0
  25. dataforge/cli/revert.py +139 -0
  26. dataforge/cli/watch.py +144 -0
  27. dataforge/datasets/__init__.py +25 -0
  28. dataforge/datasets/embedded/hospital/clean.csv +11 -0
  29. dataforge/datasets/embedded/hospital/dirty.csv +11 -0
  30. dataforge/datasets/real_world.py +290 -0
  31. dataforge/datasets/registry.py +103 -0
  32. dataforge/detectors/__init__.py +80 -0
  33. dataforge/detectors/base.py +145 -0
  34. dataforge/detectors/decimal_shift.py +166 -0
  35. dataforge/detectors/fd_violation.py +157 -0
  36. dataforge/detectors/type_mismatch.py +173 -0
  37. dataforge/engine/__init__.py +39 -0
  38. dataforge/engine/repair.py +905 -0
  39. dataforge/env/__init__.py +22 -0
  40. dataforge/env/environment.py +883 -0
  41. dataforge/env/observation.py +61 -0
  42. dataforge/env/openenv_core.py +161 -0
  43. dataforge/env/reward.py +128 -0
  44. dataforge/env/server.py +176 -0
  45. dataforge/evaluation_contract.py +76 -0
  46. dataforge/fixtures/hospital_10rows.csv +11 -0
  47. dataforge/fixtures/hospital_schema.yaml +17 -0
  48. dataforge/http/__init__.py +1 -0
  49. dataforge/http/problem.py +103 -0
  50. dataforge/integrations/__init__.py +1 -0
  51. dataforge/integrations/dbt.py +164 -0
  52. dataforge/observability.py +76 -0
  53. dataforge/py.typed +1 -0
  54. dataforge/release/__init__.py +1 -0
  55. dataforge/release/doctor.py +367 -0
  56. dataforge/release/full_vision.py +702 -0
  57. dataforge/release/gate.py +861 -0
  58. dataforge/release/playground_check.py +411 -0
  59. dataforge/repair_contract.py +468 -0
  60. dataforge/repairers/__init__.py +88 -0
  61. dataforge/repairers/base.py +77 -0
  62. dataforge/repairers/decimal_shift.py +43 -0
  63. dataforge/repairers/fd_violation.py +225 -0
  64. dataforge/repairers/type_mismatch.py +73 -0
  65. dataforge/safety/__init__.py +5 -0
  66. dataforge/safety/adversarial/attack_01_phone_pii.yaml +8 -0
  67. dataforge/safety/adversarial/attack_02_phone_pii.yaml +8 -0
  68. dataforge/safety/adversarial/attack_03_phone_pii.yaml +8 -0
  69. dataforge/safety/adversarial/attack_04_phone_pii.yaml +8 -0
  70. dataforge/safety/adversarial/attack_05_phone_pii.yaml +8 -0
  71. dataforge/safety/adversarial/attack_06_phone_pii.yaml +8 -0
  72. dataforge/safety/adversarial/attack_07_phone_pii.yaml +8 -0
  73. dataforge/safety/adversarial/attack_08_phone_pii.yaml +8 -0
  74. dataforge/safety/adversarial/attack_09_phone_pii.yaml +8 -0
  75. dataforge/safety/adversarial/attack_10_phone_pii.yaml +8 -0
  76. dataforge/safety/adversarial/attack_11_ssn_pii.yaml +8 -0
  77. dataforge/safety/adversarial/attack_12_ssn_pii.yaml +8 -0
  78. dataforge/safety/adversarial/attack_13_ssn_pii.yaml +8 -0
  79. dataforge/safety/adversarial/attack_14_ssn_pii.yaml +8 -0
  80. dataforge/safety/adversarial/attack_15_ssn_pii.yaml +8 -0
  81. dataforge/safety/adversarial/attack_16_ssn_pii.yaml +8 -0
  82. dataforge/safety/adversarial/attack_17_ssn_pii.yaml +8 -0
  83. dataforge/safety/adversarial/attack_18_ssn_pii.yaml +8 -0
  84. dataforge/safety/adversarial/attack_19_ssn_pii.yaml +8 -0
  85. dataforge/safety/adversarial/attack_20_ssn_pii.yaml +8 -0
  86. dataforge/safety/adversarial/attack_21_email_pii.yaml +8 -0
  87. dataforge/safety/adversarial/attack_22_email_pii.yaml +8 -0
  88. dataforge/safety/adversarial/attack_23_email_pii.yaml +8 -0
  89. dataforge/safety/adversarial/attack_24_email_pii.yaml +8 -0
  90. dataforge/safety/adversarial/attack_25_email_pii.yaml +8 -0
  91. dataforge/safety/adversarial/attack_26_email_pii.yaml +8 -0
  92. dataforge/safety/adversarial/attack_27_email_pii.yaml +8 -0
  93. dataforge/safety/adversarial/attack_28_email_pii.yaml +8 -0
  94. dataforge/safety/adversarial/attack_29_email_pii.yaml +8 -0
  95. dataforge/safety/adversarial/attack_30_email_pii.yaml +8 -0
  96. dataforge/safety/adversarial/attack_31_row_delete.yaml +7 -0
  97. dataforge/safety/adversarial/attack_32_row_delete.yaml +8 -0
  98. dataforge/safety/adversarial/attack_33_row_delete.yaml +7 -0
  99. dataforge/safety/adversarial/attack_34_row_delete.yaml +7 -0
  100. dataforge/safety/adversarial/attack_35_row_delete.yaml +7 -0
  101. dataforge/safety/adversarial/attack_36_row_delete.yaml +11 -0
  102. dataforge/safety/adversarial/attack_37_row_delete.yaml +7 -0
  103. dataforge/safety/adversarial/attack_38_row_delete.yaml +7 -0
  104. dataforge/safety/adversarial/attack_39_row_delete.yaml +8 -0
  105. dataforge/safety/adversarial/attack_40_row_delete.yaml +7 -0
  106. dataforge/safety/adversarial/attack_41_row_delete.yaml +7 -0
  107. dataforge/safety/adversarial/attack_42_row_delete.yaml +7 -0
  108. dataforge/safety/adversarial/attack_43_row_delete.yaml +7 -0
  109. dataforge/safety/adversarial/attack_44_row_delete.yaml +7 -0
  110. dataforge/safety/adversarial/attack_45_row_delete.yaml +8 -0
  111. dataforge/safety/adversarial/attack_46_row_delete.yaml +8 -0
  112. dataforge/safety/adversarial/attack_47_row_delete.yaml +7 -0
  113. dataforge/safety/adversarial/attack_48_row_delete.yaml +7 -0
  114. dataforge/safety/adversarial/attack_49_row_delete.yaml +8 -0
  115. dataforge/safety/adversarial/attack_50_row_delete.yaml +7 -0
  116. dataforge/safety/constitution.py +307 -0
  117. dataforge/safety/constitutions/default.yaml +40 -0
  118. dataforge/safety/filter.py +134 -0
  119. dataforge/schema_inference.py +620 -0
  120. dataforge/stores/__init__.py +46 -0
  121. dataforge/stores/base.py +73 -0
  122. dataforge/stores/cloud.py +78 -0
  123. dataforge/stores/csv.py +94 -0
  124. dataforge/stores/duckdb.py +313 -0
  125. dataforge/stores/patch_plan.py +178 -0
  126. dataforge/stores/registry.py +82 -0
  127. dataforge/stores/repair.py +121 -0
  128. dataforge/stores/revert.py +22 -0
  129. dataforge/stores/sql.py +27 -0
  130. dataforge/table.py +228 -0
  131. dataforge/transactions/__init__.py +34 -0
  132. dataforge/transactions/files.py +96 -0
  133. dataforge/transactions/log.py +613 -0
  134. dataforge/transactions/revert.py +102 -0
  135. dataforge/transactions/txn.py +104 -0
  136. dataforge/ui/__init__.py +1 -0
  137. dataforge/ui/profile_view.py +136 -0
  138. dataforge/ui/repair_diff.py +91 -0
  139. dataforge/verifier/__init__.py +55 -0
  140. dataforge/verifier/constraint_ir.py +155 -0
  141. dataforge/verifier/explain.py +47 -0
  142. dataforge/verifier/gate.py +5 -0
  143. dataforge/verifier/schema.py +111 -0
  144. dataforge/verifier/smt.py +433 -0
  145. dataforge_07-0.1.0.dist-info/METADATA +436 -0
  146. dataforge_07-0.1.0.dist-info/RECORD +150 -0
  147. dataforge_07-0.1.0.dist-info/WHEEL +5 -0
  148. dataforge_07-0.1.0.dist-info/entry_points.txt +3 -0
  149. dataforge_07-0.1.0.dist-info/licenses/LICENSE +176 -0
  150. dataforge_07-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,73 @@
1
+ """Table-store abstractions shared by warehouse-capable repair paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ from dataforge.detectors.base import Schema
11
+ from dataforge.repairers.base import ProposedFix
12
+ from dataforge.stores.patch_plan import PatchPlan
13
+ from dataforge.table import TableLike
14
+
15
+
16
+ class StoreApplyReceipt(BaseModel):
17
+ """Receipt returned after a table-store patch plan is applied."""
18
+
19
+ schema_version: str = "table_store_apply_receipt_v1"
20
+ ok: bool
21
+ txn_id: str | None = None
22
+ backend: str
23
+ target: str
24
+ patch_plan_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
25
+ post_state_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
26
+ reason: str = Field(min_length=1)
27
+
28
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
29
+
30
+
31
+ class StoreRevertReceipt(BaseModel):
32
+ """Receipt returned after a table-store transaction rollback."""
33
+
34
+ schema_version: str = "table_store_revert_receipt_v1"
35
+ ok: bool
36
+ txn_id: str
37
+ backend: str
38
+ target: str
39
+ reason: str = Field(min_length=1)
40
+
41
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
42
+
43
+
44
+ class TableStore(Protocol):
45
+ """Minimal interface for backends that can host DataForge patch plans."""
46
+
47
+ backend: str
48
+ target: str
49
+ relation: str
50
+ row_identity_columns: tuple[str, ...]
51
+
52
+ def read_table(self) -> TableLike:
53
+ """Read the target relation into DataForge's string-preserving table surface."""
54
+
55
+ def build_patch_plan(
56
+ self,
57
+ fixes: list[ProposedFix],
58
+ *,
59
+ schema: Schema | None,
60
+ safety_verdict: str,
61
+ touched_constraints: tuple[str, ...] = (),
62
+ smt_obligations: tuple[str, ...] = (),
63
+ ) -> PatchPlan:
64
+ """Build a reversible patch plan for verified fixes."""
65
+
66
+ def apply_patch_plan(
67
+ self, plan: PatchPlan, *, state_root: Path | None = None
68
+ ) -> StoreApplyReceipt:
69
+ """Apply a patch plan through the backend transaction mechanism."""
70
+
71
+
72
+ class TableStoreError(RuntimeError):
73
+ """Raised when a table-store operation cannot complete safely."""
@@ -0,0 +1,78 @@
1
+ """Explicit cloud warehouse adapter boundaries.
2
+
3
+ These adapters intentionally support dry-run patch-plan manifests only until
4
+ their credentialed conformance suites prove apply, audit, and rollback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from dataforge.detectors.base import Schema
12
+ from dataforge.repairers.base import ProposedFix
13
+ from dataforge.stores.base import StoreApplyReceipt, TableStore, TableStoreError
14
+ from dataforge.stores.patch_plan import PatchPlan
15
+ from dataforge.table import Table, TableLike
16
+
17
+
18
+ class CloudWarehouseStore(TableStore):
19
+ """Dry-run-only boundary for cloud warehouses."""
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ backend: str,
25
+ target: str,
26
+ relation: str,
27
+ row_identity_columns: tuple[str, ...] = (),
28
+ ) -> None:
29
+ self.backend = backend
30
+ self.target = target
31
+ self.relation = relation
32
+ self.row_identity_columns = row_identity_columns
33
+
34
+ def read_table(self) -> TableLike:
35
+ """Return an empty table because credentials are not configured in OSS core."""
36
+ return Table([], [])
37
+
38
+ def build_patch_plan(
39
+ self,
40
+ fixes: list[ProposedFix],
41
+ *,
42
+ schema: Schema | None,
43
+ safety_verdict: str,
44
+ touched_constraints: tuple[str, ...] = (),
45
+ smt_obligations: tuple[str, ...] = (),
46
+ ) -> PatchPlan:
47
+ """Create an explicit non-mutating plan boundary."""
48
+ del schema, fixes
49
+ return PatchPlan.new(
50
+ backend=self.backend,
51
+ target=self.target,
52
+ relation=self.relation,
53
+ row_identity_columns=self.row_identity_columns,
54
+ operations=(),
55
+ safety_verdict=safety_verdict,
56
+ rows_scanned=0,
57
+ reason=(
58
+ f"{self.backend} dry-run boundary created. Apply is disabled until "
59
+ "credentialed conformance tests prove reversible transactions."
60
+ ),
61
+ touched_constraints=touched_constraints,
62
+ smt_obligations=smt_obligations,
63
+ audit_metadata={"adapter_status": "dry_run_only"},
64
+ apply_supported=False,
65
+ reversible=False,
66
+ )
67
+
68
+ def apply_patch_plan(
69
+ self,
70
+ plan: PatchPlan,
71
+ *,
72
+ state_root: Path | None = None,
73
+ ) -> StoreApplyReceipt:
74
+ """Refuse mutation for unproven cloud adapters."""
75
+ del state_root
76
+ raise TableStoreError(
77
+ f"{self.backend} apply is disabled until its conformance suite is enabled."
78
+ )
@@ -0,0 +1,94 @@
1
+ """CSV table-store wrapper around DataForge's existing repair engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from dataforge.detectors.base import Schema
8
+ from dataforge.engine.repair import apply_transaction, read_csv
9
+ from dataforge.repairers.base import ProposedFix
10
+ from dataforge.stores.base import StoreApplyReceipt, TableStore
11
+ from dataforge.stores.patch_plan import PatchOperation, PatchPlan, RowIdentity
12
+ from dataforge.table import TableLike, row_count
13
+
14
+
15
+ class CSVStore(TableStore):
16
+ """Reference table-store implementation for local CSV files."""
17
+
18
+ backend = "csv"
19
+
20
+ def __init__(self, path: Path) -> None:
21
+ self.path = path.resolve()
22
+ self.target = str(self.path)
23
+ self.relation = self.path.name
24
+ self.row_identity_columns: tuple[str, ...] = ("_row",)
25
+
26
+ def read_table(self) -> TableLike:
27
+ """Read the CSV using the existing string-preserving reader."""
28
+ return read_csv(self.path)
29
+
30
+ def build_patch_plan(
31
+ self,
32
+ fixes: list[ProposedFix],
33
+ *,
34
+ schema: Schema | None,
35
+ safety_verdict: str,
36
+ touched_constraints: tuple[str, ...] = (),
37
+ smt_obligations: tuple[str, ...] = (),
38
+ ) -> PatchPlan:
39
+ """Describe existing CSV cell edits as a patch plan."""
40
+ del schema
41
+ operations = tuple(
42
+ PatchOperation.from_cell_fix(
43
+ fix.fix,
44
+ relation=self.relation,
45
+ row_identity=RowIdentity(
46
+ kind="csv_position",
47
+ columns=("_row",),
48
+ values={"_row": str(fix.fix.row)},
49
+ stable=True,
50
+ reason="CSV byte snapshot plus row position is reversible in the local engine.",
51
+ ),
52
+ reason=fix.reason,
53
+ confidence=fix.confidence,
54
+ provenance=fix.provenance,
55
+ )
56
+ for fix in fixes
57
+ )
58
+ return PatchPlan.new(
59
+ backend=self.backend,
60
+ target=self.target,
61
+ relation=self.relation,
62
+ row_identity_columns=self.row_identity_columns,
63
+ operations=operations,
64
+ safety_verdict=safety_verdict,
65
+ rows_scanned=row_count(self.read_table()),
66
+ reason="CSV patch plan mirrors the existing reversible transaction engine.",
67
+ touched_constraints=touched_constraints,
68
+ smt_obligations=smt_obligations,
69
+ audit_metadata={"source": "csv_reference_engine"},
70
+ apply_supported=bool(operations),
71
+ reversible=True,
72
+ )
73
+
74
+ def apply_patch_plan(
75
+ self,
76
+ plan: PatchPlan,
77
+ *,
78
+ state_root: Path | None = None,
79
+ source_bytes: bytes | None = None,
80
+ fixes: list[ProposedFix] | None = None,
81
+ ) -> StoreApplyReceipt:
82
+ """Apply through the existing CSV transaction path."""
83
+ del state_root
84
+ if fixes is None or source_bytes is None:
85
+ raise ValueError("CSVStore.apply_patch_plan requires source bytes and fixes.")
86
+ txn_id = apply_transaction(self.path, fixes, source_bytes)
87
+ return StoreApplyReceipt(
88
+ ok=True,
89
+ txn_id=txn_id,
90
+ backend=self.backend,
91
+ target=self.target,
92
+ patch_plan_sha256=plan.sha256(),
93
+ reason=f"Applied {len(fixes)} CSV fix(es).",
94
+ )
@@ -0,0 +1,313 @@
1
+ """DuckDB table-store implementation for local warehouse repair proofs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from dataforge.detectors.base import Schema
12
+ from dataforge.repairers.base import ProposedFix
13
+ from dataforge.stores.base import StoreApplyReceipt, TableStore, TableStoreError
14
+ from dataforge.stores.patch_plan import PatchOperation, PatchPlan, RowIdentity
15
+ from dataforge.stores.sql import ensure_safe_relation, quote_identifier, sql_literal
16
+ from dataforge.table import Table, TableLike, cell_value, column_names, row_count
17
+ from dataforge.transactions.log import (
18
+ append_applied_event,
19
+ append_created_transaction,
20
+ append_reverted_event,
21
+ load_transaction,
22
+ sha256_bytes,
23
+ )
24
+ from dataforge.transactions.txn import RepairTransaction, generate_txn_id
25
+
26
+
27
+ class DuckDBStore(TableStore):
28
+ """Local DuckDB relation adapter with real apply and rollback."""
29
+
30
+ backend = "duckdb"
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ database_path: Path,
36
+ relation: str,
37
+ row_identity_columns: tuple[str, ...] = (),
38
+ target: str | None = None,
39
+ ) -> None:
40
+ self.database_path = database_path.resolve()
41
+ self.relation = ensure_safe_relation(relation)
42
+ self.row_identity_columns = row_identity_columns
43
+ self.target = (
44
+ target or f"warehouse://duckdb?database={self.database_path}&relation={relation}"
45
+ )
46
+
47
+ def _connect(self, *, read_only: bool) -> Any:
48
+ try:
49
+ import duckdb
50
+ except ImportError as exc:
51
+ raise TableStoreError("DuckDB table-store support requires duckdb.") from exc
52
+ return duckdb.connect(str(self.database_path), read_only=read_only)
53
+
54
+ def read_table(self) -> TableLike:
55
+ """Read the configured relation into the DataForge table surface."""
56
+ with self._connect(read_only=True) as connection:
57
+ cursor = connection.execute(f"SELECT * FROM {self.relation}")
58
+ columns = [str(item[0]) for item in cursor.description]
59
+ return Table(
60
+ columns, (dict(zip(columns, row, strict=True)) for row in cursor.fetchall())
61
+ )
62
+
63
+ def _identity_for_row(self, table: TableLike, row: int) -> RowIdentity:
64
+ columns = column_names(table)
65
+ if not self.row_identity_columns:
66
+ return RowIdentity(
67
+ kind="unavailable",
68
+ stable=False,
69
+ reason="Warehouse apply requires explicit row identity columns.",
70
+ )
71
+ missing = [column for column in self.row_identity_columns if column not in columns]
72
+ if missing:
73
+ return RowIdentity(
74
+ kind="unavailable",
75
+ columns=self.row_identity_columns,
76
+ stable=False,
77
+ reason="Missing row identity columns: " + ", ".join(missing),
78
+ )
79
+ return RowIdentity(
80
+ kind="column_values",
81
+ columns=self.row_identity_columns,
82
+ values={column: cell_value(table, row, column) for column in self.row_identity_columns},
83
+ stable=True,
84
+ reason="Explicit row identity columns are present in the relation snapshot.",
85
+ )
86
+
87
+ def _where_sql(
88
+ self,
89
+ identity: RowIdentity,
90
+ *,
91
+ column: str | None = None,
92
+ value: str | None = None,
93
+ ) -> str:
94
+ clauses = [
95
+ f"{quote_identifier(key)} = {sql_literal(identity.values[key])}"
96
+ for key in identity.columns
97
+ ]
98
+ if column is not None and value is not None:
99
+ clauses.append(f"{quote_identifier(column)} = {sql_literal(value)}")
100
+ return " AND ".join(clauses)
101
+
102
+ def build_patch_plan(
103
+ self,
104
+ fixes: list[ProposedFix],
105
+ *,
106
+ schema: Schema | None,
107
+ safety_verdict: str,
108
+ touched_constraints: tuple[str, ...] = (),
109
+ smt_obligations: tuple[str, ...] = (),
110
+ ) -> PatchPlan:
111
+ """Build SQL patch and rollback statements for verified fixes."""
112
+ del schema
113
+ table = self.read_table()
114
+ operations: list[PatchOperation] = []
115
+ for proposed in fixes:
116
+ fix = proposed.fix
117
+ identity = self._identity_for_row(table, fix.row)
118
+ precondition_sql = forward_sql = rollback_sql = None
119
+ verification_sql: tuple[str, ...] = ()
120
+ if identity.stable:
121
+ old_where = self._where_sql(identity, column=fix.column, value=fix.old_value)
122
+ new_where = self._where_sql(identity, column=fix.column, value=fix.new_value)
123
+ precondition_sql = f"SELECT COUNT(*) FROM {self.relation} WHERE {old_where}"
124
+ forward_sql = (
125
+ f"UPDATE {self.relation} SET {quote_identifier(fix.column)} = "
126
+ f"{sql_literal(fix.new_value)} WHERE {old_where}"
127
+ )
128
+ rollback_sql = (
129
+ f"UPDATE {self.relation} SET {quote_identifier(fix.column)} = "
130
+ f"{sql_literal(fix.old_value)} WHERE {new_where}"
131
+ )
132
+ verification_sql = (f"SELECT COUNT(*) FROM {self.relation} WHERE {new_where}",)
133
+ operations.append(
134
+ PatchOperation.from_cell_fix(
135
+ fix,
136
+ relation=self.relation,
137
+ row_identity=identity,
138
+ reason=proposed.reason,
139
+ confidence=proposed.confidence,
140
+ provenance=proposed.provenance,
141
+ precondition_sql=precondition_sql,
142
+ forward_sql=forward_sql,
143
+ rollback_sql=rollback_sql,
144
+ verification_sql=verification_sql,
145
+ )
146
+ )
147
+
148
+ reason = (
149
+ "DuckDB patch plan is apply-ready."
150
+ if operations and all(operation.row_identity.stable for operation in operations)
151
+ else "DuckDB patch plan is dry-run only until row identity is configured."
152
+ )
153
+ return PatchPlan.new(
154
+ backend=self.backend,
155
+ target=self.target,
156
+ relation=self.relation,
157
+ row_identity_columns=self.row_identity_columns,
158
+ operations=tuple(operations),
159
+ safety_verdict=safety_verdict,
160
+ rows_scanned=row_count(table),
161
+ reason=reason,
162
+ touched_constraints=touched_constraints,
163
+ smt_obligations=smt_obligations,
164
+ audit_metadata={"database": str(self.database_path)},
165
+ )
166
+
167
+ def _relation_rows(self, connection: Any) -> list[dict[str, str]]:
168
+ order_by = ""
169
+ if self.row_identity_columns:
170
+ quoted = ", ".join(quote_identifier(column) for column in self.row_identity_columns)
171
+ order_by = f" ORDER BY {quoted}"
172
+ cursor = connection.execute(f"SELECT * FROM {self.relation}{order_by}")
173
+ columns = [str(item[0]) for item in cursor.description]
174
+ return [
175
+ {
176
+ column: "" if value is None else str(value)
177
+ for column, value in zip(columns, row, strict=True)
178
+ }
179
+ for row in cursor.fetchall()
180
+ ]
181
+
182
+ def _snapshot_bytes(self, plan: PatchPlan) -> bytes:
183
+ with self._connect(read_only=True) as connection:
184
+ rows = self._relation_rows(connection)
185
+ payload = {
186
+ "schema_version": "table_store_snapshot_v1",
187
+ "backend": self.backend,
188
+ "target": self.target,
189
+ "relation": self.relation,
190
+ "patch_plan_sha256": plan.sha256(),
191
+ "rows": rows,
192
+ }
193
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
194
+
195
+ def _write_snapshot(self, state_root: Path, txn_id: str, payload: bytes) -> Path:
196
+ snapshot_path = state_root.resolve() / ".dataforge" / "snapshots" / f"{txn_id}.bin"
197
+ snapshot_path.parent.mkdir(parents=True, exist_ok=True)
198
+ try:
199
+ with snapshot_path.open("xb") as handle:
200
+ handle.write(payload)
201
+ handle.flush()
202
+ os.fsync(handle.fileno())
203
+ except FileExistsError as exc:
204
+ raise TableStoreError(f"Transaction snapshot already exists: {snapshot_path}") from exc
205
+ return snapshot_path
206
+
207
+ def _execute_scalar_int(self, connection: Any, sql: str) -> int:
208
+ value = connection.execute(sql).fetchone()[0]
209
+ return int(value)
210
+
211
+ def apply_patch_plan(
212
+ self,
213
+ plan: PatchPlan,
214
+ *,
215
+ state_root: Path | None = None,
216
+ ) -> StoreApplyReceipt:
217
+ """Apply a verified DuckDB patch plan inside a transaction."""
218
+ if plan.backend != self.backend:
219
+ raise TableStoreError(f"Patch plan backend {plan.backend!r} does not match DuckDB.")
220
+ if not plan.apply_supported or not plan.reversible:
221
+ raise TableStoreError(plan.reason)
222
+ state_dir = (state_root or Path.cwd()).resolve()
223
+ txn_id = generate_txn_id()
224
+ snapshot_bytes = self._snapshot_bytes(plan)
225
+ snapshot_path = self._write_snapshot(state_dir, txn_id, snapshot_bytes)
226
+ transaction = RepairTransaction(
227
+ txn_id=txn_id,
228
+ created_at=datetime.now(UTC),
229
+ source_path=self.target,
230
+ source_sha256=sha256_bytes(snapshot_bytes),
231
+ source_snapshot_path=str(snapshot_path),
232
+ fixes=[],
233
+ applied=False,
234
+ source_kind="table_store",
235
+ backend=self.backend,
236
+ patch_plan=plan.model_dump(mode="json"),
237
+ )
238
+ try:
239
+ log_path = append_created_transaction(transaction, log_root=state_dir)
240
+ except Exception:
241
+ snapshot_path.unlink(missing_ok=True)
242
+ raise
243
+
244
+ with self._connect(read_only=False) as connection:
245
+ try:
246
+ connection.execute("BEGIN TRANSACTION")
247
+ for sql in plan.preflight_probes:
248
+ if self._execute_scalar_int(connection, sql) != 1:
249
+ raise TableStoreError(
250
+ f"Preflight probe did not match exactly one row: {sql}"
251
+ )
252
+ for sql in plan.forward_sql:
253
+ connection.execute(sql)
254
+ for sql in plan.verification_queries:
255
+ if self._execute_scalar_int(connection, sql) != 1:
256
+ raise TableStoreError(f"Verification query failed: {sql}")
257
+ post_rows = self._relation_rows(connection)
258
+ post_sha256 = sha256_bytes(
259
+ json.dumps(post_rows, sort_keys=True, separators=(",", ":")).encode("utf-8")
260
+ )
261
+ connection.execute("COMMIT")
262
+ append_applied_event(log_path, txn_id, post_sha256=post_sha256)
263
+ except Exception:
264
+ connection.execute("ROLLBACK")
265
+ raise
266
+
267
+ return StoreApplyReceipt(
268
+ ok=True,
269
+ txn_id=txn_id,
270
+ backend=self.backend,
271
+ target=self.target,
272
+ patch_plan_sha256=plan.sha256(),
273
+ post_state_sha256=post_sha256,
274
+ reason=f"Applied {len(plan.operations)} DuckDB operation(s).",
275
+ )
276
+
277
+ def revert_transaction(self, transaction: RepairTransaction, *, log_path: Path) -> None:
278
+ """Execute rollback SQL for a recorded DuckDB table-store transaction."""
279
+ if transaction.patch_plan is None:
280
+ raise TableStoreError("Table-store transaction is missing its patch plan.")
281
+ plan = PatchPlan.model_validate(transaction.patch_plan)
282
+ if plan.backend != self.backend:
283
+ raise TableStoreError(f"Patch plan backend {plan.backend!r} does not match DuckDB.")
284
+ with self._connect(read_only=False) as connection:
285
+ try:
286
+ connection.execute("BEGIN TRANSACTION")
287
+ for sql in reversed(plan.rollback_sql):
288
+ connection.execute(sql)
289
+ connection.execute("COMMIT")
290
+ append_reverted_event(log_path, transaction.txn_id)
291
+ except Exception:
292
+ connection.execute("ROLLBACK")
293
+ raise
294
+
295
+
296
+ def load_duckdb_transaction(log_path: Path) -> tuple[DuckDBStore, RepairTransaction]:
297
+ """Load a DuckDB table-store transaction and recreate its store."""
298
+ transaction = load_transaction(log_path)
299
+ if transaction.patch_plan is None:
300
+ raise TableStoreError("Transaction is missing a patch plan.")
301
+ plan = PatchPlan.model_validate(transaction.patch_plan)
302
+ database = plan.audit_metadata.get("database")
303
+ if not database:
304
+ raise TableStoreError("DuckDB transaction is missing database metadata.")
305
+ return (
306
+ DuckDBStore(
307
+ database_path=Path(database),
308
+ relation=plan.relation,
309
+ row_identity_columns=plan.row_identity_columns,
310
+ target=plan.target,
311
+ ),
312
+ transaction,
313
+ )