datascope-dq 2.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.
datascope/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """datascope -- data quality diagnostics for tabular datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "2.1.0"
datascope/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m datascope``."""
2
+
3
+ from datascope.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,16 @@
1
+ """datascope.analyzers -- detectors that produce Finding objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datascope.analyzers.type_consistency import analyze_type_consistency
6
+ from datascope.analyzers.sentinel import analyze_sentinels
7
+ from datascope.analyzers.format_check import analyze_leading_zeros, analyze_mixed_dates
8
+ from datascope.analyzers.cardinality import analyze_cardinality
9
+
10
+ __all__ = [
11
+ "analyze_type_consistency",
12
+ "analyze_sentinels",
13
+ "analyze_leading_zeros",
14
+ "analyze_mixed_dates",
15
+ "analyze_cardinality",
16
+ ]
@@ -0,0 +1,19 @@
1
+ """Analyzer contract and registry.
2
+
3
+ There is no abstract base class. An analyzer is any callable matching::
4
+
5
+ Callable[[LoaderResult], list[Finding]]
6
+
7
+ It receives a :class:`~datascope.models.LoaderResult` and returns zero or
8
+ more :class:`~datascope.models.Finding` instances. The pipeline runner
9
+ (U9) will call each registered analyzer and merge the results.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Callable
15
+
16
+ from datascope.models import Finding, LoaderResult
17
+
18
+ #: Type alias documenting the analyzer contract.
19
+ Analyzer = Callable[[LoaderResult], list[Finding]]
@@ -0,0 +1,112 @@
1
+ """Cardinality-anomaly detector.
2
+
3
+ Flags columns that are near-constant (very low uniqueness) or that look
4
+ like ID columns with unexpected duplicates (very high but not 100%
5
+ uniqueness).
6
+
7
+ Produces :class:`~datascope.models.Finding` instances with
8
+ :attr:`~datascope.models.FindingType.NEAR_CONSTANT` or
9
+ :attr:`~datascope.models.FindingType.DUPLICATE_IDS`.
10
+
11
+ Severity is *not* assigned here -- that is the severity classifier's job (U7).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+
18
+ from datascope.models import Finding, FindingType, LoaderResult
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Thresholds
23
+ # ---------------------------------------------------------------------------
24
+
25
+ _MIN_ROWS = 10 # Skip columns with fewer non-null rows
26
+ _NEAR_CONSTANT_MAX = 0.01 # uniqueness_ratio < this => near-constant
27
+ _SUSPECTED_ID_MIN = 0.95 # uniqueness_ratio > this AND < 1.0 => suspected-ID dups
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Detector
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def analyze_cardinality(result: LoaderResult) -> list[Finding]:
35
+ """Detect cardinality anomalies in each column.
36
+
37
+ Two patterns are flagged:
38
+
39
+ * **Near-constant**: fewer than 1% of values are unique.
40
+ * **Suspected duplicate IDs**: more than 95% unique but less than
41
+ 100%, suggesting an ID column with unexpected duplicates.
42
+
43
+ Columns with fewer than 10 non-null rows are skipped because
44
+ cardinality ratios are unreliable for tiny samples.
45
+
46
+ Parameters
47
+ ----------
48
+ result:
49
+ A :class:`~datascope.models.LoaderResult`.
50
+
51
+ Returns
52
+ -------
53
+ list[Finding]
54
+ One finding per column that exhibits a cardinality anomaly.
55
+ """
56
+ findings: list[Finding] = []
57
+
58
+ for col_name in result.dataframe.columns:
59
+ series = result.dataframe[col_name]
60
+ filled = series.dropna()
61
+ total_count = len(filled)
62
+
63
+ if total_count < _MIN_ROWS:
64
+ continue
65
+
66
+ unique_count = filled.nunique()
67
+ uniqueness_ratio = round(unique_count / total_count, 4)
68
+
69
+ if uniqueness_ratio < _NEAR_CONSTANT_MAX:
70
+ # Near-constant column
71
+ value_counts = Counter(filled)
72
+ top_values = [
73
+ {"value": str(val), "count": cnt}
74
+ for val, cnt in value_counts.most_common(5)
75
+ ]
76
+
77
+ evidence = {
78
+ "unique_count": unique_count,
79
+ "total_count": total_count,
80
+ "uniqueness_ratio": uniqueness_ratio,
81
+ "top_values": top_values,
82
+ }
83
+
84
+ findings.append(Finding(
85
+ field_name=col_name,
86
+ finding_type=FindingType.NEAR_CONSTANT,
87
+ evidence=evidence,
88
+ ))
89
+
90
+ elif _SUSPECTED_ID_MIN < uniqueness_ratio < 1.0:
91
+ # Suspected duplicate IDs
92
+ value_counts = Counter(filled)
93
+ duplicate_values = [
94
+ str(val)
95
+ for val, cnt in value_counts.most_common()
96
+ if cnt > 1
97
+ ][:10]
98
+
99
+ evidence = {
100
+ "unique_count": unique_count,
101
+ "total_count": total_count,
102
+ "uniqueness_ratio": uniqueness_ratio,
103
+ "duplicate_values": duplicate_values,
104
+ }
105
+
106
+ findings.append(Finding(
107
+ field_name=col_name,
108
+ finding_type=FindingType.DUPLICATE_IDS,
109
+ evidence=evidence,
110
+ ))
111
+
112
+ return findings
@@ -0,0 +1,232 @@
1
+ """Format-inconsistency detectors: leading zeros and mixed date formats.
2
+
3
+ Produces :class:`~datascope.models.Finding` instances with
4
+ :attr:`~datascope.models.FindingType.LEADING_ZEROS` or
5
+ :attr:`~datascope.models.FindingType.MIXED_DATES`.
6
+
7
+ Severity is *not* assigned here -- that is the severity classifier's job (U7).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from datetime import datetime
14
+
15
+ from datascope.models import Finding, FindingType, LoaderResult
16
+ from datascope.analyzers.type_consistency import normalize_type
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Leading-zero detector
21
+ # ---------------------------------------------------------------------------
22
+
23
+ # Matches strings that are purely digits (at least two chars) and start with 0.
24
+ _LEADING_ZERO_RE = re.compile(r"^0\d+$")
25
+
26
+
27
+ def analyze_leading_zeros(result: LoaderResult) -> list[Finding]:
28
+ """Detect leading-zero inconsistencies within string-typed cell values.
29
+
30
+ For each column, examines cells whose Python type is ``str``.
31
+ Among those that consist entirely of digits, checks whether some
32
+ have leading zeros and others do not. Also checks for the
33
+ cross-type case: a column with mostly numeric cells but some
34
+ string cells that carry leading zeros (e.g. ``"00123"`` alongside
35
+ ``123``).
36
+
37
+ Leading-zero detection is limited to string-typed cells because
38
+ Excel returns numeric cells as Python numbers with no original
39
+ string representation (openpyxl limitation).
40
+
41
+ Parameters
42
+ ----------
43
+ result:
44
+ A :class:`~datascope.models.LoaderResult`.
45
+
46
+ Returns
47
+ -------
48
+ list[Finding]
49
+ One finding per column that exhibits leading-zero
50
+ inconsistency.
51
+ """
52
+ findings: list[Finding] = []
53
+
54
+ for col_name, types_list in result.cell_types.items():
55
+ col_values = list(result.dataframe[col_name])
56
+
57
+ # Collect string-typed values that are purely digits
58
+ with_leading_zero: list[str] = []
59
+ without_leading_zero: list[str] = []
60
+
61
+ for val, ct in zip(col_values, types_list):
62
+ if ct is not str:
63
+ continue
64
+ if val is None:
65
+ continue
66
+ val_str = str(val)
67
+ # Must be purely digits and at least one char
68
+ if not val_str.isdigit() or len(val_str) == 0:
69
+ continue
70
+ if _LEADING_ZERO_RE.match(val_str):
71
+ with_leading_zero.append(val_str)
72
+ else:
73
+ without_leading_zero.append(val_str)
74
+
75
+ leading_zero_count = len(with_leading_zero)
76
+ no_leading_zero_count = len(without_leading_zero)
77
+
78
+ if leading_zero_count == 0:
79
+ # No leading zeros found at all -- nothing to report
80
+ continue
81
+
82
+ # Case 1: mixed within string-typed cells (some have zeros, some don't)
83
+ has_string_inconsistency = (
84
+ leading_zero_count > 0 and no_leading_zero_count > 0
85
+ )
86
+
87
+ # Case 2: cross-type -- string cells with leading zeros alongside
88
+ # numeric cells (the numeric cells implicitly lack leading zeros)
89
+ non_null_types = [ct for ct in types_list if ct is not type(None)]
90
+ normalized = [normalize_type(ct) for ct in non_null_types]
91
+ numeric_count = sum(1 for n in normalized if n == "numeric")
92
+ has_cross_type = leading_zero_count > 0 and numeric_count > 0
93
+
94
+ if not has_string_inconsistency and not has_cross_type:
95
+ # All string-digit values consistently have leading zeros,
96
+ # and no numeric cells exist -- no inconsistency.
97
+ continue
98
+
99
+ evidence = {
100
+ "leading_zero_count": leading_zero_count,
101
+ "no_leading_zero_count": no_leading_zero_count,
102
+ "examples_with_zeros": with_leading_zero[:5],
103
+ "examples_without_zeros": without_leading_zero[:5],
104
+ "total_checked": leading_zero_count + no_leading_zero_count,
105
+ }
106
+
107
+ findings.append(Finding(
108
+ field_name=col_name,
109
+ finding_type=FindingType.LEADING_ZEROS,
110
+ evidence=evidence,
111
+ ))
112
+
113
+ return findings
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Mixed-date-format detector
118
+ # ---------------------------------------------------------------------------
119
+
120
+ # Common date format patterns to try, ordered from most specific to least.
121
+ _DATE_FORMATS: list[str] = [
122
+ "%Y-%m-%d", # 2026-01-15
123
+ "%Y/%m/%d", # 2026/01/15
124
+ "%m/%d/%Y", # 01/15/2026
125
+ "%d/%m/%Y", # 15/01/2026
126
+ "%m-%d-%Y", # 01-15-2026
127
+ "%d-%m-%Y", # 15-01-2026
128
+ "%m/%d/%y", # 01/15/26
129
+ "%d/%m/%y", # 15/01/26
130
+ "%Y.%m.%d", # 2026.01.15
131
+ "%d.%m.%Y", # 15.01.2026
132
+ "%b %d, %Y", # Jan 15, 2026
133
+ "%B %d, %Y", # January 15, 2026
134
+ "%d %b %Y", # 15 Jan 2026
135
+ "%d %B %Y", # 15 January 2026
136
+ ]
137
+
138
+ # Quick pre-filter: a date-like string has at least one digit and a
139
+ # separator or is in month-name form.
140
+ _DATE_LIKE_RE = re.compile(
141
+ r"^\d{1,4}[\-/\.]\d{1,2}[\-/\.]\d{1,4}$"
142
+ r"|^\w+ \d{1,2},? \d{2,4}$"
143
+ r"|^\d{1,2} \w+ \d{2,4}$"
144
+ )
145
+
146
+
147
+ def _try_parse_date(value: str) -> str | None:
148
+ """Try to parse *value* with each known format.
149
+
150
+ Returns the first matching format string, or ``None`` if no
151
+ format matched.
152
+ """
153
+ value = value.strip()
154
+ for fmt in _DATE_FORMATS:
155
+ try:
156
+ datetime.strptime(value, fmt)
157
+ return fmt
158
+ except ValueError:
159
+ continue
160
+ return None
161
+
162
+
163
+ def analyze_mixed_dates(result: LoaderResult) -> list[Finding]:
164
+ """Detect columns where date-like strings use multiple date formats.
165
+
166
+ Only inspects string-typed cell values that look date-like.
167
+ If all parseable dates within a column match a single format,
168
+ no finding is produced.
169
+
170
+ Parameters
171
+ ----------
172
+ result:
173
+ A :class:`~datascope.models.LoaderResult`.
174
+
175
+ Returns
176
+ -------
177
+ list[Finding]
178
+ One finding per column that contains mixed date formats.
179
+ """
180
+ findings: list[Finding] = []
181
+
182
+ for col_name, types_list in result.cell_types.items():
183
+ col_values = list(result.dataframe[col_name])
184
+
185
+ # Track which format each date-like string matched
186
+ format_to_examples: dict[str, list[str]] = {}
187
+ total_date_values = 0
188
+
189
+ for val, ct in zip(col_values, types_list):
190
+ if ct is not str:
191
+ continue
192
+ if val is None:
193
+ continue
194
+ val_str = str(val).strip()
195
+ if not val_str:
196
+ continue
197
+
198
+ # Quick pre-filter before expensive strptime calls
199
+ if not _DATE_LIKE_RE.match(val_str):
200
+ continue
201
+
202
+ fmt = _try_parse_date(val_str)
203
+ if fmt is None:
204
+ # Unparseable -- skip gracefully
205
+ continue
206
+
207
+ total_date_values += 1
208
+ if fmt not in format_to_examples:
209
+ format_to_examples[fmt] = []
210
+ if len(format_to_examples[fmt]) < 5:
211
+ format_to_examples[fmt].append(val_str)
212
+
213
+ if len(format_to_examples) <= 1:
214
+ # Zero or one format -- no inconsistency
215
+ continue
216
+
217
+ evidence = {
218
+ "formats_found": list(format_to_examples.keys()),
219
+ "examples_per_format": {
220
+ fmt: examples
221
+ for fmt, examples in format_to_examples.items()
222
+ },
223
+ "total_date_values": total_date_values,
224
+ }
225
+
226
+ findings.append(Finding(
227
+ field_name=col_name,
228
+ finding_type=FindingType.MIXED_DATES,
229
+ evidence=evidence,
230
+ ))
231
+
232
+ return findings
@@ -0,0 +1,157 @@
1
+ """Sentinel-value detector.
2
+
3
+ Scans each column for hidden sentinel strings -- values like "N/A", "TBD",
4
+ or "pending" that lurk inside otherwise-typed columns. Tools silently drop
5
+ or coerce these, so surfacing them early prevents data loss.
6
+
7
+ Produces a :class:`~datascope.models.Finding` for every non-string column
8
+ that contains sentinel values after frequency disambiguation.
9
+
10
+ Severity is *not* assigned here -- that is the severity classifier's job (U7).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import Counter
16
+
17
+ from datascope.models import Finding, FindingType, LoaderResult
18
+ from datascope.analyzers.type_consistency import normalize_type
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Default sentinel list
23
+ # ---------------------------------------------------------------------------
24
+
25
+ DEFAULT_SENTINELS: frozenset[str] = frozenset({
26
+ "n/a",
27
+ "na",
28
+ "n.a.",
29
+ "null",
30
+ "none",
31
+ "nil",
32
+ "tbd",
33
+ "pending",
34
+ "unknown",
35
+ "missing",
36
+ "—", # em-dash
37
+ "-",
38
+ ".",
39
+ "..",
40
+ "...",
41
+ "#n/a",
42
+ "#ref!",
43
+ "#value!",
44
+ "#div/0!",
45
+ "#name?",
46
+ })
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Detector
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def analyze_sentinels(
54
+ result: LoaderResult,
55
+ sentinel_list: frozenset[str] | None = None,
56
+ ) -> list[Finding]:
57
+ """Detect hidden sentinel values in non-string columns.
58
+
59
+ Parameters
60
+ ----------
61
+ result:
62
+ A :class:`~datascope.models.LoaderResult` whose ``cell_types``
63
+ dict maps column names to lists of Python ``type`` objects (one
64
+ per row).
65
+ sentinel_list:
66
+ Optional custom set of lowercase sentinel strings to match
67
+ against. Defaults to :data:`DEFAULT_SENTINELS`.
68
+
69
+ Returns
70
+ -------
71
+ list[Finding]
72
+ One finding per non-string column that contains sentinel values
73
+ (after frequency disambiguation). String/categorical columns
74
+ are skipped because sentinels there are not "hidden".
75
+ """
76
+ sentinels = sentinel_list if sentinel_list is not None else DEFAULT_SENTINELS
77
+ findings: list[Finding] = []
78
+
79
+ for col_name, types_list in result.cell_types.items():
80
+ # 1. Determine majority type (ignoring NoneType)
81
+ non_null_types = [t for t in types_list if t is not type(None)]
82
+ if not non_null_types:
83
+ continue
84
+
85
+ normalized = [normalize_type(t) for t in non_null_types]
86
+ type_counts = Counter(normalized)
87
+ majority_type = type_counts.most_common(1)[0][0]
88
+
89
+ # 2. Skip string/categorical columns -- sentinels are not hidden there
90
+ if majority_type == "str":
91
+ continue
92
+
93
+ # 3. Scan cell values for sentinel matches
94
+ col_values = list(result.dataframe[col_name])
95
+ total_non_null = sum(
96
+ 1 for v in col_values if v is not None and str(v).strip() != ""
97
+ )
98
+
99
+ if total_non_null == 0:
100
+ continue
101
+
102
+ # Collect sentinel hits: track original-case value and count
103
+ sentinel_hits: Counter[str] = Counter() # lowercase -> count
104
+ sentinel_originals: dict[str, str] = {} # lowercase -> first original-case
105
+ for val in col_values:
106
+ if val is None:
107
+ continue
108
+ val_str = str(val)
109
+ if not val_str.strip():
110
+ continue
111
+ val_lower = val_str.lower()
112
+ if val_lower in sentinels:
113
+ sentinel_hits[val_lower] += 1
114
+ if val_lower not in sentinel_originals:
115
+ sentinel_originals[val_lower] = val_str
116
+
117
+ if not sentinel_hits:
118
+ continue
119
+
120
+ # 4. Frequency disambiguation: if a specific sentinel is >50%
121
+ # of non-null values, treat it as a legitimate category
122
+ surviving: dict[str, int] = {}
123
+ for val_lower, count in sentinel_hits.items():
124
+ if count / total_non_null <= 0.50:
125
+ surviving[val_lower] = count
126
+ sentinel_hits = Counter(surviving)
127
+
128
+ if not sentinel_hits:
129
+ continue
130
+
131
+ # 5. Build evidence and produce Finding
132
+ total_sentinel_count = sum(sentinel_hits.values())
133
+ sentinel_pct = round(total_sentinel_count / total_non_null * 100, 2) if total_non_null else 0.0
134
+
135
+ sentinels_found = [
136
+ {
137
+ "value": sentinel_originals[val_lower],
138
+ "count": count,
139
+ "normalized": val_lower,
140
+ }
141
+ for val_lower, count in sentinel_hits.most_common()
142
+ ]
143
+
144
+ evidence = {
145
+ "sentinels_found": sentinels_found,
146
+ "column_majority_type": majority_type,
147
+ "total_non_null": total_non_null,
148
+ "sentinel_pct": sentinel_pct,
149
+ }
150
+
151
+ findings.append(Finding(
152
+ field_name=col_name,
153
+ finding_type=FindingType.SENTINEL_VALUE,
154
+ evidence=evidence,
155
+ ))
156
+
157
+ return findings
@@ -0,0 +1,134 @@
1
+ """Type-inconsistency detector.
2
+
3
+ Scans each column's ``cell_types`` for mixed Python types that pandas would
4
+ silently coerce. Produces a :class:`~datascope.models.Finding` for every
5
+ column where more than one non-null type is present after normalization.
6
+
7
+ Severity is *not* assigned here -- that is the severity classifier's job.
8
+ The ``evidence`` dict carries all the raw counts and examples that
9
+ downstream stages (severity classifier, NL composer) need.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import Counter
15
+ from typing import Any
16
+
17
+ from datascope.models import Finding, FindingType, LoaderResult
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Helpers
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def normalize_type(t: type) -> str:
25
+ """Map a Python type to a canonical bucket for type-consistency analysis.
26
+
27
+ ``int`` and ``float`` are both mapped to ``"numeric"`` so that the
28
+ openpyxl int-vs-float distinction (which is an artefact of the Excel
29
+ number format, not a real type difference) does not create false
30
+ positives.
31
+
32
+ ``NoneType`` passes through unchanged -- callers filter it out before
33
+ counting.
34
+ """
35
+ if t in (int, float):
36
+ return "numeric"
37
+ return t.__name__
38
+
39
+
40
+ def _examples_for_type(
41
+ values: list[Any],
42
+ cell_types: list[type],
43
+ target_type: str,
44
+ limit: int = 5,
45
+ ) -> list[Any]:
46
+ """Return up to *limit* example values whose normalized type matches *target_type*."""
47
+ examples: list[Any] = []
48
+ for val, ct in zip(values, cell_types):
49
+ if normalize_type(ct) == target_type:
50
+ examples.append(val)
51
+ if len(examples) >= limit:
52
+ break
53
+ return examples
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Detector
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def analyze_type_consistency(result: LoaderResult) -> list[Finding]:
61
+ """Detect columns with mixed non-null types.
62
+
63
+ Parameters
64
+ ----------
65
+ result:
66
+ A :class:`~datascope.models.LoaderResult` whose ``cell_types``
67
+ dict maps column names to lists of Python ``type`` objects (one
68
+ per row).
69
+
70
+ Returns
71
+ -------
72
+ list[Finding]
73
+ One finding per column that has more than one distinct non-null
74
+ type after normalization. Columns that are homogeneous (or
75
+ entirely null) produce no finding.
76
+ """
77
+ findings: list[Finding] = []
78
+
79
+ for col_name, types_list in result.cell_types.items():
80
+ # 1. Filter out NoneType (null cells don't count as a "type")
81
+ non_null_types = [t for t in types_list if t is not type(None)]
82
+
83
+ if not non_null_types:
84
+ # All null -- nothing to report
85
+ continue
86
+
87
+ # 2. Normalize (collapse int/float -> "numeric")
88
+ normalized = [normalize_type(t) for t in non_null_types]
89
+
90
+ # 3. Count types after normalization
91
+ counts = Counter(normalized)
92
+
93
+ if len(counts) <= 1:
94
+ # Homogeneous column -- no finding
95
+ continue
96
+
97
+ # 4. Multiple types detected -- build evidence
98
+ total_non_null = len(non_null_types)
99
+ majority_type = counts.most_common(1)[0][0]
100
+ majority_count = counts[majority_type]
101
+ majority_pct = round(majority_count / total_non_null * 100, 2)
102
+
103
+ # Column values for gathering examples
104
+ col_values = list(result.dataframe[col_name])
105
+
106
+ # Build minority_types list (everything except majority)
107
+ minority_types = []
108
+ for type_name, count in counts.most_common():
109
+ if type_name == majority_type:
110
+ continue
111
+ examples = _examples_for_type(
112
+ col_values, types_list, type_name, limit=5,
113
+ )
114
+ minority_types.append({
115
+ "type_name": type_name,
116
+ "count": count,
117
+ "examples": examples,
118
+ })
119
+
120
+ evidence = {
121
+ "majority_type": majority_type,
122
+ "majority_count": majority_count,
123
+ "minority_types": minority_types,
124
+ "total_non_null": total_non_null,
125
+ "majority_pct": majority_pct,
126
+ }
127
+
128
+ findings.append(Finding(
129
+ field_name=col_name,
130
+ finding_type=FindingType.TYPE_INCONSISTENCY,
131
+ evidence=evidence,
132
+ ))
133
+
134
+ return findings