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,102 @@
1
+ """Revert an applied DataForge transaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from dataforge.transactions.files import (
8
+ SourceLockError,
9
+ atomic_write_bytes,
10
+ source_path_lock,
11
+ )
12
+ from dataforge.transactions.log import (
13
+ TransactionAuditVerdict,
14
+ append_reverted_event,
15
+ find_transaction_log,
16
+ load_transaction,
17
+ sha256_file,
18
+ verify_transaction_log,
19
+ )
20
+ from dataforge.transactions.txn import RepairTransaction
21
+
22
+
23
+ class TransactionRevertError(Exception):
24
+ """Raised when a transaction cannot be safely reverted."""
25
+
26
+
27
+ def revert_transaction(txn_id: str, *, search_root: Path | None = None) -> RepairTransaction:
28
+ """Revert a previously applied transaction by restoring its source snapshot.
29
+
30
+ Args:
31
+ txn_id: Canonical transaction identifier.
32
+ search_root: Optional root directory used to locate the transaction log.
33
+
34
+ Returns:
35
+ The replayed transaction state after appending the revert event.
36
+
37
+ Raises:
38
+ TransactionRevertError: If the transaction is not revertible or hash checks fail.
39
+ """
40
+ log_path = find_transaction_log(txn_id, search_root=search_root)
41
+ audit_report = verify_transaction_log(txn_id, log_path=log_path)
42
+ if audit_report.verdict not in {
43
+ TransactionAuditVerdict.VERIFIED,
44
+ TransactionAuditVerdict.LEGACY_UNVERIFIED,
45
+ }:
46
+ details = "; ".join(audit_report.errors) or audit_report.verdict.value
47
+ raise TransactionRevertError(
48
+ f"Refusing to revert because transaction audit verification failed: {details}"
49
+ )
50
+ transaction = load_transaction(log_path)
51
+
52
+ if not transaction.applied or transaction.post_sha256 is None:
53
+ raise TransactionRevertError(
54
+ f"Transaction '{txn_id}' was recorded but never applied, so there is nothing to revert."
55
+ )
56
+ if transaction.reverted_at is not None:
57
+ raise TransactionRevertError(f"Transaction '{txn_id}' has already been reverted.")
58
+
59
+ if transaction.source_kind == "table_store":
60
+ try:
61
+ from dataforge.stores.revert import revert_table_store_transaction
62
+
63
+ return revert_table_store_transaction(log_path)
64
+ except Exception as exc:
65
+ raise TransactionRevertError(str(exc)) from exc
66
+
67
+ source_path = Path(transaction.source_path)
68
+ snapshot_path = Path(transaction.source_snapshot_path)
69
+
70
+ if not source_path.exists():
71
+ raise TransactionRevertError(f"Source file not found: '{source_path}'.")
72
+ if not snapshot_path.exists():
73
+ raise TransactionRevertError(
74
+ f"Source snapshot not found for transaction '{txn_id}': '{snapshot_path}'."
75
+ )
76
+
77
+ try:
78
+ with source_path_lock(source_path):
79
+ current_bytes = source_path.read_bytes()
80
+ current_sha256 = sha256_file(source_path)
81
+ if current_sha256 != transaction.post_sha256:
82
+ raise TransactionRevertError(
83
+ "Refusing to revert because the current file no longer matches the recorded "
84
+ "post-state hash. The file may have been edited after apply."
85
+ )
86
+
87
+ atomic_write_bytes(source_path, snapshot_path.read_bytes())
88
+ reverted_sha256 = sha256_file(source_path)
89
+ if reverted_sha256 != transaction.source_sha256:
90
+ atomic_write_bytes(source_path, current_bytes)
91
+ raise TransactionRevertError(
92
+ f"Revert failed integrity verification for transaction '{txn_id}'."
93
+ )
94
+
95
+ try:
96
+ append_reverted_event(log_path, txn_id)
97
+ except Exception:
98
+ atomic_write_bytes(source_path, current_bytes)
99
+ raise
100
+ except SourceLockError as exc:
101
+ raise TransactionRevertError(str(exc)) from exc
102
+ return load_transaction(log_path)
@@ -0,0 +1,104 @@
1
+ """Transaction models for reversible DataForge repairs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from datetime import UTC, datetime
7
+ from typing import Annotated, Any, Literal
8
+
9
+ from pydantic import BaseModel, Field, field_validator
10
+
11
+ TxnId = Annotated[str, Field(pattern=r"^txn-\d{4}-\d{2}-\d{2}-[0-9a-f]{6}$")]
12
+ Sha256Hex = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
13
+
14
+
15
+ def _require_utc(value: datetime, field_name: str) -> datetime:
16
+ """Validate that a datetime is timezone-aware UTC."""
17
+ if value.tzinfo is None or value.utcoffset() is None:
18
+ raise ValueError(f"{field_name} must be timezone-aware UTC")
19
+ if value.utcoffset() != UTC.utcoffset(value):
20
+ raise ValueError(f"{field_name} must be UTC")
21
+ return value
22
+
23
+
24
+ def generate_txn_id(now: datetime | None = None) -> str:
25
+ """Generate a transaction identifier in the canonical Week 2 format.
26
+
27
+ Args:
28
+ now: Optional timestamp override. If omitted, current UTC time is used.
29
+
30
+ Returns:
31
+ A transaction identifier like ``txn-2026-04-20-a1b2c3``.
32
+ """
33
+ current = now or datetime.now(UTC)
34
+ current_utc = current.astimezone(UTC)
35
+ return f"txn-{current_utc:%Y-%m-%d}-{secrets.token_hex(3)}"
36
+
37
+
38
+ class CellFix(BaseModel):
39
+ """A single cell mutation proposed or applied by DataForge.
40
+
41
+ Args:
42
+ row: Zero-indexed row number in the CSV body.
43
+ column: Column name to update.
44
+ old_value: The value observed before repair.
45
+ new_value: The value to write during repair.
46
+ detector_id: The detector / repairer family that produced the fix.
47
+ """
48
+
49
+ row: int = Field(ge=0, description="Zero-indexed row number")
50
+ column: str = Field(min_length=1, description="Column name")
51
+ old_value: str = Field(description="Original value before repair")
52
+ new_value: str = Field(description="Replacement value after repair")
53
+ detector_id: str = Field(min_length=1, description="Detector / repairer identifier")
54
+ operation: Literal["update", "delete_row"] = Field(
55
+ default="update",
56
+ description="Repair operation kind",
57
+ )
58
+
59
+ model_config = {"frozen": True}
60
+
61
+
62
+ class RepairTransaction(BaseModel):
63
+ """Audit record for a reversible repair transaction.
64
+
65
+ Args:
66
+ txn_id: Canonical transaction identifier.
67
+ created_at: UTC timestamp when the transaction was recorded.
68
+ source_path: Absolute path to the repaired source file.
69
+ source_sha256: SHA-256 of the original source bytes.
70
+ post_sha256: SHA-256 of the applied file bytes, once written.
71
+ source_snapshot_path: Absolute path to the immutable source snapshot.
72
+ fixes: Ordered list of cell fixes recorded for auditability.
73
+ applied: Whether the journal records that the repair was applied.
74
+ reverted_at: UTC timestamp when the transaction was reverted, if any.
75
+ """
76
+
77
+ txn_id: TxnId
78
+ created_at: datetime
79
+ source_path: str = Field(min_length=1)
80
+ source_sha256: Sha256Hex
81
+ post_sha256: Sha256Hex | None = None
82
+ source_snapshot_path: str = Field(min_length=1)
83
+ fixes: list[CellFix] = Field(default_factory=list)
84
+ applied: bool
85
+ reverted_at: datetime | None = None
86
+ source_kind: Literal["file", "table_store"] = "file"
87
+ backend: str | None = None
88
+ patch_plan: dict[str, Any] | None = None
89
+
90
+ @field_validator("created_at")
91
+ @classmethod
92
+ def _validate_created_at(cls, value: datetime) -> datetime:
93
+ """Require ``created_at`` to be UTC."""
94
+ return _require_utc(value, "created_at")
95
+
96
+ @field_validator("reverted_at")
97
+ @classmethod
98
+ def _validate_reverted_at(cls, value: datetime | None) -> datetime | None:
99
+ """Require ``reverted_at`` to be UTC when present."""
100
+ if value is None:
101
+ return None
102
+ return _require_utc(value, "reverted_at")
103
+
104
+ model_config = {"frozen": True}
@@ -0,0 +1 @@
1
+ """Terminal UI package scaffolding for DataForge."""
@@ -0,0 +1,136 @@
1
+ """Rich-based terminal rendering for the ``dataforge profile`` command.
2
+
3
+ This module contains **rendering only** — no business logic, no data loading,
4
+ no detector invocation. It receives a list of :class:`Issue` objects and
5
+ renders them as a Rich table with color-coded severity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from dataforge.detectors.base import Issue, Severity
16
+
17
+ # Severity-to-color mapping for the Rich table.
18
+ _SEVERITY_STYLE = {
19
+ Severity.SAFE: "green",
20
+ Severity.REVIEW: "yellow",
21
+ Severity.UNSAFE: "bold red",
22
+ }
23
+
24
+ # Severity sort key: UNSAFE first, then REVIEW, then SAFE.
25
+ _SEVERITY_ORDER = {Severity.UNSAFE: 0, Severity.REVIEW: 1, Severity.SAFE: 2}
26
+
27
+
28
+ def render_profile_table(
29
+ issues: list[Issue],
30
+ console: Console | None = None,
31
+ *,
32
+ file_path: str = "",
33
+ ) -> None:
34
+ """Render detected issues as a rich-formatted terminal table.
35
+
36
+ The table is sorted by severity (UNSAFE first) then confidence
37
+ (highest first). Each severity level is color-coded.
38
+
39
+ Args:
40
+ issues: List of Issue objects to display.
41
+ console: Optional Rich Console instance (for testing / capture).
42
+ If None, a new Console is created.
43
+ file_path: Optional file path to display in the header panel.
44
+
45
+ Example:
46
+ >>> from dataforge.detectors.base import Issue, Severity
47
+ >>> issues = [
48
+ ... Issue(row=3, column="price", issue_type="decimal_shift",
49
+ ... severity=Severity.REVIEW, confidence=0.92,
50
+ ... actual="1020.0", expected="102.0",
51
+ ... reason="Value appears 10x too large"),
52
+ ... ]
53
+ >>> render_profile_table(issues) # doctest: +SKIP
54
+ """
55
+ if console is None:
56
+ console = Console()
57
+
58
+ # Header panel.
59
+ header_text = "DataForge Profile Report"
60
+ if file_path:
61
+ header_text += f" | {file_path}"
62
+ console.print(
63
+ Panel(
64
+ header_text,
65
+ style="bold cyan",
66
+ expand=True,
67
+ )
68
+ )
69
+
70
+ if not issues:
71
+ console.print(
72
+ Panel(
73
+ "[green]No issues detected.[/green]",
74
+ title="Result",
75
+ style="green",
76
+ )
77
+ )
78
+ return
79
+
80
+ # Sort: UNSAFE first, then REVIEW, then SAFE; highest confidence first.
81
+ sorted_issues = sorted(
82
+ issues,
83
+ key=lambda i: (_SEVERITY_ORDER[i.severity], -i.confidence),
84
+ )
85
+
86
+ # Build the table.
87
+ table = Table(
88
+ title="Detected Issues",
89
+ show_lines=True,
90
+ header_style="bold magenta",
91
+ title_style="bold white",
92
+ )
93
+ table.add_column("Row", style="dim", justify="right", width=5)
94
+ table.add_column("Column", style="cyan", min_width=12)
95
+ table.add_column("Issue Type", style="white", min_width=14)
96
+ table.add_column("Severity", justify="center", min_width=8)
97
+ table.add_column("Confidence", justify="right", min_width=10)
98
+ table.add_column("Reason", min_width=30)
99
+
100
+ for issue in sorted_issues:
101
+ severity_style = _SEVERITY_STYLE[issue.severity]
102
+ severity_text = Text(issue.severity.value.upper(), style=severity_style)
103
+
104
+ confidence_pct = f"{issue.confidence:.0%}"
105
+
106
+ table.add_row(
107
+ str(issue.row),
108
+ issue.column,
109
+ issue.issue_type,
110
+ severity_text,
111
+ confidence_pct,
112
+ issue.reason,
113
+ )
114
+
115
+ console.print(table)
116
+
117
+ # Summary panel.
118
+ total = len(sorted_issues)
119
+ by_severity: dict[Severity, int] = {}
120
+ for issue in sorted_issues:
121
+ by_severity[issue.severity] = by_severity.get(issue.severity, 0) + 1
122
+
123
+ summary_parts: list[str] = [f"[bold]{total}[/bold] issues found"]
124
+ for sev in (Severity.UNSAFE, Severity.REVIEW, Severity.SAFE):
125
+ count = by_severity.get(sev, 0)
126
+ if count > 0:
127
+ style = _SEVERITY_STYLE[sev]
128
+ summary_parts.append(f"[{style}]{sev.value.upper()}: {count}[/{style}]")
129
+
130
+ console.print(
131
+ Panel(
132
+ " | ".join(summary_parts),
133
+ title="Summary",
134
+ style="dim",
135
+ )
136
+ )
@@ -0,0 +1,91 @@
1
+ """Rich rendering for repair proposals and transaction output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class _RepairDiffRow:
16
+ row: int
17
+ column: str
18
+ old_value: str
19
+ new_value: str
20
+ detector_id: str
21
+ confidence: float
22
+ provenance: str
23
+
24
+
25
+ def _row_from_fix(fix: object) -> _RepairDiffRow:
26
+ """Normalize internal ProposedFix and public VerifiedFix objects."""
27
+ nested = getattr(fix, "fix", None)
28
+ if nested is None:
29
+ public = cast(Any, fix)
30
+ return _RepairDiffRow(
31
+ row=public.row,
32
+ column=public.column,
33
+ old_value=public.old_value,
34
+ new_value=public.new_value,
35
+ detector_id=public.detector_id,
36
+ confidence=public.confidence,
37
+ provenance=public.provenance,
38
+ )
39
+ proposed = cast(Any, fix)
40
+ return _RepairDiffRow(
41
+ row=nested.row,
42
+ column=nested.column,
43
+ old_value=nested.old_value,
44
+ new_value=nested.new_value,
45
+ detector_id=nested.detector_id,
46
+ confidence=proposed.confidence,
47
+ provenance=proposed.provenance,
48
+ )
49
+
50
+
51
+ def render_repair_diff(
52
+ fixes: Sequence[object],
53
+ console: Console | None = None,
54
+ *,
55
+ file_path: str = "",
56
+ ) -> None:
57
+ """Render a rich table describing proposed repairs."""
58
+ target_console = console or Console()
59
+ title = "Proposed Repairs"
60
+ if file_path:
61
+ title = f"{title} | {file_path}"
62
+ target_console.print(Panel(title, style="bold cyan", expand=True))
63
+
64
+ if not fixes:
65
+ target_console.print(
66
+ Panel("[yellow]No fixes proposed.[/yellow]", title="Result", style="yellow")
67
+ )
68
+ return
69
+
70
+ table = Table(title="Repair Diff", show_lines=True, header_style="bold magenta")
71
+ table.add_column("Row", justify="right", width=5)
72
+ table.add_column("Column", style="cyan", min_width=12)
73
+ table.add_column("Old", min_width=12)
74
+ table.add_column("New", min_width=12)
75
+ table.add_column("Detector", min_width=14)
76
+ table.add_column("Confidence", justify="right", min_width=10)
77
+ table.add_column("Provenance", min_width=13)
78
+
79
+ for fix in fixes:
80
+ row = _row_from_fix(fix)
81
+ table.add_row(
82
+ str(row.row),
83
+ row.column,
84
+ row.old_value,
85
+ row.new_value,
86
+ row.detector_id,
87
+ f"{row.confidence:.0%}",
88
+ row.provenance,
89
+ )
90
+
91
+ target_console.print(table)
@@ -0,0 +1,55 @@
1
+ """Verifier exports for DataForge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = [
8
+ "AggregateDependency",
9
+ "AcceptedValues",
10
+ "ConstraintIR",
11
+ "DomainBound",
12
+ "FunctionalDependency",
13
+ "RegexConstraint",
14
+ "RelationshipConstraint",
15
+ "SMTVerifier",
16
+ "Schema",
17
+ "SchemaToSMT",
18
+ "VerificationResult",
19
+ "VerificationVerdict",
20
+ "constraint_ir_from_schema",
21
+ "explain_unsat_core",
22
+ ]
23
+
24
+
25
+ def __getattr__(name: str) -> Any:
26
+ """Lazily expose verifier symbols without import-time cycles."""
27
+ if name in {
28
+ "AcceptedValues",
29
+ "AggregateDependency",
30
+ "DomainBound",
31
+ "FunctionalDependency",
32
+ "RegexConstraint",
33
+ "RelationshipConstraint",
34
+ "Schema",
35
+ }:
36
+ from dataforge.verifier import schema as schema_module
37
+
38
+ return getattr(schema_module, name)
39
+ if name in {"ConstraintIR", "constraint_ir_from_schema"}:
40
+ from dataforge.verifier import constraint_ir as constraint_ir_module
41
+
42
+ return getattr(constraint_ir_module, name)
43
+ if name in {"SchemaToSMT", "VerificationResult", "VerificationVerdict"}:
44
+ from dataforge.verifier import smt as smt_module
45
+
46
+ return getattr(smt_module, name)
47
+ if name == "SMTVerifier":
48
+ from dataforge.verifier import gate as gate_module
49
+
50
+ return gate_module.SMTVerifier
51
+ if name == "explain_unsat_core":
52
+ from dataforge.verifier import explain as explain_module
53
+
54
+ return explain_module.explain_unsat_core
55
+ raise AttributeError(name)
@@ -0,0 +1,155 @@
1
+ """ConstraintIR models bridging schemas, dbt tests, SMT, and SQL proof queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from dataforge.verifier.schema import Schema
10
+
11
+ ConstraintIRKind = Literal[
12
+ "column_type",
13
+ "not_null",
14
+ "domain_bound",
15
+ "regex",
16
+ "unique",
17
+ "accepted_values",
18
+ "functional_dependency",
19
+ "referential",
20
+ "dbt_generic_test",
21
+ ]
22
+
23
+
24
+ class ConstraintIR(BaseModel):
25
+ """Backend-neutral constraint representation used by patch plans."""
26
+
27
+ constraint_id: str = Field(min_length=1)
28
+ kind: ConstraintIRKind
29
+ columns: tuple[str, ...] = Field(default_factory=tuple)
30
+ expression: str | None = None
31
+ verifier: Literal["smt", "sql", "dbt"] = "smt"
32
+ repair_supported: bool = False
33
+
34
+ model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
35
+
36
+
37
+ def constraint_ir_from_schema(schema: Schema | None) -> tuple[ConstraintIR, ...]:
38
+ """Map DataForge's current schema model into the v1 ConstraintIR."""
39
+ if schema is None:
40
+ return ()
41
+ constraints: list[ConstraintIR] = []
42
+ for column, column_type in sorted(schema.columns.items()):
43
+ constraints.append(
44
+ ConstraintIR(
45
+ constraint_id=f"column_type::{column}",
46
+ kind="column_type",
47
+ columns=(column,),
48
+ expression=column_type,
49
+ verifier="smt",
50
+ repair_supported=True,
51
+ )
52
+ )
53
+ for bound in schema.domain_bounds:
54
+ parts: list[str] = []
55
+ if bound.min_value is not None:
56
+ operator = ">=" if bound.inclusive_min else ">"
57
+ parts.append(f"{bound.column} {operator} {bound.min_value}")
58
+ if bound.max_value is not None:
59
+ operator = "<=" if bound.inclusive_max else "<"
60
+ parts.append(f"{bound.column} {operator} {bound.max_value}")
61
+ constraints.append(
62
+ ConstraintIR(
63
+ constraint_id=f"domain_bound::{bound.column}",
64
+ kind="domain_bound",
65
+ columns=(bound.column,),
66
+ expression=" AND ".join(parts) if parts else None,
67
+ verifier="smt",
68
+ repair_supported=True,
69
+ )
70
+ )
71
+ for column in sorted(schema.not_null_columns):
72
+ constraints.append(
73
+ ConstraintIR(
74
+ constraint_id=f"not_null::{column}",
75
+ kind="not_null",
76
+ columns=(column,),
77
+ expression=f"{column} IS NOT NULL",
78
+ verifier="smt",
79
+ repair_supported=True,
80
+ )
81
+ )
82
+ for column in sorted(schema.unique_columns):
83
+ constraints.append(
84
+ ConstraintIR(
85
+ constraint_id=f"unique::{column}",
86
+ kind="unique",
87
+ columns=(column,),
88
+ expression=f"{column} must be unique",
89
+ verifier="smt",
90
+ repair_supported=True,
91
+ )
92
+ )
93
+ for column in sorted(schema.primary_key_columns):
94
+ constraints.append(
95
+ ConstraintIR(
96
+ constraint_id=f"primary_key::{column}",
97
+ kind="unique",
98
+ columns=(column,),
99
+ expression=f"{column} must be not null and unique",
100
+ verifier="smt",
101
+ repair_supported=False,
102
+ )
103
+ )
104
+ for accepted_rule in schema.accepted_values:
105
+ constraints.append(
106
+ ConstraintIR(
107
+ constraint_id=f"accepted_values::{accepted_rule.column}",
108
+ kind="accepted_values",
109
+ columns=(accepted_rule.column,),
110
+ expression=", ".join(accepted_rule.values),
111
+ verifier="smt",
112
+ repair_supported=True,
113
+ )
114
+ )
115
+ for regex_rule in schema.regex_constraints:
116
+ constraints.append(
117
+ ConstraintIR(
118
+ constraint_id=f"regex::{regex_rule.column}",
119
+ kind="regex",
120
+ columns=(regex_rule.column,),
121
+ expression=regex_rule.pattern,
122
+ verifier="smt",
123
+ repair_supported=True,
124
+ )
125
+ )
126
+ for relationship_rule in schema.relationships:
127
+ constraints.append(
128
+ ConstraintIR(
129
+ constraint_id=(
130
+ f"relationship::{relationship_rule.column}->"
131
+ f"{relationship_rule.reference}.{relationship_rule.reference_column}"
132
+ ),
133
+ kind="referential",
134
+ columns=(relationship_rule.column,),
135
+ expression=(
136
+ f"{relationship_rule.column} references "
137
+ f"{relationship_rule.reference}({relationship_rule.reference_column})"
138
+ ),
139
+ verifier="sql",
140
+ repair_supported=False,
141
+ )
142
+ )
143
+ for fd in schema.functional_dependencies:
144
+ determinant = "+".join(fd.determinant)
145
+ constraints.append(
146
+ ConstraintIR(
147
+ constraint_id=f"fd::{determinant}->{fd.dependent}",
148
+ kind="functional_dependency",
149
+ columns=(*fd.determinant, fd.dependent),
150
+ expression=f"{determinant} -> {fd.dependent}",
151
+ verifier="smt",
152
+ repair_supported=True,
153
+ )
154
+ )
155
+ return tuple(constraints)
@@ -0,0 +1,47 @@
1
+ """Unsat-core explanation helpers for the Week 3 verifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataforge.verifier.schema import Schema
6
+
7
+
8
+ def explain_unsat_core(unsat_core: tuple[str, ...], schema: Schema) -> str:
9
+ """Convert tracked unsat-core labels into user-facing text."""
10
+ if not unsat_core:
11
+ return "The verifier rejected the fix, but did not expose a tracked explanation."
12
+
13
+ parts: list[str] = []
14
+ for label in unsat_core:
15
+ tokens = label.split("::")
16
+ if len(tokens) >= 5 and tokens[0] == "domain":
17
+ _, column, bound_kind, _, row = tokens[:5]
18
+ adjective = "minimum" if bound_kind == "min" else "maximum"
19
+ parts.append(f"Row {row} would violate the {adjective} bound for column '{column}'.")
20
+ continue
21
+ if len(tokens) >= 5 and tokens[0] == "fd":
22
+ _, determinant, dependent, _, row = tokens[:5]
23
+ determinant_text = determinant.replace("+", ", ")
24
+ parts.append(
25
+ f"Row {row} would violate FD (functional dependency) "
26
+ f"{determinant_text} -> {dependent}."
27
+ )
28
+ continue
29
+ if len(tokens) >= 4 and tokens[0] in {"not_null", "primary_key_not_null"}:
30
+ _, column, _, row = tokens[:4]
31
+ parts.append(f"Row {row} would violate the not-null constraint for '{column}'.")
32
+ continue
33
+ if len(tokens) >= 4 and tokens[0] in {"unique", "primary_key_unique"}:
34
+ _, column, _, row = tokens[:4]
35
+ parts.append(f"Row {row} would violate the unique constraint for '{column}'.")
36
+ continue
37
+ if len(tokens) >= 4 and tokens[0] == "accepted_values":
38
+ _, column, _, row = tokens[:4]
39
+ parts.append(f"Row {row} would violate the accepted values constraint for '{column}'.")
40
+ continue
41
+ if len(tokens) >= 4 and tokens[0] == "regex":
42
+ _, column, _, row = tokens[:4]
43
+ parts.append(f"Row {row} would violate the regex constraint for '{column}'.")
44
+ continue
45
+ parts.append(f"Tracked verifier rule '{label}' rejected the fix.")
46
+
47
+ return " ".join(parts)