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,103 @@
|
|
|
1
|
+
"""Canonical metadata for real-world benchmark datasets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HeaderMismatch(BaseModel):
|
|
9
|
+
"""Pair of dirty/clean header names that align by column position."""
|
|
10
|
+
|
|
11
|
+
dirty_name: str = Field(min_length=1)
|
|
12
|
+
clean_name: str = Field(min_length=1)
|
|
13
|
+
|
|
14
|
+
model_config = {"frozen": True}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatasetMetadata(BaseModel):
|
|
18
|
+
"""Metadata describing a canonical benchmark dataset."""
|
|
19
|
+
|
|
20
|
+
name: str = Field(min_length=1)
|
|
21
|
+
domain: str = Field(min_length=1)
|
|
22
|
+
n_rows: int = Field(ge=0)
|
|
23
|
+
n_columns: int = Field(ge=1)
|
|
24
|
+
error_types: tuple[str, ...] = Field(default_factory=tuple)
|
|
25
|
+
source_urls: tuple[str, str]
|
|
26
|
+
source_revision: str = Field(min_length=7)
|
|
27
|
+
dirty_sha256: str = Field(min_length=64, max_length=64)
|
|
28
|
+
clean_sha256: str = Field(min_length=64, max_length=64)
|
|
29
|
+
citation: str = Field(min_length=1)
|
|
30
|
+
header_mismatches: tuple[HeaderMismatch, ...] = Field(default_factory=tuple)
|
|
31
|
+
|
|
32
|
+
model_config = {"frozen": True}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
RAHA_GIT_REVISION = "7be1334b8c7bbdac3f47ef514fb3e1e8c5fc181c"
|
|
36
|
+
_BASE_URL = f"https://raw.githubusercontent.com/BigDaMa/raha/{RAHA_GIT_REVISION}/datasets"
|
|
37
|
+
|
|
38
|
+
DATASET_REGISTRY: dict[str, DatasetMetadata] = {
|
|
39
|
+
"hospital": DatasetMetadata(
|
|
40
|
+
name="hospital",
|
|
41
|
+
domain="healthcare",
|
|
42
|
+
n_rows=1000,
|
|
43
|
+
n_columns=20,
|
|
44
|
+
error_types=("typo", "missing_value", "formatting"),
|
|
45
|
+
source_urls=(
|
|
46
|
+
f"{_BASE_URL}/hospital/dirty.csv",
|
|
47
|
+
f"{_BASE_URL}/hospital/clean.csv",
|
|
48
|
+
),
|
|
49
|
+
source_revision=RAHA_GIT_REVISION,
|
|
50
|
+
dirty_sha256="dbc5575b915fe8b5e0ac6dc6172f38ba91e611fdb76d09a8f4a81cb7ea9925ac",
|
|
51
|
+
clean_sha256="ea3ee44998455c0b491750c348509de176c758a3bbf58e4530c0a136bb248b4b",
|
|
52
|
+
citation=(
|
|
53
|
+
"Mahdavi et al. Raha benchmark dataset (Hospital) via the BigDaMa/raha repository."
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
"flights": DatasetMetadata(
|
|
57
|
+
name="flights",
|
|
58
|
+
domain="aviation",
|
|
59
|
+
n_rows=2376,
|
|
60
|
+
n_columns=7,
|
|
61
|
+
error_types=("missing_value", "formatting", "datetime"),
|
|
62
|
+
source_urls=(
|
|
63
|
+
f"{_BASE_URL}/flights/dirty.csv",
|
|
64
|
+
f"{_BASE_URL}/flights/clean.csv",
|
|
65
|
+
),
|
|
66
|
+
source_revision=RAHA_GIT_REVISION,
|
|
67
|
+
dirty_sha256="1b5c1afa10aa0e7c20fd7e14d05c56772715b2771aa0f5fa67ed1709e1eecd46",
|
|
68
|
+
clean_sha256="0acfcfd8985b06fdd363965c9e8d9522c43e7589a93d79ae7dc311e1c37fdf3b",
|
|
69
|
+
citation=(
|
|
70
|
+
"Mahdavi et al. Raha benchmark dataset (Flights) via the BigDaMa/raha repository."
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
"beers": DatasetMetadata(
|
|
74
|
+
name="beers",
|
|
75
|
+
domain="consumer",
|
|
76
|
+
n_rows=2410,
|
|
77
|
+
n_columns=11,
|
|
78
|
+
error_types=("formatting", "missing_value", "normalization"),
|
|
79
|
+
source_urls=(
|
|
80
|
+
f"{_BASE_URL}/beers/dirty.csv",
|
|
81
|
+
f"{_BASE_URL}/beers/clean.csv",
|
|
82
|
+
),
|
|
83
|
+
source_revision=RAHA_GIT_REVISION,
|
|
84
|
+
dirty_sha256="7110bf4931a9445a1675e544d6c996817c739136239f8a2b02e088c7ec0a1f68",
|
|
85
|
+
clean_sha256="373227df59ad197e154dd5149125789e415019535c7223355e9486ee1b3b93de",
|
|
86
|
+
citation=("Mahdavi et al. Raha benchmark dataset (Beers) via the BigDaMa/raha repository."),
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_dataset_metadata(name: str) -> DatasetMetadata:
|
|
92
|
+
"""Return canonical metadata for a named benchmark dataset.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
name: Canonical dataset name.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The immutable metadata entry for the dataset.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
KeyError: If the dataset is not registered.
|
|
102
|
+
"""
|
|
103
|
+
return DATASET_REGISTRY[name]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""DataForge detector package — pure data-quality issue detection.
|
|
2
|
+
|
|
3
|
+
This package provides the detector infrastructure and three Week 1 detectors:
|
|
4
|
+
|
|
5
|
+
- :class:`TypeMismatchDetector` — numeric/string/date type conflicts.
|
|
6
|
+
- :class:`DecimalShiftDetector` — power-of-10 outliers in numeric columns.
|
|
7
|
+
- :class:`FDViolationDetector` — rows violating declared functional dependencies.
|
|
8
|
+
|
|
9
|
+
Use :func:`run_all_detectors` to run all detectors and get a merged,
|
|
10
|
+
deduplicated, severity-sorted issue list.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataforge.detectors.base import Detector, Issue, Schema, Severity
|
|
16
|
+
from dataforge.detectors.decimal_shift import DecimalShiftDetector
|
|
17
|
+
from dataforge.detectors.fd_violation import FDViolationDetector
|
|
18
|
+
from dataforge.detectors.type_mismatch import TypeMismatchDetector
|
|
19
|
+
from dataforge.table import TableLike
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DecimalShiftDetector",
|
|
23
|
+
"FDViolationDetector",
|
|
24
|
+
"Issue",
|
|
25
|
+
"Schema",
|
|
26
|
+
"Severity",
|
|
27
|
+
"TypeMismatchDetector",
|
|
28
|
+
"run_all_detectors",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Severity sort key: UNSAFE first, then REVIEW, then SAFE.
|
|
32
|
+
_SEVERITY_ORDER = {Severity.UNSAFE: 0, Severity.REVIEW: 1, Severity.SAFE: 2}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_all_detectors(df: TableLike, schema: Schema | None = None) -> list[Issue]:
|
|
36
|
+
"""Run all registered detectors and return a merged, sorted issue list.
|
|
37
|
+
|
|
38
|
+
Issues are deduplicated by (row, column, issue_type) and sorted by
|
|
39
|
+
severity (UNSAFE first) then confidence (highest first).
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
df: The input table to analyze.
|
|
43
|
+
schema: Optional declared schema with column types and constraints.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A list of Issue objects from all detectors, sorted by severity
|
|
47
|
+
then confidence descending.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> import pandas as pd
|
|
51
|
+
>>> from dataforge.detectors import run_all_detectors
|
|
52
|
+
>>> df = pd.DataFrame({"age": ["25", "30", "N/A", "40"]})
|
|
53
|
+
>>> issues = run_all_detectors(df)
|
|
54
|
+
>>> len(issues)
|
|
55
|
+
1
|
|
56
|
+
"""
|
|
57
|
+
detectors: list[Detector] = [
|
|
58
|
+
TypeMismatchDetector(),
|
|
59
|
+
DecimalShiftDetector(),
|
|
60
|
+
FDViolationDetector(),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
all_issues: list[Issue] = []
|
|
64
|
+
for detector in detectors:
|
|
65
|
+
all_issues.extend(detector.detect(df, schema))
|
|
66
|
+
|
|
67
|
+
# Deduplicate by (row, column, issue_type).
|
|
68
|
+
seen: set[tuple[int, str, str]] = set()
|
|
69
|
+
unique: list[Issue] = []
|
|
70
|
+
for issue in all_issues:
|
|
71
|
+
key = (issue.row, issue.column, issue.issue_type)
|
|
72
|
+
if key not in seen:
|
|
73
|
+
seen.add(key)
|
|
74
|
+
unique.append(issue)
|
|
75
|
+
|
|
76
|
+
# Sort: UNSAFE first, then REVIEW, then SAFE; within same severity,
|
|
77
|
+
# highest confidence first.
|
|
78
|
+
unique.sort(key=lambda i: (_SEVERITY_ORDER[i.severity], -i.confidence))
|
|
79
|
+
|
|
80
|
+
return unique
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Core models and protocol for the DataForge detector subsystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from typing import Literal, Protocol
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from dataforge.table import TableLike
|
|
11
|
+
from dataforge.verifier.schema import (
|
|
12
|
+
AcceptedValues,
|
|
13
|
+
AggregateDependency,
|
|
14
|
+
DomainBound,
|
|
15
|
+
FunctionalDependency,
|
|
16
|
+
RegexConstraint,
|
|
17
|
+
RelationshipConstraint,
|
|
18
|
+
Schema,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AggregateDependency",
|
|
23
|
+
"AcceptedValues",
|
|
24
|
+
"Detector",
|
|
25
|
+
"DomainBound",
|
|
26
|
+
"FunctionalDependency",
|
|
27
|
+
"Issue",
|
|
28
|
+
"IssueTypeLiteral",
|
|
29
|
+
"RegexConstraint",
|
|
30
|
+
"RelationshipConstraint",
|
|
31
|
+
"Schema",
|
|
32
|
+
"Severity",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Severity(enum.Enum):
|
|
37
|
+
"""Three-tier severity for data-quality issues.
|
|
38
|
+
|
|
39
|
+
Ordering: SAFE < REVIEW < UNSAFE (higher = more severe).
|
|
40
|
+
|
|
41
|
+
- SAFE: likely benign; can be auto-applied in bulk without human review.
|
|
42
|
+
- REVIEW: ambiguous; should appear in the profile table for human triage.
|
|
43
|
+
- UNSAFE: structural error; blocks automated repair without explicit approval.
|
|
44
|
+
|
|
45
|
+
See DECISIONS.md entry "Issue severity tiers" for the rationale behind
|
|
46
|
+
choosing exactly 3 levels.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
SAFE = "safe"
|
|
50
|
+
REVIEW = "review"
|
|
51
|
+
UNSAFE = "unsafe"
|
|
52
|
+
|
|
53
|
+
def __lt__(self, other: object) -> bool:
|
|
54
|
+
"""Enable ordering so SAFE < REVIEW < UNSAFE."""
|
|
55
|
+
if not isinstance(other, Severity):
|
|
56
|
+
return NotImplemented
|
|
57
|
+
order = {Severity.SAFE: 0, Severity.REVIEW: 1, Severity.UNSAFE: 2}
|
|
58
|
+
return order[self] < order[other]
|
|
59
|
+
|
|
60
|
+
def __le__(self, other: object) -> bool:
|
|
61
|
+
"""Enable ordering so SAFE <= REVIEW <= UNSAFE."""
|
|
62
|
+
if not isinstance(other, Severity):
|
|
63
|
+
return NotImplemented
|
|
64
|
+
order = {Severity.SAFE: 0, Severity.REVIEW: 1, Severity.UNSAFE: 2}
|
|
65
|
+
return order[self] <= order[other]
|
|
66
|
+
|
|
67
|
+
def __gt__(self, other: object) -> bool:
|
|
68
|
+
"""Enable ordering so UNSAFE > REVIEW > SAFE."""
|
|
69
|
+
if not isinstance(other, Severity):
|
|
70
|
+
return NotImplemented
|
|
71
|
+
order = {Severity.SAFE: 0, Severity.REVIEW: 1, Severity.UNSAFE: 2}
|
|
72
|
+
return order[self] > order[other]
|
|
73
|
+
|
|
74
|
+
def __ge__(self, other: object) -> bool:
|
|
75
|
+
"""Enable ordering so UNSAFE >= REVIEW >= SAFE."""
|
|
76
|
+
if not isinstance(other, Severity):
|
|
77
|
+
return NotImplemented
|
|
78
|
+
order = {Severity.SAFE: 0, Severity.REVIEW: 1, Severity.UNSAFE: 2}
|
|
79
|
+
return order[self] >= order[other]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Closed vocabulary of issue types. Extend this Literal as new detectors ship.
|
|
83
|
+
IssueTypeLiteral = Literal["type_mismatch", "decimal_shift", "fd_violation"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Issue(BaseModel):
|
|
87
|
+
"""A single data-quality finding at a specific (row, column) location.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
row: Zero-indexed row number in the DataFrame.
|
|
91
|
+
column: Column name where the issue was detected.
|
|
92
|
+
issue_type: Machine-readable issue category (closed vocabulary).
|
|
93
|
+
severity: Three-tier severity classification.
|
|
94
|
+
confidence: Detector's confidence in the finding (0.0 to 1.0).
|
|
95
|
+
expected: What the value should be (if known); None for detection-only.
|
|
96
|
+
actual: The actual value found in the cell.
|
|
97
|
+
reason: Human-readable explanation of the issue.
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
>>> issue = Issue(
|
|
101
|
+
... row=3, column="price", issue_type="decimal_shift",
|
|
102
|
+
... severity=Severity.REVIEW, confidence=0.92,
|
|
103
|
+
... expected="102.0", actual="1020.0",
|
|
104
|
+
... reason="Value 1020.0 appears to be ~10x the typical value",
|
|
105
|
+
... )
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
row: int = Field(ge=0, description="Zero-indexed row number")
|
|
109
|
+
column: str = Field(min_length=1, description="Column name")
|
|
110
|
+
issue_type: IssueTypeLiteral = Field(description="Machine-readable issue category")
|
|
111
|
+
severity: Severity = Field(description="Three-tier severity")
|
|
112
|
+
confidence: float = Field(ge=0.0, le=1.0, description="Detector confidence")
|
|
113
|
+
expected: str | None = Field(default=None, description="Expected value (if known)")
|
|
114
|
+
actual: str = Field(description="Actual value found in the cell")
|
|
115
|
+
reason: str = Field(min_length=1, description="Human-readable explanation")
|
|
116
|
+
|
|
117
|
+
model_config = {"frozen": True}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Detector(Protocol):
|
|
121
|
+
"""Structural protocol that every detector must implement.
|
|
122
|
+
|
|
123
|
+
A detector is a pure function over tabular data: it receives a table
|
|
124
|
+
and an optional Schema, and returns a list of Issue objects. No LLM calls,
|
|
125
|
+
no disk I/O, no side effects.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
>>> class MyDetector:
|
|
129
|
+
... def detect(
|
|
130
|
+
... self, df: TableLike, schema: Schema | None = None
|
|
131
|
+
... ) -> list[Issue]:
|
|
132
|
+
... return []
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def detect(self, df: TableLike, schema: Schema | None = None) -> list[Issue]:
|
|
136
|
+
"""Detect data-quality issues in the given DataFrame.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
df: The input table to analyze.
|
|
140
|
+
schema: Optional declared schema with column types and constraints.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A list of Issue objects describing detected anomalies.
|
|
144
|
+
"""
|
|
145
|
+
... # pragma: no cover
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Detector for decimal-shift anomalies in numeric columns.
|
|
2
|
+
|
|
3
|
+
Identifies values that are exact powers-of-10 multiples (10x, 100x, 0.1x,
|
|
4
|
+
0.01x, etc.) of the column's central tendency. This is the canonical
|
|
5
|
+
"decimal point was moved" data-entry error pattern.
|
|
6
|
+
|
|
7
|
+
The detector is **pure**: no LLM calls, no I/O, no side effects.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from statistics import median
|
|
14
|
+
|
|
15
|
+
from dataforge.detectors.base import Issue, Schema, Severity
|
|
16
|
+
from dataforge.table import TableLike, column_names, column_values
|
|
17
|
+
|
|
18
|
+
# Minimum non-null numeric values required for meaningful statistics.
|
|
19
|
+
_MIN_COLUMN_SIZE = 5
|
|
20
|
+
|
|
21
|
+
# Powers of 10 to check. Positive = value is N× too large;
|
|
22
|
+
# negative = value is N× too small.
|
|
23
|
+
_SHIFT_POWERS = (-3, -2, -1, 1, 2, 3)
|
|
24
|
+
|
|
25
|
+
# How close ratio must be to a power of 10 (in log10 space).
|
|
26
|
+
# 0.15 means we accept ratios within 10^±0.15 ≈ 0.71× – 1.41× of the
|
|
27
|
+
# exact power. Tight enough to avoid false positives on natural variance.
|
|
28
|
+
_LOG_TOLERANCE = 0.15
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _try_float(value: object) -> float | None:
|
|
32
|
+
"""Attempt to parse a value as float, returning None on failure.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
value: Any value (string, int, float, None, …).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The float value or None if parsing fails.
|
|
39
|
+
"""
|
|
40
|
+
if value is None or (isinstance(value, float) and math.isnan(value)):
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
return float(str(value))
|
|
44
|
+
except (ValueError, TypeError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DecimalShiftDetector:
|
|
49
|
+
"""Detects values that are power-of-10 multiples of the column distribution.
|
|
50
|
+
|
|
51
|
+
For each numeric column, computes the median and checks every value
|
|
52
|
+
to see if ``value / median`` is close to 10^k for k in {-3, -2, -1,
|
|
53
|
+
1, 2, 3}. Flagged values get an ``expected`` field with the corrected
|
|
54
|
+
value (``value / 10^k``).
|
|
55
|
+
|
|
56
|
+
Requires at least 5 non-null numeric values per column. Columns with
|
|
57
|
+
zero or near-zero median are handled gracefully.
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> import pandas as pd
|
|
61
|
+
>>> detector = DecimalShiftDetector()
|
|
62
|
+
>>> df = pd.DataFrame({"price": [100.0, 105.0, 98.0, 1020.0, 103.0]})
|
|
63
|
+
>>> issues = detector.detect(df)
|
|
64
|
+
>>> issues[0].row
|
|
65
|
+
3
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def detect(self, df: TableLike, schema: Schema | None = None) -> list[Issue]:
|
|
69
|
+
"""Detect decimal-shift issues in the DataFrame.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
df: The input DataFrame to analyze.
|
|
73
|
+
schema: Optional declared schema (unused by this detector).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A list of Issue objects for values that appear to be shifted
|
|
77
|
+
by a power of 10 relative to the column distribution.
|
|
78
|
+
"""
|
|
79
|
+
issues: list[Issue] = []
|
|
80
|
+
|
|
81
|
+
for col_name in column_names(df):
|
|
82
|
+
col_issues = self._check_column(df, str(col_name))
|
|
83
|
+
issues.extend(col_issues)
|
|
84
|
+
|
|
85
|
+
return issues
|
|
86
|
+
|
|
87
|
+
def _check_column(self, df: TableLike, col_name: str) -> list[Issue]:
|
|
88
|
+
"""Check a single column for decimal-shift outliers.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
df: The DataFrame containing the column.
|
|
92
|
+
col_name: Name of the column to check.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Issues found in this column.
|
|
96
|
+
"""
|
|
97
|
+
# Parse all values to float, keeping track of original indices.
|
|
98
|
+
parsed: list[tuple[int, float, str]] = []
|
|
99
|
+
for row_idx, val in enumerate(column_values(df, col_name)):
|
|
100
|
+
fval = _try_float(val)
|
|
101
|
+
if fval is not None:
|
|
102
|
+
parsed.append((row_idx, fval, str(val)))
|
|
103
|
+
|
|
104
|
+
if len(parsed) < _MIN_COLUMN_SIZE:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
center = float(median([v for _, v, _ in parsed]))
|
|
108
|
+
|
|
109
|
+
# If median is zero or very close, we cannot compute meaningful ratios.
|
|
110
|
+
if abs(center) < 1e-10:
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
issues: list[Issue] = []
|
|
114
|
+
for row_idx, fval, str_val in parsed:
|
|
115
|
+
if abs(fval) < 1e-10:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
ratio = fval / center
|
|
119
|
+
if abs(ratio) < 1e-10:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
log_ratio = math.log10(abs(ratio))
|
|
123
|
+
|
|
124
|
+
best_power: int | None = None
|
|
125
|
+
best_distance = float("inf")
|
|
126
|
+
|
|
127
|
+
for power in _SHIFT_POWERS:
|
|
128
|
+
distance = abs(log_ratio - power)
|
|
129
|
+
if distance < _LOG_TOLERANCE and distance < best_distance:
|
|
130
|
+
best_distance = distance
|
|
131
|
+
best_power = power
|
|
132
|
+
|
|
133
|
+
if best_power is not None:
|
|
134
|
+
correction_factor = 10.0**best_power
|
|
135
|
+
expected_val = fval / correction_factor
|
|
136
|
+
|
|
137
|
+
# Confidence: closer to exact power → higher confidence.
|
|
138
|
+
confidence = round(min(0.95, max(0.70, 1.0 - best_distance * 2.0)), 2)
|
|
139
|
+
|
|
140
|
+
if best_power > 0:
|
|
141
|
+
reason = (
|
|
142
|
+
f"Value {fval:g} in column '{col_name}' appears to be "
|
|
143
|
+
f"~{int(correction_factor)}x the typical value "
|
|
144
|
+
f"(median ~{center:g})"
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
reason = (
|
|
148
|
+
f"Value {fval:g} in column '{col_name}' appears to be "
|
|
149
|
+
f"~{1.0 / correction_factor:g}x too small compared to "
|
|
150
|
+
f"the typical value (median ~{center:g})"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
issues.append(
|
|
154
|
+
Issue(
|
|
155
|
+
row=row_idx,
|
|
156
|
+
column=col_name,
|
|
157
|
+
issue_type="decimal_shift",
|
|
158
|
+
severity=Severity.REVIEW,
|
|
159
|
+
confidence=confidence,
|
|
160
|
+
expected=f"{expected_val:g}",
|
|
161
|
+
actual=str_val.strip(),
|
|
162
|
+
reason=reason,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return issues
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Detector for functional-dependency violations in tabular data.
|
|
2
|
+
|
|
3
|
+
Given a declared functional dependency X -> Y (where X is a set of
|
|
4
|
+
determinant columns and Y is a dependent column), this detector groups
|
|
5
|
+
rows by X and flags any group where Y takes more than one distinct value.
|
|
6
|
+
|
|
7
|
+
Week 1 scope: declared FDs only (from the schema YAML). Automatic FD
|
|
8
|
+
mining is deferred to a later milestone.
|
|
9
|
+
|
|
10
|
+
The detector is **pure**: no LLM calls, no I/O, no side effects.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataforge.detectors.base import Issue, Schema, Severity
|
|
16
|
+
from dataforge.table import TableLike, cell_value, column_names, row_count
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FDViolationDetector:
|
|
20
|
+
"""Detects rows that violate declared functional dependencies.
|
|
21
|
+
|
|
22
|
+
For each FD ``determinant -> dependent`` in the schema, groups the
|
|
23
|
+
DataFrame by the determinant columns and checks that each group has
|
|
24
|
+
exactly one unique value in the dependent column. All rows in a
|
|
25
|
+
violating group are flagged.
|
|
26
|
+
|
|
27
|
+
Requires a ``Schema`` with ``functional_dependencies`` to do anything;
|
|
28
|
+
returns an empty list if no schema or no FDs are provided.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> import pandas as pd
|
|
32
|
+
>>> from dataforge.detectors.base import FunctionalDependency, Schema
|
|
33
|
+
>>> detector = FDViolationDetector()
|
|
34
|
+
>>> df = pd.DataFrame({
|
|
35
|
+
... "zip": ["10001", "10001", "90210"],
|
|
36
|
+
... "city": ["NY", "Manhattan", "LA"],
|
|
37
|
+
... })
|
|
38
|
+
>>> schema = Schema(functional_dependencies=[
|
|
39
|
+
... FunctionalDependency(determinant=["zip"], dependent="city"),
|
|
40
|
+
... ])
|
|
41
|
+
>>> issues = detector.detect(df, schema)
|
|
42
|
+
>>> len(issues)
|
|
43
|
+
2
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def detect(self, df: TableLike, schema: Schema | None = None) -> list[Issue]:
|
|
47
|
+
"""Detect FD-violation issues in the DataFrame.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
df: The input DataFrame to analyze.
|
|
51
|
+
schema: Schema containing declared functional dependencies.
|
|
52
|
+
If None or no FDs declared, returns an empty list.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A list of Issue objects for rows violating declared FDs.
|
|
56
|
+
"""
|
|
57
|
+
if schema is None or not schema.functional_dependencies:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
issues: list[Issue] = []
|
|
61
|
+
|
|
62
|
+
for fd in schema.functional_dependencies:
|
|
63
|
+
fd_issues = self._check_fd(df, fd.determinant, fd.dependent)
|
|
64
|
+
issues.extend(fd_issues)
|
|
65
|
+
|
|
66
|
+
return issues
|
|
67
|
+
|
|
68
|
+
def _check_fd(
|
|
69
|
+
self,
|
|
70
|
+
df: TableLike,
|
|
71
|
+
determinant: tuple[str, ...],
|
|
72
|
+
dependent: str,
|
|
73
|
+
) -> list[Issue]:
|
|
74
|
+
"""Check a single functional dependency X -> Y.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
df: The DataFrame to check.
|
|
78
|
+
determinant: List of determinant column names (X).
|
|
79
|
+
dependent: The dependent column name (Y).
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Issues for all rows in groups that violate the FD.
|
|
83
|
+
"""
|
|
84
|
+
determinant_columns = list(determinant)
|
|
85
|
+
|
|
86
|
+
# Verify all columns exist in the DataFrame.
|
|
87
|
+
all_cols = [*determinant_columns, dependent]
|
|
88
|
+
available_columns = set(column_names(df))
|
|
89
|
+
for col in all_cols:
|
|
90
|
+
if col not in available_columns:
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
groups: dict[tuple[str, ...], list[int]] = {}
|
|
94
|
+
for row in range(row_count(df)):
|
|
95
|
+
group_key = tuple(cell_value(df, row, column) for column in determinant_columns)
|
|
96
|
+
if any(value == "" for value in group_key):
|
|
97
|
+
continue
|
|
98
|
+
groups.setdefault(group_key, []).append(row)
|
|
99
|
+
|
|
100
|
+
if not groups:
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
issues: list[Issue] = []
|
|
104
|
+
for group_key, row_indices in groups.items():
|
|
105
|
+
unique_deps: list[str] = []
|
|
106
|
+
for row in row_indices:
|
|
107
|
+
value = cell_value(df, row, dependent)
|
|
108
|
+
if value == "" or value in unique_deps:
|
|
109
|
+
continue
|
|
110
|
+
unique_deps.append(value)
|
|
111
|
+
if len(unique_deps) <= 1:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
det_desc = self._format_determinant(determinant, group_key)
|
|
115
|
+
unique_str = ", ".join(repr(str(v)) for v in unique_deps)
|
|
116
|
+
|
|
117
|
+
for idx in row_indices:
|
|
118
|
+
actual_val = cell_value(df, idx, dependent)
|
|
119
|
+
reason = (
|
|
120
|
+
f"Functional dependency {determinant} -> {dependent} "
|
|
121
|
+
f"violated: {det_desc} maps to multiple values: "
|
|
122
|
+
f"{{{unique_str}}}"
|
|
123
|
+
)
|
|
124
|
+
issues.append(
|
|
125
|
+
Issue(
|
|
126
|
+
row=int(idx),
|
|
127
|
+
column=dependent,
|
|
128
|
+
issue_type="fd_violation",
|
|
129
|
+
severity=Severity.UNSAFE,
|
|
130
|
+
confidence=0.95,
|
|
131
|
+
actual=actual_val,
|
|
132
|
+
reason=reason,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return issues
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _format_determinant(determinant: tuple[str, ...], group_key: object) -> str:
|
|
140
|
+
"""Format the determinant key for human-readable output.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
determinant: List of determinant column names.
|
|
144
|
+
group_key: The group key (scalar or tuple).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
A formatted string like ``zip_code='10001'``.
|
|
148
|
+
"""
|
|
149
|
+
if len(determinant) == 1:
|
|
150
|
+
return f"{determinant[0]}='{group_key}'"
|
|
151
|
+
|
|
152
|
+
# Composite key: group_key is a tuple.
|
|
153
|
+
if isinstance(group_key, tuple):
|
|
154
|
+
parts = [f"{col}='{val}'" for col, val in zip(determinant, group_key, strict=True)]
|
|
155
|
+
return ", ".join(parts)
|
|
156
|
+
|
|
157
|
+
return f"{determinant}='{group_key}'"
|