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,134 @@
1
+ """OOD detection 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 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
+ "DetectorOODResultDict",
16
+ "FactorDeviationDict",
17
+ "OODDetectionMetadata",
18
+ "OODDetectionOutputs",
19
+ "OODDetectionRawOutputs",
20
+ "OODDetectionReport",
21
+ "OODDetectionResult",
22
+ "OODSampleDict",
23
+ "is_ood_result",
24
+ ]
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # TypedDicts for serialized detector outputs
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ class OODSampleDict(TypedDict):
33
+ """Per-sample OOD result."""
34
+
35
+ index: int
36
+ score: float
37
+ is_ood: bool
38
+
39
+
40
+ class _DetectorOODResultRequired(TypedDict):
41
+ """Required fields for an OOD detector result."""
42
+
43
+ method: str
44
+ ood_count: int
45
+ total_count: int
46
+ ood_percentage: float
47
+ threshold_score: float
48
+
49
+
50
+ class DetectorOODResultDict(_DetectorOODResultRequired, total=False):
51
+ """Serialized result from a single OOD detector.
52
+
53
+ ``samples`` contains per-sample scores and OOD flags for all test
54
+ samples.
55
+ """
56
+
57
+ samples: list[OODSampleDict]
58
+
59
+
60
+ class FactorDeviationDict(TypedDict):
61
+ """Per-sample metadata factor deviations for an OOD sample."""
62
+
63
+ index: int
64
+ deviations: dict[str, float]
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Pydantic output models
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class OODDetectionRawOutputs(WorkflowOutputsBase):
73
+ """Machine-readable results from OOD detection workflow."""
74
+
75
+ reference_size: int = Field(
76
+ default=0,
77
+ description="Number of items in the reference dataset.",
78
+ )
79
+ test_size: int = Field(
80
+ default=0,
81
+ description="Number of items in the test dataset(s).",
82
+ )
83
+ detectors: dict[str, DetectorOODResultDict] = Field(
84
+ default_factory=dict,
85
+ description="Per-detector results keyed by method name.",
86
+ )
87
+ ood_indices: list[int] = Field(
88
+ default_factory=list,
89
+ description="Union of OOD sample indices across all detectors.",
90
+ )
91
+ factor_deviations: list[FactorDeviationDict] | None = Field(
92
+ default=None,
93
+ description="Per-OOD-sample metadata factor deviations. None if metadata insights disabled.",
94
+ )
95
+ factor_predictors: dict[str, float] | None = Field(
96
+ default=None,
97
+ description="Mutual information (bits) per metadata factor with OOD status. None if insights disabled.",
98
+ )
99
+
100
+
101
+ class OODDetectionReport(WorkflowReportBase):
102
+ """Human-readable report for OOD detection workflow."""
103
+
104
+ findings: list[Reportable] = Field(default_factory=list)
105
+
106
+
107
+ class OODDetectionOutputs(BaseModel):
108
+ """Complete OOD detection workflow output."""
109
+
110
+ raw: OODDetectionRawOutputs
111
+ report: OODDetectionReport
112
+
113
+
114
+ class OODDetectionMetadata(ResultMetadata):
115
+ """Metadata for the ood-detection workflow."""
116
+
117
+ mode: Literal["advisory", "preparatory"] = "advisory"
118
+ detectors_used: list[str] = Field(default_factory=list)
119
+ metadata_insights_enabled: bool = False
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Type alias and TypeIs guard for type narrowing
124
+ # ---------------------------------------------------------------------------
125
+
126
+ #: Fully typed result alias for the ood-detection workflow.
127
+ OODDetectionResult: TypeAlias = "WorkflowResult[OODDetectionMetadata, OODDetectionOutputs]"
128
+
129
+
130
+ def is_ood_result(
131
+ result: "WorkflowResult[Any, Any]",
132
+ ) -> TypeIs["WorkflowResult[OODDetectionMetadata, OODDetectionOutputs]"]:
133
+ """Narrow a generic ``WorkflowResult`` to an OOD detection result."""
134
+ return isinstance(result.metadata, OODDetectionMetadata)
@@ -0,0 +1,161 @@
1
+ """OOD detection workflow parameters."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Annotated, ClassVar, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from dataeval_flow.workflow.base import WorkflowParametersBase
9
+
10
+ __all__ = [
11
+ "OODDetectionParameters",
12
+ "OODDetectorConfig",
13
+ "OODDetectorDomainClassifier",
14
+ "OODDetectorKNeighbors",
15
+ "OODHealthThresholds",
16
+ ]
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # OOD detector configs — discriminated union on ``method``
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class OODDetectorKNeighbors(BaseModel):
25
+ """K-nearest neighbors OOD detector.
26
+
27
+ Uses average distance to k nearest neighbors in embedding space to
28
+ detect OOD samples. Samples with larger average distances to their
29
+ k nearest neighbors in the reference set are considered more likely OOD.
30
+ """
31
+
32
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
33
+
34
+ method: Literal["kneighbors"] = "kneighbors"
35
+ k: int = Field(
36
+ default=10,
37
+ gt=0,
38
+ description="Number of nearest neighbors to consider.",
39
+ )
40
+ distance_metric: Literal["cosine", "euclidean"] = Field(
41
+ default="cosine",
42
+ description="Distance metric for k-NN computation.",
43
+ )
44
+ threshold_perc: float = Field(
45
+ default=95.0,
46
+ gt=0.0,
47
+ le=100.0,
48
+ description=(
49
+ "Percentage of reference data considered normal (0-100). "
50
+ "Higher values result in more permissive thresholds."
51
+ ),
52
+ )
53
+
54
+
55
+ class OODDetectorDomainClassifier(BaseModel):
56
+ """Domain classifier OOD detector.
57
+
58
+ Uses a LightGBM classifier's ability to distinguish test samples from
59
+ reference samples as an OOD signal. Samples that a classifier can easily
60
+ identify as 'not reference' are likely OOD.
61
+ """
62
+
63
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
64
+
65
+ method: Literal["domain_classifier"] = "domain_classifier"
66
+ n_folds: int = Field(
67
+ default=5,
68
+ ge=2,
69
+ description="Number of cross-validation folds per repeat.",
70
+ )
71
+ n_repeats: int = Field(
72
+ default=5,
73
+ ge=1,
74
+ description="Number of times to repeat the k-fold split.",
75
+ )
76
+ n_std: float = Field(
77
+ default=2.0,
78
+ gt=0.0,
79
+ description="Number of standard deviations above the null mean for threshold.",
80
+ )
81
+ threshold_perc: float = Field(
82
+ default=95.0,
83
+ gt=0.0,
84
+ le=100.0,
85
+ description=(
86
+ "Percentage of reference data considered normal (0-100). "
87
+ "Higher values result in more permissive thresholds."
88
+ ),
89
+ )
90
+
91
+
92
+ # Discriminated union — Pydantic selects the right model based on ``method``.
93
+ OODDetectorConfig = Annotated[
94
+ OODDetectorKNeighbors | OODDetectorDomainClassifier,
95
+ Field(discriminator="method"),
96
+ ]
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Health thresholds
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ class OODHealthThresholds(BaseModel):
105
+ """Configurable thresholds that control finding severity.
106
+
107
+ Findings that exceed a threshold are elevated to ``severity="warning"``;
108
+ otherwise they stay at ``severity="info"`` or ``severity="ok"``.
109
+ """
110
+
111
+ ood_pct_warning: float = Field(
112
+ default=10.0,
113
+ ge=0.0,
114
+ le=100.0,
115
+ description="Percentage of test samples flagged OOD that triggers a warning.",
116
+ )
117
+ ood_pct_info: float = Field(
118
+ default=1.0,
119
+ ge=0.0,
120
+ le=100.0,
121
+ description=(
122
+ "Percentage of test samples flagged OOD that triggers an info finding. "
123
+ "Below this percentage, severity is 'ok'."
124
+ ),
125
+ )
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Top-level parameters
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ class OODDetectionParameters(WorkflowParametersBase):
134
+ """Parameters for the ood-detection workflow.
135
+
136
+ At least one detector must be configured. Metadata insights are
137
+ enabled by default to explain why samples are flagged OOD.
138
+ """
139
+
140
+ detectors: Sequence[OODDetectorConfig] = Field(
141
+ min_length=1,
142
+ description="List of OOD detectors to run. At least one required.",
143
+ )
144
+ health_thresholds: OODHealthThresholds = Field(
145
+ default_factory=OODHealthThresholds,
146
+ description="Warning thresholds for OOD severity classification.",
147
+ )
148
+ metadata_insights: bool = Field(
149
+ default=True,
150
+ description=(
151
+ "Whether to compute factor_deviation and factor_predictors for OOD samples to explain why they are flagged."
152
+ ),
153
+ )
154
+ max_ood_insights: int = Field(
155
+ default=50,
156
+ gt=0,
157
+ description=(
158
+ "Maximum number of OOD samples to compute detailed metadata "
159
+ "deviation for. Caps compute cost on large datasets."
160
+ ),
161
+ )
@@ -0,0 +1,311 @@
1
+ """Findings builders for the OOD detection workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from dataeval_flow.workflow.base import Reportable
8
+ from dataeval_flow.workflows.ood.outputs import (
9
+ DetectorOODResultDict,
10
+ FactorDeviationDict,
11
+ OODDetectionRawOutputs,
12
+ )
13
+ from dataeval_flow.workflows.ood.params import (
14
+ OODDetectionParameters,
15
+ OODHealthThresholds,
16
+ )
17
+
18
+
19
+ def _severity_for_ood(
20
+ ood_pct: float,
21
+ thresholds: OODHealthThresholds,
22
+ ) -> Literal["ok", "info", "warning"]:
23
+ """Determine severity for an OOD detector result based on OOD percentage."""
24
+ if ood_pct >= thresholds.ood_pct_warning:
25
+ return "warning"
26
+ if ood_pct >= thresholds.ood_pct_info:
27
+ return "info"
28
+ return "ok"
29
+
30
+
31
+ def _score_histogram_lines(det_result: DetectorOODResultDict, n_bins: int = 10) -> list[str]:
32
+ """Build ASCII histogram lines for a detector's score distribution."""
33
+ samples = det_result.get("samples", [])
34
+ if not samples:
35
+ return []
36
+
37
+ in_scores = [s["score"] for s in samples if not s["is_ood"]]
38
+ ood_scores = [s["score"] for s in samples if s["is_ood"]]
39
+ all_scores = [s["score"] for s in samples]
40
+
41
+ lo = min(all_scores)
42
+ hi = max(all_scores)
43
+ if hi == lo:
44
+ return [f"All scores = {lo:.4f}"]
45
+
46
+ bin_w = (hi - lo) / n_bins
47
+ threshold = det_result["threshold_score"]
48
+ bar_max = 30
49
+
50
+ # Build bins
51
+ bins: list[tuple[float, float, int, int]] = []
52
+ for i in range(n_bins):
53
+ b_lo = lo + i * bin_w
54
+ b_hi = b_lo + bin_w
55
+ ic = sum(1 for s in in_scores if (b_lo <= s < b_hi) or (i == n_bins - 1 and s == b_hi))
56
+ oc = sum(1 for s in ood_scores if (b_lo <= s < b_hi) or (i == n_bins - 1 and s == b_hi))
57
+ bins.append((b_lo, b_hi, ic, oc))
58
+
59
+ max_count = max(ic + oc for _, _, ic, oc in bins) or 1
60
+
61
+ # Format ranges to determine column width
62
+ range_strs = [f"{lo:.3f}-{hi:.3f}" for lo, hi, _, _ in bins]
63
+ w_range = max(len(r) for r in range_strs)
64
+
65
+ lines = [
66
+ "",
67
+ f"{'Range':>{w_range}} {'In':>4} {'OOD':>4}",
68
+ f"{'-' * w_range} {'-' * 4} {'-' * 4} {'-' * bar_max}",
69
+ ]
70
+
71
+ for range_str, (_, _, ic, oc) in zip(range_strs, bins, strict=True):
72
+ total_bar = int(((ic + oc) / max_count) * bar_max)
73
+ in_bar = int((ic / max_count) * bar_max) if ic else 0
74
+ ood_bar = total_bar - in_bar
75
+ bar = "\u2588" * in_bar + "\u2591" * ood_bar
76
+ b_lo = float(range_str.split("-")[0])
77
+ b_hi = float(range_str.split("-")[1])
78
+ marker = " \u2190 threshold" if b_lo <= threshold < b_hi else ""
79
+ lines.append(f"{range_str:>{w_range}} {ic:4d} {oc:4d} {bar}{marker}")
80
+
81
+ lines.append(f"\u2588 in-dist \u2591 OOD (threshold={threshold:.4f})")
82
+ return lines
83
+
84
+
85
+ def _build_detector_finding(
86
+ name: str,
87
+ result: DetectorOODResultDict,
88
+ thresholds: OODHealthThresholds,
89
+ ) -> Reportable:
90
+ """Build a finding for a single OOD detector."""
91
+ ood_pct = result["ood_percentage"]
92
+ severity = _severity_for_ood(ood_pct, thresholds)
93
+
94
+ detail_lines = _score_histogram_lines(result)
95
+
96
+ data: dict[str, Any] = {
97
+ "ood_count": result["ood_count"],
98
+ "total_count": result["total_count"],
99
+ "ood_percentage": f"{ood_pct:.1f}%",
100
+ "threshold_score": round(result["threshold_score"], 6),
101
+ "detail_lines": detail_lines,
102
+ }
103
+
104
+ description = f"{name}: {result['ood_count']}/{result['total_count']} samples OOD ({ood_pct:.1f}%)"
105
+
106
+ return Reportable(
107
+ report_type="key_value",
108
+ severity=severity,
109
+ title=name,
110
+ data=data,
111
+ description=description,
112
+ )
113
+
114
+
115
+ def _build_factor_predictors_finding(
116
+ predictors: dict[str, float],
117
+ ) -> Reportable:
118
+ """Build a table finding showing mutual information per factor."""
119
+ data: dict[str, Any] = {
120
+ "table_data": {k: round(v, 4) for k, v in predictors.items()},
121
+ "table_headers": ("Factor", "MI (bits)"),
122
+ }
123
+
124
+ return Reportable(
125
+ report_type="table",
126
+ severity="info",
127
+ title="OOD Factor Predictors",
128
+ data=data,
129
+ description="Mutual information between metadata factors and OOD status (higher = stronger association)",
130
+ )
131
+
132
+
133
+ def _compute_normalized_scores(
134
+ detectors: dict[str, DetectorOODResultDict],
135
+ ) -> tuple[dict[int, float], set[int], dict[str, set[int]]]:
136
+ """Normalize OOD scores across detectors and find mutually agreed OOD samples.
137
+
138
+ Scores are normalized by dividing by the detector's threshold, giving a
139
+ unitless ratio where 1.0 = at threshold and >1.0 = OOD. This makes scores
140
+ comparable across detectors with different scales (e.g. distance-based
141
+ KNeighbors vs probability-based DomainClassifier).
142
+
143
+ Returns
144
+ -------
145
+ normalized_scores
146
+ Mapping of sample index to mean normalized score across all detectors.
147
+ mutual_ood
148
+ Set of sample indices flagged as OOD by *every* detector.
149
+ unique_ood
150
+ Per-detector sets of OOD indices unique to that detector (not in mutual).
151
+ """
152
+ # Collect per-detector OOD index sets and normalized scores
153
+ per_detector_ood: dict[str, set[int]] = {}
154
+ per_sample_norm: dict[int, list[float]] = {}
155
+
156
+ for method, det_result in detectors.items():
157
+ threshold = det_result["threshold_score"]
158
+ if threshold <= 0:
159
+ continue
160
+
161
+ ood_set: set[int] = set()
162
+ for s in det_result.get("samples", []):
163
+ norm = s["score"] / threshold
164
+ per_sample_norm.setdefault(s["index"], []).append(norm)
165
+ if s["is_ood"]:
166
+ ood_set.add(s["index"])
167
+ per_detector_ood[method] = ood_set
168
+
169
+ # Mutual agreement: intersection of all detectors' OOD sets
170
+ ood_sets = list(per_detector_ood.values())
171
+ mutual_ood = ood_sets[0].intersection(*ood_sets[1:]) if ood_sets else set()
172
+
173
+ # Per-detector unique OOD (flagged by this detector only, not in mutual)
174
+ unique_ood = {method: ood - mutual_ood for method, ood in per_detector_ood.items()}
175
+
176
+ # Average normalized score across detectors
177
+ normalized_scores = {idx: sum(vals) / len(vals) for idx, vals in per_sample_norm.items()}
178
+
179
+ return normalized_scores, mutual_ood, unique_ood
180
+
181
+
182
+ def _build_factor_deviations_finding(
183
+ deviations: list[FactorDeviationDict],
184
+ normalized_scores: dict[int, float],
185
+ mutual_ood: set[int],
186
+ ) -> Reportable:
187
+ """Build a finding for per-sample metadata deviations.
188
+
189
+ Only includes samples that all detectors agree are OOD, sorted by
190
+ normalized OOD score (descending).
191
+ """
192
+ # Filter to mutually agreed OOD samples
193
+ agreed_devs = [d for d in deviations if d["index"] in mutual_ood]
194
+
195
+ # Sort by normalized score (most OOD first)
196
+ agreed_devs.sort(key=lambda d: normalized_scores.get(d["index"], 0.0), reverse=True)
197
+
198
+ detail_lines: list[str] = []
199
+ for dev in agreed_devs[:10]: # Cap display at 10 samples
200
+ norm = normalized_scores.get(dev["index"], 0.0)
201
+ top_factors = list(dev["deviations"].items())[:3]
202
+ factors_str = ", ".join(f"{k}={v:.2f}" for k, v in top_factors)
203
+ detail_lines.append(f"Sample {dev['index']:4d} (score={norm:.2f}x): {factors_str}")
204
+
205
+ n_agreed = len(agreed_devs)
206
+ n_total = len(deviations)
207
+
208
+ return Reportable(
209
+ report_type="key_value",
210
+ severity="info",
211
+ title="OOD Sample Metadata Deviations",
212
+ data={"detail_lines": detail_lines},
213
+ description=(
214
+ f"{n_agreed}/{n_total} OOD samples agreed by all detectors "
215
+ f"(sorted by normalized score, showing top {min(10, n_agreed)})"
216
+ ),
217
+ )
218
+
219
+
220
+ def _build_aggregate_finding(
221
+ mutual_ood: set[int],
222
+ normalized_scores: dict[int, float],
223
+ total_ood: int,
224
+ test_size: int,
225
+ thresholds: OODHealthThresholds,
226
+ ) -> Reportable:
227
+ """Build a finding for the aggregate (mutually agreed) OOD result."""
228
+ sorted_indices = sorted(mutual_ood, key=lambda i: normalized_scores.get(i, 0.0), reverse=True)
229
+ detail_lines: list[str] = []
230
+ for idx in sorted_indices[:10]:
231
+ norm = normalized_scores.get(idx, 0.0)
232
+ detail_lines.append(f"Sample {idx:4d} (score={norm:.2f}x)")
233
+
234
+ n_mutual = len(mutual_ood)
235
+ ood_pct = (n_mutual / test_size * 100) if test_size else 0.0
236
+ severity = _severity_for_ood(ood_pct, thresholds)
237
+
238
+ return Reportable(
239
+ report_type="key_value",
240
+ severity=severity,
241
+ title="Aggregate OOD (all detectors agree)",
242
+ data={"detail_lines": detail_lines},
243
+ description=(
244
+ f"{n_mutual}/{total_ood} OOD samples agreed by all detectors ({ood_pct:.1f}%) "
245
+ f"(sorted by normalized score, showing top {min(10, n_mutual)})"
246
+ ),
247
+ )
248
+
249
+
250
+ def _build_unique_ood_finding(
251
+ unique_ood: dict[str, set[int]],
252
+ normalized_scores: dict[int, float],
253
+ detector_names: dict[str, str],
254
+ ) -> Reportable:
255
+ """Build a single finding listing OOD samples unique to each detector."""
256
+ detail_lines: list[str] = []
257
+ for method_key, unique_indices in unique_ood.items():
258
+ if not unique_indices:
259
+ continue
260
+ name = detector_names.get(method_key, method_key)
261
+ sorted_indices = sorted(unique_indices, key=lambda i: normalized_scores.get(i, 0.0), reverse=True)
262
+ detail_lines.append(f"{name}: {len(unique_indices)} unique sample(s)")
263
+ for idx in sorted_indices[:10]:
264
+ norm = normalized_scores.get(idx, 0.0)
265
+ detail_lines.append(f" Sample {idx:4d} (score={norm:.2f}x)")
266
+
267
+ total_unique = sum(len(v) for v in unique_ood.values())
268
+
269
+ return Reportable(
270
+ report_type="key_value",
271
+ severity="info",
272
+ title="Unique OOD Samples (single-detector only)",
273
+ data={"detail_lines": detail_lines},
274
+ description=f"{total_unique} sample(s) flagged by only one detector",
275
+ )
276
+
277
+
278
+ def build_findings(
279
+ raw: OODDetectionRawOutputs,
280
+ params: OODDetectionParameters,
281
+ detector_names: dict[str, str],
282
+ ) -> list[Reportable]:
283
+ """Build all report findings from raw results."""
284
+ findings: list[Reportable] = []
285
+ multi_detector = len(raw.detectors) > 1
286
+
287
+ # Per-detector findings
288
+ for method_key, result in raw.detectors.items():
289
+ name = detector_names.get(method_key, method_key)
290
+ findings.append(_build_detector_finding(name, result, params.health_thresholds))
291
+
292
+ # Compute normalized scores for cross-detector comparison
293
+ normalized_scores, mutual_ood, unique_ood = _compute_normalized_scores(raw.detectors)
294
+
295
+ # Aggregate + unique findings (only when multiple detectors)
296
+ if multi_detector:
297
+ total_ood = len(raw.ood_indices)
298
+ findings.append(
299
+ _build_aggregate_finding(mutual_ood, normalized_scores, total_ood, raw.test_size, params.health_thresholds)
300
+ )
301
+ if any(unique_ood.values()):
302
+ findings.append(_build_unique_ood_finding(unique_ood, normalized_scores, detector_names))
303
+
304
+ # Metadata insights findings
305
+ if raw.factor_predictors:
306
+ findings.append(_build_factor_predictors_finding(raw.factor_predictors))
307
+
308
+ if raw.factor_deviations:
309
+ findings.append(_build_factor_deviations_finding(raw.factor_deviations, normalized_scores, mutual_ood))
310
+
311
+ return findings