1
0

2 Коммиты b07670e9ac ... 5121f04829

Автор SHA1 Сообщение Дата
  Nicholas Schense 5121f04829 Implementation of K-fold for better analysis 1 месяц назад
  Nicholas Schense 4a1a83559e Initial analysis work 1 месяц назад
13 измененных файлов с 1342 добавлено и 105 удалено
  1. 78 0
      alnn_rewrite.log
  2. 23 2
      config.toml
  3. 166 48
      main.py
  4. 117 30
      model/dataset.py
  5. 1 0
      pyproject.toml
  6. 128 2
      tasks/__init__.py
  7. 523 0
      tasks/analysis.py
  8. 151 0
      tasks/kfold.py
  9. 31 15
      tasks/load_data.py
  10. 69 0
      tasks/templates/model_report.typ
  11. 8 2
      util/progress.py
  12. 20 6
      util/screens.py
  13. 27 0
      uv.lock

+ 78 - 0
alnn_rewrite.log

@@ -4764,3 +4764,81 @@ RuntimeError: Error(s) in loading state_dict for CNN3D:
 2026-07-12 09:40:51,271 - INFO - [Evaluate Models on Noised Data] Saved bayesian evaluation to outputs/exp1/evaluations/noisy_bayesian.nc (models=30, samples=97, noise_levels=5).
 2026-07-12 09:40:51,272 - INFO - [Evaluate Models on Noised Data] Task completed.
 2026-07-12 09:40:51,274 - INFO - [SYSTEM] All tasks finished successfully!
+2026-07-15 09:58:02,551 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-15 09:58:10,021 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-15 09:59:00,769 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-15 09:59:04,466 - INFO - [SYSTEM] Initiating Scenario: 4. Analyze Existing Evaluations
+2026-07-15 09:59:04,469 - INFO - [SYSTEM] Starting Task 1/1: Analyze Evaluations
+2026-07-15 09:59:04,567 - INFO - [Analyze Evaluations] Loaded evaluation 'bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 09:59:04,571 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_bayesian' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 09:59:04,575 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_normal' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 09:59:04,602 - INFO - [Analyze Evaluations] Loaded evaluation 'normal' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 09:59:04,603 - INFO - [Analyze Evaluations] Running analysis: model_report
+2026-07-15 09:59:04,670 - INFO - [Analyze Evaluations] model_report: wrote model_report.pdf (2 family/families, 60 model(s)).
+2026-07-15 09:59:04,671 - INFO - [Analyze Evaluations] Analysis artifacts written to outputs/exp1/analysis/.
+2026-07-15 09:59:04,673 - INFO - [Analyze Evaluations] Task completed.
+2026-07-15 09:59:04,674 - INFO - [SYSTEM] All tasks finished successfully!
+2026-07-15 10:11:12,119 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-15 10:11:12,120 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-15 10:11:12,163 - INFO - [SYSTEM] Initiating Scenario: 4. Analyze Existing Evaluations
+2026-07-15 10:11:12,179 - INFO - [SYSTEM] Starting Task 1/1: Analyze Evaluations
+2026-07-15 10:11:12,191 - INFO - [Analyze Evaluations] Loaded evaluation 'bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:11:12,200 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_bayesian' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:11:12,204 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_normal' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:11:12,211 - INFO - [Analyze Evaluations] Loaded evaluation 'normal' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:11:12,212 - INFO - [Analyze Evaluations] Running analysis: model_report
+2026-07-15 10:11:12,271 - INFO - [Analyze Evaluations] model_report: wrote model_report.pdf (2 family/families, 60 model(s)).
+2026-07-15 10:11:12,272 - INFO - [Analyze Evaluations] Analysis artifacts written to outputs/exp1/analysis/.
+2026-07-15 10:11:12,273 - INFO - [Analyze Evaluations] Task completed.
+2026-07-15 10:11:12,275 - INFO - [SYSTEM] All tasks finished successfully!
+2026-07-15 10:19:03,576 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-15 10:19:03,577 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-15 10:19:03,621 - INFO - [SYSTEM] Initiating Scenario: 4. Analyze Existing Evaluations
+2026-07-15 10:19:03,630 - INFO - [SYSTEM] Starting Task 1/1: Analyze Evaluations
+2026-07-15 10:19:03,649 - INFO - [Analyze Evaluations] Loaded evaluation 'bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:19:03,658 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_bayesian' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:19:03,662 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_normal' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:19:03,665 - INFO - [Analyze Evaluations] Loaded evaluation 'normal' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:19:03,669 - INFO - [Analyze Evaluations] Running analysis: model_report
+2026-07-15 10:19:03,729 - INFO - [Analyze Evaluations] model_report: wrote model_report.pdf (2 family/families, 60 model(s)).
+2026-07-15 10:19:03,730 - INFO - [Analyze Evaluations] Running analysis: ensemble_size_accuracy
+2026-07-15 10:19:03,743 - INFO - [Analyze Evaluations] ensemble_size_accuracy: normal - sizes 1..30, up to 30 configs each.
+2026-07-15 10:19:03,757 - INFO - [Analyze Evaluations] ensemble_size_accuracy: bayesian - sizes 1..30, up to 30 configs each.
+2026-07-15 10:19:04,199 - INFO - [Analyze Evaluations] ensemble_size_accuracy: wrote ensemble_size_accuracy.png (2 family/families).
+2026-07-15 10:19:04,201 - INFO - [Analyze Evaluations] Analysis artifacts written to outputs/exp1/analysis/.
+2026-07-15 10:19:04,202 - INFO - [Analyze Evaluations] Task completed.
+2026-07-15 10:19:04,204 - INFO - [SYSTEM] All tasks finished successfully!
+2026-07-15 10:24:33,887 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-15 10:24:33,888 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-15 10:24:33,932 - INFO - [SYSTEM] Initiating Scenario: 4. Analyze Existing Evaluations
+2026-07-15 10:24:33,942 - INFO - [SYSTEM] Starting Task 1/1: Analyze Evaluations
+2026-07-15 10:24:33,959 - INFO - [Analyze Evaluations] Loaded evaluation 'bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:33,967 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_bayesian' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:33,971 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_normal' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:33,974 - INFO - [Analyze Evaluations] Loaded evaluation 'normal' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:33,975 - INFO - [Analyze Evaluations] Running analysis: model_report
+2026-07-15 10:24:34,038 - INFO - [Analyze Evaluations] model_report: wrote model_report.pdf (2 family/families, 60 model(s)).
+2026-07-15 10:24:34,040 - INFO - [Analyze Evaluations] Running analysis: ensemble_size_accuracy
+2026-07-15 10:24:34,054 - INFO - [Analyze Evaluations] ensemble_size_accuracy: normal - sizes 1..30, up to 30 configs each.
+2026-07-15 10:24:34,068 - INFO - [Analyze Evaluations] ensemble_size_accuracy: bayesian - sizes 1..30, up to 30 configs each.
+2026-07-15 10:24:34,280 - INFO - [Analyze Evaluations] ensemble_size_accuracy: wrote ensemble_size_accuracy.png (2 family/families).
+2026-07-15 10:24:34,283 - INFO - [Analyze Evaluations] Analysis artifacts written to outputs/exp1/analysis/.
+2026-07-15 10:24:34,284 - INFO - [Analyze Evaluations] Task completed.
+2026-07-15 10:24:34,286 - INFO - [SYSTEM] All tasks finished successfully!
+2026-07-15 10:24:46,864 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-15 10:24:46,865 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-15 10:24:46,907 - INFO - [SYSTEM] Initiating Scenario: 4. Analyze Existing Evaluations
+2026-07-15 10:24:46,917 - INFO - [SYSTEM] Starting Task 1/1: Analyze Evaluations
+2026-07-15 10:24:46,934 - INFO - [Analyze Evaluations] Loaded evaluation 'bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:46,943 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_bayesian' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:46,947 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_normal' with dims {'model': 30, 'noise_level': 5, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:46,951 - INFO - [Analyze Evaluations] Loaded evaluation 'normal' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-15 10:24:46,955 - INFO - [Analyze Evaluations] Running analysis: model_report
+2026-07-15 10:24:47,014 - INFO - [Analyze Evaluations] model_report: wrote model_report.pdf (2 family/families, 60 model(s)).
+2026-07-15 10:24:47,016 - INFO - [Analyze Evaluations] Running analysis: ensemble_size_accuracy
+2026-07-15 10:24:47,028 - INFO - [Analyze Evaluations] ensemble_size_accuracy: normal - sizes 1..30, up to 30 configs each.
+2026-07-15 10:24:47,043 - INFO - [Analyze Evaluations] ensemble_size_accuracy: bayesian - sizes 1..30, up to 30 configs each.
+2026-07-15 10:24:47,255 - INFO - [Analyze Evaluations] ensemble_size_accuracy: wrote ensemble_size_accuracy.png (2 family/families).
+2026-07-15 10:24:47,258 - INFO - [Analyze Evaluations] Analysis artifacts written to outputs/exp1/analysis/.
+2026-07-15 10:24:47,259 - INFO - [Analyze Evaluations] Task completed.
+2026-07-15 10:24:47,261 - INFO - [SYSTEM] All tasks finished successfully!

+ 23 - 2
config.toml

@@ -1,9 +1,21 @@
 # Pipline Scenario to run
 # "scen_train_all" - Train and evaluate model, with noise
-# "scen_load_all" - Load and evaluate model, with noise
+# "scen_load_all"  - Load and evaluate model, with noise
 # "scen_load_eval" - Load and evaluate model, without noise
+# "scen_analyze"   - Analyze existing evaluation files only (no training/eval)
+# "scen_kfold"     - Patient-grouped k-fold CV: train+eval per fold, then OOF + analyze
 scenario = "scen_train_all"
 
+
+[run]
+# Script/automation options. Leave both false for normal interactive use.
+autorun = false          # start the pipeline automatically once the config loads
+autoquit = false         # exit the app when the pipeline finishes (0/1 return code)
+# In autorun, allow overwriting PROTECTED outputs (models/evaluations) without a
+# prompt. Not needed for "scen_analyze", which only writes regeneratable analysis
+# artifacts and therefore never triggers the overwrite guard.
+force_overwrite = false
+
 [data]
 mri_files_path = "../data/PET_volumes_customtemplate_float32/"
 xls_file_path = "../data/LP_ADNIMERGE.csv"
@@ -27,7 +39,16 @@ deterministic = false # force deterministic cuDNN kernels (slower, exact reprodu
 [evaluation]
 # Gaussian noise standard deviations applied to images during noisy evaluation
 # (step 6). 0.0 is the clean baseline and should be kept first.
-noise_levels = [0.0, 0.02, 0.05, 0.1, 0.2]
+noise_levels = [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 3.0, 5.0]
 # Monte-Carlo forward passes used to estimate Bayesian predictive/model
 # uncertainty (step 5/6). Ignored for the deterministic ensemble.
 mc_passes = 30
+
+
+[kfold]
+# Used only by the "scen_kfold" scenario. Each fold trains a full ensemble on
+# ~(k-1)/k of the patients and evaluates on the held-out 1/k; out-of-fold results
+# are pooled to cover every sample. NOTE: cost is ~k * a normal train+eval run.
+k_folds = 4          # number of patient-grouped folds (each ~1/k held out)
+val_fraction = 0.15  # fraction of each fold's non-test patients used for validation
+include_noise = false # also run the noise sweep within each fold (much slower)

+ 166 - 48
main.py

@@ -1,7 +1,8 @@
+import argparse
 import os
 import threading
 import gc
-from typing import Any, Dict
+from typing import Any, Dict, Optional
 
 # Must be set before first CUDA context initialization.
 os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
@@ -21,23 +22,38 @@ from textual.widgets import (
     Rule,
 )
 
-from tasks import PIPELINE_TASKS, SCENARIOS
+from tasks import PIPELINE_TASKS, SCENARIOS, describe_protected_conflicts
 from util.config_manager import handle_directory_config
-from util.progress import ProgressTracker
+from util.progress import MAX_LEVELS, ProgressTracker
 from util.screens import OverwriteConfirmScreen
 from util.ui_logger import PipelineLogger
 
 
-class CNNHarnessApp(App):
+class CNNHarnessApp(App[None]):
     CSS_PATH = "style.tcss"
 
-    def __init__(self):
+    def __init__(
+        self,
+        work_dir: Optional[str] = None,
+        autorun: bool = False,
+        autoquit: bool = False,
+        force: bool = False,
+    ):
         super().__init__()
         self.stop_event = threading.Event()
         self.pause_event = threading.Event()
         self.current_config: Dict[str, Any] = {}
         self.ui_logger = PipelineLogger(self, widget_id="#live_log")
 
+        # CLI overrides. These force the corresponding behavior ON; when not
+        # passed, the effective value falls back to the loaded config's [run]
+        # section (see _effective_* helpers).
+        self._cli_work_dir = work_dir
+        self._cli_autorun = autorun
+        self._cli_autoquit = autoquit
+        self._cli_force = force
+        self._last_pipeline_ok = False
+
     # ==========================================
     # UI Layout
     # ==========================================
@@ -73,7 +89,7 @@ class CNNHarnessApp(App):
 
                     # --- Progress Trackers ---
                     with Vertical(id="progress_area"):
-                        for i in range(5):
+                        for i in range(MAX_LEVELS):
                             with Horizontal(
                                 id=f"progress_row_{i}", classes="progress_row"
                             ):
@@ -103,6 +119,17 @@ class CNNHarnessApp(App):
         self.query_one("#active_controls").display = False
         self.ui_logger.info("Application initialized and ready.")
 
+        # Script mode: a CLI work_dir auto-loads its config; autorun then starts
+        # the pipeline automatically once the config is available.
+        if self._cli_work_dir:
+            self.query_one("#work_dir", Input).value = self._cli_work_dir
+            if self._load_directory(self._cli_work_dir):
+                if self._effective_autorun():
+                    self.call_after_refresh(self._request_start, False)
+            elif self._effective_autoquit():
+                # Load failed and we're running headless: exit with error code.
+                self.call_after_refresh(self._quit_after_run, False)
+
     # ==========================================
     # Helper Methods
     # ==========================================
@@ -128,58 +155,100 @@ class CNNHarnessApp(App):
         self.query_one("#load_btn", Button).disabled = disabled
         self.query_one("#reload_btn", Button).disabled = disabled
 
-    # ==========================================
-    # Event Handlers
-    # ==========================================
-    def on_button_pressed(self, event: Button.Pressed) -> None:
-
-        if event.button.id in ("load_btn", "reload_btn"):
-            work_dir = self.query_one("#work_dir", Input).value
-            success, message, config_data = handle_directory_config(work_dir)
-
-            if success:
-                self.current_config = config_data
-                self.current_config["work_dir"] = work_dir
-                self._update_config_display()
-                self.ui_logger.info(message)
-            else:
-                self.ui_logger.error(message)
-
-        elif event.button.id == "btn_start":
-            if not self.current_config:
-                self.ui_logger.error(
-                    "No configuration loaded! Please load a directory first."
-                )
-                return
-
-            scenario_id = self.current_config.get("scenario")
-            if not scenario_id or scenario_id not in SCENARIOS:
-                self.ui_logger.error(
-                    f"Invalid or missing scenario '{scenario_id}' in config.toml!"
-                )
-                return
+    # ------------------------------------------
+    # Script-mode helpers (autorun / autoquit / force)
+    # ------------------------------------------
+    def _run_cfg(self) -> Dict[str, Any]:
+        run = self.current_config.get("run", {}) if self.current_config else {}
+        return run if isinstance(run, dict) else {}
+
+    def _effective_autorun(self) -> bool:
+        return self._cli_autorun or bool(self._run_cfg().get("autorun", False))
+
+    def _effective_autoquit(self) -> bool:
+        return self._cli_autoquit or bool(self._run_cfg().get("autoquit", False))
+
+    def _effective_force(self) -> bool:
+        return self._cli_force or bool(self._run_cfg().get("force_overwrite", False))
+
+    def _load_directory(self, work_dir: str) -> bool:
+        """Load-or-init a work_dir's config into current_config. Returns success."""
+        success, message, config_data = handle_directory_config(work_dir)
+        if success:
+            self.current_config = config_data
+            self.current_config["work_dir"] = work_dir
+            self._update_config_display()
+            self.ui_logger.info(message)
+            return True
+        self.ui_logger.error(message)
+        return False
+
+    def _quit_after_run(self, ok: bool) -> None:
+        """Exit the app with a script-friendly return code (0 ok, 1 failure)."""
+        self.exit(return_code=0 if ok else 1)
+
+    def _request_start(self, interactive: bool) -> None:
+        """Validate and start the pipeline. In non-interactive (autorun) mode a
+        protected-overwrite conflict aborts unless force is set, since a modal
+        prompt cannot be answered by a script."""
+        if not self.current_config:
+            self.ui_logger.error(
+                "No configuration loaded! Please load a directory first."
+            )
+            if not interactive and self._effective_autoquit():
+                self._quit_after_run(False)
+            return
 
-            work_dir = self.current_config["work_dir"]
+        scenario_id = self.current_config.get("scenario")
+        if not scenario_id or scenario_id not in SCENARIOS:
+            self.ui_logger.error(
+                f"Invalid or missing scenario '{scenario_id}' in config.toml!"
+            )
+            if not interactive and self._effective_autoquit():
+                self._quit_after_run(False)
+            return
 
-            # Check if the working directory exists and is non-empty (except for the config.toml file)
-            if os.path.exists(work_dir) and any(
-                f for f in os.listdir(work_dir) if f != "config.toml"
-            ):
+        work_dir = self.current_config["work_dir"]
+        # Only PROTECTED (hard-to-regenerate) outputs trigger a warning. An
+        # analysis-only rerun writes nothing protected, so it proceeds silently.
+        conflicts = describe_protected_conflicts(work_dir, scenario_id)
 
+        if conflicts and not self._effective_force():
+            if interactive:
                 def check_overwrite_callback(proceed: bool) -> None:
                     if proceed:
                         self._execute_pipeline()
                     else:
-                        self.ui_logger.error(
-                            "Pipeline start cancelled by user (folder non-empty)."
-                        )
+                        self.ui_logger.error("Pipeline start cancelled by user.")
 
                 self.app.push_screen(
-                    OverwriteConfirmScreen(work_dir),
+                    OverwriteConfirmScreen(work_dir, conflicts),
                     check_overwrite_callback,  # pyright: ignore
                 )
-            else:
-                self._execute_pipeline()
+                return
+            # Non-interactive with a protected conflict and no force: refuse.
+            self.ui_logger.error(
+                "Autorun aborted: scenario would overwrite protected outputs "
+                f"({'; '.join(conflicts)}). Set run.force_overwrite = true or pass "
+                "--force to allow."
+            )
+            if self._effective_autoquit():
+                self._quit_after_run(False)
+            return
+
+        self._execute_pipeline()
+
+    # ==========================================
+    # Event Handlers
+    # ==========================================
+    def on_button_pressed(self, event: Button.Pressed) -> None:
+
+        if event.button.id in ("load_btn", "reload_btn"):
+            work_dir = self.query_one("#work_dir", Input).value
+            self._load_directory(work_dir)
+
+        elif event.button.id == "btn_start":
+            self._request_start(interactive=True)
 
         elif event.button.id == "btn_pause":
             if self.pause_event.is_set():
@@ -232,6 +301,7 @@ class CNNHarnessApp(App):
         pipeline_state: Dict[str, Any] = {
             "events": {"stop": self.stop_event, "pause": self.pause_event}
         }
+        self._last_pipeline_ok = False
 
         try:
             self.ui_logger.info(f"Initiating Scenario: {scenario_label}")
@@ -271,6 +341,7 @@ class CNNHarnessApp(App):
                     "[bold blue]PIPELINE COMPLETE[/bold blue]",
                 )
                 self.ui_logger.info("All tasks finished successfully!")
+                self._last_pipeline_ok = True
 
         except InterruptedError as e:
             self.ui_logger.error(f"STOPPED: {str(e)}")
@@ -292,6 +363,10 @@ class CNNHarnessApp(App):
             pipeline_state.clear()
             gc.collect()
             self.call_from_thread(self._reset_ui)
+            # Script mode: quit once the run finishes, with a return code that
+            # reflects success/failure so it can be used in shell pipelines.
+            if self._effective_autoquit():
+                self.call_from_thread(self._quit_after_run, self._last_pipeline_ok)
 
     def _reset_ui(self) -> None:
         self.query_one("#btn_stop", Button).disabled = False
@@ -300,6 +375,49 @@ class CNNHarnessApp(App):
         self._toggle_config_inputs(disabled=False)
 
 
+def _parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description=(
+            "ALNN evaluation harness (TUI). Pass a working directory to auto-load "
+            "it; combine with autorun/autoquit (CLI flags or the config [run] "
+            "section) to use it as a simple script."
+        )
+    )
+    parser.add_argument(
+        "work_dir",
+        nargs="?",
+        default=None,
+        help="Working/output directory to load on startup (its config.toml is "
+        "loaded, or the default config is copied in if absent).",
+    )
+    parser.add_argument(
+        "--autorun",
+        action="store_true",
+        help="Start the pipeline automatically once the config loads "
+        "(overrides run.autorun).",
+    )
+    parser.add_argument(
+        "--autoquit",
+        action="store_true",
+        help="Exit the app when the pipeline finishes, with a 0/1 return code "
+        "(overrides run.autoquit).",
+    )
+    parser.add_argument(
+        "--force",
+        action="store_true",
+        help="In autorun, overwrite protected outputs (models/evaluations) "
+        "without prompting (overrides run.force_overwrite).",
+    )
+    return parser.parse_args()
+
+
 if __name__ == "__main__":
-    app = CNNHarnessApp()
+    args = _parse_args()
+    app = CNNHarnessApp(
+        work_dir=args.work_dir,
+        autorun=args.autorun,
+        autoquit=args.autoquit,
+        force=args.force,
+    )
     app.run()
+    raise SystemExit(app.return_code or 0)

+ 117 - 30
model/dataset.py

@@ -285,32 +285,14 @@ def initalize_dataloaders(
     return loaders
 
 
-def divide_dataset_by_patient_id(
-    dataset: ADNIDataset,
-    ptids: List[Tuple[int, str]],
-    ratios: Tuple[float, float, float],
-    seed: int,
-) -> List[data.Subset[ADNIDataset]]:
-    """
-    Divides the dataset into training, validation, and test sets based on patient IDs.
-    Ensures that all samples from the same patient are in the same set.
+def _patient_index(
+    dataset: ADNIDataset, ptids: List[Tuple[int, str]]
+) -> Dict[str, List[int]]:
+    """Map patient id -> dataset sample indices, validating PTID consistency.
 
-    Args:
-        dataset (ADNIDataset): The dataset to divide.
-        ptids (List[Tuple[int, str]]): A list of tuples containing image file IDs and their corresponding patient IDs.
-        ratios (Tuple[float, float, float]): The ratios for training, validation, and test sets.
-        seed (int): The random seed for reproducibility.
-
-    Returns:
-        List[data.Subset[ADNIDataset]]: A list of subsets for training, validation, and test sets.
-
-    Notes:
-        This split is grouped by PTID, so all images from the same patient are assigned
-        to exactly one partition to avoid patient-level leakage across train/val/test.
+    Shared by the single-split and k-fold partitioners so both enforce the same
+    patient-grouping and leakage checks.
     """
-    if not math.isclose(sum(ratios), 1.0, rel_tol=1e-9, abs_tol=1e-9):
-        raise ValueError(f"Ratios must sum to 1.0, got {ratios} (sum={sum(ratios)}).")
-
     if not ptids:
         raise ValueError("ptids list cannot be empty.")
 
@@ -320,7 +302,6 @@ def divide_dataset_by_patient_id(
         patient_id_str = str(patient_id).strip()
         if not patient_id_str or patient_id_str.lower() == "nan":
             raise ValueError(f"Invalid PTID for Image Data ID {image_id_int}.")
-
         if (
             image_id_int in image_to_patient
             and image_to_patient[image_id_int] != patient_id_str
@@ -329,7 +310,6 @@ def divide_dataset_by_patient_id(
                 f"Conflicting PTIDs for Image Data ID {image_id_int}: "
                 f"{image_to_patient[image_id_int]} vs {patient_id_str}."
             )
-
         image_to_patient[image_id_int] = patient_id_str
 
     patient_to_indices: Dict[str, List[int]] = {}
@@ -338,11 +318,38 @@ def divide_dataset_by_patient_id(
             raise ValueError(
                 f"Missing PTID mapping for dataset Image Data ID {image_id}."
             )
+        patient_to_indices.setdefault(image_to_patient[image_id], []).append(idx)
+
+    return patient_to_indices
+
+
+def divide_dataset_by_patient_id(
+    dataset: ADNIDataset,
+    ptids: List[Tuple[int, str]],
+    ratios: Tuple[float, float, float],
+    seed: int,
+) -> List[data.Subset[ADNIDataset]]:
+    """
+    Divides the dataset into training, validation, and test sets based on patient IDs.
+    Ensures that all samples from the same patient are in the same set.
 
-        patient_id = image_to_patient[image_id]
-        if patient_id not in patient_to_indices:
-            patient_to_indices[patient_id] = []
-        patient_to_indices[patient_id].append(idx)
+    Args:
+        dataset (ADNIDataset): The dataset to divide.
+        ptids (List[Tuple[int, str]]): A list of tuples containing image file IDs and their corresponding patient IDs.
+        ratios (Tuple[float, float, float]): The ratios for training, validation, and test sets.
+        seed (int): The random seed for reproducibility.
+
+    Returns:
+        List[data.Subset[ADNIDataset]]: A list of subsets for training, validation, and test sets.
+
+    Notes:
+        This split is grouped by PTID, so all images from the same patient are assigned
+        to exactly one partition to avoid patient-level leakage across train/val/test.
+    """
+    if not math.isclose(sum(ratios), 1.0, rel_tol=1e-9, abs_tol=1e-9):
+        raise ValueError(f"Ratios must sum to 1.0, got {ratios} (sum={sum(ratios)}).")
+
+    patient_to_indices = _patient_index(dataset, ptids)
 
     shuffled_patients = list(patient_to_indices.keys())
     random.Random(seed).shuffle(shuffled_patients)
@@ -402,3 +409,83 @@ def divide_dataset_by_patient_id(
         Subset(dataset, val_indices),
         Subset(dataset, test_indices),
     ]
+
+
+def kfold_split_by_patient_id(
+    dataset: ADNIDataset,
+    ptids: List[Tuple[int, str]],
+    k_folds: int,
+    fold: int,
+    seed: int,
+    val_fraction: float = 0.15,
+) -> List[data.Subset[ADNIDataset]]:
+    """Patient-grouped k-fold partition for a single fold.
+
+    Patients are shuffled once (deterministically from ``seed``) and dealt
+    round-robin into ``k_folds`` folds, so every patient is in exactly one fold
+    regardless of which fold is requested. For the requested ``fold``:
+      - test  = that fold's patients (the held-out slice),
+      - the remaining patients are split into val/train by ``val_fraction``.
+    All splits are patient-disjoint (no leakage), and across all folds every
+    sample is a test point exactly once.
+
+    Args:
+        dataset: The dataset to partition.
+        ptids: (Image Data ID, PTID) pairs covering the dataset.
+        k_folds: Number of folds (>= 2).
+        fold: Which fold to hold out as test (0 <= fold < k_folds).
+        seed: Seed for the (shared across folds) patient shuffle.
+        val_fraction: Fraction of the NON-test patients used for validation.
+
+    Returns:
+        ``[train, val, test]`` subsets.
+    """
+    if k_folds < 2:
+        raise ValueError(f"k_folds must be >= 2, got {k_folds}.")
+    if not 0 <= fold < k_folds:
+        raise ValueError(f"fold must be in [0, {k_folds}), got {fold}.")
+    if not 0.0 <= val_fraction < 1.0:
+        raise ValueError(f"val_fraction must be in [0.0, 1.0), got {val_fraction}.")
+
+    patient_to_indices = _patient_index(dataset, ptids)
+    patients = list(patient_to_indices.keys())
+    if len(patients) < k_folds:
+        raise ValueError(
+            f"Cannot make {k_folds} folds from {len(patients)} patients."
+        )
+    # Deterministic shuffle shared across all folds, then round-robin deal so
+    # fold sizes are balanced and the same partition is reproduced for every fold.
+    random.Random(seed).shuffle(patients)
+    fold_patients = [patients[i::k_folds] for i in range(k_folds)]
+
+    test_patients = fold_patients[fold]
+    rest = [p for f in range(k_folds) if f != fold for p in fold_patients[f]]
+    n_val = int(round(len(rest) * val_fraction))
+    val_patients = rest[:n_val]
+    train_patients = rest[n_val:]
+
+    def _indices(group: List[str]) -> List[int]:
+        return [idx for patient in group for idx in patient_to_indices[patient]]
+
+    train_indices = _indices(train_patients)
+    val_indices = _indices(val_patients)
+    test_indices = _indices(test_patients)
+
+    # Safety: patient-disjoint and full, exact coverage of the dataset.
+    train_set, val_set, test_set = (
+        set(train_indices),
+        set(val_indices),
+        set(test_indices),
+    )
+    if train_set & val_set or train_set & test_set or val_set & test_set:
+        raise ValueError("Sample index overlap detected across k-fold splits.")
+    if train_set | val_set | test_set != set(range(len(dataset))):
+        raise ValueError(
+            "k-fold coverage check failed: not all samples assigned exactly once."
+        )
+
+    return [
+        Subset(dataset, train_indices),
+        Subset(dataset, val_indices),
+        Subset(dataset, test_indices),
+    ]

+ 1 - 0
pyproject.toml

@@ -23,6 +23,7 @@ dependencies = [
     "toml",
     "xarray",
     "netcdf4>=1.7.4",
+    "typst>=0.15.0",
 ]
 
 

+ 128 - 2
tasks/__init__.py

@@ -11,19 +11,36 @@ Pipeline stages (see ai/ARCHITECTURE.md):
     4. evaluate_regular -> implemented
     5. evaluate_bayesian-> implemented
     6. evaluate_noisy   -> implemented
+    7. run_analysis     -> skeleton (step 7); reads evaluations, writes analysis/.
     load_models         -> implemented; discovers saved .pt ensembles on disk and
                            records their paths in ``state`` so evaluation is
                            decoupled from training (training frees models from VRAM
                            after saving). Evaluation loads one model at a time.
 """
 
+import os
+from typing import Any, Callable, Dict, List, Tuple, TypedDict
+
+from . import analysis
 from . import evaluate
 from . import load_data
 from . import load_models
 from . import train_bayesian
 from . import train_normal
+from . import kfold  # imported last: depends on the sibling task modules above
+
+
+class TaskEntry(TypedDict):
+    task_name: str
+    task_func: Callable[..., None]
+
 
-PIPELINE_TASKS = {
+class ScenarioEntry(TypedDict):
+    label: str
+    tasks: List[str]
+
+
+PIPELINE_TASKS: Dict[str, TaskEntry] = {
     "load_data": {
         "task_name": "Load Image and ADNIMERGE",
         "task_func": load_data.load_data_task,
@@ -52,9 +69,105 @@ PIPELINE_TASKS = {
         "task_name": "Evaluate Models on Noised Data",
         "task_func": evaluate.evaluate_noisy_task,
     },
+    "run_analysis": {
+        "task_name": "Analyze Evaluations",
+        "task_func": analysis.run_analysis_task,
+    },
+    "run_kfold": {
+        "task_name": "K-Fold Cross-Validation",
+        "task_func": kfold.run_kfold_task,
+    },
+}
+
+# --------------------------------------------------------------------------
+# Output artifact categories and overwrite protection
+# --------------------------------------------------------------------------
+# Each category maps to the work_dir subdirectories it owns and whether it is
+# "protected" -- i.e. expensive/slow to regenerate, so overwriting it warrants a
+# confirmation prompt. Analysis artifacts are cheap to regenerate and therefore
+# unprotected: reruns of an analysis-only scenario proceed without a warning.
+class ArtifactCategory(TypedDict):
+    dirs: Tuple[str, ...]
+    protected: bool
+    desc: str
+
+
+ARTIFACT_CATEGORIES: Dict[str, ArtifactCategory] = {
+    "models": {
+        "dirs": ("normal_models", "bayesian_models"),
+        "protected": True,
+        "desc": "trained model checkpoints",
+    },
+    "evaluations": {
+        "dirs": ("evaluations",),
+        "protected": True,
+        "desc": "model evaluation outputs (netCDF)",
+    },
+    "kfold": {
+        "dirs": ("folds",),
+        "protected": True,
+        "desc": "per-fold k-fold models & evaluations",
+    },
+    "analysis": {
+        "dirs": (analysis.ANALYSIS_SUBDIR,),
+        "protected": False,
+        "desc": "analysis artifacts (regeneratable)",
+    },
+}
+
+# Which artifact category each task writes to disk. Tasks not listed here (e.g.
+# load_data, load_models) produce no persistent, overwriteable output.
+TASK_WRITES = {
+    "train_regular": ("models",),
+    "train_bayesian": ("models",),
+    "evaluate_regular": ("evaluations",),
+    "evaluate_bayesian": ("evaluations",),
+    "evaluate_noisy": ("evaluations",),
+    "run_analysis": ("analysis",),
+    # k-fold writes per-fold models+evals under folds/ and the aggregated OOF
+    # files under evaluations/; both are protected (slow to regenerate).
+    "run_kfold": ("kfold", "evaluations"),
 }
 
-SCENARIOS = {
+
+def scenario_output_categories(scenario_id: str) -> List[str]:
+    """Return the artifact categories a scenario writes, in first-seen order."""
+    categories: List[str] = []
+    for task_id in SCENARIOS[scenario_id]["tasks"]:
+        for category in TASK_WRITES.get(task_id, ()):
+            if category not in categories:
+                categories.append(category)
+    return categories
+
+
+def protected_output_conflicts(
+    work_dir: str, scenario_id: str
+) -> List[Tuple[str, str]]:
+    """Return ``(category, subdir)`` for PROTECTED outputs the scenario writes
+    that already exist and are non-empty. Empty result => safe to run without a
+    destructive-overwrite prompt (only unprotected/regeneratable outputs, if any,
+    would be replaced)."""
+    conflicts: List[Tuple[str, str]] = []
+    for category in scenario_output_categories(scenario_id):
+        meta = ARTIFACT_CATEGORIES.get(category)
+        if not meta or not meta["protected"]:
+            continue
+        for subdir in meta["dirs"]:
+            path = os.path.join(work_dir, subdir)
+            if os.path.isdir(path) and os.listdir(path):
+                conflicts.append((category, subdir))
+    return conflicts
+
+
+def describe_protected_conflicts(work_dir: str, scenario_id: str) -> List[str]:
+    """Human-readable one-liners for each protected overwrite conflict."""
+    lines: List[str] = []
+    for category, subdir in protected_output_conflicts(work_dir, scenario_id):
+        desc = ARTIFACT_CATEGORIES[category]["desc"]
+        lines.append(f"{subdir}/ — {desc}")
+    return lines
+
+SCENARIOS: Dict[str, ScenarioEntry] = {
     "scen_train_all": {
         "label": "1. Train, Evaluate, & Noise Analysis",
         "tasks": [
@@ -86,4 +199,17 @@ SCENARIOS = {
             "evaluate_bayesian",
         ],
     },
+    "scen_analyze": {
+        "label": "4. Analyze Existing Evaluations",
+        "tasks": [
+            "run_analysis",
+        ],
+    },
+    "scen_kfold": {
+        "label": "5. K-Fold Cross-Validation (train + OOF eval + analyze)",
+        "tasks": [
+            "run_kfold",
+            "run_analysis",
+        ],
+    },
 }

+ 523 - 0
tasks/analysis.py

@@ -0,0 +1,523 @@
+"""Step 7: analysis over saved evaluation netCDF files.
+
+Analysis is deliberately decoupled from models and data: it reads ONLY the
+evaluation ``.nc`` files in ``<work_dir>/evaluations/`` and writes artifacts to
+``<work_dir>/analysis/``. That makes an analysis rerun cheap and safe -- the only
+outputs it can clobber are the easily-regeneratable files under ``analysis/``.
+
+To add an analysis, register a callable in :data:`ANALYSES`. Each receives the
+loaded evaluation datasets (keyed by file stem, e.g. ``"normal"``,
+``"noisy_bayesian"``), an output directory, and a logger. Because every analysis
+is a pure function of the schema-locked datasets, new plots (coverage,
+accuracy-vs-noise, uncertainty-vs-noise, calibration, ...) drop in here without
+touching the rest of the pipeline.
+"""
+
+import datetime
+import itertools
+import json
+import math
+import pathlib as pl
+from typing import Any, Callable, Dict, List, Tuple
+
+import matplotlib
+
+matplotlib.use("Agg")  # headless; analysis runs in a background worker thread
+
+import matplotlib.pyplot as plt  # noqa: E402
+import numpy as np  # noqa: E402
+import typst  # noqa: E402
+import xarray as xr  # noqa: E402
+from matplotlib.ticker import MaxNLocator  # noqa: E402
+
+from evaluation import (  # noqa: E402
+    CLASS_NAMES,
+    MODEL_KIND_BAYESIAN,
+    MODEL_KIND_NORMAL,
+    load_evaluation,
+)
+from util.progress import ProgressTracker  # noqa: E402
+from util.ui_logger import PipelineLogger  # noqa: E402
+
+# Categorical colors (dataviz palette slots 1 & 2, CVD-safe in fixed order).
+_FAMILY_COLORS = {
+    MODEL_KIND_NORMAL: "#003f5c",  # blue
+    MODEL_KIND_BAYESIAN: "#ffa600",  # orange
+}
+
+# Number of random member-subset configurations sampled per ensemble size
+# (fewer only when the total number of distinct subsets is smaller).
+_TARGET_CONFIGS = 30
+
+# Bootstrap CI settings for full-ensemble accuracy.
+_BOOTSTRAP_ITERS = 2000
+_CI_LEVEL = 0.95
+
+EVAL_SUBDIR = "evaluations"
+ANALYSIS_SUBDIR = "analysis"
+
+# Analysis registry: name -> function(datasets, out_dir, log) -> None.
+# Each analysis is a pure function of the schema-locked evaluation datasets.
+AnalysisFn = Callable[[Dict[str, xr.Dataset], pl.Path, PipelineLogger], None]
+
+_TEMPLATE_DIR = pl.Path(__file__).parent / "templates"
+_MODEL_REPORT_TEMPLATE = _TEMPLATE_DIR / "model_report.typ"
+
+# Display labels + ordering for known model families.
+_FAMILY_LABELS = {
+    MODEL_KIND_NORMAL: "Normal (deterministic)",
+    MODEL_KIND_BAYESIAN: "Bayesian (MC)",
+}
+_FAMILY_ORDER = [MODEL_KIND_NORMAL, MODEL_KIND_BAYESIAN]
+
+
+def _baseline_noise_index(ds: xr.Dataset) -> int:
+    """Index of the noise level closest to the clean baseline (sigma = 0)."""
+    levels = np.asarray(ds["noise_level"].values, dtype=float)
+    return int(np.abs(levels).argmin())
+
+
+def _family_metrics(kind: str, source: str, ds: xr.Dataset) -> Dict[str, Any]:
+    """Per-model + ensemble metrics for one model family at the clean baseline."""
+    baseline = _baseline_noise_index(ds)
+    kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
+    positions = [i for i, k in enumerate(kinds) if k == kind]
+
+    correct = ds["correct"].isel(noise_level=baseline).values  # (model, sample)
+    entropy = ds["predictive_entropy"].isel(noise_level=baseline).values
+    mut_info = ds["mutual_information"].isel(noise_level=baseline).values
+    labels = np.atleast_1d(ds["model"].values)
+
+    models: List[Dict[str, Any]] = []
+    accuracies: List[float] = []
+    for pos in positions:
+        acc = float(np.mean(correct[pos]))
+        accuracies.append(acc)
+        models.append(
+            {
+                "index": int(labels[pos]) + 1,  # 1-based for display
+                "accuracy": acc,
+                "mean_entropy": float(np.mean(entropy[pos])),
+                "mean_mi": float(np.mean(mut_info[pos])),
+            }
+        )
+
+    acc_arr = np.asarray(accuracies, dtype=float)
+    return {
+        "name": _FAMILY_LABELS.get(kind, kind),
+        "kind": kind,
+        "source": source,
+        "noise_sigma": float(np.asarray(ds["noise_level"].values, dtype=float)[baseline]),
+        "n_models": len(models),
+        "n_samples": int(ds.sizes["sample"]),
+        "n_mc": int(ds.attrs.get("n_mc", 1)),
+        "accuracy_mean": float(acc_arr.mean()) if acc_arr.size else 0.0,
+        "accuracy_std": float(acc_arr.std()) if acc_arr.size else 0.0,
+        "accuracy_best": float(acc_arr.max()) if acc_arr.size else 0.0,
+        "models": models,
+    }
+
+
+def _family_sources(
+    datasets: Dict[str, xr.Dataset]
+) -> Dict[str, Tuple[str, xr.Dataset]]:
+    """Pick one evaluation dataset per model family, preferring clean files.
+
+    ``normal.nc`` and ``noisy_normal.nc`` share the same members at sigma=0, so
+    only the first source seen per family is kept (non-noisy stems sort first) to
+    avoid analyzing a family twice.
+    """
+    chosen: Dict[str, Tuple[str, xr.Dataset]] = {}
+    for stem in sorted(datasets, key=lambda s: ("noisy" in s, s)):
+        ds = datasets[stem]
+        if "model_kind" not in ds.coords:
+            continue
+        for kind in {str(k) for k in np.atleast_1d(ds["model_kind"].values)}:
+            chosen.setdefault(kind, (stem, ds))
+    return chosen
+
+
+def _ordered_families(kinds: Any) -> List[str]:
+    """Sort family kinds into the canonical Normal-then-Bayesian order."""
+    order = {kind: i for i, kind in enumerate(_FAMILY_ORDER)}
+    return sorted(kinds, key=lambda k: order.get(k, len(order)))
+
+
+def _collect_families(datasets: Dict[str, xr.Dataset]) -> List[Dict[str, Any]]:
+    """One metrics block per model family, in canonical order."""
+    sources = _family_sources(datasets)
+    families = [
+        _family_metrics(kind, source, ds) for kind, (source, ds) in sources.items()
+    ]
+    families.sort(key=lambda f: _ordered_families(sources.keys()).index(f["kind"]))
+    return families
+
+
+def model_report(
+    datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
+) -> None:
+    """Compile a short per-model PDF report (accuracy + uncertainty) via Typst."""
+    families = _collect_families(datasets)
+    if not families:
+        log.error("model_report: no model families found in evaluations; skipping.")
+        return
+
+    meta = next(iter(datasets.values())).attrs
+    seed = meta.get("seed")
+    payload = {
+        "title": "Model Evaluation Report",
+        "generated": datetime.datetime.now(datetime.timezone.utc).strftime(
+            "%Y-%m-%d %H:%M UTC"
+        ),
+        "work_dir": str(out_dir.parent),
+        "schema_version": str(meta.get("schema_version", "?")),
+        "seed": str(seed) if seed is not None else "n/a",
+        "git_commit": str(meta.get("git_commit", "unknown"))[:10],
+        "families": families,
+    }
+    payload_json = json.dumps(payload)
+
+    # The template parses this JSON via `json(bytes(sys.inputs.data))`.
+    pdf_bytes = typst.compile(
+        str(_MODEL_REPORT_TEMPLATE),
+        sys_inputs={"data": payload_json},
+        format="pdf",
+    )
+    (out_dir / "model_report.pdf").write_bytes(pdf_bytes)
+    # Keep the JSON alongside for debugging / reuse (regeneratable).
+    (out_dir / "model_report.json").write_text(payload_json)
+
+    total_models = sum(f["n_models"] for f in families)
+    log.info(
+        f"model_report: wrote model_report.pdf "
+        f"({len(families)} family/families, {total_models} model(s))."
+    )
+
+
+def _baseline_probs(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
+    """Return ``(probs (M, S, C), true_idx (S,))`` for ``kind``'s members at the
+    clean-baseline noise level."""
+    baseline = _baseline_noise_index(ds)
+    kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
+    positions = [i for i, k in enumerate(kinds) if k == kind]
+    probs = np.asarray(ds["prob"].isel(noise_level=baseline).values, dtype=float)
+    probs = probs[positions]  # (M, S, C)
+    true_idx = np.array(
+        [CLASS_NAMES.index(str(t)) for t in np.atleast_1d(ds["true_class"].values)],
+        dtype=int,
+    )
+    return probs, true_idx
+
+
+def _sample_subsets(
+    n_models: int, size: int, target: int, rng: np.random.Generator
+) -> List[List[int]]:
+    """Distinct member-index subsets of ``size``.
+
+    Returns every subset when the number of distinct combinations is <= ``target``
+    (e.g. size == 1, size == M, or size near M); otherwise ``target`` random
+    distinct subsets.
+    """
+    total = math.comb(n_models, size)
+    if total <= target:
+        return [list(combo) for combo in itertools.combinations(range(n_models), size)]
+
+    seen: set[Tuple[int, ...]] = set()
+    subsets: List[List[int]] = []
+    while len(subsets) < target:
+        pick = tuple(
+            sorted(int(i) for i in rng.choice(n_models, size=size, replace=False))
+        )
+        if pick not in seen:
+            seen.add(pick)
+            subsets.append(list(pick))
+    return subsets
+
+
+def _ensemble_size_curve(
+    probs: np.ndarray, true_idx: np.ndarray, target: int, rng: np.random.Generator
+) -> Dict[str, Any]:
+    """Sampled-configuration accuracy for every ensemble size ``1..M``.
+
+    An ensemble prediction is the argmax of the mean of its members' output
+    probabilities. For each size we average accuracy over sampled member subsets
+    and report the spread (std) as the uncertainty estimate.
+    """
+    n_models = probs.shape[0]
+    sizes: List[int] = []
+    means: List[float] = []
+    stds: List[float] = []
+    n_configs: List[int] = []
+    for size in range(1, n_models + 1):
+        subsets = _sample_subsets(n_models, size, target, rng)
+        accuracies = []
+        for subset in subsets:
+            ensemble_prob = probs[subset].mean(axis=0)  # (S, C)
+            predictions = ensemble_prob.argmax(axis=1)
+            accuracies.append(float(np.mean(predictions == true_idx)))
+        acc = np.asarray(accuracies, dtype=float)
+        sizes.append(size)
+        means.append(float(acc.mean()))
+        stds.append(float(acc.std()))
+        n_configs.append(len(subsets))
+    return {"sizes": sizes, "mean": means, "std": stds, "n_configs": n_configs}
+
+
+def ensemble_size_accuracy(
+    datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
+) -> None:
+    """Plot ensemble accuracy vs. number of member models, with an uncertainty
+    band from sampling different member configurations, for each family."""
+    sources = _family_sources(datasets)
+    if not sources:
+        log.error("ensemble_size_accuracy: no model families found; skipping.")
+        return
+
+    base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
+
+    curves: Dict[str, Dict[str, Any]] = {}
+    for fam_index, kind in enumerate(_ordered_families(sources.keys())):
+        source, ds = sources[kind]
+        probs, true_idx = _baseline_probs(kind, ds)
+        if probs.shape[0] < 1:
+            continue
+        # Distinct, reproducible RNG stream per family for subset sampling.
+        rng = np.random.default_rng([base_seed, fam_index])
+        curve = _ensemble_size_curve(probs, true_idx, _TARGET_CONFIGS, rng)
+        curve["source"] = source
+        curves[kind] = curve
+        log.info(
+            f"ensemble_size_accuracy: {kind} - sizes 1..{probs.shape[0]}, "
+            f"up to {_TARGET_CONFIGS} configs each."
+        )
+
+    if not curves:
+        log.error("ensemble_size_accuracy: no usable probabilities; skipping.")
+        return
+
+    fig, ax = plt.subplots(figsize=(7.0, 4.3))
+    for kind in _ordered_families(curves.keys()):
+        curve = curves[kind]
+        color = _FAMILY_COLORS.get(kind, "#666666")
+        x = np.asarray(curve["sizes"])
+        mean = np.asarray(curve["mean"])
+        std = np.asarray(curve["std"])
+        ax.fill_between(x, mean - std, mean + std, color=color, alpha=0.18, linewidth=0)
+        ax.plot(
+            x, mean, color=color, marker="o", markersize=4, linewidth=2,
+            label=_FAMILY_LABELS.get(kind, kind),
+        )
+
+    ax.set_xlabel("Ensemble size (number of models)")
+    ax.set_ylabel("Test accuracy")
+    ax.set_title("Ensemble accuracy vs. number of models")
+    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
+    ax.margins(x=0.02)
+    ax.grid(True, color="#000000", alpha=0.08, linewidth=0.7)
+    for spine in ("top", "right"):
+        ax.spines[spine].set_visible(False)
+    ax.legend(frameon=False, loc="lower right")
+    fig.tight_layout()
+
+    png_path = out_dir / "ensemble_size_accuracy.png"
+    fig.savefig(png_path, dpi=150)
+    fig.savefig(out_dir / "ensemble_size_accuracy.pdf")
+    plt.close(fig)
+
+    # Persist the underlying curve data for reuse / regeneration.
+    (out_dir / "ensemble_size_accuracy.json").write_text(
+        json.dumps({"target_configs": _TARGET_CONFIGS, "families": curves})
+    )
+    log.info(
+        f"ensemble_size_accuracy: wrote {png_path.name} "
+        f"({len(curves)} family/families)."
+    )
+
+
+def _full_ensemble_correct(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
+    """Per-sample correctness (0/1) of the FULL ensemble at baseline, plus ptids.
+
+    The ensemble prediction is the argmax of the mean of ALL members' output
+    probabilities -- i.e. the rightmost point of the ensemble-size curve.
+    """
+    probs, true_idx = _baseline_probs(kind, ds)  # (M, S, C), (S,)
+    ensemble_pred = probs.mean(axis=0).argmax(axis=1)  # (S,)
+    correct = (ensemble_pred == true_idx).astype(float)
+    ptids = np.asarray(ds["ptid"].values).astype(str)
+    return correct, ptids
+
+
+def _bootstrap_accuracy_ci(
+    correct: np.ndarray,
+    ptids: np.ndarray,
+    iters: int,
+    ci_level: float,
+    rng: np.random.Generator,
+) -> Dict[str, Any]:
+    """Patient-clustered percentile bootstrap CI for a mean-accuracy statistic.
+
+    Resamples whole PATIENTS with replacement (not individual scans), because
+    scans from one patient are correlated; a per-scan bootstrap would understate
+    the interval. Reduces to an ordinary bootstrap when every patient has one scan.
+    """
+    point = float(np.mean(correct)) if correct.size else 0.0
+    _, inverse = np.unique(ptids, return_inverse=True)
+    n_patients = int(inverse.max()) + 1 if inverse.size else 0
+    groups = [np.where(inverse == p)[0] for p in range(n_patients)]
+
+    boot = np.empty(iters, dtype=float)
+    for b in range(iters):
+        chosen = rng.integers(0, n_patients, size=n_patients)
+        idx = np.concatenate([groups[c] for c in chosen])
+        boot[b] = correct[idx].mean()
+
+    tail = (1.0 - ci_level) / 2.0
+    return {
+        "point": point,
+        "ci_low": float(np.quantile(boot, tail)),
+        "ci_high": float(np.quantile(boot, 1.0 - tail)),
+        "n_samples": int(correct.size),
+        "n_patients": n_patients,
+    }
+
+
+def bootstrap_ensemble_ci(
+    datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
+) -> None:
+    """Patient-clustered bootstrap CI for each family's full-ensemble test
+    accuracy; writes a JSON summary and a compact point-range plot."""
+    sources = _family_sources(datasets)
+    if not sources:
+        log.error("bootstrap_ensemble_ci: no model families found; skipping.")
+        return
+
+    base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
+
+    results: Dict[str, Dict[str, Any]] = {}
+    for fam_index, kind in enumerate(_ordered_families(sources.keys())):
+        source, ds = sources[kind]
+        correct, ptids = _full_ensemble_correct(kind, ds)
+        if correct.size == 0:
+            continue
+        rng = np.random.default_rng([base_seed, 1000 + fam_index])
+        ci = _bootstrap_accuracy_ci(correct, ptids, _BOOTSTRAP_ITERS, _CI_LEVEL, rng)
+        ci["source"] = source
+        results[kind] = ci
+        log.info(
+            f"bootstrap_ensemble_ci: {kind} full-ensemble acc = "
+            f"{ci['point'] * 100:.1f}% (95% CI "
+            f"{ci['ci_low'] * 100:.1f}-{ci['ci_high'] * 100:.1f}%, "
+            f"n={ci['n_samples']} scans / {ci['n_patients']} patients)."
+        )
+
+    if not results:
+        log.error("bootstrap_ensemble_ci: nothing to compute; skipping.")
+        return
+
+    ordered = _ordered_families(results.keys())
+    fig, ax = plt.subplots(figsize=(7.0, 1.4 + 0.8 * len(ordered)))
+    for row, kind in enumerate(ordered):
+        ci = results[kind]
+        color = _FAMILY_COLORS.get(kind, "#666666")
+        ax.plot(
+            [ci["ci_low"], ci["ci_high"]], [row, row],
+            color=color, linewidth=2.5, solid_capstyle="round",
+        )
+        ax.plot([ci["point"]], [row], "o", color=color, markersize=8)
+        ax.annotate(
+            f"{ci['point'] * 100:.1f}%  "
+            f"[{ci['ci_low'] * 100:.1f}, {ci['ci_high'] * 100:.1f}]",
+            (ci["point"], row), xytext=(0, 10), textcoords="offset points",
+            ha="center", va="bottom", fontsize=9, color="#222222",
+        )
+    ax.set_yticks(range(len(ordered)))
+    ax.set_yticklabels([_FAMILY_LABELS.get(k, k) for k in ordered])
+    ax.invert_yaxis()  # first family on top
+    ax.set_xlabel("Full-ensemble test accuracy (95% patient-clustered bootstrap CI)")
+    ax.set_title("Ensemble accuracy with bootstrap confidence intervals")
+    ax.margins(x=0.18, y=0.35)
+    ax.grid(True, axis="x", color="#000000", alpha=0.08, linewidth=0.7)
+    for spine in ("top", "right", "left"):
+        ax.spines[spine].set_visible(False)
+    ax.tick_params(left=False)
+    fig.tight_layout()
+    fig.savefig(out_dir / "bootstrap_ci.png", dpi=150)
+    fig.savefig(out_dir / "bootstrap_ci.pdf")
+    plt.close(fig)
+
+    (out_dir / "bootstrap_ci.json").write_text(
+        json.dumps(
+            {"iters": _BOOTSTRAP_ITERS, "ci_level": _CI_LEVEL, "families": results}
+        )
+    )
+    log.info(
+        f"bootstrap_ensemble_ci: wrote bootstrap_ci.png ({len(results)} family/families)."
+    )
+
+
+ANALYSES: Dict[str, AnalysisFn] = {
+    "model_report": model_report,
+    "ensemble_size_accuracy": ensemble_size_accuracy,
+    "bootstrap_ensemble_ci": bootstrap_ensemble_ci,
+}
+
+
+def _load_evaluations(
+    eval_dir: pl.Path, log: PipelineLogger
+) -> Dict[str, xr.Dataset]:
+    """Load every ``*.nc`` in ``eval_dir``, keyed by file stem."""
+    datasets: Dict[str, xr.Dataset] = {}
+    for nc in sorted(eval_dir.glob("*.nc")):
+        try:
+            ds = load_evaluation(str(nc))
+        except Exception as exc:  # noqa: BLE001 - report and skip a bad file
+            log.error(f"Could not load evaluation '{nc.name}': {exc}")
+            continue
+        datasets[nc.stem] = ds
+        log.info(f"Loaded evaluation '{nc.stem}' with dims {dict(ds.sizes)}.")
+    return datasets
+
+
+def run_analysis_task(
+    track: ProgressTracker,
+    log: PipelineLogger,
+    config: Dict[str, Any],
+    state: Dict[str, Any],
+) -> None:
+    work_dir = pl.Path(config["work_dir"])
+    eval_dir = work_dir / EVAL_SUBDIR
+    out_dir = work_dir / ANALYSIS_SUBDIR
+
+    if not eval_dir.is_dir():
+        log.error(
+            f"No evaluations directory at {eval_dir}. Run an evaluation scenario "
+            "first (or point the working directory at one that has evaluations)."
+        )
+        return
+
+    datasets = _load_evaluations(eval_dir, log)
+    if not datasets:
+        log.error(f"No evaluation .nc files found in {eval_dir}; nothing to analyze.")
+        return
+
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    try:
+        if not ANALYSES:
+            log.info(
+                f"[NOT IMPLEMENTED] No analyses registered yet (step 7). "
+                f"Loaded {len(datasets)} evaluation file(s): "
+                f"{', '.join(sorted(datasets))}. Register functions in "
+                f"tasks.analysis.ANALYSES to produce artifacts under {out_dir}/."
+            )
+            return
+
+        track.update(total=len(ANALYSES), advance=0)
+        for name, analysis_fn in ANALYSES.items():
+            log.info(f"Running analysis: {name}")
+            analysis_fn(datasets, out_dir, log)
+            track.update(advance=1)
+        log.info(f"Analysis artifacts written to {out_dir}/.")
+    finally:
+        for ds in datasets.values():
+            ds.close()

+ 151 - 0
tasks/kfold.py

@@ -0,0 +1,151 @@
+"""Patient-grouped k-fold cross-validation driver (single process, sequential).
+
+Runs the ordinary train->evaluate pipeline once per fold, each on a different
+patient-grouped partition, writing per-fold artifacts under
+``<work_dir>/folds/fold_<k>/``. It then concatenates every fold's held-out
+evaluation along the ``sample`` axis into out-of-fold (OOF) files at
+``<work_dir>/evaluations/oof_*.nc`` -- a single evaluation covering EVERY sample,
+each predicted by the fold-ensemble that never trained on it. The existing
+analyses then run unchanged on the OOF files (via ``scen_analyze`` or the
+``run_analysis`` step appended to ``scen_kfold``).
+
+Reuses the existing task functions verbatim: each fold just gets its own
+dataloaders in ``state`` and a per-fold ``work_dir`` in a shallow config copy.
+The full dataset is loaded ONCE and every fold is a set of Subset indices over
+it, so memory use matches a single run.
+"""
+
+import pathlib as pl
+from typing import Any, Dict, List
+
+import numpy as np
+import xarray as xr
+
+import model.dataset as ds
+from evaluation import CLASS_NAMES, load_evaluation, save_evaluation
+from util.control import check_control_events
+from util.progress import ProgressTracker
+from util.ui_logger import PipelineLogger
+
+from . import evaluate, load_data, load_models, train_bayesian, train_normal
+
+# Inner per-fold pipeline (load_data is handled by the driver; noise optional).
+_BASE_INNER_TASKS = [
+    ("Train Regular", train_normal.train_normal_task),
+    ("Train Bayesian", train_bayesian.train_bayesian_task),
+    ("Load Models", load_models.load_models_task),
+    ("Evaluate Regular", evaluate.evaluate_normal_task),
+    ("Evaluate Bayesian", evaluate.evaluate_bayesian_task),
+]
+
+_FOLDS_SUBDIR = "folds"
+_EVAL_SUBDIR = "evaluations"
+
+
+def _kfold_cfg(config: Dict[str, Any]):
+    cfg = config.get("kfold", {})
+    k_folds = int(cfg.get("k_folds", 4))
+    val_fraction = float(cfg.get("val_fraction", 0.15))
+    include_noise = bool(cfg.get("include_noise", False))
+    return k_folds, val_fraction, include_noise
+
+
+def _aggregate_oof(
+    work_dir: pl.Path, fold_dirs: List[pl.Path], log: PipelineLogger
+) -> None:
+    """Concatenate each fold's held-out evaluations into OOF files."""
+    out_dir = work_dir / _EVAL_SUBDIR
+    out_dir.mkdir(parents=True, exist_ok=True)
+
+    # Evaluation file names are identical across folds (normal.nc, bayesian.nc,
+    # and noisy_* when enabled); discover them from the folds that produced them.
+    stems = sorted(
+        {p.stem for d in fold_dirs for p in (d / _EVAL_SUBDIR).glob("*.nc")}
+    )
+    for stem in stems:
+        parts = [
+            load_evaluation(str(d / _EVAL_SUBDIR / f"{stem}.nc"))
+            for d in fold_dirs
+            if (d / _EVAL_SUBDIR / f"{stem}.nc").exists()
+        ]
+        if not parts:
+            continue
+        # Folds hold disjoint samples; concatenating along `sample` yields full,
+        # non-overlapping out-of-fold coverage. Reset the (per-fold) sample index.
+        oof = xr.concat(parts, dim="sample")
+        oof = oof.assign_coords(sample=np.arange(oof.sizes["sample"]))
+        oof.attrs["n_folds"] = len(parts)
+        oof.attrs["kfold"] = 1
+        save_evaluation(oof, str(out_dir / f"oof_{stem}.nc"))
+        for part in parts:
+            part.close()
+        log.info(
+            f"Aggregated OOF: oof_{stem}.nc "
+            f"({oof.sizes['sample']} samples from {len(parts)} folds)."
+        )
+
+
+def run_kfold_task(
+    track: ProgressTracker,
+    log: PipelineLogger,
+    config: Dict[str, Any],
+    state: Dict[str, Any],
+) -> None:
+    events = state.get("events")
+    stop_event = events.get("stop") if isinstance(events, dict) else None
+    pause_event = events.get("pause") if isinstance(events, dict) else None
+
+    base_seed = load_data.seed_pipeline(config, log)
+    k_folds, val_fraction, include_noise = _kfold_cfg(config)
+
+    dataset, ptids, image_to_ptid = load_data.load_full_dataset(config, log)
+    state["image_to_ptid"] = image_to_ptid
+
+    inner_tasks = list(_BASE_INNER_TASKS)
+    if include_noise:
+        inner_tasks.append(("Evaluate Noisy", evaluate.evaluate_noisy_task))
+
+    work_dir = pl.Path(config["work_dir"])
+    fold_dirs: List[pl.Path] = []
+
+    log.info(
+        f"Starting {k_folds}-fold cross-validation "
+        f"(val_fraction={val_fraction}, include_noise={include_noise})."
+    )
+    track.set_title("K-Fold Cross-Validation")
+    track.update(total=k_folds, advance=0)
+
+    for fold in range(k_folds):
+        check_control_events(stop_event, pause_event)
+
+        subsets = ds.kfold_split_by_patient_id(
+            dataset, ptids, k_folds, fold, base_seed, val_fraction
+        )
+        train_loader, val_loader, test_loader = ds.initalize_dataloaders(
+            subsets, batch_size=config["training"]["batch_size"], seed=base_seed
+        )
+        state["train_loader"] = train_loader
+        state["val_loader"] = val_loader
+        state["test_loader"] = test_loader
+
+        fold_dir = work_dir / _FOLDS_SUBDIR / f"fold_{fold}"
+        fold_dirs.append(fold_dir)
+        # Per-fold config: same everything, but outputs go under the fold dir.
+        fold_config = {**config, "work_dir": str(fold_dir)}
+
+        log.info(
+            f"===== Fold {fold + 1}/{k_folds} "
+            f"(train={len(subsets[0])}, val={len(subsets[1])}, "
+            f"test={len(subsets[2])} scans) ====="
+        )
+        for task_label, task_func in inner_tasks:
+            check_control_events(stop_event, pause_event)
+            fold_track = track.get_sub_tracker(
+                f"Fold {fold + 1}/{k_folds}: {task_label}"
+            )
+            task_func(fold_track, log, fold_config, state)
+
+        track.update(advance=1)
+
+    log.info("All folds complete; aggregating out-of-fold evaluations.")
+    _aggregate_oof(work_dir, fold_dirs, log)

+ 31 - 15
tasks/load_data.py

@@ -1,5 +1,5 @@
 import pathlib as pl
-from typing import Any, Dict
+from typing import Any, Dict, List, Tuple
 
 import pandas as pd
 
@@ -9,20 +9,11 @@ from util.seeding import seed_everything
 from util.ui_logger import PipelineLogger
 
 
-def load_data_task(
-    track: ProgressTracker,
-    log: PipelineLogger,
-    config: Dict[str, Any],
-    state: Dict[str, Any],
-):
-
-    if config["data"]["seed"] is None:
+def seed_pipeline(config: Dict[str, Any], log: PipelineLogger) -> int:
+    """Resolve the base seed (default 0) and seed all RNGs once. Returns it."""
+    if config["data"].get("seed") is None:
         log.info("Seed is not defined - using default seed of 0")
         config["data"]["seed"] = 0
-
-    # Seed everything once at the start of the pipeline so data loading, weight
-    # init, and shuffling are reproducible. Per-member seeds are derived from
-    # this base inside the training tasks.
     base_seed = int(config["data"]["seed"])
     deterministic = bool(config.get("training", {}).get("deterministic", False))
     applied = seed_everything(base_seed, deterministic=deterministic)
@@ -30,7 +21,17 @@ def load_data_task(
         f"Seeded RNGs with base seed {applied}"
         + (" (deterministic cuDNN)" if deterministic else "")
     )
+    return base_seed
+
+
+def load_full_dataset(
+    config: Dict[str, Any], log: PipelineLogger
+) -> Tuple["ds.ADNIDataset", List[Tuple[int, str]], Dict[int, str]]:
+    """Load the full ADNI dataset and its patient mapping (no split).
 
+    Shared by the single-split loader and the k-fold driver, which partition the
+    returned dataset differently.
+    """
     log.info("Loading Files")
     mri_files = pl.Path(config["data"]["mri_files_path"]).glob("*.nii")
     xls_file = pl.Path(config["data"]["xls_file_path"])
@@ -44,7 +45,6 @@ def load_data_task(
 
     ptid_df = pd.read_csv(xls_file)
     ptid_df.columns = ptid_df.columns.str.strip()
-
     ptid_df = ptid_df[["Image Data ID", "PTID"]].dropna(  # type: ignore
         subset=["Image Data ID", "PTID"]
     )
@@ -53,9 +53,25 @@ def load_data_task(
     ptid_df = ptid_df[ptid_df["PTID"] != ""]
 
     ptids = list(zip(ptid_df["Image Data ID"].tolist(), ptid_df["PTID"].tolist()))
+    image_to_ptid = {int(iid): str(pid) for iid, pid in ptids}
+    return dataset, ptids, image_to_ptid
+
+
+def load_data_task(
+    track: ProgressTracker,
+    log: PipelineLogger,
+    config: Dict[str, Any],
+    state: Dict[str, Any],
+):
+    # Seed everything once at the start of the pipeline so data loading, weight
+    # init, and shuffling are reproducible. Per-member seeds are derived from
+    # this base inside the training tasks.
+    base_seed = seed_pipeline(config, log)
+
+    dataset, ptids, image_to_ptid = load_full_dataset(config, log)
 
     # Mapping used later by evaluation to attach patient ids to the sample axis.
-    state["image_to_ptid"] = {int(iid): str(pid) for iid, pid in ptids}
+    state["image_to_ptid"] = image_to_ptid
 
     # Split is grouped by PTID to prevent patient-level leakage across partitions.
     datasets = ds.divide_dataset_by_patient_id(

+ 69 - 0
tasks/templates/model_report.typ

@@ -0,0 +1,69 @@
+// Per-model evaluation report.
+//
+// Data is injected as a JSON string via `sys.inputs.data` by
+// tasks/analysis.py (model_report). The bundled Typst compiler has no
+// `json.decode`, so we parse with `json(bytes(...))`.
+
+#let data = json(bytes(sys.inputs.at("data")))
+
+#set document(title: data.title)
+#set page(
+  paper: "a4",
+  margin: (x: 2cm, top: 2cm, bottom: 1.6cm),
+  numbering: "1 / 1",
+)
+#set text(size: 10pt)
+#set par(justify: true)
+
+#let fmt-pct(x) = [#calc.round(x * 100, digits: 1)%]
+#let fmt-num(x) = [#calc.round(x, digits: 3)]
+
+// ---- Header ----
+#align(center)[
+  #text(18pt, weight: "bold")[#data.title] \
+  #v(-3pt)
+  #text(9pt, fill: luma(110))[
+    Generated #data.generated · schema v#data.schema_version ·
+    seed #data.seed · commit #raw(data.git_commit)
+  ]
+]
+#v(2pt)
+#text(9pt)[*Working directory:* #raw(data.work_dir)]
+#line(length: 100%, stroke: 0.5pt + luma(200))
+#v(4pt)
+
+// ---- One section per model family ----
+#let family-block(fam) = {
+  heading(level: 2, fam.name)
+  text(9pt, fill: luma(90))[
+    Source: #raw(fam.source + ".nc") · noise σ = #fmt-num(fam.noise_sigma) ·
+    #fam.n_models model(s) · #fam.n_samples test samples · #fam.n_mc MC pass(es)
+  ]
+  v(4pt)
+  block(fill: luma(245), inset: 8pt, radius: 4pt, width: 100%)[
+    *Ensemble accuracy:* #fmt-pct(fam.accuracy_mean) ± #fmt-pct(fam.accuracy_std)
+    #h(1fr) *Best member:* #fmt-pct(fam.accuracy_best)
+  ]
+  v(6pt)
+  table(
+    columns: (auto, 1fr, 1fr, 1fr),
+    align: (col, row) => if col == 0 { left } else { right },
+    stroke: none,
+    inset: (x: 8pt, y: 5pt),
+    fill: (col, row) => if row == 0 { luma(228) } else if calc.odd(row) { luma(248) },
+    table.header(
+      [*Model*], [*Accuracy*], [*Mean pred. entropy*], [*Mean mutual info.*],
+    ),
+    ..fam.models
+      .map(m => ([#m.index], fmt-pct(m.accuracy), fmt-num(m.mean_entropy), fmt-num(m.mean_mi)))
+      .flatten()
+  )
+  v(10pt)
+}
+
+#for fam in data.families {
+  family-block(fam)
+}
+
+#v(1fr)
+#align(center)[#text(8pt, fill: luma(150))[ALNN evaluation harness — model report]]

+ 8 - 2
util/progress.py

@@ -6,6 +6,12 @@ from textual.widgets import Label, ProgressBar
 
 logger = logging.getLogger(__name__)
 
+# Number of stacked progress-bar rows (levels 0 .. MAX_LEVELS-1). Deep enough for
+# the k-fold nesting: root(0) -> run_kfold(1) -> fold task(2) -> train/eval(3) ->
+# epochs/passes(4) -> batches(5). main.py builds exactly this many rows.
+MAX_LEVELS = 6
+
+
 class ProgressTracker:
     """Manages hierarchical progress bars for the UI thread."""
 
@@ -15,7 +21,7 @@ class ProgressTracker:
 
     def get_sub_tracker(self, title: str = "") -> "ProgressTracker":
         """Returns a sub-tracker for child processes."""
-        if self.level >= 4:
+        if self.level >= MAX_LEVELS - 1:
             return self  # Max depth reached
         sub = ProgressTracker(self.app, self.level + 1)
         if title:
@@ -88,7 +94,7 @@ class ProgressTracker:
 
     def _clear_subs(self) -> None:
         """Clears and hides all child progress bars (must run in UI thread)."""
-        for l in range(self.level + 1, 5):
+        for l in range(self.level + 1, MAX_LEVELS):
             try:
                 bar = self.app.query_one(f"#progress_bar_{l}", ProgressBar)
                 row = self.app.query_one(f"#progress_row_{l}", Horizontal)

+ 20 - 6
util/screens.py

@@ -1,3 +1,5 @@
+from typing import List, Optional
+
 from textual.app import ComposeResult
 from textual.containers import Horizontal, Vertical
 from textual.screen import ModalScreen
@@ -5,19 +7,31 @@ from textual.widgets import Button, Label
 
 
 class OverwriteConfirmScreen(ModalScreen[bool]):
-    """Screen with a dialog to confirm overwriting data."""
+    """Dialog confirming an overwrite of protected (hard-to-regenerate) outputs."""
 
-    def __init__(self, directory: str):
+    def __init__(self, directory: str, conflicts: Optional[List[str]] = None):
         super().__init__()
         self.directory = directory
+        # Human-readable descriptions of the protected artifacts at risk. When
+        # empty, falls back to a generic non-empty-directory warning.
+        self.conflicts = conflicts or []
 
     def compose(self) -> ComposeResult:
+        if self.conflicts:
+            items = "\n".join(f"  • {c}" for c in self.conflicts)
+            body = (
+                f"This scenario will overwrite existing, hard-to-regenerate "
+                f"outputs in:\n[dim]{self.directory}[/dim]\n\n{items}\n\nProceed?"
+            )
+        else:
+            body = (
+                f"The output directory is not empty:\n[dim]{self.directory}[/dim]"
+                "\n\nProceeding will likely overwrite existing experiment data."
+            )
+
         with Vertical(id="dialog"):
             yield Label("[bold red]WARNING[/bold red]")
-            yield Label(
-                f"The output directory is not empty:\n[dim]{self.directory}[/dim]\n\nProceeding will likely overwrite existing experiment data.",
-                id="warning_label",
-            )
+            yield Label(body, id="warning_label")
             with Horizontal(id="dialog_buttons"):
                 yield Button("Cancel", variant="primary", id="btn_cancel")
                 yield Button("Overwrite", variant="error", id="btn_proceed")

+ 27 - 0
uv.lock

@@ -38,6 +38,7 @@ dependencies = [
     { name = "torch" },
     { name = "torchvision" },
     { name = "tqdm" },
+    { name = "typst" },
     { name = "xarray" },
 ]
 
@@ -64,6 +65,7 @@ requires-dist = [
     { name = "torch" },
     { name = "torchvision" },
     { name = "tqdm" },
+    { name = "typst", specifier = ">=0.15.0" },
     { name = "xarray" },
 ]
 
@@ -2270,6 +2272,31 @@ wheels = [
 ]
 
 [[package]]
+name = "typst"
+version = "0.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/69/5d6700379124632f243c7eb2b41b3244ef991fe8ff29b27333e0bb655918/typst-0.15.0.tar.gz", hash = "sha256:a60231b55f0a793c2401b26577522dbf7528207407b383de3a7f0cf7fd3ce28a", size = 66887, upload-time = "2026-06-16T13:02:31.809Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/92/8c/53e4acb6095fc20d2ec981155a1b9a1364b34aa86a884a75f9be1addb88d/typst-0.15.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:880da56762b240649492186a24cc53427e8a41108b2e73fa337ac4cb314eb3b0", size = 30925413, upload-time = "2026-06-16T13:01:32.627Z" },
+    { url = "https://files.pythonhosted.org/packages/21/5e/fb330894aa9a80e39a5e9d0a3f6f3ea4fcb44ba883965635a281323a027d/typst-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:89aafbd9f3d788b72486a90106d927f17dba1fe30c55c3522f77a201397bc107", size = 30486424, upload-time = "2026-06-16T13:01:36.322Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/83/32c54f97c2638076a4b5301b0c7d7b282f232c85bcab539ccb80284983dd/typst-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7152f62e1737d82d55650162f03534be4639ae800921a1a84848387c0f3b0ba4", size = 34917438, upload-time = "2026-06-16T13:01:39.833Z" },
+    { url = "https://files.pythonhosted.org/packages/44/e1/499c395e83ab44da091d51f99ece04dd7edcbb1b6cd5b2ec8ce5906202c6/typst-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:686fdf83684e4ada66a841442c6fcf8dc934e14ba5458fceb5cf50fb2a0c80d6", size = 34356766, upload-time = "2026-06-16T13:01:43.105Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/ae/da45903d5b939a07979e4ba9a360f55cf76f2be1025a2ed3c631f07bbcdd/typst-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07351f26991ed61e732fe3f1035076ee6b4a241dcdef789e78cbcf3fcdb267d7", size = 36442334, upload-time = "2026-06-16T13:01:47.439Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/5b/ff49f4f2ed7591f76566e1f14fc46f4cfd638bf6be36ca6e0d3c9b54ee7d/typst-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e2f5cd0cffc7a0d388ad6c38d7c1d7bc1cf630abfe1bc682e09614e8d203a48", size = 35187180, upload-time = "2026-06-16T13:01:50.775Z" },
+    { url = "https://files.pythonhosted.org/packages/28/58/a78f0620dceabbd4f2e5ee7dc377cfeb331ebaacd8c541de07c6a9892c47/typst-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7007ccb3cd3cd3a5fe23876b413eca927b4d210ddbebc087b9394fe0cea8e91a", size = 34139808, upload-time = "2026-06-16T13:01:54.17Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/6b/9715202f2179a00a8be7fee6e9c890d10dc41ac145c03e09ec336906e93f/typst-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5a942eb7a86885f30cd34c0f42c24bf14bd270fb20fe37e268b2061d7d783daa", size = 29355085, upload-time = "2026-06-16T13:01:57.56Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/30/cce48475a335eced15769252bc5b2631b02196f07c001ab34ccd79664afb/typst-0.15.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a9c02ca7503d1916fb3eaa22aef413bd23b6d54abef5c6c5ecac8d1b804deb8d", size = 30936670, upload-time = "2026-06-16T13:02:01.038Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/a9/8cb66f027d644572836423382a8e063c388c9d87fed474e0f499c4cb17e1/typst-0.15.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:98afafa47e372728bce7fe1153b8d3ace4619d6c3a549908989d65f9aec96247", size = 30504579, upload-time = "2026-06-16T13:02:04.481Z" },
+    { url = "https://files.pythonhosted.org/packages/83/b5/29e6218486259056c2649fb245c5066c3a821cb8b56d6710c3007062136a/typst-0.15.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97350fcf5eebe5b6c75415e005ac42136744aa9950f4c0e4c484dc015e38d9de", size = 34934501, upload-time = "2026-06-16T13:02:08.207Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/1c/6134b210a08c929663f7e3913713758fb475ce76696eea92aeba68f62d7f/typst-0.15.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a400a27115b85acc020cc514c76ea1d56e607ac40e99e0d3e7413e105ff3485d", size = 34372306, upload-time = "2026-06-16T13:02:11.675Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/dd/ca5c10380b63d3f4914be09b694f34c7c7ba24640f2f0713076c77e6b8bb/typst-0.15.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3eadd17f2170e48c73c386b7ccbab2fc1cc4a190969fce8bbad3b3cdc5bc58cf", size = 36463681, upload-time = "2026-06-16T13:02:15.359Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/67/3c78adb30f715cbcd0612039b621033a8a57c1d6053a7618837ddf6c19c4/typst-0.15.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb95304a78d4a068d7d19f036a9ab60872aca4e514a4abf214ff65e657ab9bc0", size = 35199094, upload-time = "2026-06-16T13:02:18.678Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/57/e2bb9b7823c049361c9e7d2d971996430b71260bfc3a7ed289ca4b37c1b0/typst-0.15.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f33d98451bab132a612b98ffc8d1830c97a076ea3f3fde11f6ff7ab9bcae89c", size = 34161270, upload-time = "2026-06-16T13:02:23.051Z" },
+    { url = "https://files.pythonhosted.org/packages/07/3f/6d526ddd93e6a7dd26c2b180245df8d1957d2723860030a10bcc0f93650c/typst-0.15.0-cp38-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:019b4282daa892e0a540687efdd2909808a07453700332c7f61a2c1455950ec9", size = 25710679, upload-time = "2026-06-16T13:02:26.169Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/5f/7f19bc9f7a2917a52aa39981aff19f86972f4055b432f77f31642ab57625/typst-0.15.0-cp38-abi3-win_amd64.whl", hash = "sha256:7c12706685dbaf5bb7e43f0fa32e57f2a42549b9ec3de539ad0d32bd8d1ca92e", size = 29372618, upload-time = "2026-06-16T13:02:29.651Z" },
+]
+
+[[package]]
 name = "tzdata"
 version = "2026.2"
 source = { registry = "https://pypi.org/simple" }