datasentry-core 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.
- datasentry_core/__init__.py +111 -0
- datasentry_core/connectors/__init__.py +49 -0
- datasentry_core/connectors/base.py +108 -0
- datasentry_core/connectors/csv.py +312 -0
- datasentry_core/connectors/duckdb.py +105 -0
- datasentry_core/connectors/errors.py +19 -0
- datasentry_core/connectors/file_based.py +209 -0
- datasentry_core/connectors/jsonl.py +71 -0
- datasentry_core/connectors/parquet.py +64 -0
- datasentry_core/connectors/registry.py +55 -0
- datasentry_core/connectors/spec.py +37 -0
- datasentry_core/connectors/xlsx.py +109 -0
- datasentry_core/contracts/__init__.py +24 -0
- datasentry_core/contracts/exporters.py +330 -0
- datasentry_core/detectors/__init__.py +15 -0
- datasentry_core/detectors/base.py +120 -0
- datasentry_core/detectors/common.py +156 -0
- datasentry_core/detectors/cross_field.py +157 -0
- datasentry_core/detectors/cross_table.py +220 -0
- datasentry_core/detectors/datetime.py +343 -0
- datasentry_core/detectors/initial/__init__.py +117 -0
- datasentry_core/detectors/initial/anomaly_ml.py +204 -0
- datasentry_core/detectors/initial/categorical.py +252 -0
- datasentry_core/detectors/initial/formula.py +66 -0
- datasentry_core/detectors/initial/missing.py +150 -0
- datasentry_core/detectors/initial/numeric.py +302 -0
- datasentry_core/detectors/initial/textual.py +407 -0
- datasentry_core/detectors/initial/uniqueness.py +183 -0
- datasentry_core/detectors/missingness.py +424 -0
- datasentry_core/detectors/runner.py +228 -0
- datasentry_core/detectors/safe_eval.py +255 -0
- datasentry_core/detectors/textual_variants.py +300 -0
- datasentry_core/drift/__init__.py +8 -0
- datasentry_core/drift/engine.py +182 -0
- datasentry_core/engine/__init__.py +16 -0
- datasentry_core/engine/base.py +34 -0
- datasentry_core/engine/duckdb.py +79 -0
- datasentry_core/engine/fusion.py +193 -0
- datasentry_core/engine/profiler.py +165 -0
- datasentry_core/engine/sql_guard.py +23 -0
- datasentry_core/llm/provider.py +66 -0
- datasentry_core/models/__init__.py +118 -0
- datasentry_core/models/audit.py +46 -0
- datasentry_core/models/contract.py +89 -0
- datasentry_core/models/detector.py +66 -0
- datasentry_core/models/drift.py +47 -0
- datasentry_core/models/enums.py +126 -0
- datasentry_core/models/evidence.py +39 -0
- datasentry_core/models/feedback.py +30 -0
- datasentry_core/models/fingerprint.py +24 -0
- datasentry_core/models/issue.py +49 -0
- datasentry_core/models/llm.py +69 -0
- datasentry_core/models/profile.py +79 -0
- datasentry_core/models/quality.py +16 -0
- datasentry_core/models/repair.py +94 -0
- datasentry_core/models/rules.py +82 -0
- datasentry_core/models/scan.py +112 -0
- datasentry_core/models/validation.py +22 -0
- datasentry_core/plugins.py +91 -0
- datasentry_core/privacy/redactor.py +155 -0
- datasentry_core/repair/__init__.py +5 -0
- datasentry_core/repair/engine.py +455 -0
- datasentry_core/reporting/__init__.py +100 -0
- datasentry_core/reporting/html.py +230 -0
- datasentry_core/reporting/junit.py +80 -0
- datasentry_core/reporting/markdown.py +83 -0
- datasentry_core/reporting/sarif.py +92 -0
- datasentry_core/rules/engine.py +133 -0
- datasentry_core/scoring/__init__.py +30 -0
- datasentry_core/scoring/engine.py +138 -0
- datasentry_core/scoring/gate.py +96 -0
- datasentry_core/scoring/quality.py +137 -0
- datasentry_core/scoring/weights.py +33 -0
- datasentry_core/storage/__init__.py +15 -0
- datasentry_core/storage/paths.py +47 -0
- datasentry_core/storage/schema.py +343 -0
- datasentry_core/storage/store.py +675 -0
- datasentry_core-0.1.0.dist-info/METADATA +59 -0
- datasentry_core-0.1.0.dist-info/RECORD +80 -0
- datasentry_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""缺失模式检测器(11.4 核心,Step 16):sudden/conditional/group/correlated。
|
|
2
|
+
|
|
3
|
+
四个检测器全部 SQL pushdown、列级统计证据(双列/分组/时间桶场景不取行级证据,
|
|
4
|
+
ADR-017):
|
|
5
|
+
- sudden_missingness:时间桶缺失率突变(3× 整体或 ≥0.2,桶样本 ≥10)
|
|
6
|
+
- group_missingness:按类别列分组的目标列缺失率异常组
|
|
7
|
+
- conditional_missingness:定向级联缺失(A 缺失时 B 缺失率 ≥0.8)
|
|
8
|
+
- correlated_missingness:双列缺失高共现(共现率 ≥0.5)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from datasentry_core.detectors.base import DetectionContext
|
|
16
|
+
from datasentry_core.detectors.common import (
|
|
17
|
+
DetectorBase,
|
|
18
|
+
datetime_columns,
|
|
19
|
+
make_candidate,
|
|
20
|
+
make_evidence,
|
|
21
|
+
quote_ident,
|
|
22
|
+
string_columns,
|
|
23
|
+
)
|
|
24
|
+
from datasentry_core.models.detector import DetectorCapabilities, IssueCandidate
|
|
25
|
+
from datasentry_core.models.enums import EvidenceType, QualityDimension, Severity
|
|
26
|
+
|
|
27
|
+
# 列对/列预算:防组合爆炸(性能预算见 ADR-017)
|
|
28
|
+
_NULL_RATE_MIN = 0.02
|
|
29
|
+
_PAIR_COL_CAP = 15
|
|
30
|
+
_PAIR_CAP = 50
|
|
31
|
+
_GROUP_COL_CAP = 10
|
|
32
|
+
_TARGET_COL_CAP = 5
|
|
33
|
+
|
|
34
|
+
# 时间桶粒度自适应:桶数不足则升级粒度
|
|
35
|
+
_BUCKET_GRANULARITIES = (("%Y-%m-%d", "day"), ("%Y-%m", "month"), ("%Y", "year"))
|
|
36
|
+
_BUCKET_MIN_COUNT = 5
|
|
37
|
+
_BUCKET_MIN_ROWS = 10
|
|
38
|
+
|
|
39
|
+
# 字符串时间列名特征(created_at / event_date 等)
|
|
40
|
+
_TIME_STR_HINTS = ("_at", "_time", "date", "time")
|
|
41
|
+
|
|
42
|
+
_ISO_DT_RE = r"^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?$"
|
|
43
|
+
|
|
44
|
+
# 阈值固定为规格默认值;可配置化经契约引擎(V1,ADR-004)接入
|
|
45
|
+
# 异常判定 = 桶/组缺失率 ≥ max(0.2, 整体 + 0.2):
|
|
46
|
+
# 相对 3× 在整体缺失率 >1/3 时会超 1 永不触发,故用绝对差(ADR-017)
|
|
47
|
+
_GROUP_MIN_RATIO = 0.2
|
|
48
|
+
_GROUP_MIN_GAP = 0.2
|
|
49
|
+
_CONDITIONAL_COVERAGE = 0.8
|
|
50
|
+
_CORRELATED_COVERAGE = 0.5
|
|
51
|
+
_MIN_COOCCURRENCE = 5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _row_count(context: DetectionContext) -> int:
|
|
55
|
+
table = context.handle.sql_aggregate("SELECT count(*) AS n FROM data").table
|
|
56
|
+
return int(table.column("n").to_pylist()[0])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _column_nulls(context: DetectionContext, column: str) -> tuple[int, int]:
|
|
60
|
+
q = quote_ident(column)
|
|
61
|
+
table = context.handle.sql_aggregate(
|
|
62
|
+
f"SELECT count(*) AS n, sum({q} IS NULL) AS nulls FROM data"
|
|
63
|
+
).table
|
|
64
|
+
total = int(table.column("n").to_pylist()[0])
|
|
65
|
+
nulls = int(table.column("nulls").to_pylist()[0])
|
|
66
|
+
return total, nulls
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _null_rates(context: DetectionContext) -> dict[str, float]:
|
|
70
|
+
"""全列 null 率(总行数统一)。"""
|
|
71
|
+
total = _row_count(context)
|
|
72
|
+
if total <= 0:
|
|
73
|
+
return {}
|
|
74
|
+
rates: dict[str, float] = {}
|
|
75
|
+
for col in context.columns:
|
|
76
|
+
_, nulls = _column_nulls(context, col)
|
|
77
|
+
rates[col] = nulls / total
|
|
78
|
+
return rates
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _sparse_columns(context: DetectionContext, rates: dict[str, float]) -> list[str]:
|
|
82
|
+
"""缺失率 ≥ _NULL_RATE_MIN 的列(截断至 _PAIR_COL_CAP,按缺失率降序)。"""
|
|
83
|
+
ordered = [c for c in context.columns if rates.get(c, 0.0) >= _NULL_RATE_MIN]
|
|
84
|
+
ordered.sort(key=lambda c: rates[c], reverse=True)
|
|
85
|
+
return ordered[:_PAIR_COL_CAP]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _anomalous_threshold(overall: float) -> float:
|
|
89
|
+
"""桶/组缺失率异常阈值(绝对差判定,见模块 docstring)。"""
|
|
90
|
+
return max(_GROUP_MIN_RATIO, overall + _GROUP_MIN_GAP)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _column_pairs(columns: list[str]) -> list[tuple[str, str]]:
|
|
94
|
+
"""有序列对(按缺失率降序的列序),截断至 _PAIR_CAP。"""
|
|
95
|
+
pairs = [(a, b) for i, a in enumerate(columns) for b in columns[i + 1 :]]
|
|
96
|
+
return pairs[:_PAIR_CAP]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CorrelatedMissingnessDetector(DetectorBase):
|
|
100
|
+
"""缺失共现(11.4):两列同时缺失的行显著(对称,共现率 ≥0.5)。"""
|
|
101
|
+
|
|
102
|
+
detector_id = "correlated_missingness"
|
|
103
|
+
display_name = "Correlated Missingness"
|
|
104
|
+
description = "Reports column pairs that are missing together."
|
|
105
|
+
quality_dimension = QualityDimension.COMPLETENESS
|
|
106
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
107
|
+
|
|
108
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
109
|
+
return len(context.columns) >= 2
|
|
110
|
+
|
|
111
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
112
|
+
rates = _null_rates(context)
|
|
113
|
+
candidates: list[IssueCandidate] = []
|
|
114
|
+
for a, b in _column_pairs(_sparse_columns(context, rates)):
|
|
115
|
+
qa, qb = quote_ident(a), quote_ident(b)
|
|
116
|
+
table = context.handle.sql_aggregate(
|
|
117
|
+
f"SELECT count(*) AS n, "
|
|
118
|
+
f"sum({qa} IS NULL AND {qb} IS NULL) AS both_null, "
|
|
119
|
+
f"sum({qa} IS NULL) AS a_null, "
|
|
120
|
+
f"sum({qb} IS NULL) AS b_null "
|
|
121
|
+
f"FROM data"
|
|
122
|
+
).table
|
|
123
|
+
both_null = int(table.column("both_null").to_pylist()[0])
|
|
124
|
+
a_null = int(table.column("a_null").to_pylist()[0])
|
|
125
|
+
b_null = int(table.column("b_null").to_pylist()[0])
|
|
126
|
+
if both_null < _MIN_COOCCURRENCE:
|
|
127
|
+
continue
|
|
128
|
+
denominator = min(a_null, b_null)
|
|
129
|
+
coverage = both_null / denominator if denominator else 0.0
|
|
130
|
+
if coverage >= _CORRELATED_COVERAGE:
|
|
131
|
+
candidates.append(
|
|
132
|
+
make_candidate(
|
|
133
|
+
detector_id=self.detector_id,
|
|
134
|
+
detector_version=self.detector_version,
|
|
135
|
+
context=context,
|
|
136
|
+
issue_type="correlated_missingness",
|
|
137
|
+
columns=[a, b],
|
|
138
|
+
affected_count=both_null,
|
|
139
|
+
evidence=[
|
|
140
|
+
make_evidence(
|
|
141
|
+
detector_id=self.detector_id,
|
|
142
|
+
detector_version=self.detector_version,
|
|
143
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
144
|
+
description=(
|
|
145
|
+
f"{both_null} rows are missing in both {a} and {b} "
|
|
146
|
+
f"(coverage={coverage:.2f})"
|
|
147
|
+
),
|
|
148
|
+
data={
|
|
149
|
+
"column_a": a,
|
|
150
|
+
"column_b": b,
|
|
151
|
+
"both_null": both_null,
|
|
152
|
+
"a_null": a_null,
|
|
153
|
+
"b_null": b_null,
|
|
154
|
+
"coverage": round(coverage, 6),
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
],
|
|
158
|
+
raw_score=coverage,
|
|
159
|
+
confidence=0.85,
|
|
160
|
+
severity=Severity.LOW,
|
|
161
|
+
fpr=0.15,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return candidates
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ConditionalMissingnessDetector(DetectorBase):
|
|
168
|
+
"""条件性缺失(11.4):A 缺失 ⟹ B 缺失 的级联(定向覆盖率 ≥0.8)。"""
|
|
169
|
+
|
|
170
|
+
detector_id = "conditional_missingness"
|
|
171
|
+
display_name = "Conditional Missingness"
|
|
172
|
+
description = "Reports columns whose values are missing whenever another is."
|
|
173
|
+
quality_dimension = QualityDimension.COMPLETENESS
|
|
174
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
175
|
+
|
|
176
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
177
|
+
return len(context.columns) >= 2
|
|
178
|
+
|
|
179
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
180
|
+
rates = _null_rates(context)
|
|
181
|
+
candidates: list[IssueCandidate] = []
|
|
182
|
+
for a, b in _column_pairs(_sparse_columns(context, rates)):
|
|
183
|
+
qa, qb = quote_ident(a), quote_ident(b)
|
|
184
|
+
table = context.handle.sql_aggregate(
|
|
185
|
+
f"SELECT count(*) AS n, "
|
|
186
|
+
f"sum({qa} IS NULL AND {qb} IS NULL) AS both_null, "
|
|
187
|
+
f"sum({qa} IS NULL) AS a_null "
|
|
188
|
+
f"FROM data"
|
|
189
|
+
).table
|
|
190
|
+
both_null = int(table.column("both_null").to_pylist()[0])
|
|
191
|
+
a_null = int(table.column("a_null").to_pylist()[0])
|
|
192
|
+
if a_null < 10:
|
|
193
|
+
continue
|
|
194
|
+
coverage = both_null / a_null
|
|
195
|
+
if coverage >= _CONDITIONAL_COVERAGE:
|
|
196
|
+
candidates.append(
|
|
197
|
+
make_candidate(
|
|
198
|
+
detector_id=self.detector_id,
|
|
199
|
+
detector_version=self.detector_version,
|
|
200
|
+
context=context,
|
|
201
|
+
issue_type="conditional_missingness",
|
|
202
|
+
columns=[a, b],
|
|
203
|
+
affected_count=both_null,
|
|
204
|
+
evidence=[
|
|
205
|
+
make_evidence(
|
|
206
|
+
detector_id=self.detector_id,
|
|
207
|
+
detector_version=self.detector_version,
|
|
208
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
209
|
+
description=(
|
|
210
|
+
f"{both_null} of {a_null} missing rows of {a} are also "
|
|
211
|
+
f"missing in {b} (coverage={coverage:.2f})"
|
|
212
|
+
),
|
|
213
|
+
data={
|
|
214
|
+
"condition_column": a,
|
|
215
|
+
"target_column": b,
|
|
216
|
+
"both_null": both_null,
|
|
217
|
+
"condition_null": a_null,
|
|
218
|
+
"coverage": round(coverage, 6),
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
],
|
|
222
|
+
raw_score=coverage,
|
|
223
|
+
confidence=0.85,
|
|
224
|
+
severity=Severity.MEDIUM,
|
|
225
|
+
fpr=0.15,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return candidates
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _distinct_counts(context: DetectionContext, columns: list[str]) -> dict[str, int]:
|
|
232
|
+
counts: dict[str, int] = {}
|
|
233
|
+
for col in columns:
|
|
234
|
+
q = quote_ident(col)
|
|
235
|
+
table = context.handle.sql_aggregate(
|
|
236
|
+
f"SELECT count(DISTINCT {q}) AS n FROM data WHERE {q} IS NOT NULL"
|
|
237
|
+
).table
|
|
238
|
+
counts[col] = int(table.column("n").to_pylist()[0])
|
|
239
|
+
return counts
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _group_columns(context: DetectionContext) -> list[str]:
|
|
243
|
+
"""类别分组列候选:字符串列中 distinct ∈ [2, 50](截断 _GROUP_COL_CAP)。"""
|
|
244
|
+
cols = [c for c in string_columns(context) if len(c) <= 64]
|
|
245
|
+
if len(cols) > _GROUP_COL_CAP:
|
|
246
|
+
cols = cols[:_GROUP_COL_CAP]
|
|
247
|
+
counts = _distinct_counts(context, cols)
|
|
248
|
+
return [c for c in cols if 2 <= counts[c] <= 50]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class GroupMissingnessDetector(DetectorBase):
|
|
252
|
+
"""分组缺失(11.4):按类别列分组后目标列缺失率异常组(≥max(0.2, 3×整体))。"""
|
|
253
|
+
|
|
254
|
+
detector_id = "group_missingness"
|
|
255
|
+
display_name = "Grouped Missingness"
|
|
256
|
+
description = "Reports groups whose values are missing much more than usual."
|
|
257
|
+
quality_dimension = QualityDimension.COMPLETENESS
|
|
258
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
259
|
+
|
|
260
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
261
|
+
return len(context.columns) >= 2
|
|
262
|
+
|
|
263
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
264
|
+
rates = _null_rates(context)
|
|
265
|
+
targets = [c for c in context.columns if rates.get(c, 0.0) > 0.0]
|
|
266
|
+
group_cols = [c for c in _group_columns(context) if c not in targets]
|
|
267
|
+
candidates: list[IssueCandidate] = []
|
|
268
|
+
for group_col in group_cols:
|
|
269
|
+
for target in targets:
|
|
270
|
+
if len(candidates) >= _TARGET_COL_CAP * len(group_cols):
|
|
271
|
+
break
|
|
272
|
+
qg, qt = quote_ident(group_col), quote_ident(target)
|
|
273
|
+
overall = rates.get(target, 0.0)
|
|
274
|
+
table = context.handle.sql_aggregate(
|
|
275
|
+
f"SELECT {qg} AS gv, count(*) AS n, sum({qt} IS NULL) AS nulls "
|
|
276
|
+
f"FROM data GROUP BY 1"
|
|
277
|
+
).table
|
|
278
|
+
for row in table.to_pylist():
|
|
279
|
+
n = int(row["n"])
|
|
280
|
+
nulls = int(row["nulls"])
|
|
281
|
+
ratio = nulls / n if n else 0.0
|
|
282
|
+
if n < _BUCKET_MIN_ROWS or ratio < _anomalous_threshold(overall):
|
|
283
|
+
continue
|
|
284
|
+
candidates.append(
|
|
285
|
+
make_candidate(
|
|
286
|
+
detector_id=self.detector_id,
|
|
287
|
+
detector_version=self.detector_version,
|
|
288
|
+
context=context,
|
|
289
|
+
issue_type="group_missingness",
|
|
290
|
+
columns=[target],
|
|
291
|
+
affected_count=nulls,
|
|
292
|
+
evidence=[
|
|
293
|
+
make_evidence(
|
|
294
|
+
detector_id=self.detector_id,
|
|
295
|
+
detector_version=self.detector_version,
|
|
296
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
297
|
+
description=(
|
|
298
|
+
f"{target} missing in {nulls}/{n} rows where "
|
|
299
|
+
f"{group_col}={row['gv']!r} (overall {overall:.4f})"
|
|
300
|
+
),
|
|
301
|
+
data={
|
|
302
|
+
"group_column": group_col,
|
|
303
|
+
"group_value": str(row["gv"]),
|
|
304
|
+
"target_column": target,
|
|
305
|
+
"nulls": nulls,
|
|
306
|
+
"group_size": n,
|
|
307
|
+
"null_ratio": round(ratio, 6),
|
|
308
|
+
"overall_ratio": round(overall, 6),
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
],
|
|
312
|
+
raw_score=ratio,
|
|
313
|
+
confidence=0.85,
|
|
314
|
+
severity=Severity.MEDIUM,
|
|
315
|
+
fpr=0.15,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return candidates
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _time_string_columns(context: DetectionContext) -> list[str]:
|
|
322
|
+
"""字符串时间列候选(列名含 _at/_time/date 特征)。"""
|
|
323
|
+
return [
|
|
324
|
+
col for col in string_columns(context) if any(h in col.lower() for h in _TIME_STR_HINTS)
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _bucket_sql(column: str, granularity: str) -> str:
|
|
329
|
+
q = quote_ident(column)
|
|
330
|
+
return f"strftime({q}, '{granularity}') AS bucket"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _time_bucket_counts(
|
|
334
|
+
context: DetectionContext, column: str, target: str, granularity: str
|
|
335
|
+
) -> tuple[list[dict[str, int | str]], int]:
|
|
336
|
+
"""按桶统计(target 缺失行数),返回 (桶列表, 有效桶数)。"""
|
|
337
|
+
qt = quote_ident(target)
|
|
338
|
+
bucket_expr = _bucket_sql(column, granularity)
|
|
339
|
+
table = context.handle.sql_aggregate(
|
|
340
|
+
f"SELECT {bucket_expr}, count(*) AS n, sum({qt} IS NULL) AS nulls FROM data GROUP BY 1"
|
|
341
|
+
).table
|
|
342
|
+
buckets: list[dict[str, int | str]] = []
|
|
343
|
+
for row in table.to_pylist():
|
|
344
|
+
if row["bucket"] is None:
|
|
345
|
+
continue
|
|
346
|
+
buckets.append(
|
|
347
|
+
{"bucket": str(row["bucket"]), "n": int(row["n"]), "nulls": int(row["nulls"])}
|
|
348
|
+
)
|
|
349
|
+
return buckets, len(buckets)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class SuddenMissingnessDetector(DetectorBase):
|
|
353
|
+
"""突变缺失(11.4):时间桶缺失率骤增(≥max(0.2, 3×整体),桶样本 ≥10)。"""
|
|
354
|
+
|
|
355
|
+
detector_id = "sudden_missingness"
|
|
356
|
+
display_name = "Sudden Missingness"
|
|
357
|
+
description = "Reports time buckets where missingness spikes abruptly."
|
|
358
|
+
quality_dimension = QualityDimension.COMPLETENESS
|
|
359
|
+
capabilities: ClassVar[DetectorCapabilities] = DetectorCapabilities(supports_sql_pushdown=True)
|
|
360
|
+
|
|
361
|
+
def supports(self, context: DetectionContext) -> bool:
|
|
362
|
+
return bool(datetime_columns(context) or _time_string_columns(context))
|
|
363
|
+
|
|
364
|
+
def detect(self, context: DetectionContext) -> list[IssueCandidate]:
|
|
365
|
+
rates = _null_rates(context)
|
|
366
|
+
targets = [c for c in context.columns if rates.get(c, 0.0) >= 0.01]
|
|
367
|
+
targets = targets[:_TARGET_COL_CAP] if targets else []
|
|
368
|
+
time_cols = datetime_columns(context) + _time_string_columns(context)
|
|
369
|
+
if not time_cols or not targets:
|
|
370
|
+
return []
|
|
371
|
+
candidates: list[IssueCandidate] = []
|
|
372
|
+
for time_col in time_cols:
|
|
373
|
+
for target in targets:
|
|
374
|
+
overall = rates.get(target, 0.0)
|
|
375
|
+
granularity = _BUCKET_GRANULARITIES[0][0]
|
|
376
|
+
buckets, count = _time_bucket_counts(context, time_col, target, granularity)
|
|
377
|
+
if count < _BUCKET_MIN_COUNT:
|
|
378
|
+
granularity = _BUCKET_GRANULARITIES[1][0]
|
|
379
|
+
buckets, count = _time_bucket_counts(context, time_col, target, granularity)
|
|
380
|
+
if count < _BUCKET_MIN_COUNT:
|
|
381
|
+
granularity = _BUCKET_GRANULARITIES[2][0]
|
|
382
|
+
buckets, count = _time_bucket_counts(context, time_col, target, granularity)
|
|
383
|
+
threshold = _anomalous_threshold(overall)
|
|
384
|
+
for b in buckets:
|
|
385
|
+
n, nulls = int(b["n"]), int(b["nulls"])
|
|
386
|
+
ratio = nulls / n if n else 0.0
|
|
387
|
+
if n < _BUCKET_MIN_ROWS or ratio < threshold:
|
|
388
|
+
continue
|
|
389
|
+
candidates.append(
|
|
390
|
+
make_candidate(
|
|
391
|
+
detector_id=self.detector_id,
|
|
392
|
+
detector_version=self.detector_version,
|
|
393
|
+
context=context,
|
|
394
|
+
issue_type="sudden_missingness",
|
|
395
|
+
columns=[target],
|
|
396
|
+
affected_count=nulls,
|
|
397
|
+
evidence=[
|
|
398
|
+
make_evidence(
|
|
399
|
+
detector_id=self.detector_id,
|
|
400
|
+
detector_version=self.detector_version,
|
|
401
|
+
evidence_type=EvidenceType.STATISTICAL_MEASURE,
|
|
402
|
+
description=(
|
|
403
|
+
f"{target} missing in {nulls}/{n} rows in "
|
|
404
|
+
f"bucket {b['bucket']} (overall {overall:.4f})"
|
|
405
|
+
),
|
|
406
|
+
data={
|
|
407
|
+
"time_column": time_col,
|
|
408
|
+
"bucket": str(b["bucket"]),
|
|
409
|
+
"granularity": granularity,
|
|
410
|
+
"target_column": target,
|
|
411
|
+
"nulls": nulls,
|
|
412
|
+
"bucket_size": n,
|
|
413
|
+
"null_ratio": round(ratio, 6),
|
|
414
|
+
"overall_ratio": round(overall, 6),
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
],
|
|
418
|
+
raw_score=ratio,
|
|
419
|
+
confidence=0.9,
|
|
420
|
+
severity=Severity.MEDIUM,
|
|
421
|
+
fpr=0.15,
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
return candidates
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""扫描调度(Step 7 配套):跑检测器 → DetectorRun 记录 → 融合 → 评分 → ScanRun 组装。
|
|
2
|
+
|
|
3
|
+
Step 35:ScanConfig.custom_rules(契约 rules)接入扫描主流程——逐规则
|
|
4
|
+
run_preflight,违规行生成 IssueCandidate 参与融合(issue_type=rule.id)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from datasentry_core import __version__
|
|
16
|
+
from datasentry_core.detectors.base import (
|
|
17
|
+
DetectionContext,
|
|
18
|
+
Detector,
|
|
19
|
+
DetectorRegistry,
|
|
20
|
+
filter_by_config,
|
|
21
|
+
)
|
|
22
|
+
from datasentry_core.detectors.common import make_candidate, make_evidence
|
|
23
|
+
from datasentry_core.engine.fusion import EvidenceFusionEngine
|
|
24
|
+
from datasentry_core.models.detector import IssueCandidate
|
|
25
|
+
from datasentry_core.models.enums import EvidenceType, Severity
|
|
26
|
+
from datasentry_core.models.issue import Issue
|
|
27
|
+
from datasentry_core.models.rules import Rule
|
|
28
|
+
from datasentry_core.models.scan import DetectorRun, ReproducibilityInfo, ScanConfig, ScanRun
|
|
29
|
+
from datasentry_core.rules.engine import run_preflight
|
|
30
|
+
from datasentry_core.scoring import ScoringEngine
|
|
31
|
+
from datasentry_core.scoring.quality import QualityScoreEngine
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _count_by_severity(issues: list[Issue]) -> dict[Severity, int]:
|
|
35
|
+
"""各严重度 Issue 计数(ScanRun.issues_count,18.2)。"""
|
|
36
|
+
counts = Counter(issue.severity for issue in issues)
|
|
37
|
+
return {severity: counts.get(severity, 0) for severity in Severity}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ScanRunner:
|
|
41
|
+
"""单数据集扫描器:支持过滤 → detect → DetectorRun → 融合 → 评分。"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, registry: DetectorRegistry) -> None:
|
|
44
|
+
self._registry = registry
|
|
45
|
+
self._fusion = EvidenceFusionEngine()
|
|
46
|
+
self._scoring = ScoringEngine()
|
|
47
|
+
self._quality = QualityScoreEngine()
|
|
48
|
+
|
|
49
|
+
def run(
|
|
50
|
+
self,
|
|
51
|
+
context: DetectionContext,
|
|
52
|
+
config: ScanConfig,
|
|
53
|
+
scan_run_id: str,
|
|
54
|
+
) -> tuple[list[DetectorRun], list[Issue]]:
|
|
55
|
+
detectors = filter_by_config(self._registry.list_active(), config.detectors)
|
|
56
|
+
runs: list[DetectorRun] = []
|
|
57
|
+
candidates: list[IssueCandidate] = []
|
|
58
|
+
for detector in detectors:
|
|
59
|
+
run = self._run_detector(detector, context, scan_run_id)
|
|
60
|
+
runs.append(run)
|
|
61
|
+
if run.status == "completed":
|
|
62
|
+
candidates.extend(detector.detect(context))
|
|
63
|
+
if config.custom_rules:
|
|
64
|
+
runs.append(
|
|
65
|
+
self._run_contract_rules(context, config.custom_rules, candidates, scan_run_id)
|
|
66
|
+
)
|
|
67
|
+
fused = self._fusion.fuse(candidates, scan_run_id, row_count=context.handle.count_rows())
|
|
68
|
+
issues = [self._scoring.apply(issue) for issue in fused]
|
|
69
|
+
return runs, issues
|
|
70
|
+
|
|
71
|
+
def _run_contract_rules(
|
|
72
|
+
self,
|
|
73
|
+
context: DetectionContext,
|
|
74
|
+
rules: list[Rule],
|
|
75
|
+
candidates: list[IssueCandidate],
|
|
76
|
+
scan_run_id: str,
|
|
77
|
+
) -> DetectorRun:
|
|
78
|
+
"""契约规则执行(Step 35):逐规则 run_preflight,违规进融合。
|
|
79
|
+
|
|
80
|
+
schema/表达式无效的规则记为该 DetectorRun 失败(scan 随之 failed)。
|
|
81
|
+
"""
|
|
82
|
+
started = time.monotonic()
|
|
83
|
+
issues_candidates = len(rules)
|
|
84
|
+
failed_rules: list[str] = []
|
|
85
|
+
for rule in rules:
|
|
86
|
+
report = run_preflight(rule, context.handle)
|
|
87
|
+
if not report.valid:
|
|
88
|
+
reason = "missing columns" if not report.schema_valid else "invalid expression"
|
|
89
|
+
failed_rules.append(f"{rule.id}: {reason}")
|
|
90
|
+
continue
|
|
91
|
+
sample = report.sample_run
|
|
92
|
+
if sample is None or sample.failures <= 0:
|
|
93
|
+
continue
|
|
94
|
+
evidence = [
|
|
95
|
+
make_evidence(
|
|
96
|
+
detector_id="contract_rule",
|
|
97
|
+
detector_version=str(rule.version),
|
|
98
|
+
evidence_type=EvidenceType.RULE_VIOLATION,
|
|
99
|
+
description=(
|
|
100
|
+
f"rule {rule.id}: {sample.failures} of {sample.rows_tested} rows "
|
|
101
|
+
f"violate ({sample.failure_ratio:.4f})"
|
|
102
|
+
),
|
|
103
|
+
data={
|
|
104
|
+
"rule_id": rule.id,
|
|
105
|
+
"rule_version": rule.version,
|
|
106
|
+
"failures": sample.failures,
|
|
107
|
+
"rows_tested": sample.rows_tested,
|
|
108
|
+
"examples": sample.example_rows[:5],
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
candidates.append(
|
|
113
|
+
make_candidate(
|
|
114
|
+
detector_id="contract_rule",
|
|
115
|
+
detector_version=str(rule.version),
|
|
116
|
+
context=context,
|
|
117
|
+
issue_type=f"rule:{rule.id}",
|
|
118
|
+
columns=[rule.when.column] if rule.when else list(rule.columns),
|
|
119
|
+
affected_count=sample.failures,
|
|
120
|
+
affected_rows=None,
|
|
121
|
+
evidence=evidence,
|
|
122
|
+
raw_score=sample.failure_ratio,
|
|
123
|
+
confidence=0.95,
|
|
124
|
+
severity=rule.severity,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
status: Literal["completed", "failed"] = "failed" if failed_rules else "completed"
|
|
128
|
+
error = "; ".join(failed_rules) if failed_rules else None
|
|
129
|
+
return DetectorRun(
|
|
130
|
+
id=f"det_{uuid.uuid4().hex[:12]}",
|
|
131
|
+
scan_run_id=scan_run_id,
|
|
132
|
+
detector_id="contract_rule",
|
|
133
|
+
detector_version="1.0",
|
|
134
|
+
status=status,
|
|
135
|
+
rows_scanned=context.handle.count_rows(),
|
|
136
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
137
|
+
issues_candidates=issues_candidates,
|
|
138
|
+
error=error,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def run_scan(
|
|
142
|
+
self,
|
|
143
|
+
context: DetectionContext,
|
|
144
|
+
config: ScanConfig | None = None,
|
|
145
|
+
) -> tuple[ScanRun, list[DetectorRun], list[Issue]]:
|
|
146
|
+
"""完整扫描入口:跑检测器 → 融合 → 评分 → 组装 ScanRun(18.2)。
|
|
147
|
+
|
|
148
|
+
返回 (scan_run, detector_runs, issues),便于调用方持久化与展示。
|
|
149
|
+
"""
|
|
150
|
+
config = config or ScanConfig()
|
|
151
|
+
scan_run_id = f"scan_{uuid.uuid4().hex[:12]}"
|
|
152
|
+
started_at = datetime.now(UTC)
|
|
153
|
+
runs, issues = self.run(context, config, scan_run_id)
|
|
154
|
+
failed = [r for r in runs if r.status == "failed"]
|
|
155
|
+
status: Literal["completed", "failed"] = "failed" if failed else "completed"
|
|
156
|
+
error = "; ".join(f"{r.detector_id}: {r.error}" for r in failed) if failed else None
|
|
157
|
+
ran_dimensions = set()
|
|
158
|
+
for run in runs:
|
|
159
|
+
if run.status != "completed":
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
detector = self._registry.get(run.detector_id)
|
|
163
|
+
except KeyError:
|
|
164
|
+
continue # 契约规则 run(非注册检测器)不参与维度统计
|
|
165
|
+
ran_dimensions.add(detector.quality_dimension)
|
|
166
|
+
try:
|
|
167
|
+
quality_score = self._quality.score(issues, ran_dimensions=ran_dimensions)
|
|
168
|
+
except ValueError: # 无任何检测器运行(空白名单)→ 未评分
|
|
169
|
+
quality_score = None
|
|
170
|
+
scan_run = ScanRun(
|
|
171
|
+
id=scan_run_id,
|
|
172
|
+
dataset_id=context.dataset_id,
|
|
173
|
+
status=status,
|
|
174
|
+
config=config,
|
|
175
|
+
fingerprint=context.handle.fingerprint(),
|
|
176
|
+
issues_count=_count_by_severity(issues),
|
|
177
|
+
started_at=started_at,
|
|
178
|
+
finished_at=datetime.now(UTC),
|
|
179
|
+
error=error,
|
|
180
|
+
quality_score=quality_score,
|
|
181
|
+
reproducibility=ReproducibilityInfo(
|
|
182
|
+
datasentry_version=__version__,
|
|
183
|
+
detector_versions={r.detector_id: r.detector_version for r in runs},
|
|
184
|
+
seed=config.seed,
|
|
185
|
+
scanned_at=datetime.now(UTC),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
return scan_run, runs, issues
|
|
189
|
+
|
|
190
|
+
def _run_detector(
|
|
191
|
+
self, detector: Detector, context: DetectionContext, scan_run_id: str
|
|
192
|
+
) -> DetectorRun:
|
|
193
|
+
started = time.perf_counter()
|
|
194
|
+
try:
|
|
195
|
+
if not detector.supports(context):
|
|
196
|
+
return DetectorRun(
|
|
197
|
+
id=f"dr_{uuid.uuid4().hex[:12]}",
|
|
198
|
+
scan_run_id=scan_run_id,
|
|
199
|
+
detector_id=detector.detector_id,
|
|
200
|
+
detector_version=detector.detector_version,
|
|
201
|
+
status="skipped",
|
|
202
|
+
rows_scanned=0,
|
|
203
|
+
duration_ms=0,
|
|
204
|
+
issues_candidates=0,
|
|
205
|
+
)
|
|
206
|
+
candidates = detector.detect(context)
|
|
207
|
+
return DetectorRun(
|
|
208
|
+
id=f"dr_{uuid.uuid4().hex[:12]}",
|
|
209
|
+
scan_run_id=scan_run_id,
|
|
210
|
+
detector_id=detector.detector_id,
|
|
211
|
+
detector_version=detector.detector_version,
|
|
212
|
+
status="completed",
|
|
213
|
+
rows_scanned=context.handle.count_rows(),
|
|
214
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
215
|
+
issues_candidates=len(candidates),
|
|
216
|
+
)
|
|
217
|
+
except Exception as exc: # 单检测器失败不中断整次扫描
|
|
218
|
+
return DetectorRun(
|
|
219
|
+
id=f"dr_{uuid.uuid4().hex[:12]}",
|
|
220
|
+
scan_run_id=scan_run_id,
|
|
221
|
+
detector_id=detector.detector_id,
|
|
222
|
+
detector_version=detector.detector_version,
|
|
223
|
+
status="failed",
|
|
224
|
+
rows_scanned=0,
|
|
225
|
+
duration_ms=int((time.perf_counter() - started) * 1000),
|
|
226
|
+
issues_candidates=0,
|
|
227
|
+
error=str(exc),
|
|
228
|
+
)
|