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.
- dataforge/__init__.py +204 -0
- dataforge/__main__.py +5 -0
- dataforge/agent/__init__.py +16 -0
- dataforge/agent/providers.py +259 -0
- dataforge/agent/scratchpad.py +183 -0
- dataforge/agent/tool_actions.py +343 -0
- dataforge/bench/__init__.py +31 -0
- dataforge/bench/core.py +426 -0
- dataforge/bench/groq_client.py +386 -0
- dataforge/bench/methods.py +443 -0
- dataforge/bench/report.py +309 -0
- dataforge/bench/runner.py +247 -0
- dataforge/causal/__init__.py +21 -0
- dataforge/causal/dag.py +174 -0
- dataforge/causal/pc.py +232 -0
- dataforge/causal/root_cause.py +193 -0
- dataforge/cli/__init__.py +50 -0
- dataforge/cli/audit.py +70 -0
- dataforge/cli/bench.py +154 -0
- dataforge/cli/common.py +267 -0
- dataforge/cli/constraints.py +407 -0
- dataforge/cli/profile.py +147 -0
- dataforge/cli/release.py +166 -0
- dataforge/cli/repair.py +407 -0
- dataforge/cli/revert.py +139 -0
- dataforge/cli/watch.py +144 -0
- dataforge/datasets/__init__.py +25 -0
- dataforge/datasets/embedded/hospital/clean.csv +11 -0
- dataforge/datasets/embedded/hospital/dirty.csv +11 -0
- dataforge/datasets/real_world.py +290 -0
- dataforge/datasets/registry.py +103 -0
- dataforge/detectors/__init__.py +80 -0
- dataforge/detectors/base.py +145 -0
- dataforge/detectors/decimal_shift.py +166 -0
- dataforge/detectors/fd_violation.py +157 -0
- dataforge/detectors/type_mismatch.py +173 -0
- dataforge/engine/__init__.py +39 -0
- dataforge/engine/repair.py +905 -0
- dataforge/env/__init__.py +22 -0
- dataforge/env/environment.py +883 -0
- dataforge/env/observation.py +61 -0
- dataforge/env/openenv_core.py +161 -0
- dataforge/env/reward.py +128 -0
- dataforge/env/server.py +176 -0
- dataforge/evaluation_contract.py +76 -0
- dataforge/fixtures/hospital_10rows.csv +11 -0
- dataforge/fixtures/hospital_schema.yaml +17 -0
- dataforge/http/__init__.py +1 -0
- dataforge/http/problem.py +103 -0
- dataforge/integrations/__init__.py +1 -0
- dataforge/integrations/dbt.py +164 -0
- dataforge/observability.py +76 -0
- dataforge/py.typed +1 -0
- dataforge/release/__init__.py +1 -0
- dataforge/release/doctor.py +367 -0
- dataforge/release/full_vision.py +702 -0
- dataforge/release/gate.py +861 -0
- dataforge/release/playground_check.py +411 -0
- dataforge/repair_contract.py +468 -0
- dataforge/repairers/__init__.py +88 -0
- dataforge/repairers/base.py +77 -0
- dataforge/repairers/decimal_shift.py +43 -0
- dataforge/repairers/fd_violation.py +225 -0
- dataforge/repairers/type_mismatch.py +73 -0
- dataforge/safety/__init__.py +5 -0
- dataforge/safety/adversarial/attack_01_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_02_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_03_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_04_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_05_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_06_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_07_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_08_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_09_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_10_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_11_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_12_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_13_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_14_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_15_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_16_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_17_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_18_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_19_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_20_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_21_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_22_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_23_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_24_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_25_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_26_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_27_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_28_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_29_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_30_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_31_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_32_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_33_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_34_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_35_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_36_row_delete.yaml +11 -0
- dataforge/safety/adversarial/attack_37_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_38_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_39_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_40_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_41_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_42_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_43_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_44_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_45_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_46_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_47_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_48_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_49_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_50_row_delete.yaml +7 -0
- dataforge/safety/constitution.py +307 -0
- dataforge/safety/constitutions/default.yaml +40 -0
- dataforge/safety/filter.py +134 -0
- dataforge/schema_inference.py +620 -0
- dataforge/stores/__init__.py +46 -0
- dataforge/stores/base.py +73 -0
- dataforge/stores/cloud.py +78 -0
- dataforge/stores/csv.py +94 -0
- dataforge/stores/duckdb.py +313 -0
- dataforge/stores/patch_plan.py +178 -0
- dataforge/stores/registry.py +82 -0
- dataforge/stores/repair.py +121 -0
- dataforge/stores/revert.py +22 -0
- dataforge/stores/sql.py +27 -0
- dataforge/table.py +228 -0
- dataforge/transactions/__init__.py +34 -0
- dataforge/transactions/files.py +96 -0
- dataforge/transactions/log.py +613 -0
- dataforge/transactions/revert.py +102 -0
- dataforge/transactions/txn.py +104 -0
- dataforge/ui/__init__.py +1 -0
- dataforge/ui/profile_view.py +136 -0
- dataforge/ui/repair_diff.py +91 -0
- dataforge/verifier/__init__.py +55 -0
- dataforge/verifier/constraint_ir.py +155 -0
- dataforge/verifier/explain.py +47 -0
- dataforge/verifier/gate.py +5 -0
- dataforge/verifier/schema.py +111 -0
- dataforge/verifier/smt.py +433 -0
- dataforge_07-0.1.0.dist-info/METADATA +436 -0
- dataforge_07-0.1.0.dist-info/RECORD +150 -0
- dataforge_07-0.1.0.dist-info/WHEEL +5 -0
- dataforge_07-0.1.0.dist-info/entry_points.txt +3 -0
- dataforge_07-0.1.0.dist-info/licenses/LICENSE +176 -0
- 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}
|
dataforge/ui/__init__.py
ADDED
|
@@ -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)
|