qualipilot 2.0.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 (50) hide show
  1. qualipilot/__init__.py +16 -0
  2. qualipilot/checker.py +195 -0
  3. qualipilot/checks/__init__.py +28 -0
  4. qualipilot/checks/base.py +61 -0
  5. qualipilot/checks/cardinality.py +42 -0
  6. qualipilot/checks/duplicates.py +28 -0
  7. qualipilot/checks/freshness.py +77 -0
  8. qualipilot/checks/linkage.py +73 -0
  9. qualipilot/checks/missing.py +43 -0
  10. qualipilot/checks/outliers.py +67 -0
  11. qualipilot/checks/ranges.py +63 -0
  12. qualipilot/checks/types.py +25 -0
  13. qualipilot/cli.py +434 -0
  14. qualipilot/engines/__init__.py +95 -0
  15. qualipilot/engines/base.py +118 -0
  16. qualipilot/engines/cudf_engine.py +147 -0
  17. qualipilot/engines/dask_engine.py +152 -0
  18. qualipilot/engines/duckdb_engine.py +283 -0
  19. qualipilot/engines/pandas_engine.py +139 -0
  20. qualipilot/engines/polars_engine.py +203 -0
  21. qualipilot/engines/spark_engine.py +245 -0
  22. qualipilot/lakehouse.py +116 -0
  23. qualipilot/lambda_handler.py +95 -0
  24. qualipilot/linking/__init__.py +27 -0
  25. qualipilot/linking/blocking.py +127 -0
  26. qualipilot/linking/cluster.py +54 -0
  27. qualipilot/linking/comparisons.py +133 -0
  28. qualipilot/linking/config.py +63 -0
  29. qualipilot/linking/duckdb_linker.py +248 -0
  30. qualipilot/linking/em.py +212 -0
  31. qualipilot/linking/linker.py +261 -0
  32. qualipilot/llm/__init__.py +46 -0
  33. qualipilot/llm/base.py +23 -0
  34. qualipilot/llm/bedrock.py +105 -0
  35. qualipilot/llm/null_provider.py +14 -0
  36. qualipilot/llm/ollama.py +72 -0
  37. qualipilot/llm/openai_compat.py +76 -0
  38. qualipilot/logging_setup.py +89 -0
  39. qualipilot/models/__init__.py +1 -0
  40. qualipilot/models/config.py +145 -0
  41. qualipilot/models/results.py +101 -0
  42. qualipilot/py.typed +0 -0
  43. qualipilot/reporting/__init__.py +6 -0
  44. qualipilot/reporting/html.py +84 -0
  45. qualipilot/reporting/markdown.py +77 -0
  46. qualipilot-2.0.0.dist-info/METADATA +301 -0
  47. qualipilot-2.0.0.dist-info/RECORD +50 -0
  48. qualipilot-2.0.0.dist-info/WHEEL +4 -0
  49. qualipilot-2.0.0.dist-info/entry_points.txt +2 -0
  50. qualipilot-2.0.0.dist-info/licenses/LICENSE +5 -0
qualipilot/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Production-grade data quality checker with pluggable LLM backends."""
2
+
3
+ from qualipilot.checker import DataQualityChecker
4
+ from qualipilot.models.config import CheckConfig, LLMConfig, QualipilotConfig
5
+ from qualipilot.models.results import CheckResult, QualityReport
6
+
7
+ __all__ = [
8
+ "CheckConfig",
9
+ "CheckResult",
10
+ "DataQualityChecker",
11
+ "LLMConfig",
12
+ "QualipilotConfig",
13
+ "QualityReport",
14
+ ]
15
+
16
+ __version__ = "2.0.0"
qualipilot/checker.py ADDED
@@ -0,0 +1,195 @@
1
+ """Orchestrator tying engines, checks and LLM reporting together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from qualipilot.checks import (
12
+ CardinalityCheck,
13
+ Check,
14
+ CheckContext,
15
+ DataTypesCheck,
16
+ DuplicatesCheck,
17
+ FreshnessCheck,
18
+ LinkageCheck,
19
+ MissingValuesCheck,
20
+ OutliersCheck,
21
+ RangesCheck,
22
+ )
23
+ from qualipilot.engines import build_engine
24
+ from qualipilot.models.config import QualipilotConfig
25
+ from qualipilot.models.results import (
26
+ CheckResult,
27
+ DatasetStats,
28
+ QualityReport,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from qualipilot.engines.base import Engine
33
+ from qualipilot.llm.base import LLMProvider
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class DataQualityChecker:
39
+ """Run configurable data quality checks against a dataframe/file.
40
+
41
+ Example:
42
+ >>> import pandas as pd
43
+ >>> from qualipilot import DataQualityChecker, QualipilotConfig
44
+ >>> df = pd.read_csv("orders.csv")
45
+ >>> checker = DataQualityChecker(df, QualipilotConfig())
46
+ >>> report = checker.run()
47
+ >>> print(report.to_json())
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ data: Any,
53
+ config: QualipilotConfig | None = None,
54
+ ) -> None:
55
+ self.config = config or QualipilotConfig()
56
+ self.engine: Engine = build_engine(data, kind=self.config.engine)
57
+ logger.info(
58
+ "initialised checker with %s engine over %d rows",
59
+ self.engine.name,
60
+ self.engine.row_count(),
61
+ )
62
+
63
+ def run(self) -> QualityReport:
64
+ """Run every enabled check and return the aggregate report."""
65
+ ctx = CheckContext(engine=self.engine, config=self.config.checks)
66
+ results: list[CheckResult] = []
67
+ for check in self._build_check_list():
68
+ logger.info("running check: %s", check.name)
69
+ result = check.run(ctx)
70
+ logger.info(
71
+ "check %s finished in %.3fs (severity=%s)",
72
+ result.name,
73
+ result.duration_seconds,
74
+ result.severity,
75
+ )
76
+ results.append(result)
77
+
78
+ dataset = DatasetStats(
79
+ row_count=self.engine.row_count(),
80
+ column_count=len(self.engine.columns()),
81
+ columns=self.engine.columns(),
82
+ dtypes=self.engine.dtypes(),
83
+ engine=self.engine.name,
84
+ )
85
+
86
+ report = QualityReport(
87
+ dataset=dataset,
88
+ results=results,
89
+ config_hash=_config_fingerprint(self.config),
90
+ )
91
+
92
+ llm_report = self._maybe_render_llm_report(report)
93
+ if llm_report:
94
+ report.llm_report = llm_report
95
+
96
+ if self.config.output_path is not None:
97
+ self.save(report, self.config.output_path)
98
+
99
+ return report
100
+
101
+ # ---- helpers ------------------------------------------------------
102
+
103
+ def _build_check_list(self) -> list[Check]:
104
+ cfg = self.config.checks
105
+ checks: list[Check] = []
106
+ if cfg.missing_values:
107
+ checks.append(MissingValuesCheck())
108
+ if cfg.duplicates:
109
+ checks.append(DuplicatesCheck())
110
+ if cfg.data_types:
111
+ checks.append(DataTypesCheck())
112
+ if cfg.outliers:
113
+ checks.append(OutliersCheck())
114
+ if cfg.ranges:
115
+ checks.append(RangesCheck())
116
+ if cfg.cardinality:
117
+ checks.append(CardinalityCheck())
118
+ if cfg.freshness:
119
+ checks.append(FreshnessCheck())
120
+ if cfg.linkage is not None:
121
+ checks.append(LinkageCheck())
122
+ return checks
123
+
124
+ def _maybe_render_llm_report(self, report: QualityReport) -> str | None:
125
+ provider_name = self.config.llm.provider
126
+ if provider_name == "none":
127
+ return None
128
+ provider = _build_llm_provider(self.config.llm)
129
+ prompt = _build_llm_prompt(report)
130
+ try:
131
+ return provider.generate(
132
+ system=self.config.llm.system_prompt, user=prompt
133
+ )
134
+ except Exception as exc:
135
+ # llm problems should not fail the check pipeline
136
+ logger.error("llm report generation failed: %s", exc)
137
+ return f"LLM report failed: {exc}"
138
+
139
+ @staticmethod
140
+ def save(report: QualityReport, path: str | Path) -> None:
141
+ """Persist the JSON report to disk (utf-8, pretty-printed)."""
142
+ target = Path(path)
143
+ target.parent.mkdir(parents=True, exist_ok=True)
144
+ target.write_text(report.to_json(), encoding="utf-8")
145
+ logger.info("report written to %s", target)
146
+
147
+
148
+ def _config_fingerprint(cfg: QualipilotConfig) -> str:
149
+ """Stable SHA-1 of the config, handy for report dedup in pipelines."""
150
+ payload = cfg.model_dump_json(exclude={"output_path"})
151
+ return hashlib.sha1(
152
+ payload.encode("utf-8"), usedforsecurity=False
153
+ ).hexdigest()
154
+
155
+
156
+ def _build_llm_provider(cfg: Any) -> LLMProvider:
157
+ # late import so the optional boto3/openai deps stay optional
158
+ from qualipilot.llm import build_provider
159
+
160
+ return build_provider(cfg)
161
+
162
+
163
+ def _build_llm_prompt(report: QualityReport) -> str:
164
+ # compact the report before shipping to the model so we do not
165
+ # waste tokens on huge sample arrays
166
+ compact = {
167
+ "dataset": report.dataset.model_dump(),
168
+ "results": [
169
+ {
170
+ "name": r.name,
171
+ "severity": r.severity,
172
+ "duration_seconds": round(r.duration_seconds, 3),
173
+ "summary": _summarise_payload(r.payload),
174
+ "error": r.error,
175
+ }
176
+ for r in report.results
177
+ ],
178
+ }
179
+ return (
180
+ "Analyse this data quality report and produce actionable "
181
+ "findings. Keep samples out; focus on what to fix.\n\n"
182
+ + json.dumps(compact, default=str)
183
+ )
184
+
185
+
186
+ def _summarise_payload(payload: dict[str, Any]) -> dict[str, Any]:
187
+ """Strip large sample arrays so prompts stay cheap."""
188
+ keep: dict[str, Any] = {}
189
+ for key, value in payload.items():
190
+ if isinstance(value, list):
191
+ # keep length only, drop row-level samples
192
+ keep[key] = {"count": len(value)}
193
+ else:
194
+ keep[key] = value
195
+ return keep
@@ -0,0 +1,28 @@
1
+ """Individual data quality checks.
2
+
3
+ Each module implements a single ``Check`` subclass. The orchestrator
4
+ in ``qualipilot.checker`` picks which to run based on ``CheckConfig``.
5
+ """
6
+
7
+ from qualipilot.checks.base import Check, CheckContext
8
+ from qualipilot.checks.cardinality import CardinalityCheck
9
+ from qualipilot.checks.duplicates import DuplicatesCheck
10
+ from qualipilot.checks.freshness import FreshnessCheck
11
+ from qualipilot.checks.linkage import LinkageCheck
12
+ from qualipilot.checks.missing import MissingValuesCheck
13
+ from qualipilot.checks.outliers import OutliersCheck
14
+ from qualipilot.checks.ranges import RangesCheck
15
+ from qualipilot.checks.types import DataTypesCheck
16
+
17
+ __all__ = [
18
+ "CardinalityCheck",
19
+ "Check",
20
+ "CheckContext",
21
+ "DataTypesCheck",
22
+ "DuplicatesCheck",
23
+ "FreshnessCheck",
24
+ "LinkageCheck",
25
+ "MissingValuesCheck",
26
+ "OutliersCheck",
27
+ "RangesCheck",
28
+ ]
@@ -0,0 +1,61 @@
1
+ """Base types shared by every check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from qualipilot.engines.base import Engine
11
+ from qualipilot.models.config import CheckConfig
12
+ from qualipilot.models.results import CheckResult
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class CheckContext:
17
+ """Immutable inputs handed to every check."""
18
+
19
+ engine: Engine
20
+ config: CheckConfig
21
+
22
+
23
+ class Check(ABC):
24
+ """Base class for data quality checks.
25
+
26
+ Subclasses implement ``_execute`` which returns the serialisable
27
+ payload. The base class times execution, captures exceptions, and
28
+ assigns the severity.
29
+ """
30
+
31
+ name: str
32
+
33
+ def run(self, ctx: CheckContext) -> CheckResult:
34
+ """Execute the check, wrapping timing and error handling."""
35
+ start = time.perf_counter()
36
+ try:
37
+ severity, payload = self._execute(ctx)
38
+ except Exception as exc:
39
+ return CheckResult(
40
+ name=self.name,
41
+ severity="error",
42
+ duration_seconds=time.perf_counter() - start,
43
+ payload={},
44
+ error=f"{type(exc).__name__}: {exc}",
45
+ )
46
+ return CheckResult(
47
+ name=self.name,
48
+ severity=severity,
49
+ duration_seconds=time.perf_counter() - start,
50
+ payload=payload,
51
+ )
52
+
53
+ @abstractmethod
54
+ def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
55
+ """Run the check logic.
56
+
57
+ Returns:
58
+ Tuple of ``(severity, payload)``. Severity is ``"ok"``,
59
+ ``"warn"`` or ``"error"``. Payload is a JSON-serialisable
60
+ dict stored on the resulting ``CheckResult``.
61
+ """
@@ -0,0 +1,42 @@
1
+ """Cardinality / uniqueness profile.
2
+
3
+ Helps spot accidentally-constant columns and highly categorical ones
4
+ that should be bucketed. Reports distinct count plus the top-10 values
5
+ for every column.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from qualipilot.checks.base import Check, CheckContext
13
+
14
+
15
+ class CardinalityCheck(Check):
16
+ name = "cardinality"
17
+
18
+ def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
19
+ total_rows = ctx.engine.row_count() or 1
20
+ report: list[dict[str, Any]] = []
21
+ any_constant = False
22
+
23
+ for col in ctx.engine.columns():
24
+ try:
25
+ distinct = ctx.engine.distinct_count(col)
26
+ except Exception: # pragma: no cover
27
+ # some engines fail on nested dtypes, we log + move on
28
+ continue
29
+ top = ctx.engine.top_values(col, n=10)
30
+ if distinct <= 1 and total_rows > 1:
31
+ any_constant = True
32
+ report.append(
33
+ {
34
+ "column": col,
35
+ "distinct_count": distinct,
36
+ "unique_ratio": round(distinct / total_rows, 6),
37
+ "top_values": top,
38
+ }
39
+ )
40
+
41
+ severity = "warn" if any_constant else "ok"
42
+ return severity, {"per_column": report}
@@ -0,0 +1,28 @@
1
+ """Duplicate row check.
2
+
3
+ Uses a global duplicate count so distributed backends do not
4
+ under-count the way ``map_partitions(.duplicated())`` would.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from qualipilot.checks.base import Check, CheckContext
12
+
13
+
14
+ class DuplicatesCheck(Check):
15
+ name = "duplicates"
16
+
17
+ def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
18
+ subset = ctx.config.duplicate_subset
19
+ total = ctx.engine.duplicate_count(subset=subset)
20
+ sample = ctx.engine.sample_duplicates(
21
+ n=ctx.config.sample_size, subset=subset
22
+ )
23
+ severity = "warn" if total > 0 else "ok"
24
+ return severity, {
25
+ "total_duplicate_rows": total,
26
+ "subset": subset,
27
+ "sample": sample,
28
+ }
@@ -0,0 +1,77 @@
1
+ """Data freshness check.
2
+
3
+ Flags datasets where the latest timestamp in configured columns is
4
+ older than ``freshness_max_age_hours``. Useful for scheduled batch
5
+ jobs that should never publish stale snapshots.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, datetime, timedelta
11
+ from typing import Any
12
+
13
+ from qualipilot.checks.base import Check, CheckContext
14
+
15
+
16
+ class FreshnessCheck(Check):
17
+ name = "freshness"
18
+
19
+ def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
20
+ cols = ctx.config.freshness_columns or ctx.engine.datetime_columns()
21
+ if not cols:
22
+ return "ok", {"per_column": []}
23
+
24
+ max_age = timedelta(hours=ctx.config.freshness_max_age_hours)
25
+ now = datetime.now(UTC)
26
+ report: list[dict[str, Any]] = []
27
+ any_stale = False
28
+
29
+ for col in cols:
30
+ max_ts = ctx.engine.max_datetime(col)
31
+ if max_ts is None:
32
+ report.append(
33
+ {
34
+ "column": col,
35
+ "max_timestamp": None,
36
+ "is_stale": True,
37
+ "note": "no non-null values",
38
+ }
39
+ )
40
+ any_stale = True
41
+ continue
42
+
43
+ # normalise naive timestamps so subtraction is safe
44
+ ts = _as_aware(max_ts)
45
+ age = now - ts
46
+ stale = age > max_age
47
+ if stale:
48
+ any_stale = True
49
+ report.append(
50
+ {
51
+ "column": col,
52
+ "max_timestamp": ts.isoformat(),
53
+ "age_hours": round(age.total_seconds() / 3600, 3),
54
+ "is_stale": stale,
55
+ }
56
+ )
57
+
58
+ severity = "error" if any_stale else "ok"
59
+ return severity, {"per_column": report}
60
+
61
+
62
+ def _as_aware(value: Any) -> datetime:
63
+ """Coerce engine-returned timestamps into timezone-aware datetime."""
64
+ # pandas.Timestamp, polars Datetime and stdlib datetime all satisfy
65
+ # the duck-typing we need; pandas ts has .to_pydatetime()
66
+ dt: datetime
67
+ if hasattr(value, "to_pydatetime"):
68
+ dt = value.to_pydatetime()
69
+ elif isinstance(value, datetime):
70
+ dt = value
71
+ else:
72
+ # last-resort coerce via iso parsing for exotic types
73
+ dt = datetime.fromisoformat(str(value))
74
+
75
+ if dt.tzinfo is None:
76
+ dt = dt.replace(tzinfo=UTC)
77
+ return dt
@@ -0,0 +1,73 @@
1
+ """Optional probabilistic duplicate check.
2
+
3
+ Unlike ``DuplicatesCheck`` (exact row match), this runs the
4
+ Fellegi-Sunter linker on a user-supplied comparison spec and reports
5
+ the number of probable duplicate clusters.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from qualipilot.checks.base import Check, CheckContext
13
+
14
+
15
+ class LinkageCheck(Check):
16
+ """Report probabilistic duplicate clusters.
17
+
18
+ Active only when ``CheckConfig.linkage`` is populated.
19
+ """
20
+
21
+ name = "linkage"
22
+
23
+ def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
24
+ link_cfg = ctx.config.linkage
25
+ if link_cfg is None:
26
+ return "ok", {"skipped": True}
27
+
28
+ # deferred import so the core install stays slim
29
+ from qualipilot.linking import RecordLinker
30
+
31
+ # engine-agnostic: always pull a polars frame for the linker.
32
+ # the per-engine cost is tiny compared with the linkage run.
33
+ pl_df = _engine_to_polars(ctx.engine)
34
+ result = RecordLinker(pl_df, link_cfg).run()
35
+ summary = result.summary()
36
+
37
+ # clusters of size > 1 are the "probable duplicate groups"
38
+ counts: dict[int, int] = {}
39
+ for cid in result.clusters.values():
40
+ counts[cid] = counts.get(cid, 0) + 1
41
+ multi = sum(1 for sz in counts.values() if sz > 1)
42
+ total_records_in_dupe_group = sum(
43
+ sz for sz in counts.values() if sz > 1
44
+ )
45
+
46
+ severity = "warn" if multi > 0 else "ok"
47
+ return severity, {
48
+ **summary,
49
+ "duplicate_clusters": multi,
50
+ "records_in_duplicate_groups": total_records_in_dupe_group,
51
+ }
52
+
53
+
54
+ def _engine_to_polars(engine: Any) -> Any:
55
+ """Return the underlying frame as polars regardless of engine."""
56
+ # PolarsEngine stores a DataFrame directly
57
+ name = getattr(engine, "name", "")
58
+ if name == "polars":
59
+ return engine._df
60
+ if name == "pandas":
61
+ import polars as pl
62
+
63
+ return pl.from_pandas(engine._df)
64
+ # dask / cudf — round-trip via pandas
65
+ import polars as pl
66
+
67
+ underlying = engine._df
68
+ materialised = (
69
+ underlying.compute()
70
+ if hasattr(underlying, "compute")
71
+ else underlying.to_pandas()
72
+ )
73
+ return pl.from_pandas(materialised)
@@ -0,0 +1,43 @@
1
+ """Missing value check.
2
+
3
+ Reports per-column null counts and percentages. Severity rules:
4
+ * any nulls -> warn
5
+ * columns where >50% are null -> error
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from qualipilot.checks.base import Check, CheckContext
13
+
14
+
15
+ class MissingValuesCheck(Check):
16
+ name = "missing_values"
17
+
18
+ def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
19
+ nulls = ctx.engine.null_counts()
20
+ total_rows = ctx.engine.row_count() or 1
21
+ stats = [
22
+ {
23
+ "column": col,
24
+ "null_count": int(count),
25
+ "null_percentage": round((count / total_rows) * 100, 4),
26
+ }
27
+ for col, count in nulls.items()
28
+ ]
29
+
30
+ total_nulls = sum(s["null_count"] for s in stats)
31
+ worst = max((s["null_percentage"] for s in stats), default=0.0)
32
+
33
+ severity = "ok"
34
+ if worst > 50:
35
+ severity = "error"
36
+ elif total_nulls > 0:
37
+ severity = "warn"
38
+
39
+ return severity, {
40
+ "total_null_count": total_nulls,
41
+ "worst_column_pct": worst,
42
+ "per_column": stats,
43
+ }
@@ -0,0 +1,67 @@
1
+ """Outlier check using the IQR rule.
2
+
3
+ Computes Q1 and Q3 for every numeric column in a single pass via the
4
+ engine's batched ``quantiles`` API, then counts/samples values outside
5
+ ``[Q1 - k*IQR, Q3 + k*IQR]``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Any
12
+
13
+ from qualipilot.checks.base import Check, CheckContext
14
+
15
+
16
+ class OutliersCheck(Check):
17
+ name = "outliers"
18
+
19
+ def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
20
+ numeric = ctx.engine.numeric_columns()
21
+ if not numeric:
22
+ return "ok", {"per_column": []}
23
+
24
+ qmap = ctx.engine.quantiles(numeric, qs=(0.25, 0.75))
25
+ k = ctx.config.outlier_iqr_multiplier
26
+
27
+ report: list[dict[str, Any]] = []
28
+ any_outliers = False
29
+ for col in numeric:
30
+ q1 = qmap[col][0.25]
31
+ q3 = qmap[col][0.75]
32
+ if _is_nan(q1) or _is_nan(q3):
33
+ # constant or empty column, nothing to flag
34
+ continue
35
+ iqr = q3 - q1
36
+ low = q1 - k * iqr
37
+ high = q3 + k * iqr
38
+ count = ctx.engine.count_outside(col, low, high)
39
+ sample = (
40
+ ctx.engine.sample_outside(
41
+ col, low, high, ctx.config.sample_size
42
+ )
43
+ if count
44
+ else []
45
+ )
46
+ if count:
47
+ any_outliers = True
48
+ report.append(
49
+ {
50
+ "column": col,
51
+ "lower_bound": low,
52
+ "upper_bound": high,
53
+ "outlier_count": count,
54
+ "sample": sample,
55
+ }
56
+ )
57
+
58
+ severity = "warn" if any_outliers else "ok"
59
+ return severity, {"per_column": report}
60
+
61
+
62
+ def _is_nan(value: float) -> bool:
63
+ # protects against None-like sentinels alongside real nan floats
64
+ try:
65
+ return math.isnan(value)
66
+ except TypeError:
67
+ return False