dataeval-flow 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 (94) hide show
  1. dataeval_flow/__init__.py +93 -0
  2. dataeval_flow/__main__.py +149 -0
  3. dataeval_flow/_app/__init__.py +5 -0
  4. dataeval_flow/_app/_model/__init__.py +5 -0
  5. dataeval_flow/_app/_model/_coerce.py +126 -0
  6. dataeval_flow/_app/_model/_discover.py +171 -0
  7. dataeval_flow/_app/_model/_execution.py +108 -0
  8. dataeval_flow/_app/_model/_introspect.py +280 -0
  9. dataeval_flow/_app/_model/_item.py +213 -0
  10. dataeval_flow/_app/_model/_registry.py +255 -0
  11. dataeval_flow/_app/_model/_state.py +322 -0
  12. dataeval_flow/_app/_model/_undo.py +61 -0
  13. dataeval_flow/_app/_panes/__init__.py +35 -0
  14. dataeval_flow/_app/_panes/_config_pane.py +173 -0
  15. dataeval_flow/_app/_panes/_result_pane.py +125 -0
  16. dataeval_flow/_app/_panes/_task_pane.py +91 -0
  17. dataeval_flow/_app/_panes/_widgets.py +111 -0
  18. dataeval_flow/_app/_screens/__init__.py +25 -0
  19. dataeval_flow/_app/_screens/_base.py +242 -0
  20. dataeval_flow/_app/_screens/_detail.py +333 -0
  21. dataeval_flow/_app/_screens/_model.py +102 -0
  22. dataeval_flow/_app/_screens/_params.py +80 -0
  23. dataeval_flow/_app/_screens/_pathpicker.py +68 -0
  24. dataeval_flow/_app/_screens/_section.py +621 -0
  25. dataeval_flow/_app/_screens/_settings.py +183 -0
  26. dataeval_flow/_app/_viewmodel/__init__.py +15 -0
  27. dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
  28. dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
  29. dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
  30. dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
  31. dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
  32. dataeval_flow/_app/app.py +742 -0
  33. dataeval_flow/_app/cli.py +592 -0
  34. dataeval_flow/_logging.py +102 -0
  35. dataeval_flow/cache.py +1355 -0
  36. dataeval_flow/config/__init__.py +80 -0
  37. dataeval_flow/config/_loader.py +79 -0
  38. dataeval_flow/config/_merge.py +92 -0
  39. dataeval_flow/config/_models.py +115 -0
  40. dataeval_flow/config/_paths.py +85 -0
  41. dataeval_flow/config/schemas/__init__.py +112 -0
  42. dataeval_flow/config/schemas/_dataset.py +111 -0
  43. dataeval_flow/config/schemas/_extractor.py +119 -0
  44. dataeval_flow/config/schemas/_metadata.py +28 -0
  45. dataeval_flow/config/schemas/_preprocessor.py +18 -0
  46. dataeval_flow/config/schemas/_selection.py +100 -0
  47. dataeval_flow/config/schemas/_task.py +89 -0
  48. dataeval_flow/config/schemas/_workflow.py +135 -0
  49. dataeval_flow/dataset.py +635 -0
  50. dataeval_flow/embeddings.py +135 -0
  51. dataeval_flow/metadata.py +48 -0
  52. dataeval_flow/preprocessing.py +141 -0
  53. dataeval_flow/py.typed +0 -0
  54. dataeval_flow/runner.py +118 -0
  55. dataeval_flow/selection.py +50 -0
  56. dataeval_flow/workflow/__init__.py +328 -0
  57. dataeval_flow/workflow/_text_report.py +511 -0
  58. dataeval_flow/workflow/base.py +69 -0
  59. dataeval_flow/workflow/orchestrator.py +454 -0
  60. dataeval_flow/workflows/__init__.py +1 -0
  61. dataeval_flow/workflows/analysis/__init__.py +38 -0
  62. dataeval_flow/workflows/analysis/outputs.py +202 -0
  63. dataeval_flow/workflows/analysis/params.py +114 -0
  64. dataeval_flow/workflows/analysis/workflow.py +1313 -0
  65. dataeval_flow/workflows/cleaning/__init__.py +23 -0
  66. dataeval_flow/workflows/cleaning/outputs.py +200 -0
  67. dataeval_flow/workflows/cleaning/params.py +160 -0
  68. dataeval_flow/workflows/cleaning/report.py +304 -0
  69. dataeval_flow/workflows/cleaning/workflow.py +794 -0
  70. dataeval_flow/workflows/drift/__init__.py +1 -0
  71. dataeval_flow/workflows/drift/outputs.py +144 -0
  72. dataeval_flow/workflows/drift/params.py +332 -0
  73. dataeval_flow/workflows/drift/report.py +201 -0
  74. dataeval_flow/workflows/drift/workflow.py +647 -0
  75. dataeval_flow/workflows/ood/__init__.py +1 -0
  76. dataeval_flow/workflows/ood/outputs.py +134 -0
  77. dataeval_flow/workflows/ood/params.py +161 -0
  78. dataeval_flow/workflows/ood/report.py +311 -0
  79. dataeval_flow/workflows/ood/workflow.py +728 -0
  80. dataeval_flow/workflows/prioritization/__init__.py +1 -0
  81. dataeval_flow/workflows/prioritization/outputs.py +122 -0
  82. dataeval_flow/workflows/prioritization/params.py +124 -0
  83. dataeval_flow/workflows/prioritization/report.py +117 -0
  84. dataeval_flow/workflows/prioritization/workflow.py +587 -0
  85. dataeval_flow/workflows/splitting/__init__.py +25 -0
  86. dataeval_flow/workflows/splitting/outputs.py +101 -0
  87. dataeval_flow/workflows/splitting/params.py +61 -0
  88. dataeval_flow/workflows/splitting/report.py +485 -0
  89. dataeval_flow/workflows/splitting/workflow.py +371 -0
  90. dataeval_flow-0.1.0.dist-info/METADATA +305 -0
  91. dataeval_flow-0.1.0.dist-info/RECORD +94 -0
  92. dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
  93. dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
  94. dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ """Drift monitoring workflow."""
@@ -0,0 +1,144 @@
1
+ """Drift monitoring workflow outputs."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Literal, TypeAlias
4
+
5
+ from pydantic import BaseModel, Field
6
+ from typing_extensions import TypedDict, TypeIs
7
+
8
+ from dataeval_flow.config.schemas import ResultMetadata
9
+ from dataeval_flow.workflow.base import Reportable, WorkflowOutputsBase, WorkflowReportBase
10
+
11
+ if TYPE_CHECKING:
12
+ from dataeval_flow.workflow import WorkflowResult
13
+
14
+ __all__ = [
15
+ "ChunkResultDict",
16
+ "ClasswiseDriftDict",
17
+ "ClasswiseDriftRowDict",
18
+ "DetectorResultDict",
19
+ "DriftMonitoringMetadata",
20
+ "DriftMonitoringOutputs",
21
+ "DriftMonitoringRawOutputs",
22
+ "DriftMonitoringReport",
23
+ "DriftMonitoringResult",
24
+ "is_drift_result",
25
+ ]
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # TypedDicts for serialized detector outputs
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ class ChunkResultDict(TypedDict):
34
+ """Single chunk result from chunked drift analysis."""
35
+
36
+ key: str # e.g. "[0:100]"
37
+ index: int
38
+ start_index: int
39
+ end_index: int
40
+ value: float
41
+ upper_threshold: float | None
42
+ lower_threshold: float | None
43
+ drifted: bool
44
+
45
+
46
+ class _DetectorResultRequired(TypedDict):
47
+ """Required fields for a detector result."""
48
+
49
+ method: str
50
+ drifted: bool
51
+ distance: float
52
+ threshold: float
53
+ metric_name: str
54
+
55
+
56
+ class DetectorResultDict(_DetectorResultRequired, total=False):
57
+ """Serialized result from a single drift detector.
58
+
59
+ ``details`` contains detector-specific statistics (p-values, per-feature
60
+ flags, fold AUROCs, etc.). ``chunks`` is present only when chunked
61
+ analysis is enabled.
62
+ """
63
+
64
+ details: dict[str, Any]
65
+ chunks: list[ChunkResultDict]
66
+
67
+
68
+ class ClasswiseDriftRowDict(TypedDict):
69
+ """Per-class drift result for a single detector."""
70
+
71
+ class_name: str
72
+ drifted: bool
73
+ distance: float
74
+ p_val: float | None
75
+
76
+
77
+ class ClasswiseDriftDict(TypedDict):
78
+ """Classwise drift results for one detector."""
79
+
80
+ detector: str
81
+ rows: list[ClasswiseDriftRowDict]
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Pydantic output models
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ class DriftMonitoringRawOutputs(WorkflowOutputsBase):
90
+ """Machine-readable results from drift monitoring workflow."""
91
+
92
+ reference_size: int = Field(
93
+ default=0,
94
+ description="Number of items in the reference dataset.",
95
+ )
96
+ test_size: int = Field(
97
+ default=0,
98
+ description="Number of items in the test dataset(s).",
99
+ )
100
+ detectors: dict[str, DetectorResultDict] = Field(
101
+ default_factory=dict,
102
+ description="Per-detector results keyed by method name.",
103
+ )
104
+ classwise: list[ClasswiseDriftDict] | None = Field(
105
+ default=None,
106
+ description="Per-class drift results (one entry per detector). None if classwise disabled.",
107
+ )
108
+
109
+
110
+ class DriftMonitoringReport(WorkflowReportBase):
111
+ """Human-readable report for drift monitoring workflow."""
112
+
113
+ findings: list[Reportable] = Field(default_factory=list)
114
+
115
+
116
+ class DriftMonitoringOutputs(BaseModel):
117
+ """Complete drift monitoring workflow output."""
118
+
119
+ raw: DriftMonitoringRawOutputs
120
+ report: DriftMonitoringReport
121
+
122
+
123
+ class DriftMonitoringMetadata(ResultMetadata):
124
+ """Metadata for the drift-monitoring workflow."""
125
+
126
+ mode: Literal["advisory", "preparatory"] = "advisory"
127
+ detectors_used: list[str] = Field(default_factory=list)
128
+ chunking_enabled: bool = False
129
+ classwise_enabled: bool = False
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Type alias and TypeIs guard for type narrowing
134
+ # ---------------------------------------------------------------------------
135
+
136
+ #: Fully typed result alias for the drift-monitoring workflow.
137
+ DriftMonitoringResult: TypeAlias = "WorkflowResult[DriftMonitoringMetadata, DriftMonitoringOutputs]"
138
+
139
+
140
+ def is_drift_result(
141
+ result: "WorkflowResult[Any, Any]",
142
+ ) -> TypeIs["WorkflowResult[DriftMonitoringMetadata, DriftMonitoringOutputs]"]:
143
+ """Narrow a generic ``WorkflowResult`` to a drift-monitoring result."""
144
+ return isinstance(result.metadata, DriftMonitoringMetadata)
@@ -0,0 +1,332 @@
1
+ """Drift monitoring workflow parameters."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Annotated, ClassVar, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
+
8
+ from dataeval_flow.workflow.base import WorkflowParametersBase
9
+
10
+ __all__ = [
11
+ "ChunkingConfig",
12
+ "DriftDetectorConfig",
13
+ "DriftDetectorDomainClassifier",
14
+ "DriftDetectorKNeighbors",
15
+ "DriftDetectorMMD",
16
+ "DriftDetectorUnivariate",
17
+ "DriftHealthThresholds",
18
+ "DriftMonitoringParameters",
19
+ "UpdateStrategyConfig",
20
+ ]
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Chunking configuration
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ class ChunkingConfig(BaseModel):
29
+ """Configuration for chunked (temporal) drift analysis.
30
+
31
+ When present, the test data is split into sequential chunks and each
32
+ chunk is tested for drift independently. This answers "when did drift
33
+ start?" and "did it recover?".
34
+ """
35
+
36
+ chunk_size: int | None = Field(
37
+ default=None,
38
+ gt=0,
39
+ description="Fixed number of samples per chunk. Mutually exclusive with chunk_count.",
40
+ )
41
+ chunk_count: int | None = Field(
42
+ default=None,
43
+ gt=0,
44
+ description="Split test data into this many equal chunks. Mutually exclusive with chunk_size.",
45
+ )
46
+ incomplete: Literal["keep", "drop", "append"] = Field(
47
+ default="keep",
48
+ description=(
49
+ "How to handle a final chunk smaller than chunk_size. "
50
+ "'keep': retain as-is, 'drop': discard, 'append': merge into last full chunk."
51
+ ),
52
+ )
53
+ threshold_multiplier: float = Field(
54
+ default=3.0,
55
+ gt=0.0,
56
+ description=(
57
+ "Z-score multiplier for the chunk-level drift threshold. "
58
+ "Lower values are more sensitive (e.g. 2.0), higher values more conservative."
59
+ ),
60
+ )
61
+
62
+ @model_validator(mode="after")
63
+ def _validate_chunking(self) -> "ChunkingConfig":
64
+ if self.chunk_size is None and self.chunk_count is None:
65
+ raise ValueError("Either chunk_size or chunk_count must be set.")
66
+ if self.chunk_size is not None and self.chunk_count is not None:
67
+ raise ValueError("chunk_size and chunk_count are mutually exclusive.")
68
+ return self
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Drift detector configs — discriminated union on ``method``
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class DriftDetectorUnivariate(BaseModel):
77
+ """Univariate statistical test per feature.
78
+
79
+ Applies a chosen statistical test independently to each feature dimension
80
+ and flags drift when enough features reject the null hypothesis (after
81
+ multiple-testing correction).
82
+ """
83
+
84
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
85
+
86
+ method: Literal["univariate"] = "univariate"
87
+ test: Literal["ks", "cvm", "mwu", "anderson", "bws"] = Field(
88
+ default="ks",
89
+ description=(
90
+ "Statistical test to apply per feature. "
91
+ "'ks' (Kolmogorov-Smirnov) is a good general-purpose default. "
92
+ "'cvm' (Cramér-von Mises) integrates squared CDF distance. "
93
+ "'mwu' (Mann-Whitney U) is rank-based and outlier-robust. "
94
+ "'anderson' (Anderson-Darling) is tail-sensitive. "
95
+ "'bws' (Baumgartner-Weiss-Schindler) has high power with tail sensitivity."
96
+ ),
97
+ )
98
+ p_val: float = Field(
99
+ default=0.05,
100
+ gt=0.0,
101
+ lt=1.0,
102
+ description="Significance level for the test.",
103
+ )
104
+ correction: Literal["bonferroni", "fdr"] = Field(
105
+ default="bonferroni",
106
+ description="Multiple-testing correction method across features.",
107
+ )
108
+ alternative: Literal["two-sided", "less", "greater"] = Field(
109
+ default="two-sided",
110
+ description="Alternative hypothesis direction.",
111
+ )
112
+ n_features: int | None = Field(
113
+ default=None,
114
+ gt=0,
115
+ description="Expected number of features. None = infer from reference data.",
116
+ )
117
+ classwise: bool = Field(
118
+ default=False,
119
+ description=(
120
+ "Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
121
+ ),
122
+ )
123
+ chunking: ChunkingConfig | None = Field(
124
+ default=None,
125
+ description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
126
+ )
127
+
128
+
129
+ class DriftDetectorMMD(BaseModel):
130
+ """Maximum Mean Discrepancy with permutation test.
131
+
132
+ Computes the MMD² statistic between reference and test distributions
133
+ using an RBF kernel, then estimates a p-value via permutation testing.
134
+ """
135
+
136
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
137
+
138
+ method: Literal["mmd"] = "mmd"
139
+ p_val: float = Field(
140
+ default=0.05,
141
+ gt=0.0,
142
+ lt=1.0,
143
+ description="Significance level for the permutation test.",
144
+ )
145
+ n_permutations: int = Field(
146
+ default=100,
147
+ gt=0,
148
+ description="Number of permutations for the test.",
149
+ )
150
+ device: str | None = Field(
151
+ default=None,
152
+ description="PyTorch device for kernel computation (e.g. 'cpu', 'cuda:0'). None = auto.",
153
+ )
154
+ classwise: bool = Field(
155
+ default=False,
156
+ description=(
157
+ "Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
158
+ ),
159
+ )
160
+ chunking: ChunkingConfig | None = Field(
161
+ default=None,
162
+ description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
163
+ )
164
+
165
+
166
+ class DriftDetectorDomainClassifier(BaseModel):
167
+ """Binary domain classifier (LightGBM) approach.
168
+
169
+ Trains a binary classifier to distinguish reference from test data.
170
+ An AUROC significantly above 0.5 indicates the distributions are
171
+ distinguishable — i.e. drift has occurred.
172
+ """
173
+
174
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
175
+
176
+ method: Literal["domain_classifier"] = "domain_classifier"
177
+ n_folds: int = Field(
178
+ default=5,
179
+ ge=2,
180
+ description="Number of cross-validation folds.",
181
+ )
182
+ threshold: float = Field(
183
+ default=0.55,
184
+ gt=0.5,
185
+ le=1.0,
186
+ description="AUROC threshold above which drift is declared.",
187
+ )
188
+ classwise: bool = Field(
189
+ default=False,
190
+ description=(
191
+ "Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
192
+ ),
193
+ )
194
+ chunking: ChunkingConfig | None = Field(
195
+ default=None,
196
+ description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
197
+ )
198
+
199
+
200
+ class DriftDetectorKNeighbors(BaseModel):
201
+ """K-nearest neighbors distance-based drift detection.
202
+
203
+ Computes per-sample k-NN distances for test data against the reference
204
+ set. A Mann-Whitney U test determines whether test distances are
205
+ stochastically larger than reference self-distances.
206
+ """
207
+
208
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
209
+
210
+ method: Literal["kneighbors"] = "kneighbors"
211
+ k: int = Field(
212
+ default=10,
213
+ gt=0,
214
+ description="Number of nearest neighbors.",
215
+ )
216
+ distance_metric: Literal["cosine", "euclidean"] = Field(
217
+ default="euclidean",
218
+ description="Distance metric for k-NN computation.",
219
+ )
220
+ p_val: float = Field(
221
+ default=0.05,
222
+ gt=0.0,
223
+ lt=1.0,
224
+ description="Significance level for the Mann-Whitney U test.",
225
+ )
226
+ classwise: bool = Field(
227
+ default=False,
228
+ description=(
229
+ "Run drift detection per class for this detector. Requires labelled datasets. Runs non-chunked only."
230
+ ),
231
+ )
232
+ chunking: ChunkingConfig | None = Field(
233
+ default=None,
234
+ description="Chunked (temporal) drift analysis for this detector. None disables chunking.",
235
+ )
236
+
237
+
238
+ # Discriminated union — Pydantic selects the right model based on ``method``.
239
+ DriftDetectorConfig = Annotated[
240
+ DriftDetectorUnivariate | DriftDetectorMMD | DriftDetectorDomainClassifier | DriftDetectorKNeighbors,
241
+ Field(discriminator="method"),
242
+ ]
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Update strategy (stubbed for future online detection)
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ class UpdateStrategyConfig(BaseModel):
251
+ """Reference-set update strategy — **stubbed for future use**.
252
+
253
+ When online drift detection is implemented, this will control how the
254
+ reference distribution evolves after each prediction step. Currently
255
+ accepted in configuration but not applied at runtime.
256
+ """
257
+
258
+ type: Literal["last_seen", "reservoir_sampling"] = Field(
259
+ description="Update algorithm. 'last_seen': sliding window. 'reservoir_sampling': uniform random sample.",
260
+ )
261
+ n: int = Field(
262
+ gt=0,
263
+ description="Number of samples to retain in the updated reference set.",
264
+ )
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Health thresholds
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ class DriftHealthThresholds(BaseModel):
273
+ """Configurable thresholds that control finding severity.
274
+
275
+ Findings that exceed a threshold are elevated to ``severity="warning"``;
276
+ otherwise they stay at ``severity="info"``.
277
+ """
278
+
279
+ any_drift_is_warning: bool = Field(
280
+ default=True,
281
+ description=(
282
+ "Non-chunked mode: if any detector flags drift, the finding is a warning. "
283
+ "Set to False to treat all non-chunked results as informational."
284
+ ),
285
+ )
286
+ chunk_drift_pct_warning: float = Field(
287
+ default=10.0,
288
+ ge=0.0,
289
+ le=100.0,
290
+ description="Chunked mode: percentage of chunks that must drift to trigger a warning.",
291
+ )
292
+ consecutive_chunks_warning: int = Field(
293
+ default=3,
294
+ ge=1,
295
+ description=(
296
+ "Chunked mode: number of consecutive drifted chunks that triggers a warning. "
297
+ "Sustained drift is typically more concerning than sporadic drift."
298
+ ),
299
+ )
300
+ classwise_any_drift_is_warning: bool = Field(
301
+ default=True,
302
+ description="Classwise mode: if any class drifts for any detector, the finding is a warning.",
303
+ )
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Top-level parameters
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ class DriftMonitoringParameters(WorkflowParametersBase):
312
+ """Parameters for the drift-monitoring workflow.
313
+
314
+ At least one detector must be configured. Chunking and classwise
315
+ analysis are optional extensions.
316
+ """
317
+
318
+ detectors: Sequence[DriftDetectorConfig] = Field(
319
+ min_length=1,
320
+ description="List of drift detectors to run. At least one required.",
321
+ )
322
+ update_strategy: UpdateStrategyConfig | None = Field(
323
+ default=None,
324
+ description=(
325
+ "Reference-set update strategy (stubbed — not yet applied at runtime). "
326
+ "Accepted in config to preserve forward compatibility."
327
+ ),
328
+ )
329
+ health_thresholds: DriftHealthThresholds = Field(
330
+ default_factory=DriftHealthThresholds,
331
+ description="Warning thresholds for drift severity classification.",
332
+ )
@@ -0,0 +1,201 @@
1
+ """Findings builders for the drift monitoring workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ import numpy as np
8
+
9
+ from dataeval_flow.workflow.base import Reportable
10
+ from dataeval_flow.workflows.drift.outputs import (
11
+ ChunkResultDict,
12
+ ClasswiseDriftRowDict,
13
+ DetectorResultDict,
14
+ DriftMonitoringRawOutputs,
15
+ )
16
+ from dataeval_flow.workflows.drift.params import (
17
+ DriftHealthThresholds,
18
+ DriftMonitoringParameters,
19
+ )
20
+
21
+
22
+ def _severity_for_detector(
23
+ drifted: bool,
24
+ thresholds: DriftHealthThresholds,
25
+ ) -> Literal["ok", "info", "warning"]:
26
+ """Determine severity for a non-chunked detector result."""
27
+ if drifted and thresholds.any_drift_is_warning:
28
+ return "warning"
29
+ return "info" if drifted else "ok"
30
+
31
+
32
+ def _severity_for_chunks(
33
+ chunks: list[ChunkResultDict],
34
+ thresholds: DriftHealthThresholds,
35
+ ) -> Literal["ok", "info", "warning"]:
36
+ """Determine severity for chunked results."""
37
+ if not chunks:
38
+ return "ok"
39
+ n_drifted = sum(1 for c in chunks if c["drifted"])
40
+ pct = 100.0 * n_drifted / len(chunks) if chunks else 0.0
41
+
42
+ # Check consecutive drift window
43
+ max_consecutive = _max_consecutive_drifted(chunks)
44
+
45
+ if pct >= thresholds.chunk_drift_pct_warning:
46
+ return "warning"
47
+ if max_consecutive >= thresholds.consecutive_chunks_warning:
48
+ return "warning"
49
+ return "info" if n_drifted > 0 else "ok"
50
+
51
+
52
+ def _max_consecutive_drifted(chunks: list[ChunkResultDict]) -> int:
53
+ """Count the longest run of consecutive drifted chunks."""
54
+ max_run = 0
55
+ current_run = 0
56
+ for c in chunks:
57
+ if c["drifted"]:
58
+ current_run += 1
59
+ max_run = max(max_run, current_run)
60
+ else:
61
+ current_run = 0
62
+ return max_run
63
+
64
+
65
+ def _build_detector_finding(
66
+ name: str,
67
+ result: DetectorResultDict,
68
+ thresholds: DriftHealthThresholds,
69
+ classwise_rows: list[ClasswiseDriftRowDict] | None = None,
70
+ ) -> Reportable:
71
+ """Build a finding for a single detector (non-chunked)."""
72
+ drifted = result["drifted"]
73
+ severity = _severity_for_detector(drifted, thresholds)
74
+ data: dict[str, Any] = {
75
+ "distance": round(result["distance"], 4),
76
+ "threshold": round(result["threshold"], 4),
77
+ "metric": result["metric_name"],
78
+ }
79
+
80
+ # Add p_val from details if available
81
+ details = result.get("details", {})
82
+ if isinstance(details, dict) and "p_val" in details:
83
+ data["p_val"] = round(float(details["p_val"]), 6)
84
+
85
+ # Univariate: summarize feature drift
86
+ if isinstance(details, dict) and "feature_drift" in details:
87
+ fd = details["feature_drift"]
88
+ if isinstance(fd, list):
89
+ n_drifted = sum(fd)
90
+ n_total = len(fd)
91
+ else:
92
+ n_drifted = int(np.sum(fd))
93
+ n_total = len(fd)
94
+ data["features_drifted"] = f"{n_drifted} / {n_total}"
95
+
96
+ description = f"{name}: distance={data['distance']}, threshold={data['threshold']}"
97
+ if "p_val" in data:
98
+ description += f", p={data['p_val']}"
99
+
100
+ # Classwise breakdown → render as a classwise_table instead of key_value
101
+ if classwise_rows:
102
+ drifted_classes = [r["class_name"] for r in classwise_rows if r["drifted"]]
103
+ n_cls_drifted = len(drifted_classes)
104
+ n_total = len(classwise_rows)
105
+ description = f"Classes drifted: {', '.join(drifted_classes)}" if drifted_classes else "No classes drifted"
106
+ if n_cls_drifted > 0 and thresholds.classwise_any_drift_is_warning:
107
+ severity = "warning"
108
+
109
+ table_rows = [
110
+ {
111
+ "Class": r["class_name"],
112
+ "Distance": round(r["distance"], 4),
113
+ "PVal": round(r["p_val"], 6) if r["p_val"] is not None else None,
114
+ "Status": "DRIFT" if r["drifted"] else "ok",
115
+ }
116
+ for r in classwise_rows
117
+ ]
118
+
119
+ data["table_rows"] = table_rows
120
+ data["brief"] = f"{n_cls_drifted}/{n_total} classes drifted"
121
+
122
+ return Reportable(
123
+ report_type="classwise_table",
124
+ severity=severity,
125
+ title=name,
126
+ data=data,
127
+ description=description,
128
+ )
129
+
130
+ return Reportable(
131
+ report_type="key_value",
132
+ severity=severity,
133
+ title=name,
134
+ data=data,
135
+ description=description,
136
+ )
137
+
138
+
139
+ def _build_chunked_finding(
140
+ name: str,
141
+ result: DetectorResultDict,
142
+ thresholds: DriftHealthThresholds,
143
+ ) -> Reportable:
144
+ """Build a table finding for chunked detector results."""
145
+ chunks = result.get("chunks", [])
146
+ if not chunks:
147
+ return _build_detector_finding(name, result, thresholds)
148
+
149
+ severity = _severity_for_chunks(chunks, thresholds)
150
+ n_drifted = sum(1 for c in chunks if c["drifted"])
151
+ pct = 100.0 * n_drifted / len(chunks) if chunks else 0.0
152
+ max_consec = _max_consecutive_drifted(chunks)
153
+
154
+ rows: list[dict[str, Any]] = [
155
+ {
156
+ "Chunk": c["key"],
157
+ "Distance": round(c["value"], 4),
158
+ "UpperThreshold": round(c["upper_threshold"], 4) if c["upper_threshold"] is not None else None,
159
+ "LowerThreshold": round(c["lower_threshold"], 4) if c["lower_threshold"] is not None else None,
160
+ "Status": "DRIFT" if c["drifted"] else "ok",
161
+ }
162
+ for c in chunks
163
+ ]
164
+
165
+ description = f"{n_drifted}/{len(chunks)} chunks drifted ({pct:.0f}%) | max consecutive: {max_consec}"
166
+
167
+ return Reportable(
168
+ report_type="chunk_table",
169
+ severity=severity,
170
+ title=name,
171
+ data={
172
+ "table_rows": rows,
173
+ "drift_flags": [c["drifted"] for c in chunks],
174
+ },
175
+ description=description,
176
+ )
177
+
178
+
179
+ def build_findings(
180
+ raw: DriftMonitoringRawOutputs,
181
+ params: DriftMonitoringParameters,
182
+ detector_names: dict[str, str],
183
+ ) -> list[Reportable]:
184
+ """Build all report findings from raw results."""
185
+ findings: list[Reportable] = []
186
+
187
+ # Index classwise results by detector display name for per-detector lookup
188
+ classwise_by_detector: dict[str, list[ClasswiseDriftRowDict]] = {}
189
+ if raw.classwise:
190
+ for cw in raw.classwise:
191
+ classwise_by_detector[cw["detector"]] = cw["rows"]
192
+
193
+ for method_key, result in raw.detectors.items():
194
+ name = detector_names.get(method_key, method_key)
195
+ cw_rows = classwise_by_detector.get(name)
196
+ if result.get("chunks"):
197
+ findings.append(_build_chunked_finding(name, result, params.health_thresholds))
198
+ else:
199
+ findings.append(_build_detector_finding(name, result, params.health_thresholds, cw_rows))
200
+
201
+ return findings