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,183 @@
1
+ """Settings modal for execution context (F10).
2
+
3
+ These settings are *not* part of the pipeline JSON/YAML config.
4
+ They control how tasks are executed (paths, resource limits, etc.)
5
+ and are ephemeral to the dashboard session (or persisted separately).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from textual.app import ComposeResult
15
+ from textual.containers import Horizontal, Vertical
16
+ from textual.screen import ModalScreen
17
+ from textual.widgets import Button, Input, Label, Static
18
+
19
+ from dataeval_flow._app._screens._pathpicker import PathPickerScreen
20
+
21
+ __all__ = ["ExecutionSettings", "SettingsModal"]
22
+
23
+ _CSS = """
24
+ SettingsModal {
25
+ align: center middle;
26
+ }
27
+
28
+ #settings-dialog {
29
+ width: 70;
30
+ height: auto;
31
+ max-height: 80%;
32
+ border: round $accent 40%;
33
+ background: $surface;
34
+ padding: 1 2;
35
+ }
36
+
37
+ #settings-title {
38
+ text-style: bold;
39
+ margin: 0 0 1 0;
40
+ }
41
+
42
+ .settings-label {
43
+ margin: 1 0 0 0;
44
+ }
45
+
46
+ .settings-row {
47
+ height: auto;
48
+ margin: 0;
49
+ }
50
+
51
+ .settings-row Input {
52
+ width: 1fr;
53
+ }
54
+
55
+ .settings-row Button {
56
+ width: auto;
57
+ min-width: 10;
58
+ margin: 0 0 0 1;
59
+ }
60
+
61
+ .settings-hint {
62
+ color: $text-muted;
63
+ margin: 0 0 0 1;
64
+ }
65
+
66
+ #settings-buttons {
67
+ height: auto;
68
+ align: right middle;
69
+ margin: 1 0 0 0;
70
+ }
71
+ """
72
+
73
+
74
+ @dataclass
75
+ class ExecutionSettings:
76
+ """Execution context settings — not part of pipeline config."""
77
+
78
+ data_dir: str = ""
79
+ cache_dir: str = ""
80
+ output_dir: str = ""
81
+
82
+
83
+ class SettingsModal(ModalScreen[ExecutionSettings | None]):
84
+ """Modal for editing execution settings (F10)."""
85
+
86
+ CSS = _CSS
87
+ BINDINGS = [("escape", "cancel", "Cancel")]
88
+
89
+ def __init__(self, current: ExecutionSettings, **kw: Any) -> None:
90
+ super().__init__(**kw)
91
+ self._current = current
92
+
93
+ def compose(self) -> ComposeResult:
94
+ with Vertical(id="settings-dialog"):
95
+ yield Static("[bold]Execution Settings[/bold]", id="settings-title", markup=True)
96
+ yield Static(
97
+ "[dim]These control how tasks run — not saved to pipeline config.[/dim]",
98
+ markup=True,
99
+ )
100
+
101
+ yield Label("Data directory", classes="settings-label")
102
+ with Horizontal(classes="settings-row"):
103
+ yield Input(
104
+ value=self._current.data_dir,
105
+ placeholder="$DATAEVAL_DATA or working directory",
106
+ id="input-data-dir",
107
+ )
108
+ yield Button("Browse", id="btn-browse-data-dir")
109
+ yield Static("[dim]Root for resolving relative dataset paths[/dim]", classes="settings-hint", markup=True)
110
+
111
+ yield Label("Cache directory", classes="settings-label")
112
+ with Horizontal(classes="settings-row"):
113
+ yield Input(
114
+ value=self._current.cache_dir,
115
+ placeholder="(disabled — no caching)",
116
+ id="input-cache-dir",
117
+ )
118
+ yield Button("Browse", id="btn-browse-cache-dir")
119
+ yield Static(
120
+ "[dim]Disk cache for embeddings, metadata, stats[/dim]",
121
+ classes="settings-hint",
122
+ markup=True,
123
+ )
124
+
125
+ yield Label("Output directory", classes="settings-label")
126
+ with Horizontal(classes="settings-row"):
127
+ yield Input(
128
+ value=self._current.output_dir,
129
+ placeholder="(console only — no file output)",
130
+ id="input-output-dir",
131
+ )
132
+ yield Button("Browse", id="btn-browse-output-dir")
133
+ yield Static(
134
+ "[dim]Where to write result.json / result.txt on export[/dim]",
135
+ classes="settings-hint",
136
+ markup=True,
137
+ )
138
+
139
+ with Vertical(id="settings-buttons"):
140
+ yield Button("Save", id="btn-settings-save", variant="primary")
141
+ yield Button("Cancel", id="btn-settings-cancel")
142
+
143
+ def on_button_pressed(self, event: Button.Pressed) -> None:
144
+ btn_id = event.button.id
145
+ if btn_id == "btn-settings-save":
146
+ self.dismiss(self._collect())
147
+ elif btn_id == "btn-settings-cancel":
148
+ self.dismiss(None)
149
+ elif btn_id == "btn-browse-data-dir":
150
+ self._browse_for("input-data-dir")
151
+ elif btn_id == "btn-browse-cache-dir":
152
+ self._browse_for("input-cache-dir")
153
+ elif btn_id == "btn-browse-output-dir":
154
+ self._browse_for("input-output-dir")
155
+
156
+ def _browse_for(self, input_id: str) -> None:
157
+ """Open a folder picker and write the result into the input field."""
158
+ current_value = self.query_one(f"#{input_id}", Input).value.strip()
159
+ start = current_value if current_value else "."
160
+
161
+ def _on_picked(result: str | None, _input_id: str = input_id) -> None:
162
+ if result is not None:
163
+ self.query_one(f"#{_input_id}", Input).value = result
164
+
165
+ self.app.push_screen(PathPickerScreen(start_path=start, mode="folder"), callback=_on_picked)
166
+
167
+ def action_cancel(self) -> None:
168
+ self.dismiss(None)
169
+
170
+ def _collect(self) -> ExecutionSettings:
171
+ return ExecutionSettings(
172
+ data_dir=self.query_one("#input-data-dir", Input).value.strip(),
173
+ cache_dir=self.query_one("#input-cache-dir", Input).value.strip(),
174
+ output_dir=self.query_one("#input-output-dir", Input).value.strip(),
175
+ )
176
+
177
+ @staticmethod
178
+ def to_paths(settings: ExecutionSettings) -> tuple[Path | None, Path | None, Path | None]:
179
+ """Convert settings strings to Path | None triples (data, cache, output)."""
180
+ data = Path(settings.data_dir) if settings.data_dir else None
181
+ cache = Path(settings.cache_dir) if settings.cache_dir else None
182
+ output = Path(settings.output_dir) if settings.output_dir else None
183
+ return data, cache, output
@@ -0,0 +1,15 @@
1
+ """ViewModel layer for the configuration builder.
2
+
3
+ ViewModels expose state and commands that Views bind to.
4
+ They depend on the Model layer but never on UI frameworks.
5
+ """
6
+
7
+ from dataeval_flow._app._viewmodel._builder_vm import BuilderViewModel
8
+ from dataeval_flow._app._viewmodel._model_vm import ModelViewModel
9
+ from dataeval_flow._app._viewmodel._section_vm import SectionViewModel
10
+
11
+ __all__ = [
12
+ "BuilderViewModel",
13
+ "ModelViewModel",
14
+ "SectionViewModel",
15
+ ]
@@ -0,0 +1,272 @@
1
+ """ViewModel for the main builder application.
2
+
3
+ Orchestrates ConfigState, ExecutionState, UndoStack, and file I/O.
4
+ No UI dependencies. The View (FlowApp) calls methods here and
5
+ translates return values into notifications and widget updates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json as json_mod
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from dataeval_flow._app._model._execution import ExecutionState, TaskExecution
15
+ from dataeval_flow._app._model._item import DELETE_SENTINEL
16
+ from dataeval_flow._app._model._registry import SECTIONS
17
+ from dataeval_flow._app._model._state import ConfigState
18
+ from dataeval_flow._app._model._undo import UndoStack
19
+ from dataeval_flow._app._viewmodel._rendering import _item_to_yaml_snippet, _snippet_task
20
+ from dataeval_flow._app._viewmodel._section_vm import SectionViewModel
21
+
22
+ if TYPE_CHECKING:
23
+ from dataeval_flow.config._models import PipelineConfig
24
+ from dataeval_flow.workflow import WorkflowResult
25
+
26
+ __all__ = ["BuilderViewModel"]
27
+
28
+
29
+ class BuilderViewModel:
30
+ """ViewModel for the main builder application."""
31
+
32
+ def __init__(self, config_path: str | Path | None = None) -> None:
33
+ self._state = ConfigState()
34
+ self._execution = ExecutionState()
35
+ self.history = UndoStack()
36
+ self.config_file_path: str = str(config_path) if config_path else ""
37
+
38
+ # -- Queries -----------------------------------------------------------
39
+
40
+ @property
41
+ def sections(self) -> list[tuple[str, str]]:
42
+ """Return the ordered list of (key, title) section pairs."""
43
+ return SECTIONS
44
+
45
+ def section_data(self) -> list[tuple[str, str, list[dict[str, Any]]]]:
46
+ """Return ``(key, title, items)`` for every section."""
47
+ return [(key, title, self._state.items(key)) for key, title in SECTIONS]
48
+
49
+ def get_item(self, section: str, index: int) -> dict[str, Any] | None:
50
+ """Return item at *index* in *section*, or ``None``."""
51
+ return self._state.get(section, index)
52
+
53
+ def items(self, section: str) -> list[dict[str, Any]]:
54
+ """Return a shallow copy of items in *section*."""
55
+ return self._state.items(section)
56
+
57
+ def names(self, section: str) -> list[str]:
58
+ """Return the names of all items in *section*."""
59
+ return self._state.names(section)
60
+
61
+ def count(self, section: str) -> int:
62
+ """Return the number of items in *section*."""
63
+ return self._state.count(section)
64
+
65
+ def is_empty(self) -> bool:
66
+ """Return ``True`` if all sections are empty."""
67
+ return self._state.is_empty()
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ """Export state as a plain dict suitable for YAML serialization."""
71
+ return self._state.to_dict()
72
+
73
+ def validate_item(self, section: str, data: dict[str, Any]) -> list[str]:
74
+ """Validate a single item dict. Returns a list of error strings."""
75
+ return self._state.validate_item(section, data)
76
+
77
+ def validate_all(self) -> list[str]:
78
+ """Validate the full config. Returns errors."""
79
+ return self._state.validate_all()
80
+
81
+ # -- Rendering ---------------------------------------------------------
82
+
83
+ def item_snippet(self, category: str, item: dict[str, Any]) -> str:
84
+ """Render a config item as a Rich-markup snippet string."""
85
+ return _item_to_yaml_snippet(category, item)
86
+
87
+ def task_snippet(self, task: dict[str, Any]) -> str:
88
+ """Render a task item snippet (used for inline toggle updates)."""
89
+ return _snippet_task(task)
90
+
91
+ # -- Factory -----------------------------------------------------------
92
+
93
+ def create_section_vm(
94
+ self,
95
+ section: str,
96
+ existing: dict[str, Any] | None = None,
97
+ ) -> SectionViewModel:
98
+ """Create a SectionViewModel bound to this builder's state."""
99
+ return SectionViewModel(section, existing, self._state)
100
+
101
+ # -- Undo / Redo -------------------------------------------------------
102
+
103
+ def snapshot(self, description: str) -> None:
104
+ """Save a state snapshot for undo."""
105
+ self.history.push(self._state.snapshot(), description)
106
+
107
+ def undo(self) -> tuple[bool, str]:
108
+ """Undo the last action. Returns ``(success, message)``."""
109
+ entry = self.history.undo(self._state.snapshot())
110
+ if entry is None:
111
+ return False, "Nothing to undo."
112
+ self._state.restore(entry.state)
113
+ return True, f"Undone: {entry.description}"
114
+
115
+ def redo(self) -> tuple[bool, str]:
116
+ """Redo the last undone action. Returns ``(success, message)``."""
117
+ entry = self.history.redo(self._state.snapshot())
118
+ if entry is None:
119
+ return False, "Nothing to redo."
120
+ self._state.restore(entry.state)
121
+ return True, f"Redone: {entry.description}"
122
+
123
+ # -- Item mutations ----------------------------------------------------
124
+
125
+ def toggle_task(self, index: int) -> str | None:
126
+ """Toggle a task's enabled state. Returns description or None."""
127
+ task = self._state.get("tasks", index)
128
+ if task is None:
129
+ return None
130
+ toggling_to = "off" if task.get("enabled", True) else "on"
131
+ desc = f"Toggle task '{task.get('name', '?')}' {toggling_to}"
132
+ self.snapshot(desc)
133
+ task["enabled"] = not task.get("enabled", True)
134
+ return desc
135
+
136
+ def delete_item(self, category: str, index: int) -> tuple[str, list[str]] | None:
137
+ """Delete an item. Snapshots before mutation.
138
+
139
+ Returns ``(description, warnings)`` or None.
140
+ """
141
+ existing = self._state.get(category, index)
142
+ if existing is None:
143
+ return None
144
+ desc = f"Delete {category[:-1]} '{existing.get('name', '?')}'"
145
+ self.snapshot(desc)
146
+ return self._state.apply_modal_result(category, index, DELETE_SENTINEL)
147
+
148
+ def apply_result(self, category: str, index: int, result: dict | str | None) -> tuple[str, list[str]] | None:
149
+ """Apply a modal result (add/update/delete). Snapshots before mutation.
150
+
151
+ Returns ``(description, warnings)`` or None.
152
+ """
153
+ if result is None:
154
+ return None
155
+
156
+ if result == DELETE_SENTINEL:
157
+ return self.delete_item(category, index)
158
+
159
+ # Derive description before mutating
160
+ name = result.get("name", "?") if isinstance(result, dict) else "?"
161
+ action = "Update" if index >= 0 else "Add"
162
+ self.snapshot(f"{action} {category[:-1]} '{name}'")
163
+ return self._state.apply_modal_result(category, index, result)
164
+
165
+ # -- File I/O ----------------------------------------------------------
166
+
167
+ def new_config(self) -> str:
168
+ """Reset to empty config. Returns status message."""
169
+ if not self._state.is_empty():
170
+ self.snapshot("New config (clear)")
171
+ self._state = ConfigState()
172
+ self.config_file_path = ""
173
+ return "New config."
174
+
175
+ def load_file(self, path: Path) -> tuple[bool, str]:
176
+ """Load config from *path*. Returns ``(success, message)``."""
177
+ if not path.exists():
178
+ return False, f"File not found: {path}"
179
+ self.snapshot("Load config file")
180
+ try:
181
+ warning = self._state.load_file(path)
182
+ except (ValueError, TypeError, OSError) as e:
183
+ return False, f"Failed to load config: {e}"
184
+ self.config_file_path = str(path)
185
+ msg = f"Loaded config from {path}"
186
+ if warning:
187
+ msg += f" ({warning})"
188
+ return True, msg
189
+
190
+ def save_file(self, path: Path, *, disable_tasks: bool = False) -> tuple[bool, str]:
191
+ """Save config to *path*. Returns ``(success, message)``.
192
+
193
+ When *disable_tasks* is ``True``, all tasks are written with
194
+ ``enabled: false`` (for programmatic use).
195
+ """
196
+ if self._state.is_empty():
197
+ return False, "Nothing to save."
198
+ if disable_tasks:
199
+ # Temporarily disable all tasks for export
200
+ tasks = self._state.items("tasks")
201
+ original_states = [(i, t.get("enabled", True)) for i, t in enumerate(tasks)]
202
+ for task in tasks:
203
+ task["enabled"] = False
204
+ try:
205
+ self._state.save_file(path)
206
+ finally:
207
+ # Restore original enabled states
208
+ for idx, enabled in original_states:
209
+ t = self._state.get("tasks", idx)
210
+ if t is not None:
211
+ t["enabled"] = enabled
212
+ else:
213
+ self._state.save_file(path)
214
+ self.config_file_path = str(path)
215
+ return True, f"Saved to {path.resolve()}"
216
+
217
+ # -- Config building ---------------------------------------------------
218
+
219
+ def build_pipeline_config(self) -> PipelineConfig:
220
+ """Validate and return a ``PipelineConfig`` from current state.
221
+
222
+ Raises ``ValueError`` if the config is invalid.
223
+ """
224
+ return self._state.to_pipeline_config()
225
+
226
+ # -- Execution ---------------------------------------------------------
227
+
228
+ def task_execution(self, name: str) -> TaskExecution | None:
229
+ """Return execution state for task *name*, or ``None``."""
230
+ return self._execution.get(name)
231
+
232
+ def mark_task_running(self, name: str) -> TaskExecution:
233
+ """Mark a task as running."""
234
+ return self._execution.mark_running(name)
235
+
236
+ def mark_task_completed(self, name: str, result: WorkflowResult[Any, Any]) -> TaskExecution:
237
+ """Mark a task as completed with its result."""
238
+ return self._execution.mark_completed(name, result)
239
+
240
+ def mark_task_failed(self, name: str, error: str) -> TaskExecution:
241
+ """Mark a task as failed."""
242
+ return self._execution.mark_failed(name, error)
243
+
244
+ def clear_task_execution(self, name: str | None = None) -> None:
245
+ """Clear execution state. ``None`` clears all."""
246
+ self._execution.clear(name)
247
+
248
+ def all_executions(self) -> list[TaskExecution]:
249
+ """Return all execution entries."""
250
+ return self._execution.entries()
251
+
252
+ def completed_results(self) -> list[tuple[str, WorkflowResult[Any, Any]]]:
253
+ """Return ``(name, result)`` for all completed tasks."""
254
+ return self._execution.completed_results()
255
+
256
+ def export_results(self, output_dir: Path) -> tuple[bool, str]:
257
+ """Write result.json and result.txt for all completed tasks."""
258
+ results = self._execution.completed_results()
259
+ if not results:
260
+ return False, "No completed results to export."
261
+
262
+ output_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ merged: dict[str, dict] = {}
265
+ text_parts: list[str] = []
266
+ for name, result in results:
267
+ merged[name] = result.to_dict()
268
+ text_parts.append(result.report(detailed=True))
269
+
270
+ (output_dir / "result.json").write_text(json_mod.dumps(merged, indent=2), encoding="utf-8")
271
+ (output_dir / "result.txt").write_text("\n".join(text_parts), encoding="utf-8")
272
+ return True, f"Exported {len(results)} result(s) to {output_dir}"
@@ -0,0 +1,70 @@
1
+ """ViewModel for the ModelModal.
2
+
3
+ Pure logic for model creation/editing. No UI dependencies.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ __all__ = ["ModelViewModel"]
11
+
12
+ MODEL_TYPES = ["onnx", "bovw", "flatten", "torch", "uncertainty"]
13
+ _PATH_TYPES = frozenset({"onnx", "torch", "uncertainty"})
14
+
15
+
16
+ class ModelViewModel:
17
+ """ViewModel for creating/editing model entries."""
18
+
19
+ def __init__(self, existing: dict[str, Any] | None = None) -> None:
20
+ self.existing = existing
21
+ self.original: dict[str, Any] | None = dict(existing) if existing else None
22
+
23
+ @property
24
+ def is_edit_mode(self) -> bool:
25
+ return self.existing is not None
26
+
27
+ @staticmethod
28
+ def needs_path(model_type: str) -> bool:
29
+ """Return True if this model type requires a model_path."""
30
+ return model_type in _PATH_TYPES
31
+
32
+ @staticmethod
33
+ def needs_vocab(model_type: str) -> bool:
34
+ """Return True if this model type requires a vocab_size."""
35
+ return model_type == "bovw"
36
+
37
+ def build_result(
38
+ self,
39
+ name: str,
40
+ model_type: str,
41
+ model_path: str,
42
+ vocab_size_str: str,
43
+ ) -> dict[str, Any] | None:
44
+ """Assemble the result dict. Returns None if invalid."""
45
+ if not name or not model_type:
46
+ return None
47
+ result: dict[str, Any] = {"name": name, "type": model_type}
48
+ if self.needs_path(model_type):
49
+ if not model_path:
50
+ return None
51
+ result["model_path"] = model_path
52
+ if self.needs_vocab(model_type) and vocab_size_str:
53
+ try:
54
+ result["vocab_size"] = int(vocab_size_str)
55
+ except ValueError:
56
+ return None
57
+ return result
58
+
59
+ def check_dirty(self, collected: dict[str, Any] | None) -> bool:
60
+ """Return True if collected data differs from original."""
61
+ if not collected:
62
+ return False
63
+ if not self.original:
64
+ return True
65
+ return collected != self.original
66
+
67
+ @staticmethod
68
+ def validation_message() -> str:
69
+ """Return the error message for invalid input."""
70
+ return "Name and type are required. Path required for onnx/torch/uncertainty."