dataassay 0.7.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.
dataassay/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """dataassay — audit a tabular dataset.
2
+
3
+ An assay characterizes a sample before it makes any claim about it. This tool
4
+ works in that order too: establish what kind of thing each column is, run only
5
+ the checks that property makes valid, and report what could NOT be checked as
6
+ plainly as what failed.
7
+ """
8
+
9
+ __version__ = "0.7.0"
10
+
11
+ __all__ = ["__version__"]
dataassay/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from dataassay.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
dataassay/audit.py ADDED
@@ -0,0 +1,166 @@
1
+ """Orchestration: profile, infer structure, gate the checks, rank what survives.
2
+
3
+ Coverage is a first-class output, not a footnote. A reader cannot interpret an
4
+ empty findings list without knowing how many checks ran, which were withheld,
5
+ and which are waiting on an answer -- zero findings at 30% coverage and zero at
6
+ 95% are entirely different objects, and one number cannot tell them apart.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import duckdb
15
+
16
+ from dataassay import manifest as manifest_mod
17
+ from dataassay import structure as structure_mod
18
+ from dataassay.checks.base import Confidence, Finding
19
+ from dataassay.checks.registry import CATALOG, CATALOG_VERSION
20
+ from dataassay.profile import Profile, build
21
+ from dataassay.provenance import reader_for, source_expr
22
+ from dataassay.structure import Structure
23
+
24
+
25
+ @dataclass
26
+ class Coverage:
27
+ ran: list[str] = field(default_factory=list)
28
+ withheld: list[tuple[str, str]] = field(default_factory=list)
29
+ blocked: list[tuple[str, str]] = field(default_factory=list)
30
+
31
+ @property
32
+ def total(self) -> int:
33
+ return len(self.ran) + len(self.withheld) + len(self.blocked)
34
+
35
+ def to_dict(self) -> dict:
36
+ return {
37
+ "checks_total": self.total,
38
+ "ran": self.ran,
39
+ "withheld": [{"check": c, "reason": r} for c, r in self.withheld],
40
+ "blocked": [{"check": c, "question": qn} for c, qn in self.blocked],
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class Audit:
46
+ profile: Profile
47
+ structure: Structure
48
+ findings: list[Finding]
49
+ coverage: Coverage
50
+ catalog_version: str = CATALOG_VERSION
51
+ manifest_path: str | None = None
52
+ # Question codes a person has seen and deliberately declined to answer.
53
+ # An unanswered question and a declined one are different states and the
54
+ # report must not show them as the same thing: the first is work nobody has
55
+ # done yet, the second is a decision, and presenting a decision as an
56
+ # outstanding task is how a report trains people to ignore it.
57
+ skipped_questions: list[str] = field(default_factory=list)
58
+
59
+ @property
60
+ def open_questions(self) -> list:
61
+ return [q for q in self.profile.questions
62
+ if q.code not in set(self.skipped_questions)]
63
+
64
+ @property
65
+ def declined_questions(self) -> list:
66
+ return [q for q in self.profile.questions
67
+ if q.code in set(self.skipped_questions)]
68
+
69
+ def to_dict(self) -> dict:
70
+ d = self.profile.to_dict()
71
+ d |= {
72
+ "catalog_version": self.catalog_version,
73
+ "structure": self.structure.to_dict(),
74
+ "coverage": self.coverage.to_dict(),
75
+ "manifest": self.manifest_path,
76
+ "skipped_questions": self.skipped_questions,
77
+ "findings": [f.to_dict() for f in self.findings],
78
+ }
79
+ return d
80
+
81
+
82
+ def _corroborate(findings: list[Finding]) -> list[Finding]:
83
+ """Promote findings that two independent checks agree on.
84
+
85
+ Agreement is the strongest confidence signal available, and the only one
86
+ that does not depend on trusting a single detector's threshold. A column
87
+ flagged by both a saturation check and a level-shift check is real in a way
88
+ that either alone is not.
89
+ """
90
+ by_column: dict[str, set[str]] = {}
91
+ for f in findings:
92
+ if f.column:
93
+ by_column.setdefault(f.column, set()).add(f.check_id)
94
+
95
+ out = []
96
+ for f in findings:
97
+ others = by_column.get(f.column or "", set()) - {f.check_id}
98
+ if others and f.confidence.level != "high":
99
+ f = Finding(
100
+ check_id=f.check_id,
101
+ column=f.column,
102
+ disposition=f.disposition,
103
+ summary=f.summary,
104
+ evidence=f.evidence,
105
+ predicate=f.predicate,
106
+ confidence=f.confidence.corroborated_by(", ".join(sorted(others))),
107
+ raw_values=f.raw_values,
108
+ )
109
+ out.append(f)
110
+ return out
111
+
112
+
113
+ def run(
114
+ path: Path,
115
+ byte_cap: int | None = None,
116
+ manifest_path: Path | None = None,
117
+ use_manifest: bool = True,
118
+ ) -> Audit:
119
+ from dataassay.checks.base import CheckContext
120
+ from dataassay.rawscan import BYTE_CAP
121
+
122
+ reader = reader_for(path)
123
+ con = duckdb.connect(":memory:")
124
+ try:
125
+ profile = build(path, byte_cap=byte_cap or BYTE_CAP, con=con)
126
+ source = source_expr(reader, profile.provenance.read_mode)
127
+ params = [str(path)]
128
+ manifest = (
129
+ manifest_mod.discover(path, manifest_path) if use_manifest else None
130
+ )
131
+ struct = structure_mod.infer(
132
+ profile.columns, con, source, params, profile.provenance.row_count,
133
+ manifest=manifest,
134
+ )
135
+ ctx = CheckContext(
136
+ profile=profile, structure=struct, con=con, source=source,
137
+ params=params, manifest=manifest,
138
+ )
139
+
140
+ coverage = Coverage()
141
+ findings: list[Finding] = []
142
+ for check in CATALOG:
143
+ verdict = check.applies(ctx)
144
+ if not verdict.applicable:
145
+ if verdict.blocked:
146
+ coverage.blocked.append((check.spec.id, verdict.reason))
147
+ else:
148
+ coverage.withheld.append((check.spec.id, verdict.reason))
149
+ continue
150
+ coverage.ran.append(check.spec.id)
151
+ findings.extend(check.run(ctx))
152
+ finally:
153
+ con.close()
154
+
155
+ findings = _corroborate(findings)
156
+ findings.sort(key=lambda f: f.sort_key)
157
+ return Audit(
158
+ profile=profile, structure=struct, findings=findings, coverage=coverage,
159
+ manifest_path=(
160
+ str(manifest.source_path) if manifest and manifest.source_path else None
161
+ ),
162
+ skipped_questions=list(manifest.skipped) if manifest else [],
163
+ )
164
+
165
+
166
+ __all__ = ["Audit", "Coverage", "Confidence", "run"]
@@ -0,0 +1,217 @@
1
+ """What a check is, and what it is allowed to claim.
2
+
3
+ Three ideas carry the design:
4
+
5
+ A check is GATED. It declares the properties it needs, and the engine refuses
6
+ to run it when the profile has not established them. A sigma rule on a
7
+ tail-inflated column is not a weak check, it is an invalid one, and the way to
8
+ stop it producing confident nonsense is to never let it run.
9
+
10
+ A check that could not run is REPORTED. Withheld and blocked checks travel
11
+ beside the findings, because "seasonality undetermined -- 1.2 cycles of
12
+ history" is something the reader needs in order to know what the silence
13
+ means.
14
+
15
+ A finding carries its own PREDICATE. Whatever produced it must be re-runnable
16
+ by the person reading it, without our code. An audit nobody can reproduce is
17
+ an opinion.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import TYPE_CHECKING, Protocol
24
+
25
+ if TYPE_CHECKING:
26
+ import duckdb
27
+
28
+ from dataassay.columns import ColumnProfile
29
+ from dataassay.manifest import Manifest
30
+ from dataassay.profile import Profile
31
+ from dataassay.structure import Structure
32
+
33
+ # -- disposition ---------------------------------------------------------------
34
+ # Most anomalies in real data are the source's own bookkeeping. A tool that
35
+ # cannot say so gets ignored within a week, so the disposition is part of the
36
+ # finding rather than left to the reader.
37
+ DEFECT = "defect" # very likely wrong
38
+ SUSPECT = "suspect" # wrong-looking, but a benign explanation exists
39
+ BOOKKEEPING = "bookkeeping" # the source doing something legitimate
40
+
41
+ SEVERITY_ORDER = {DEFECT: 0, SUSPECT: 1, BOOKKEEPING: 2}
42
+
43
+ # -- confidence ----------------------------------------------------------------
44
+ HIGH = "high"
45
+ MEDIUM = "medium"
46
+ LOW = "low"
47
+
48
+ _CONFIDENCE_ORDER = {HIGH: 0, MEDIUM: 1, LOW: 2}
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Confidence:
53
+ """Never an opaque score. Each input is named and shown.
54
+
55
+ Agreement between independent checks is the strongest signal available and
56
+ is applied by the engine after all checks have run -- a point flagged by two
57
+ unrelated detectors is real in a way that one 3-sigma hit never is.
58
+ """
59
+
60
+ level: str
61
+ inputs: list[str] = field(default_factory=list)
62
+
63
+ def to_dict(self) -> dict:
64
+ return {"level": self.level, "inputs": list(self.inputs)}
65
+
66
+ def corroborated_by(self, other_check: str) -> Confidence:
67
+ promoted = HIGH if self.level == MEDIUM else self.level
68
+ return Confidence(
69
+ level=promoted,
70
+ inputs=[*self.inputs, f"independently flagged by {other_check}"],
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Finding:
76
+ check_id: str
77
+ column: str | None
78
+ disposition: str
79
+ summary: str
80
+ evidence: dict
81
+ predicate: str
82
+ confidence: Confidence
83
+ # Set when `evidence` carries actual cell values. Acting on a finding
84
+ # usually requires seeing the offending key, so the report shows them -- but
85
+ # anything crossing a network boundary later has to know they are in there.
86
+ raw_values: bool = False
87
+
88
+ @property
89
+ def sort_key(self) -> tuple[int, int]:
90
+ return (
91
+ SEVERITY_ORDER.get(self.disposition, 9),
92
+ _CONFIDENCE_ORDER.get(self.confidence.level, 9),
93
+ )
94
+
95
+ def to_dict(self) -> dict:
96
+ return {
97
+ "check_id": self.check_id,
98
+ "column": self.column,
99
+ "disposition": self.disposition,
100
+ "summary": self.summary,
101
+ "evidence": self.evidence,
102
+ "predicate": self.predicate,
103
+ "confidence": self.confidence.to_dict(),
104
+ "raw_values": self.raw_values,
105
+ }
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Applicability:
110
+ """Why a check will or will not run here.
111
+
112
+ `blocked` distinguishes the two kinds of no. A withheld check is impossible
113
+ on this data and nothing can change that; a blocked one is waiting on an
114
+ answer, and so belongs in the interview queue rather than the limitations
115
+ list.
116
+ """
117
+
118
+ applicable: bool
119
+ reason: str = ""
120
+ blocked: bool = False
121
+
122
+ @staticmethod
123
+ def yes() -> Applicability:
124
+ return Applicability(True)
125
+
126
+ @staticmethod
127
+ def no(reason: str) -> Applicability:
128
+ return Applicability(False, reason)
129
+
130
+ @staticmethod
131
+ def needs_answer(question: str) -> Applicability:
132
+ return Applicability(False, question, blocked=True)
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class CheckSpec:
137
+ """The catalog entry. This is the part that is worth more than the code.
138
+
139
+ `not_the_obvious` exists because the most expensive lesson in this corpus is
140
+ that the obvious detector is often the wrong one: counting states finds
141
+ nothing in a panel where absence is encoded as zero, and watching zeros move
142
+ finds nothing in a ragged panel. Where a check has a near neighbour that
143
+ fails, the catalog says so.
144
+ """
145
+
146
+ id: str
147
+ name: str
148
+ detects: str
149
+ gate: str
150
+ default_disposition: str
151
+ not_the_obvious: str = ""
152
+ traces_to: str = "" # the real finding that earned this check a place
153
+
154
+ def to_dict(self) -> dict:
155
+ return {
156
+ "id": self.id,
157
+ "name": self.name,
158
+ "detects": self.detects,
159
+ "gate": self.gate,
160
+ "default_disposition": self.default_disposition,
161
+ "not_the_obvious": self.not_the_obvious,
162
+ "traces_to": self.traces_to,
163
+ }
164
+
165
+
166
+ @dataclass
167
+ class CheckContext:
168
+ """Everything a check is allowed to look at.
169
+
170
+ The connection is shared: checks run against one open handle over the same
171
+ source, so a file larger than memory is not re-opened per check.
172
+ """
173
+
174
+ profile: Profile
175
+ structure: Structure
176
+ con: duckdb.DuckDBPyConnection
177
+ source: str
178
+ params: list[str]
179
+ manifest: Manifest | None = None
180
+
181
+ def columns(self, kind: str | None = None) -> list[ColumnProfile]:
182
+ cols = self.profile.columns
183
+ return [c for c in cols if kind is None or c.kind == kind]
184
+
185
+ def column(self, name: str) -> ColumnProfile | None:
186
+ return next((c for c in self.profile.columns if c.name == name), None)
187
+
188
+ def holds(self, column: str, prop: str) -> bool:
189
+ """Has the profile established `prop` for this column?"""
190
+ col = self.column(column)
191
+ if col is None:
192
+ return False
193
+ return any(
194
+ p["property"] == prop and p["holds"] for p in col.observed_properties()
195
+ )
196
+
197
+ def fetch(self, sql: str) -> list[tuple]:
198
+ """Run SQL against the source, binding the path once per reference.
199
+
200
+ A check that names the source twice needs the parameter twice, and
201
+ forgetting that surfaces as an opaque binder error rather than a wrong
202
+ answer -- so the repetition is handled here instead of in every check.
203
+ """
204
+ placeholders = sql.count("?")
205
+ params = self.params * max(1, placeholders // max(1, len(self.params)))
206
+ return self.con.execute(sql, params).fetchall()
207
+
208
+
209
+ class Check(Protocol):
210
+ spec: CheckSpec
211
+
212
+ def applies(self, ctx: CheckContext) -> Applicability:
213
+ """Decide from the profile and structure alone, before touching data."""
214
+ ...
215
+
216
+ def run(self, ctx: CheckContext) -> list[Finding]:
217
+ ...