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.
- dataeval_flow/__init__.py +93 -0
- dataeval_flow/__main__.py +149 -0
- dataeval_flow/_app/__init__.py +5 -0
- dataeval_flow/_app/_model/__init__.py +5 -0
- dataeval_flow/_app/_model/_coerce.py +126 -0
- dataeval_flow/_app/_model/_discover.py +171 -0
- dataeval_flow/_app/_model/_execution.py +108 -0
- dataeval_flow/_app/_model/_introspect.py +280 -0
- dataeval_flow/_app/_model/_item.py +213 -0
- dataeval_flow/_app/_model/_registry.py +255 -0
- dataeval_flow/_app/_model/_state.py +322 -0
- dataeval_flow/_app/_model/_undo.py +61 -0
- dataeval_flow/_app/_panes/__init__.py +35 -0
- dataeval_flow/_app/_panes/_config_pane.py +173 -0
- dataeval_flow/_app/_panes/_result_pane.py +125 -0
- dataeval_flow/_app/_panes/_task_pane.py +91 -0
- dataeval_flow/_app/_panes/_widgets.py +111 -0
- dataeval_flow/_app/_screens/__init__.py +25 -0
- dataeval_flow/_app/_screens/_base.py +242 -0
- dataeval_flow/_app/_screens/_detail.py +333 -0
- dataeval_flow/_app/_screens/_model.py +102 -0
- dataeval_flow/_app/_screens/_params.py +80 -0
- dataeval_flow/_app/_screens/_pathpicker.py +68 -0
- dataeval_flow/_app/_screens/_section.py +621 -0
- dataeval_flow/_app/_screens/_settings.py +183 -0
- dataeval_flow/_app/_viewmodel/__init__.py +15 -0
- dataeval_flow/_app/_viewmodel/_builder_vm.py +272 -0
- dataeval_flow/_app/_viewmodel/_model_vm.py +70 -0
- dataeval_flow/_app/_viewmodel/_rendering.py +189 -0
- dataeval_flow/_app/_viewmodel/_result_vm.py +210 -0
- dataeval_flow/_app/_viewmodel/_section_vm.py +224 -0
- dataeval_flow/_app/app.py +742 -0
- dataeval_flow/_app/cli.py +592 -0
- dataeval_flow/_logging.py +102 -0
- dataeval_flow/cache.py +1355 -0
- dataeval_flow/config/__init__.py +80 -0
- dataeval_flow/config/_loader.py +79 -0
- dataeval_flow/config/_merge.py +92 -0
- dataeval_flow/config/_models.py +115 -0
- dataeval_flow/config/_paths.py +85 -0
- dataeval_flow/config/schemas/__init__.py +112 -0
- dataeval_flow/config/schemas/_dataset.py +111 -0
- dataeval_flow/config/schemas/_extractor.py +119 -0
- dataeval_flow/config/schemas/_metadata.py +28 -0
- dataeval_flow/config/schemas/_preprocessor.py +18 -0
- dataeval_flow/config/schemas/_selection.py +100 -0
- dataeval_flow/config/schemas/_task.py +89 -0
- dataeval_flow/config/schemas/_workflow.py +135 -0
- dataeval_flow/dataset.py +635 -0
- dataeval_flow/embeddings.py +135 -0
- dataeval_flow/metadata.py +48 -0
- dataeval_flow/preprocessing.py +141 -0
- dataeval_flow/py.typed +0 -0
- dataeval_flow/runner.py +118 -0
- dataeval_flow/selection.py +50 -0
- dataeval_flow/workflow/__init__.py +328 -0
- dataeval_flow/workflow/_text_report.py +511 -0
- dataeval_flow/workflow/base.py +69 -0
- dataeval_flow/workflow/orchestrator.py +454 -0
- dataeval_flow/workflows/__init__.py +1 -0
- dataeval_flow/workflows/analysis/__init__.py +38 -0
- dataeval_flow/workflows/analysis/outputs.py +202 -0
- dataeval_flow/workflows/analysis/params.py +114 -0
- dataeval_flow/workflows/analysis/workflow.py +1313 -0
- dataeval_flow/workflows/cleaning/__init__.py +23 -0
- dataeval_flow/workflows/cleaning/outputs.py +200 -0
- dataeval_flow/workflows/cleaning/params.py +160 -0
- dataeval_flow/workflows/cleaning/report.py +304 -0
- dataeval_flow/workflows/cleaning/workflow.py +794 -0
- dataeval_flow/workflows/drift/__init__.py +1 -0
- dataeval_flow/workflows/drift/outputs.py +144 -0
- dataeval_flow/workflows/drift/params.py +332 -0
- dataeval_flow/workflows/drift/report.py +201 -0
- dataeval_flow/workflows/drift/workflow.py +647 -0
- dataeval_flow/workflows/ood/__init__.py +1 -0
- dataeval_flow/workflows/ood/outputs.py +134 -0
- dataeval_flow/workflows/ood/params.py +161 -0
- dataeval_flow/workflows/ood/report.py +311 -0
- dataeval_flow/workflows/ood/workflow.py +728 -0
- dataeval_flow/workflows/prioritization/__init__.py +1 -0
- dataeval_flow/workflows/prioritization/outputs.py +122 -0
- dataeval_flow/workflows/prioritization/params.py +124 -0
- dataeval_flow/workflows/prioritization/report.py +117 -0
- dataeval_flow/workflows/prioritization/workflow.py +587 -0
- dataeval_flow/workflows/splitting/__init__.py +25 -0
- dataeval_flow/workflows/splitting/outputs.py +101 -0
- dataeval_flow/workflows/splitting/params.py +61 -0
- dataeval_flow/workflows/splitting/report.py +485 -0
- dataeval_flow/workflows/splitting/workflow.py +371 -0
- dataeval_flow-0.1.0.dist-info/METADATA +305 -0
- dataeval_flow-0.1.0.dist-info/RECORD +94 -0
- dataeval_flow-0.1.0.dist-info/WHEEL +4 -0
- dataeval_flow-0.1.0.dist-info/entry_points.txt +2 -0
- dataeval_flow-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Text report rendering helpers for executive-summary output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from dataeval_flow.workflow.base import Reportable
|
|
9
|
+
|
|
10
|
+
__all__ = ["_WIDTH", "_render_config_section", "_render_detail_section", "_summary_line"]
|
|
11
|
+
|
|
12
|
+
# Width of the report (matches the === bars).
|
|
13
|
+
_WIDTH = 80
|
|
14
|
+
# Maximum bar chart width in characters.
|
|
15
|
+
_BAR_MAX = 30
|
|
16
|
+
# Unicode left-filling fractional block characters (index 1 = 1/8 .. 7 = 7/8).
|
|
17
|
+
_FRAC_BLOCKS = " \u258f\u258e\u258d\u258c\u258b\u258a\u2589"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Summary line (one per finding, for the SUMMARY section)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _brief_value(finding: Reportable) -> str:
|
|
26
|
+
"""Extract a short value string from a finding's data for the summary line."""
|
|
27
|
+
data = finding.data
|
|
28
|
+
if isinstance(data, dict):
|
|
29
|
+
brief = data.get("brief")
|
|
30
|
+
if brief is not None:
|
|
31
|
+
return str(brief)
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _summary_line(finding: Reportable) -> str:
|
|
36
|
+
"""Build a single dotted summary line for a finding."""
|
|
37
|
+
label = finding.title
|
|
38
|
+
value = _brief_value(finding)
|
|
39
|
+
severity = getattr(finding, "severity", "info")
|
|
40
|
+
marker = {"warning": " [!!]", "ok": " [ok]", "info": " [..]"}.get(severity, " [..]")
|
|
41
|
+
|
|
42
|
+
# Dotted fill between label and value
|
|
43
|
+
dots_len = _WIDTH - 4 - len(label) - len(value) - len(marker)
|
|
44
|
+
dots = " " + "." * max(dots_len - 2, 1) + " " if dots_len > 3 else " "
|
|
45
|
+
return f" {label}{dots}{value}{marker}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Detail section rendering
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _section_header(title: str, right_text: str = "") -> list[str]:
|
|
54
|
+
"""Render a section header with === bars."""
|
|
55
|
+
lines: list[str] = [""]
|
|
56
|
+
lines.append("=" * _WIDTH)
|
|
57
|
+
if right_text:
|
|
58
|
+
padding = _WIDTH - 2 - len(title) - len(right_text)
|
|
59
|
+
lines.append(f" {title}{' ' * max(padding, 1)}{right_text}")
|
|
60
|
+
else:
|
|
61
|
+
lines.append(f" {title}")
|
|
62
|
+
lines.append("=" * _WIDTH)
|
|
63
|
+
return lines
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _render_detail_section(finding: Reportable) -> list[str]:
|
|
67
|
+
"""Render a full detail section for a finding."""
|
|
68
|
+
data = finding.data
|
|
69
|
+
brief = _brief_value(finding)
|
|
70
|
+
lines = _section_header(finding.title.upper(), brief)
|
|
71
|
+
|
|
72
|
+
if finding.description:
|
|
73
|
+
lines.append(f" {finding.description}")
|
|
74
|
+
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
# Plain text or non-dict data
|
|
77
|
+
if isinstance(data, str) and data:
|
|
78
|
+
lines.append(f" {data}")
|
|
79
|
+
return lines
|
|
80
|
+
|
|
81
|
+
rt = finding.report_type
|
|
82
|
+
|
|
83
|
+
if rt == "pivot_table":
|
|
84
|
+
lines.extend(_render_pivot_table(data))
|
|
85
|
+
elif rt == "chunk_table":
|
|
86
|
+
lines.extend(_render_chunk_table(data))
|
|
87
|
+
elif rt == "classwise_table":
|
|
88
|
+
lines.extend(_render_classwise_table(data))
|
|
89
|
+
elif rt == "table":
|
|
90
|
+
lines.extend(_render_table(data))
|
|
91
|
+
elif rt == "key_value":
|
|
92
|
+
lines.extend(_render_key_value(data))
|
|
93
|
+
else:
|
|
94
|
+
# text / image / unknown — just show description (already added above)
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
return lines
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Type-specific renderers
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _render_key_value(data: dict[str, Any]) -> list[str]:
|
|
106
|
+
"""Render key_value findings — metric tables, detail lines, generic pairs."""
|
|
107
|
+
lines: list[str] = []
|
|
108
|
+
|
|
109
|
+
# Outlier per-metric breakdown
|
|
110
|
+
per_metric = data.get("per_metric")
|
|
111
|
+
if per_metric and isinstance(per_metric, dict):
|
|
112
|
+
lines.append("")
|
|
113
|
+
col1 = "Metric"
|
|
114
|
+
col2 = "Count"
|
|
115
|
+
w1 = max(len(col1), *(len(k) for k in per_metric))
|
|
116
|
+
lines.append(f" {col1:<{w1}} {col2}")
|
|
117
|
+
lines.append(f" {'-' * w1} -----")
|
|
118
|
+
for metric, count in sorted(per_metric.items(), key=lambda x: -x[1]):
|
|
119
|
+
lines.append(f" {metric:<{w1}} {count:>5}")
|
|
120
|
+
total_flags = data.get("total_flags", 0)
|
|
121
|
+
unique_count = data.get("count", 0)
|
|
122
|
+
if total_flags > unique_count:
|
|
123
|
+
subject = data.get("multi_metric_subject", "items")
|
|
124
|
+
lines.append("")
|
|
125
|
+
lines.append(f" (Some {subject} trigger multiple metrics.)")
|
|
126
|
+
|
|
127
|
+
# Generic detail lines (workflow-provided)
|
|
128
|
+
detail_lines = data.get("detail_lines", [])
|
|
129
|
+
if detail_lines:
|
|
130
|
+
lines.append("")
|
|
131
|
+
lines.extend(f" {line}" for line in detail_lines)
|
|
132
|
+
|
|
133
|
+
# Fallback: render remaining scalar key-value pairs as a simple table
|
|
134
|
+
handled = {"brief", "per_metric", "detail_lines", "total_flags", "count", "multi_metric_subject"}
|
|
135
|
+
generic = {k: v for k, v in data.items() if k not in handled and not isinstance(v, (dict, list))}
|
|
136
|
+
if generic:
|
|
137
|
+
lines.append("")
|
|
138
|
+
w_key = max(len(str(k)) for k in generic)
|
|
139
|
+
for key, val in generic.items():
|
|
140
|
+
lines.append(f" {key:<{w_key}} {val}")
|
|
141
|
+
|
|
142
|
+
return lines
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _render_pivot_table(data: dict[str, Any]) -> list[str]: # noqa: C901
|
|
146
|
+
"""Render pivot-table findings — multi-column tables like classwise outliers.
|
|
147
|
+
|
|
148
|
+
``table_headers`` are display names; row dicts use field keys. A
|
|
149
|
+
``field_keys`` list in *data* maps each header to its row-dict key
|
|
150
|
+
(defaults to headers themselves). The ``%`` header is special-cased
|
|
151
|
+
to read the ``pct`` field and format with one decimal + ``%`` suffix.
|
|
152
|
+
"""
|
|
153
|
+
lines: list[str] = []
|
|
154
|
+
rows: list[dict[str, Any]] = data.get("table_data", [])
|
|
155
|
+
headers: list[str] = data.get("table_headers", [])
|
|
156
|
+
if not rows or not headers:
|
|
157
|
+
return lines
|
|
158
|
+
|
|
159
|
+
# Map display header → row-dict key
|
|
160
|
+
key_aliases: dict[str, str] = {"%": "pct", "Class Name": "class_name", "Count": "count"}
|
|
161
|
+
keys = [key_aliases.get(h, h) for h in headers]
|
|
162
|
+
|
|
163
|
+
# Format cell values: pct → "12.3%", others → str
|
|
164
|
+
def _fmt(key: str, val: object) -> str:
|
|
165
|
+
if key == "pct" and isinstance(val, (int, float)):
|
|
166
|
+
return f"{val:.1f}%"
|
|
167
|
+
return str(val) if val is not None else ""
|
|
168
|
+
|
|
169
|
+
# Pre-format all cells; split on \n for multi-line support
|
|
170
|
+
formatted: list[list[str]] = [[_fmt(k, row.get(k, "")) for k in keys] for row in rows]
|
|
171
|
+
|
|
172
|
+
# Split each cell into sub-lines for multi-line cells
|
|
173
|
+
formatted_lines: list[list[list[str]]] = [[cell.split("\n") for cell in cells] for cells in formatted]
|
|
174
|
+
|
|
175
|
+
# Compute column widths (max width across all sub-lines of all rows)
|
|
176
|
+
col_widths = [len(h) for h in headers]
|
|
177
|
+
for row_cells in formatted_lines:
|
|
178
|
+
for i, sub_lines in enumerate(row_cells):
|
|
179
|
+
for sub_line in sub_lines:
|
|
180
|
+
col_widths[i] = max(col_widths[i], len(sub_line))
|
|
181
|
+
|
|
182
|
+
# Header
|
|
183
|
+
lines.append("")
|
|
184
|
+
header_line = " " + " ".join(
|
|
185
|
+
f"{h:<{w}}" if i == 0 else f"{h:>{w}}" for i, (h, w) in enumerate(zip(headers, col_widths, strict=False))
|
|
186
|
+
)
|
|
187
|
+
lines.append(header_line)
|
|
188
|
+
lines.append(" " + " ".join("-" * w for w in col_widths))
|
|
189
|
+
|
|
190
|
+
# Data rows (with multi-line cell support)
|
|
191
|
+
for row_cells in formatted_lines:
|
|
192
|
+
n_sub = max(len(sub) for sub in row_cells)
|
|
193
|
+
for line_idx in range(n_sub):
|
|
194
|
+
parts: list[str] = []
|
|
195
|
+
for i, (sub_lines, w) in enumerate(zip(row_cells, col_widths, strict=False)):
|
|
196
|
+
cell = sub_lines[line_idx] if line_idx < len(sub_lines) else ""
|
|
197
|
+
parts.append(f"{cell:<{w}}" if i == 0 else f"{cell:>{w}}")
|
|
198
|
+
lines.append(" " + " ".join(parts))
|
|
199
|
+
|
|
200
|
+
# Footer lines (workflow-provided)
|
|
201
|
+
footer_lines = data.get("footer_lines", [])
|
|
202
|
+
if footer_lines:
|
|
203
|
+
lines.append("")
|
|
204
|
+
lines.extend(f" {line}" for line in footer_lines)
|
|
205
|
+
|
|
206
|
+
return lines
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _render_chunk_table(data: dict[str, Any]) -> list[str]:
|
|
210
|
+
"""Render chunked drift results — bar chart with threshold scale."""
|
|
211
|
+
lines: list[str] = []
|
|
212
|
+
rows: list[dict[str, Any]] = data.get("table_rows", [])
|
|
213
|
+
if not rows:
|
|
214
|
+
return lines
|
|
215
|
+
|
|
216
|
+
# --- Bar chart with threshold scale ---
|
|
217
|
+
bar_width = _BAR_MAX
|
|
218
|
+
|
|
219
|
+
# Collect all values for scale calculation
|
|
220
|
+
distances = [r["Distance"] for r in rows]
|
|
221
|
+
upper_thresholds = [r["UpperThreshold"] for r in rows if r.get("UpperThreshold") is not None]
|
|
222
|
+
lower_thresholds = [r["LowerThreshold"] for r in rows if r.get("LowerThreshold") is not None]
|
|
223
|
+
|
|
224
|
+
# Thresholds (use first row's as representative)
|
|
225
|
+
lower_thresh = lower_thresholds[0] if lower_thresholds else None
|
|
226
|
+
upper_thresh = upper_thresholds[0] if upper_thresholds else None
|
|
227
|
+
|
|
228
|
+
# Scale range: extend to cover both thresholds and all distances
|
|
229
|
+
all_vals = distances + upper_thresholds + lower_thresholds
|
|
230
|
+
scale_min = min(min(all_vals), 0.0)
|
|
231
|
+
scale_max = max(all_vals) if all_vals else 1.0
|
|
232
|
+
scale_range = scale_max - scale_min or 1.0
|
|
233
|
+
|
|
234
|
+
def _val_to_pos(val: float) -> int:
|
|
235
|
+
return int(((val - scale_min) / scale_range) * bar_width)
|
|
236
|
+
|
|
237
|
+
# Threshold positions on the bar
|
|
238
|
+
lower_pos = _val_to_pos(lower_thresh) if lower_thresh is not None else None
|
|
239
|
+
upper_pos = _val_to_pos(upper_thresh) if upper_thresh is not None else None
|
|
240
|
+
|
|
241
|
+
# Column widths
|
|
242
|
+
w_chunk = max(5, *(len(str(r["Chunk"])) for r in rows))
|
|
243
|
+
w_dist = max(8, *(len(f"{r['Distance']:.4f}") for r in rows))
|
|
244
|
+
|
|
245
|
+
lines.append("")
|
|
246
|
+
hdr = f" {'Chunk':<{w_chunk}} {'Distance':>{w_dist}} {'':>{bar_width}} Status"
|
|
247
|
+
lines.append(hdr)
|
|
248
|
+
lines.append(f" {'-' * w_chunk} {'-' * w_dist} {'-' * bar_width} ------")
|
|
249
|
+
|
|
250
|
+
for row in rows:
|
|
251
|
+
dist = row["Distance"]
|
|
252
|
+
status = row["Status"]
|
|
253
|
+
|
|
254
|
+
# Bar: █ from zero to distance, ░ elsewhere
|
|
255
|
+
zero_pos = max(0, min(_val_to_pos(0.0), bar_width))
|
|
256
|
+
dist_pos = max(0, min(_val_to_pos(dist), bar_width))
|
|
257
|
+
bar_chars = ["\u2591"] * bar_width
|
|
258
|
+
lo, hi = min(zero_pos, dist_pos), max(zero_pos, dist_pos)
|
|
259
|
+
for p in range(lo, hi):
|
|
260
|
+
bar_chars[p] = "\u2588"
|
|
261
|
+
bar = "".join(bar_chars)
|
|
262
|
+
|
|
263
|
+
lines.append(f" {row['Chunk']:<{w_chunk}} {dist:>{w_dist}.4f} {bar} {status}")
|
|
264
|
+
|
|
265
|
+
# --- Threshold scale line: "(lower)|--------|(upper)" ---
|
|
266
|
+
# The lower label can extend left into the prefix area (Threshold + Distance cols).
|
|
267
|
+
if lower_pos is not None or upper_pos is not None:
|
|
268
|
+
lower_label = f"({lower_thresh:.4f})" if lower_thresh is not None else ""
|
|
269
|
+
upper_label = f"({upper_thresh:.4f})" if upper_thresh is not None else ""
|
|
270
|
+
|
|
271
|
+
# prefix_width = indentation + Chunk col + gap + Distance col + gap before bar
|
|
272
|
+
prefix_width = 2 + w_chunk + 2 + w_dist + 2
|
|
273
|
+
# "Threshold" label takes up the first part of the prefix
|
|
274
|
+
thresh_label = " Threshold"
|
|
275
|
+
min_prefix = len(thresh_label) + 1 # at least one space after "Threshold"
|
|
276
|
+
|
|
277
|
+
if lower_pos is not None and upper_pos is not None:
|
|
278
|
+
lp = min(max(lower_pos, 0), bar_width - 1)
|
|
279
|
+
up = min(max(upper_pos, 0), bar_width - 1)
|
|
280
|
+
if lp == up:
|
|
281
|
+
scale_core = f"{lower_label}|{upper_label}"
|
|
282
|
+
else:
|
|
283
|
+
gap = "-" * max(0, up - lp - 1)
|
|
284
|
+
scale_core = f"{lower_label}|{gap}|{upper_label}"
|
|
285
|
+
# Position of first | in scale_core
|
|
286
|
+
pipe_idx = len(lower_label)
|
|
287
|
+
# We want pipe_idx to land at (prefix_width + lp) in the full line
|
|
288
|
+
line_start = prefix_width + lp - pipe_idx
|
|
289
|
+
elif upper_pos is not None:
|
|
290
|
+
up = min(max(upper_pos, 0), bar_width - 1)
|
|
291
|
+
scale_core = "-" * up + f"|{upper_label}"
|
|
292
|
+
line_start = prefix_width
|
|
293
|
+
else:
|
|
294
|
+
lp = min(max(lower_pos, 0), bar_width - 1) # type: ignore[arg-type]
|
|
295
|
+
scale_core = f"{lower_label}|"
|
|
296
|
+
pipe_idx = len(lower_label)
|
|
297
|
+
line_start = prefix_width + lp - pipe_idx
|
|
298
|
+
|
|
299
|
+
# Build the full line, ensuring "Threshold" label is visible
|
|
300
|
+
line_start = max(line_start, min_prefix)
|
|
301
|
+
full_line = thresh_label + " " * (line_start - len(thresh_label)) + scale_core
|
|
302
|
+
lines.append(full_line)
|
|
303
|
+
|
|
304
|
+
return lines
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _render_classwise_table(data: dict[str, Any]) -> list[str]:
|
|
308
|
+
"""Render classwise drift results — bar chart per class."""
|
|
309
|
+
lines: list[str] = []
|
|
310
|
+
rows: list[dict[str, Any]] = data.get("table_rows", [])
|
|
311
|
+
if not rows:
|
|
312
|
+
return lines
|
|
313
|
+
|
|
314
|
+
bar_width = _BAR_MAX
|
|
315
|
+
|
|
316
|
+
# Collect distances for scale
|
|
317
|
+
distances = [abs(r["Distance"]) for r in rows]
|
|
318
|
+
scale_max = max(distances) if distances else 1.0
|
|
319
|
+
scale_max = scale_max or 1.0 # avoid division by zero
|
|
320
|
+
|
|
321
|
+
def _val_to_pos(val: float) -> int:
|
|
322
|
+
return int((val / scale_max) * bar_width)
|
|
323
|
+
|
|
324
|
+
# Column widths
|
|
325
|
+
w_class = max(5, *(len(str(r["Class"])) for r in rows))
|
|
326
|
+
w_dist = max(8, *(len(f"{r['Distance']:.4f}") for r in rows))
|
|
327
|
+
|
|
328
|
+
# Optional p_val column
|
|
329
|
+
has_pval = any(r.get("PVal") is not None for r in rows)
|
|
330
|
+
w_pval = 6
|
|
331
|
+
if has_pval:
|
|
332
|
+
w_pval = max(w_pval, *(len(f"{r['PVal']:.2f}") for r in rows if r.get("PVal") is not None))
|
|
333
|
+
|
|
334
|
+
lines.append("")
|
|
335
|
+
hdr = f" {'Class':<{w_class}} {'Distance':>{w_dist}}"
|
|
336
|
+
sep = f" {'-' * w_class} {'-' * w_dist}"
|
|
337
|
+
if has_pval:
|
|
338
|
+
hdr += f" {'PVal':>{w_pval}}"
|
|
339
|
+
sep += f" {'-' * w_pval}"
|
|
340
|
+
hdr += f" {'':>{bar_width}} Status"
|
|
341
|
+
sep += f" {'-' * bar_width} ------"
|
|
342
|
+
lines.append(hdr)
|
|
343
|
+
lines.append(sep)
|
|
344
|
+
|
|
345
|
+
for row in rows:
|
|
346
|
+
dist = abs(row["Distance"])
|
|
347
|
+
status = row["Status"]
|
|
348
|
+
drifted = status == "DRIFT"
|
|
349
|
+
|
|
350
|
+
# Bar: █ for distance, ░ for remainder
|
|
351
|
+
dist_pos = max(0, min(_val_to_pos(dist), bar_width))
|
|
352
|
+
fill_char = "\u2588" if drifted else "\u2591"
|
|
353
|
+
bar = fill_char * dist_pos + "\u2591" * (bar_width - dist_pos)
|
|
354
|
+
|
|
355
|
+
line = f" {row['Class']:<{w_class}} {row['Distance']:>{w_dist}.4f}"
|
|
356
|
+
if has_pval:
|
|
357
|
+
pval = row.get("PVal")
|
|
358
|
+
line += f" {pval:>{w_pval}.2f}" if pval is not None else f" {'':>{w_pval}}"
|
|
359
|
+
line += f" {bar} {status}"
|
|
360
|
+
lines.append(line)
|
|
361
|
+
|
|
362
|
+
return lines
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _render_table(data: dict[str, Any]) -> list[str]:
|
|
366
|
+
"""Render table findings — generic table with bar chart."""
|
|
367
|
+
lines: list[str] = []
|
|
368
|
+
table_data: dict[str, int] = data.get("table_data", {})
|
|
369
|
+
if not table_data:
|
|
370
|
+
return lines
|
|
371
|
+
|
|
372
|
+
max_count = max(table_data.values())
|
|
373
|
+
headers = data.get("table_headers", ("Name", "Value"))
|
|
374
|
+
col1, col2 = headers[0], headers[1]
|
|
375
|
+
w1 = max(len(col1), *(len(k) for k in table_data))
|
|
376
|
+
w2 = max(len(col2), len(str(max_count)))
|
|
377
|
+
|
|
378
|
+
lines.append("")
|
|
379
|
+
lines.append(f" {col1:<{w1}} {col2:>{w2}}")
|
|
380
|
+
lines.append(f" {'-' * w1} {'-' * w2}")
|
|
381
|
+
|
|
382
|
+
for cls, count in sorted(table_data.items(), key=lambda x: -x[1]):
|
|
383
|
+
bar_len = (count / max_count) * _BAR_MAX if max_count else 0
|
|
384
|
+
full = int(bar_len)
|
|
385
|
+
frac = bar_len - full
|
|
386
|
+
bar = "\u2588" * full
|
|
387
|
+
# Append fractional block (1/8 to 7/8) for the remainder
|
|
388
|
+
frac_idx = int(frac * 8)
|
|
389
|
+
if frac_idx > 0:
|
|
390
|
+
bar += _FRAC_BLOCKS[frac_idx]
|
|
391
|
+
lines.append(f" {cls:<{w1}} {count:>{w2}} {bar}")
|
|
392
|
+
|
|
393
|
+
# Generic footer lines (workflow-provided)
|
|
394
|
+
footer_lines = data.get("footer_lines", [])
|
|
395
|
+
if footer_lines:
|
|
396
|
+
lines.append("")
|
|
397
|
+
lines.extend(f" {line}" for line in footer_lines)
|
|
398
|
+
|
|
399
|
+
return lines
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
# Configuration section
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _render_config_section(resolved_config: dict[str, Any]) -> list[str]:
|
|
408
|
+
"""Render a CONFIGURATION section showing the fully resolved config."""
|
|
409
|
+
if not resolved_config:
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
lines = _section_header("CONFIGURATION")
|
|
413
|
+
_format_value(lines, resolved_config, indent=2, max_width=_WIDTH)
|
|
414
|
+
return lines
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
_INDENT_STEP = 2
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _flow_repr(obj: Any) -> str:
|
|
421
|
+
"""Render a value as a compact, unquoted, single-line string.
|
|
422
|
+
|
|
423
|
+
Dicts use ``{k: v, ...}`` syntax, lists use ``[v, ...]``, and
|
|
424
|
+
contiguous int lists collapse to ``range(...)`` shorthand.
|
|
425
|
+
"""
|
|
426
|
+
if isinstance(obj, dict):
|
|
427
|
+
inner = ", ".join(f"{k}: {_flow_repr(v)}" for k, v in obj.items())
|
|
428
|
+
return "{" + inner + "}"
|
|
429
|
+
if isinstance(obj, list):
|
|
430
|
+
if obj and all(isinstance(i, int) for i in obj):
|
|
431
|
+
compact = _compact_indices(obj)
|
|
432
|
+
if compact != str(obj):
|
|
433
|
+
return compact
|
|
434
|
+
return "[" + ", ".join(_flow_repr(v) for v in obj) + "]"
|
|
435
|
+
return str(obj)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _format_value(lines: list[str], obj: Any, indent: int, max_width: int) -> None:
|
|
439
|
+
"""Recursively format *obj*, using flow style when it fits in *max_width*.
|
|
440
|
+
|
|
441
|
+
Dicts and lists are rendered block-style (one key/item per line) only
|
|
442
|
+
when their flow representation would exceed *max_width*. Otherwise
|
|
443
|
+
the value is kept inline.
|
|
444
|
+
"""
|
|
445
|
+
prefix = " " * indent
|
|
446
|
+
|
|
447
|
+
if isinstance(obj, dict):
|
|
448
|
+
_format_dict(lines, obj, indent, max_width)
|
|
449
|
+
elif isinstance(obj, list):
|
|
450
|
+
_format_list(lines, obj, indent, max_width)
|
|
451
|
+
else:
|
|
452
|
+
lines.append(f"{prefix}{obj}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _format_dict(lines: list[str], obj: dict[str, Any], indent: int, max_width: int) -> None:
|
|
456
|
+
"""Format a dict, keeping values inline when they fit."""
|
|
457
|
+
prefix = " " * indent
|
|
458
|
+
for key, val in obj.items():
|
|
459
|
+
flow = _flow_repr(val)
|
|
460
|
+
if len(f"{prefix}{key}: {flow}") <= max_width:
|
|
461
|
+
lines.append(f"{prefix}{key}: {flow}")
|
|
462
|
+
else:
|
|
463
|
+
lines.append(f"{prefix}{key}:")
|
|
464
|
+
_format_value(lines, val, indent + _INDENT_STEP, max_width)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _format_list(lines: list[str], obj: list[Any], indent: int, max_width: int) -> None:
|
|
468
|
+
"""Format a list, putting the first dict key on the ``- `` line."""
|
|
469
|
+
prefix = " " * indent
|
|
470
|
+
for item in obj:
|
|
471
|
+
flow = _flow_repr(item)
|
|
472
|
+
if len(f"{prefix}- {flow}") <= max_width:
|
|
473
|
+
lines.append(f"{prefix}- {flow}")
|
|
474
|
+
elif isinstance(item, dict) and item:
|
|
475
|
+
_format_list_dict_item(lines, item, indent, max_width)
|
|
476
|
+
else:
|
|
477
|
+
lines.append(f"{prefix}-")
|
|
478
|
+
_format_value(lines, item, indent + _INDENT_STEP, max_width)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _format_list_dict_item(lines: list[str], item: dict[str, Any], indent: int, max_width: int) -> None:
|
|
482
|
+
"""Format a dict inside a list, inlining the first key on the ``- `` line."""
|
|
483
|
+
prefix = " " * indent
|
|
484
|
+
it = iter(item.items())
|
|
485
|
+
first_key, first_val = next(it)
|
|
486
|
+
first_flow = _flow_repr(first_val)
|
|
487
|
+
if len(f"{prefix}- {first_key}: {first_flow}") <= max_width:
|
|
488
|
+
lines.append(f"{prefix}- {first_key}: {first_flow}")
|
|
489
|
+
else:
|
|
490
|
+
lines.append(f"{prefix}- {first_key}:")
|
|
491
|
+
_format_value(lines, first_val, indent + _INDENT_STEP * 2, max_width)
|
|
492
|
+
# Remaining keys align under the first key (one indent step past the "- ")
|
|
493
|
+
rest_indent = indent + _INDENT_STEP
|
|
494
|
+
_format_dict(lines, dict(it), rest_indent, max_width)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _compact_indices(indices: list[int]) -> str:
|
|
498
|
+
"""Collapse a contiguous int list into range shorthand for display."""
|
|
499
|
+
if not indices:
|
|
500
|
+
return "[]"
|
|
501
|
+
if len(indices) < 2:
|
|
502
|
+
return str(indices)
|
|
503
|
+
step = indices[1] - indices[0]
|
|
504
|
+
if step == 0:
|
|
505
|
+
return str(indices)
|
|
506
|
+
stop = indices[-1] + (1 if step > 0 else -1)
|
|
507
|
+
if indices == list(range(indices[0], stop, step)):
|
|
508
|
+
if step == 1:
|
|
509
|
+
return f"range({indices[0]}, {stop})"
|
|
510
|
+
return f"range({indices[0]}, {stop}, {step})"
|
|
511
|
+
return str(indices)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Workflow base classes - shared by all concrete workflows."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from dataeval_flow.config.schemas import AutoBinMethod
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MetadataConfigMixin",
|
|
12
|
+
"Reportable",
|
|
13
|
+
"WorkflowOutputsBase",
|
|
14
|
+
"WorkflowParametersBase",
|
|
15
|
+
"WorkflowReportBase",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- Metadata configuration mixin ---
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MetadataConfigMixin(BaseModel):
|
|
23
|
+
"""Mixin for workflows that need dataset metadata configuration.
|
|
24
|
+
|
|
25
|
+
Provides binning and exclusion settings for metadata analysis.
|
|
26
|
+
Mix into workflow parameter classes that require metadata processing
|
|
27
|
+
(e.g. data cleaning).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
metadata_auto_bin_method: AutoBinMethod | None = None
|
|
31
|
+
metadata_exclude: Sequence[str] = Field(default_factory=list)
|
|
32
|
+
metadata_continuous_factor_bins: Mapping[str, int | Sequence[float]] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- Parameter base ---
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WorkflowParametersBase(BaseModel):
|
|
39
|
+
"""Base class for all workflow parameters."""
|
|
40
|
+
|
|
41
|
+
mode: Literal["advisory", "preparatory"] = Field(
|
|
42
|
+
default="advisory",
|
|
43
|
+
description="advisory: report only, preparatory: modify dataset",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Output bases ---
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Reportable(BaseModel):
|
|
51
|
+
"""Human-readable report item."""
|
|
52
|
+
|
|
53
|
+
report_type: Literal["table", "key_value", "image", "text", "pivot_table", "chunk_table", "classwise_table"]
|
|
54
|
+
severity: Literal["ok", "info", "warning"] = "info"
|
|
55
|
+
title: str
|
|
56
|
+
data: dict[str, Any] | list[dict[str, Any]] | str
|
|
57
|
+
description: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WorkflowOutputsBase(BaseModel):
|
|
61
|
+
"""Base class for all workflow outputs."""
|
|
62
|
+
|
|
63
|
+
dataset_size: int = Field(description="Number of items in dataset")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WorkflowReportBase(BaseModel):
|
|
67
|
+
"""Base class for all workflow reports."""
|
|
68
|
+
|
|
69
|
+
summary: str
|