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,905 @@
1
+ """Public repair engine for DataForge backend surfaces.
2
+
3
+ The engine is the stable boundary shared by CLI, Playground, MCP, and any
4
+ OpenEnv adapter that needs repair semantics. It keeps the core invariant in one
5
+ place: detect -> propose -> safety -> SMT verification -> journal/snapshot ->
6
+ atomic mutation -> byte-identical revert.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from collections.abc import Callable, Iterator
15
+ from contextlib import contextmanager
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Literal, cast
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+ from dataforge.detectors import run_all_detectors
23
+ from dataforge.detectors.base import Issue, Schema
24
+ from dataforge.observability import repair_stage_span
25
+ from dataforge.repair_contract import CONTRACT_VERSION
26
+ from dataforge.repairers import build_repairers
27
+ from dataforge.repairers.base import ProposedFix, RepairAttempt, RetryContext
28
+ from dataforge.safety import SafetyContext, SafetyFilter, SafetyResult, SafetyVerdict
29
+ from dataforge.schema_inference import (
30
+ ConstraintReviewArtifact,
31
+ merge_schema_with_reviewed_constraints,
32
+ )
33
+ from dataforge.table import (
34
+ Table,
35
+ TableLike,
36
+ cell_value,
37
+ column_names,
38
+ copy_table,
39
+ row_count,
40
+ set_cell_value,
41
+ table_to_csv_bytes,
42
+ )
43
+ from dataforge.table import (
44
+ read_csv as read_table_csv,
45
+ )
46
+ from dataforge.transactions.files import (
47
+ SourceLockError,
48
+ atomic_write_bytes,
49
+ lock_path_for,
50
+ )
51
+ from dataforge.transactions.files import (
52
+ source_path_lock as transaction_source_path_lock,
53
+ )
54
+ from dataforge.transactions.log import (
55
+ append_applied_event,
56
+ append_created_transaction,
57
+ cache_dir_for,
58
+ sha256_bytes,
59
+ sha256_file,
60
+ snapshot_path_for,
61
+ )
62
+ from dataforge.transactions.txn import CellFix, RepairTransaction, generate_txn_id
63
+ from dataforge.verifier import SMTVerifier, VerificationVerdict
64
+
65
+ RepairMode = Literal["dry_run", "apply"]
66
+ EscalationResolver = Callable[
67
+ [ProposedFix, Schema | None, SafetyContext, SafetyFilter, SafetyResult],
68
+ tuple[SafetyContext, SafetyResult],
69
+ ]
70
+
71
+
72
+ class RepairEngineError(RuntimeError):
73
+ """Base exception for public repair engine failures."""
74
+
75
+
76
+ class TransactionApplyError(RepairEngineError):
77
+ """Raised when an apply transaction cannot be completed safely."""
78
+
79
+
80
+ class CandidateFix(BaseModel):
81
+ """Stable public representation of a proposed cell repair."""
82
+
83
+ row: int = Field(ge=0)
84
+ column: str = Field(min_length=1)
85
+ old_value: str
86
+ new_value: str
87
+ detector_id: str = Field(min_length=1)
88
+ operation: Literal["update", "delete_row"] = "update"
89
+ reason: str = Field(min_length=1)
90
+ confidence: float = Field(ge=0.0, le=1.0)
91
+ provenance: str = Field(min_length=1)
92
+
93
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
94
+
95
+ @classmethod
96
+ def from_proposed(cls, proposed_fix: ProposedFix) -> CandidateFix:
97
+ """Create a public candidate from an internal repair proposal."""
98
+ fix = proposed_fix.fix
99
+ return cls(
100
+ row=fix.row,
101
+ column=fix.column,
102
+ old_value=fix.old_value,
103
+ new_value=fix.new_value,
104
+ detector_id=fix.detector_id,
105
+ operation=fix.operation,
106
+ reason=proposed_fix.reason,
107
+ confidence=proposed_fix.confidence,
108
+ provenance=proposed_fix.provenance,
109
+ )
110
+
111
+
112
+ RootCauseCategory = Literal[
113
+ "data_entry_error",
114
+ "decimal_shift",
115
+ "sentinel_null_encoding",
116
+ "fd_conflict",
117
+ "domain_violation",
118
+ "duplicate_key_violation",
119
+ "referential_break",
120
+ "unknown",
121
+ ]
122
+
123
+
124
+ class RootCause(BaseModel):
125
+ """Public diagnosis attached to an issue before repair is applied."""
126
+
127
+ row: int = Field(ge=0)
128
+ column: str = Field(min_length=1)
129
+ issue_type: str = Field(min_length=1)
130
+ category: RootCauseCategory
131
+ confidence: float = Field(ge=0.0, le=1.0)
132
+ reason: str = Field(min_length=1)
133
+
134
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
135
+
136
+
137
+ class CandidateRepair(CandidateFix):
138
+ """Public repair candidate with verifier context."""
139
+
140
+ verifier_reason: str = Field(min_length=1)
141
+
142
+
143
+ class VerifiedFix(CandidateRepair):
144
+ """A candidate that passed safety and SMT verification."""
145
+
146
+
147
+ ProofStatus = Literal[
148
+ "accepted",
149
+ "rejected",
150
+ "unknown",
151
+ "denied",
152
+ "escalated",
153
+ "attempted_not_fixed",
154
+ "not_run",
155
+ ]
156
+
157
+
158
+ class ProofObligation(BaseModel):
159
+ """Verifier/safety obligation emitted for a repair attempt."""
160
+
161
+ obligation_id: str = Field(min_length=1)
162
+ verifier: Literal["smt", "sql", "safety", "repairer"]
163
+ status: ProofStatus
164
+ reason: str = Field(min_length=1)
165
+ unsat_core: tuple[str, ...] = Field(default_factory=tuple)
166
+
167
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
168
+
169
+
170
+ class RepairFailure(BaseModel):
171
+ """Machine-readable account of an issue that could not be repaired."""
172
+
173
+ row: int = Field(ge=0)
174
+ column: str = Field(min_length=1)
175
+ issue_type: str = Field(min_length=1)
176
+ status: str = Field(min_length=1)
177
+ reason: str = Field(min_length=1)
178
+ attempt_count: int = Field(ge=1)
179
+ unsat_core: tuple[str, ...] = Field(default_factory=tuple)
180
+
181
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
182
+
183
+ @classmethod
184
+ def from_attempts(cls, attempts: list[RepairAttempt]) -> RepairFailure:
185
+ """Build a public failure record from one issue's attempt trace."""
186
+ final = attempts[-1]
187
+ issue = final.issue
188
+ return cls(
189
+ row=issue.row,
190
+ column=issue.column,
191
+ issue_type=issue.issue_type,
192
+ status=final.status,
193
+ reason=final.reason,
194
+ attempt_count=len(attempts),
195
+ unsat_core=tuple(final.unsat_core),
196
+ )
197
+
198
+
199
+ class RepairReceipt(BaseModel):
200
+ """Stable receipt for a dry-run or applied repair pipeline run."""
201
+
202
+ schema_version: Literal["repair_receipt_v1"] = "repair_receipt_v1"
203
+ receipt_version: Literal["repair_receipt_v1"] = "repair_receipt_v1"
204
+ contract_version: str = CONTRACT_VERSION
205
+ mode: RepairMode
206
+ applied: bool
207
+ reversible: bool
208
+ source_path: str
209
+ source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
210
+ post_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
211
+ txn_id: str | None = None
212
+ allowed_columns: list[str] = Field(default_factory=list)
213
+ valid_rows: list[int] = Field(default_factory=list)
214
+ safety_verdict: str = Field(default="allow", min_length=1)
215
+ verifier_verdict: str = Field(default="not_run", min_length=1)
216
+ candidate_provenance: list[str] = Field(default_factory=list)
217
+ root_causes: list[RootCause] = Field(default_factory=list)
218
+ candidate_repairs: list[CandidateRepair] = Field(default_factory=list)
219
+ proof_obligations: list[ProofObligation] = Field(default_factory=list)
220
+ accepted_constraint_ids: list[str] = Field(default_factory=list)
221
+ constraints_artifact_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
222
+ patch_plan_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
223
+ revert_command: str | None = None
224
+ limitations: list[str] = Field(default_factory=list)
225
+ abstentions: list[str] = Field(default_factory=list)
226
+ failure_reasons: list[str] = Field(default_factory=list)
227
+ issues_count: int = Field(ge=0)
228
+ fixes_count: int = Field(ge=0)
229
+ reason: str = Field(min_length=1)
230
+
231
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
232
+
233
+
234
+ class RepairPipelineRequest(BaseModel):
235
+ """Input contract for running the public repair pipeline."""
236
+
237
+ source_path: Path
238
+ mode: RepairMode = "dry_run"
239
+ repair_schema: Schema | None = Field(default=None, alias="schema")
240
+ allow_llm: bool = False
241
+ model: str = "gemini-2.0-flash"
242
+ allow_pii: bool = False
243
+ confirm_pii: bool = False
244
+ confirm_escalations: bool = False
245
+ interactive: bool = False
246
+ create_dry_run_transaction: bool = False
247
+ constraints: ConstraintReviewArtifact | None = None
248
+ constraints_artifact_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
249
+
250
+ model_config = ConfigDict(
251
+ strict=True,
252
+ arbitrary_types_allowed=True,
253
+ extra="forbid",
254
+ frozen=True,
255
+ populate_by_name=True,
256
+ )
257
+
258
+
259
+ class RepairPipelineResult(BaseModel):
260
+ """Output contract for a public repair pipeline run."""
261
+
262
+ receipt: RepairReceipt
263
+ issues: list[Issue]
264
+ fixes: list[VerifiedFix]
265
+ failures: list[RepairFailure] = Field(default_factory=list)
266
+ transaction: RepairTransaction | None = None
267
+
268
+ model_config = ConfigDict(
269
+ strict=True, arbitrary_types_allowed=True, extra="forbid", frozen=True
270
+ )
271
+
272
+
273
+ def _atomic_write_bytes(path: Path, payload: bytes) -> None:
274
+ """Write bytes to ``path`` through an atomic same-directory replacement."""
275
+ atomic_write_bytes(path, payload)
276
+
277
+
278
+ def read_csv(path: Path) -> Table:
279
+ """Read a CSV using conservative string-preserving defaults."""
280
+ return read_table_csv(path)
281
+
282
+
283
+ def _csv_bytes_after_fixes(path: Path, fixes: list[CellFix]) -> bytes:
284
+ """Validate fixes against a CSV and return the mutated CSV bytes."""
285
+ df = read_csv(path)
286
+ for fix in fixes:
287
+ if fix.operation != "update":
288
+ raise ValueError(f"Unsupported repair operation '{fix.operation}' for row {fix.row}.")
289
+ if fix.column not in column_names(df):
290
+ raise ValueError(f"Column '{fix.column}' not found in '{path}'.")
291
+ if fix.row < 0 or fix.row >= row_count(df):
292
+ raise ValueError(f"Row {fix.row} is out of bounds for '{path}'.")
293
+
294
+ current_value = cell_value(df, fix.row, fix.column)
295
+ if current_value != fix.old_value:
296
+ raise ValueError(
297
+ f"Refusing to apply stale fix for row {fix.row}, column '{fix.column}': "
298
+ f"expected '{fix.old_value}', found '{current_value}'."
299
+ )
300
+ set_cell_value(df, fix.row, fix.column, fix.new_value)
301
+
302
+ return table_to_csv_bytes(df)
303
+
304
+
305
+ def apply_fixes_to_csv(path: Path, fixes: list[CellFix]) -> str:
306
+ """Atomically apply ordered cell fixes to a CSV and return post-state SHA-256."""
307
+ payload = _csv_bytes_after_fixes(path, fixes)
308
+ _atomic_write_bytes(path, payload)
309
+ return hashlib.sha256(payload).hexdigest()
310
+
311
+
312
+ def _lock_path_for(source_path: Path) -> Path:
313
+ """Return the filesystem lock path for a source file."""
314
+ return lock_path_for(source_path)
315
+
316
+
317
+ @contextmanager
318
+ def source_path_lock(
319
+ source_path: Path,
320
+ *,
321
+ timeout_seconds: float = 5.0,
322
+ stale_after_seconds: float = 300.0,
323
+ ) -> Iterator[None]:
324
+ """Acquire an exclusive lock for a source path using an atomic lock file."""
325
+ try:
326
+ with transaction_source_path_lock(
327
+ source_path,
328
+ timeout_seconds=timeout_seconds,
329
+ stale_after_seconds=stale_after_seconds,
330
+ ):
331
+ yield
332
+ except SourceLockError as exc:
333
+ raise TransactionApplyError(str(exc)) from exc
334
+
335
+
336
+ def _write_snapshot_once(snapshot_path: Path, source_bytes: bytes) -> None:
337
+ """Write an immutable snapshot and fail if the transaction id already exists."""
338
+ snapshot_path.parent.mkdir(parents=True, exist_ok=True)
339
+ try:
340
+ with snapshot_path.open("xb") as handle:
341
+ handle.write(source_bytes)
342
+ handle.flush()
343
+ os.fsync(handle.fileno())
344
+ except FileExistsError as exc:
345
+ raise TransactionApplyError(
346
+ f"Transaction snapshot already exists: {snapshot_path}"
347
+ ) from exc
348
+
349
+
350
+ def create_repair_transaction(
351
+ path: Path,
352
+ fixes: list[ProposedFix],
353
+ source_bytes: bytes,
354
+ *,
355
+ txn_id: str | None = None,
356
+ ) -> tuple[RepairTransaction, Path]:
357
+ """Create an unapplied transaction journal and immutable source snapshot."""
358
+ resolved_path = path.resolve()
359
+ transaction_id = txn_id or generate_txn_id()
360
+ snapshot_path = snapshot_path_for(resolved_path, transaction_id)
361
+ _write_snapshot_once(snapshot_path, source_bytes)
362
+
363
+ transaction = RepairTransaction(
364
+ txn_id=transaction_id,
365
+ created_at=datetime.now(UTC),
366
+ source_path=str(resolved_path),
367
+ source_sha256=sha256_bytes(source_bytes),
368
+ source_snapshot_path=str(snapshot_path.resolve()),
369
+ fixes=[proposal.fix for proposal in fixes],
370
+ applied=False,
371
+ )
372
+ try:
373
+ log_path = append_created_transaction(transaction)
374
+ except Exception:
375
+ snapshot_path.unlink(missing_ok=True)
376
+ raise
377
+ return transaction, log_path
378
+
379
+
380
+ def apply_transaction(
381
+ path: Path,
382
+ fixes: list[ProposedFix],
383
+ source_bytes: bytes,
384
+ *,
385
+ txn_id: str | None = None,
386
+ ) -> str:
387
+ """Journal, snapshot, atomically apply fixes, and restore bytes on failure."""
388
+ resolved_path = path.resolve()
389
+ with source_path_lock(resolved_path):
390
+ current_bytes = resolved_path.read_bytes()
391
+ if current_bytes != source_bytes:
392
+ raise TransactionApplyError(
393
+ "Refusing to apply repairs because the source file changed after detection."
394
+ )
395
+
396
+ with repair_stage_span("dataforge.repair.transaction.create", fixes_count=len(fixes)):
397
+ transaction, log_path = create_repair_transaction(
398
+ resolved_path,
399
+ fixes,
400
+ source_bytes,
401
+ txn_id=txn_id,
402
+ )
403
+ try:
404
+ with repair_stage_span("dataforge.repair.transaction.apply", fixes_count=len(fixes)):
405
+ post_sha256 = apply_fixes_to_csv(
406
+ resolved_path,
407
+ [proposal.fix for proposal in fixes],
408
+ )
409
+ append_applied_event(log_path, transaction.txn_id, post_sha256=post_sha256)
410
+ except Exception as exc:
411
+ _atomic_write_bytes(resolved_path, source_bytes)
412
+ if sha256_file(resolved_path) != transaction.source_sha256:
413
+ raise TransactionApplyError(
414
+ "Apply failed and the source file could not be restored to original bytes."
415
+ ) from exc
416
+ raise
417
+
418
+ return transaction.txn_id
419
+
420
+
421
+ def _build_retry_context(issue: Issue, attempts: list[RepairAttempt]) -> RetryContext:
422
+ """Build retry hints from previous failed attempts."""
423
+ rejected_values = frozenset(
424
+ attempt.fix.fix.new_value
425
+ for attempt in attempts
426
+ if attempt.fix is not None and attempt.status in {"denied", "rejected", "unknown"}
427
+ )
428
+ hints: list[str] = []
429
+ for attempt in attempts:
430
+ hints.append(attempt.reason)
431
+ hints.extend(attempt.unsat_core)
432
+ return RetryContext(
433
+ issue=issue,
434
+ previous_attempts=tuple(attempts),
435
+ rejected_values=rejected_values,
436
+ hints=tuple(hints),
437
+ )
438
+
439
+
440
+ def propose_repairs(
441
+ issues: list[Issue],
442
+ path: Path,
443
+ working_df: TableLike,
444
+ schema: Schema | None,
445
+ *,
446
+ allow_llm: bool,
447
+ model: str,
448
+ allow_pii: bool,
449
+ confirm_pii: bool,
450
+ confirm_escalations: bool,
451
+ interactive: bool,
452
+ escalation_resolver: EscalationResolver | None = None,
453
+ ) -> tuple[list[ProposedFix], list[list[RepairAttempt]]]:
454
+ """Run repairers and gates issue-by-issue against a working dataframe."""
455
+ with repair_stage_span("dataforge.repair.repairers.build", allow_llm=allow_llm):
456
+ repairers = build_repairers(
457
+ cache_dir=cache_dir_for(path),
458
+ allow_llm=allow_llm,
459
+ model=model,
460
+ )
461
+ safety_filter = SafetyFilter()
462
+ verifier = SMTVerifier()
463
+ safety_context = SafetyContext(
464
+ allow_pii=allow_pii,
465
+ confirm_pii=confirm_pii,
466
+ confirm_escalations=confirm_escalations,
467
+ )
468
+
469
+ accepted_fixes: list[ProposedFix] = []
470
+ attempt_groups: list[list[RepairAttempt]] = []
471
+
472
+ for issue in issues:
473
+ attempts: list[RepairAttempt] = []
474
+ repairer = repairers.get(issue.issue_type)
475
+ if repairer is None:
476
+ attempts.append(
477
+ RepairAttempt(
478
+ issue=issue,
479
+ attempt_number=1,
480
+ status="attempted_not_fixed",
481
+ reason="No repairer is registered for this issue type.",
482
+ )
483
+ )
484
+ attempt_groups.append(attempts)
485
+ continue
486
+
487
+ accepted = False
488
+ retry_context = RetryContext(issue=issue)
489
+ for attempt_number in range(1, 4):
490
+ candidate = repairer.propose(issue, working_df, schema, retry_context=retry_context)
491
+ if candidate is None:
492
+ attempts.append(
493
+ RepairAttempt(
494
+ issue=issue,
495
+ attempt_number=attempt_number,
496
+ status="attempted_not_fixed",
497
+ reason="No repair proposal was available for this issue.",
498
+ )
499
+ )
500
+ break
501
+
502
+ preferred = safety_filter.choose_preferred([candidate], schema, safety_context)
503
+ safety_result = safety_filter.evaluate(preferred, schema, safety_context)
504
+ if (
505
+ safety_result.verdict == SafetyVerdict.ESCALATE
506
+ and interactive
507
+ and escalation_resolver is not None
508
+ ):
509
+ safety_context, safety_result = escalation_resolver(
510
+ preferred,
511
+ schema,
512
+ safety_context,
513
+ safety_filter,
514
+ safety_result,
515
+ )
516
+
517
+ if safety_result.verdict == SafetyVerdict.DENY:
518
+ attempts.append(
519
+ RepairAttempt(
520
+ issue=issue,
521
+ attempt_number=attempt_number,
522
+ fix=preferred,
523
+ status="denied",
524
+ reason=safety_result.reason,
525
+ )
526
+ )
527
+ retry_context = _build_retry_context(issue, attempts)
528
+ continue
529
+
530
+ if safety_result.verdict == SafetyVerdict.ESCALATE:
531
+ attempts.append(
532
+ RepairAttempt(
533
+ issue=issue,
534
+ attempt_number=attempt_number,
535
+ fix=preferred,
536
+ status="escalated",
537
+ reason=safety_result.reason,
538
+ )
539
+ )
540
+ break
541
+
542
+ with repair_stage_span(
543
+ "dataforge.repair.verifier.verify",
544
+ issue_type=issue.issue_type,
545
+ row=issue.row,
546
+ ):
547
+ verifier_result = verifier.verify(working_df, [preferred], schema)
548
+ if verifier_result.verdict == VerificationVerdict.ACCEPT:
549
+ accepted_fixes.append(preferred)
550
+ set_cell_value(
551
+ working_df,
552
+ preferred.fix.row,
553
+ preferred.fix.column,
554
+ preferred.fix.new_value,
555
+ )
556
+ attempts.append(
557
+ RepairAttempt(
558
+ issue=issue,
559
+ attempt_number=attempt_number,
560
+ fix=preferred,
561
+ status="accepted",
562
+ reason=verifier_result.reason,
563
+ )
564
+ )
565
+ accepted = True
566
+ break
567
+
568
+ attempts.append(
569
+ RepairAttempt(
570
+ issue=issue,
571
+ attempt_number=attempt_number,
572
+ fix=preferred,
573
+ status=(
574
+ "rejected"
575
+ if verifier_result.verdict == VerificationVerdict.REJECT
576
+ else "unknown"
577
+ ),
578
+ reason=verifier_result.reason,
579
+ unsat_core=verifier_result.unsat_core,
580
+ )
581
+ )
582
+ retry_context = _build_retry_context(issue, attempts)
583
+
584
+ if (
585
+ not accepted
586
+ and attempts
587
+ and attempts[-1].status not in {"attempted_not_fixed", "escalated"}
588
+ ):
589
+ last_reason = attempts[-1].reason
590
+ attempts[-1] = attempts[-1].model_copy(
591
+ update={
592
+ "status": "attempted_not_fixed",
593
+ "reason": (
594
+ f"Issue was attempted but not fixed after {len(attempts)} attempt(s). "
595
+ f"Last failure: {last_reason}"
596
+ ),
597
+ }
598
+ )
599
+ attempt_groups.append(attempts)
600
+
601
+ return accepted_fixes, attempt_groups
602
+
603
+
604
+ def _verified_fixes(
605
+ fixes: list[ProposedFix],
606
+ attempt_groups: list[list[RepairAttempt]],
607
+ ) -> list[VerifiedFix]:
608
+ """Build public verified fix payloads using accepted attempt reasons."""
609
+ accepted_reasons: dict[tuple[int, str, str], str] = {}
610
+ for attempts in attempt_groups:
611
+ for attempt in attempts:
612
+ if attempt.status == "accepted" and attempt.fix is not None:
613
+ fix = attempt.fix.fix
614
+ accepted_reasons[(fix.row, fix.column, fix.new_value)] = attempt.reason
615
+
616
+ return [
617
+ VerifiedFix(
618
+ **CandidateFix.from_proposed(fix).model_dump(),
619
+ verifier_reason=accepted_reasons.get(
620
+ (fix.fix.row, fix.fix.column, fix.fix.new_value),
621
+ "Accepted by verifier.",
622
+ ),
623
+ )
624
+ for fix in fixes
625
+ ]
626
+
627
+
628
+ def _candidate_repairs(attempt_groups: list[list[RepairAttempt]]) -> list[CandidateRepair]:
629
+ """Build public candidate payloads for every emitted repair proposal."""
630
+ candidates: list[CandidateRepair] = []
631
+ for attempts in attempt_groups:
632
+ for attempt in attempts:
633
+ if attempt.fix is None:
634
+ continue
635
+ candidates.append(
636
+ CandidateRepair(
637
+ **CandidateFix.from_proposed(attempt.fix).model_dump(),
638
+ verifier_reason=attempt.reason,
639
+ )
640
+ )
641
+ return candidates
642
+
643
+
644
+ def _failed_attempts(attempt_groups: list[list[RepairAttempt]]) -> list[RepairFailure]:
645
+ """Return failures for issue groups whose final status was not accepted."""
646
+ return [
647
+ RepairFailure.from_attempts(attempts)
648
+ for attempts in attempt_groups
649
+ if attempts and attempts[-1].status != "accepted"
650
+ ]
651
+
652
+
653
+ def _root_cause_category(issue: Issue, attempts: list[RepairAttempt]) -> RootCauseCategory:
654
+ """Classify the likely repair cause from deterministic issue evidence."""
655
+ del attempts
656
+ issue_type = issue.issue_type.lower()
657
+ actual = str(issue.actual or "").strip().lower()
658
+ expected = str(issue.expected or "").strip().lower()
659
+ sentinel_values = {"", "na", "n/a", "null", "none", "nan", "nil", "-", "unknown"}
660
+
661
+ if issue_type == "decimal_shift":
662
+ return "decimal_shift"
663
+ if issue_type in {"fd_violation", "functional_dependency"} or "fd" in issue_type:
664
+ return "fd_conflict"
665
+ if "domain" in issue_type or "accepted" in issue_type or "regex" in issue_type:
666
+ return "domain_violation"
667
+ if "duplicate" in issue_type or "unique" in issue_type or "key" in issue_type:
668
+ return "duplicate_key_violation"
669
+ if "relationship" in issue_type or "referential" in issue_type:
670
+ return "referential_break"
671
+ if actual in sentinel_values or expected in sentinel_values:
672
+ return "sentinel_null_encoding"
673
+ if issue_type in {"type_mismatch", "outlier"}:
674
+ return "data_entry_error"
675
+ return "unknown"
676
+
677
+
678
+ def _root_causes(
679
+ issues: list[Issue],
680
+ attempt_groups: list[list[RepairAttempt]],
681
+ ) -> list[RootCause]:
682
+ """Create one public root-cause diagnosis per detected issue."""
683
+ diagnoses: list[RootCause] = []
684
+ for issue, attempts in zip(issues, attempt_groups, strict=False):
685
+ category = _root_cause_category(issue, attempts)
686
+ final_reason = attempts[-1].reason if attempts else issue.reason
687
+ diagnoses.append(
688
+ RootCause(
689
+ row=issue.row,
690
+ column=issue.column,
691
+ issue_type=issue.issue_type,
692
+ category=category,
693
+ confidence=issue.confidence,
694
+ reason=final_reason if category == "unknown" else issue.reason,
695
+ )
696
+ )
697
+ return diagnoses
698
+
699
+
700
+ def _proof_obligations(attempt_groups: list[list[RepairAttempt]]) -> list[ProofObligation]:
701
+ """Expose every safety/verifier obligation evaluated during repair."""
702
+ obligations: list[ProofObligation] = []
703
+ for attempts in attempt_groups:
704
+ for attempt in attempts:
705
+ issue = attempt.issue
706
+ if attempt.status in {"denied", "escalated"}:
707
+ verifier: Literal["smt", "sql", "safety", "repairer"] = "safety"
708
+ elif attempt.status == "attempted_not_fixed" and attempt.fix is None:
709
+ verifier = "repairer"
710
+ else:
711
+ verifier = "smt"
712
+ status: ProofStatus = (
713
+ cast(ProofStatus, attempt.status)
714
+ if attempt.status
715
+ in {
716
+ "accepted",
717
+ "rejected",
718
+ "unknown",
719
+ "denied",
720
+ "escalated",
721
+ "attempted_not_fixed",
722
+ }
723
+ else "not_run"
724
+ )
725
+ obligations.append(
726
+ ProofObligation(
727
+ obligation_id=(
728
+ f"{verifier}::{issue.issue_type}::{issue.row}::"
729
+ f"{issue.column}::attempt::{attempt.attempt_number}"
730
+ ),
731
+ verifier=verifier,
732
+ status=status,
733
+ reason=attempt.reason,
734
+ unsat_core=tuple(attempt.unsat_core),
735
+ )
736
+ )
737
+ return obligations
738
+
739
+
740
+ def _receipt_limitations(
741
+ request: RepairPipelineRequest,
742
+ failures: list[RepairFailure],
743
+ batch_safety: SafetyResult,
744
+ txn_id: str | None,
745
+ ) -> list[str]:
746
+ """Describe honest limits for the exact receipt payload."""
747
+ limitations: list[str] = []
748
+ if request.mode == "dry_run":
749
+ limitations.append("Dry run only; no source data was mutated.")
750
+ if txn_id is None:
751
+ limitations.append("No reversible transaction id exists for this run.")
752
+ if failures:
753
+ limitations.append("Some issues abstained or failed repair; see failure_reasons.")
754
+ if batch_safety.verdict != SafetyVerdict.ALLOW:
755
+ limitations.append(batch_safety.reason)
756
+ if request.allow_llm:
757
+ limitations.append(
758
+ "LLM-originated candidates remain subordinate to safety and verifier gates."
759
+ )
760
+ return limitations
761
+
762
+
763
+ def _patch_plan_sha256(source_sha256: str, fixes: list[ProposedFix]) -> str | None:
764
+ """Return a stable hash for the ordered local patch plan."""
765
+ if not fixes:
766
+ return None
767
+ payload = {
768
+ "source_sha256": source_sha256,
769
+ "fixes": [
770
+ {
771
+ "row": fix.fix.row,
772
+ "column": fix.fix.column,
773
+ "old_value": fix.fix.old_value,
774
+ "new_value": fix.fix.new_value,
775
+ "operation": fix.fix.operation,
776
+ "detector_id": fix.fix.detector_id,
777
+ "provenance": fix.provenance,
778
+ }
779
+ for fix in fixes
780
+ ],
781
+ }
782
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
783
+ return hashlib.sha256(canonical).hexdigest()
784
+
785
+
786
+ def _receipt_verifier_verdict(
787
+ fixes: list[ProposedFix],
788
+ failures: list[RepairFailure],
789
+ ) -> str:
790
+ """Summarize verifier outcomes for the public repair receipt."""
791
+ statuses = {failure.status for failure in failures}
792
+ if "unknown" in statuses:
793
+ return "unknown"
794
+ if "rejected" in statuses:
795
+ return "reject"
796
+ if fixes:
797
+ return "accept"
798
+ return "not_run"
799
+
800
+
801
+ def run_repair_pipeline(request: RepairPipelineRequest) -> RepairPipelineResult:
802
+ """Run the public repair pipeline from detection through optional apply."""
803
+ source_path = request.source_path.resolve()
804
+ source_bytes = source_path.read_bytes()
805
+ source_sha256 = sha256_bytes(source_bytes)
806
+ effective_schema, accepted_constraint_ids = merge_schema_with_reviewed_constraints(
807
+ request.repair_schema,
808
+ request.constraints,
809
+ source_sha256=source_sha256,
810
+ )
811
+ df = read_csv(source_path)
812
+ with repair_stage_span("dataforge.repair.detect", row_count=row_count(df)):
813
+ issues = run_all_detectors(df, effective_schema)
814
+ with repair_stage_span("dataforge.repair.propose", issue_count=len(issues)):
815
+ accepted_fixes, attempt_groups = propose_repairs(
816
+ issues,
817
+ source_path,
818
+ copy_table(df),
819
+ effective_schema,
820
+ allow_llm=request.allow_llm,
821
+ model=request.model,
822
+ allow_pii=request.allow_pii,
823
+ confirm_pii=request.confirm_pii,
824
+ confirm_escalations=request.confirm_escalations,
825
+ interactive=request.interactive,
826
+ )
827
+
828
+ with repair_stage_span("dataforge.repair.safety.batch", fixes_count=len(accepted_fixes)):
829
+ batch_safety = SafetyFilter().evaluate_batch(
830
+ accepted_fixes,
831
+ SafetyContext(confirm_escalations=request.confirm_escalations),
832
+ )
833
+ failures = _failed_attempts(attempt_groups)
834
+ transaction: RepairTransaction | None = None
835
+ txn_id: str | None = None
836
+ post_sha256: str | None = None
837
+ applied = False
838
+ reason = "No accepted fixes were produced."
839
+
840
+ if batch_safety.verdict != SafetyVerdict.ALLOW:
841
+ accepted_fixes = []
842
+ reason = batch_safety.reason
843
+ elif request.mode == "apply" and accepted_fixes:
844
+ txn_id = apply_transaction(source_path, accepted_fixes, source_bytes)
845
+ post_sha256 = sha256_file(source_path)
846
+ applied = True
847
+ reason = f"Applied {len(accepted_fixes)} fix(es)."
848
+ elif request.create_dry_run_transaction:
849
+ transaction, _log_path = create_repair_transaction(
850
+ source_path, accepted_fixes, source_bytes
851
+ )
852
+ txn_id = transaction.txn_id
853
+ reason = (
854
+ "Dry run completed without mutating the source file."
855
+ if accepted_fixes
856
+ else "No accepted fixes were produced."
857
+ )
858
+ elif accepted_fixes:
859
+ reason = "Dry run completed without mutating the source file."
860
+
861
+ if txn_id is not None and transaction is None:
862
+ # Replaying the log is unnecessary for the public contract here; this
863
+ # minimal receipt is intentionally enough for API callers.
864
+ transaction = None
865
+
866
+ verified_fixes = _verified_fixes(accepted_fixes, attempt_groups)
867
+ candidate_repairs = _candidate_repairs(attempt_groups)
868
+ proof_obligations = _proof_obligations(attempt_groups)
869
+ root_causes = _root_causes(issues, attempt_groups)
870
+ limitations = _receipt_limitations(request, failures, batch_safety, txn_id)
871
+ patch_plan_sha256 = _patch_plan_sha256(source_sha256, accepted_fixes)
872
+ receipt = RepairReceipt(
873
+ mode=request.mode,
874
+ applied=applied,
875
+ reversible=True,
876
+ source_path=str(source_path),
877
+ source_sha256=source_sha256,
878
+ post_sha256=post_sha256,
879
+ txn_id=txn_id,
880
+ allowed_columns=column_names(df),
881
+ valid_rows=list(range(row_count(df))),
882
+ safety_verdict=batch_safety.verdict.value,
883
+ verifier_verdict=_receipt_verifier_verdict(accepted_fixes, failures),
884
+ candidate_provenance=sorted({fix.provenance for fix in accepted_fixes}),
885
+ root_causes=root_causes,
886
+ candidate_repairs=candidate_repairs,
887
+ proof_obligations=proof_obligations,
888
+ accepted_constraint_ids=accepted_constraint_ids,
889
+ constraints_artifact_sha256=request.constraints_artifact_sha256,
890
+ patch_plan_sha256=patch_plan_sha256,
891
+ revert_command=f"dataforge revert {txn_id}" if txn_id is not None else None,
892
+ limitations=limitations,
893
+ abstentions=[failure.reason for failure in failures],
894
+ failure_reasons=[failure.reason for failure in failures],
895
+ issues_count=len(issues),
896
+ fixes_count=len(accepted_fixes),
897
+ reason=reason,
898
+ )
899
+ return RepairPipelineResult(
900
+ receipt=receipt,
901
+ issues=issues,
902
+ fixes=verified_fixes,
903
+ failures=failures,
904
+ transaction=transaction,
905
+ )