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,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}'"