Procházet zdrojové kódy

Initial analysis work

Nicholas Schense před 1 týdnem
rodič
revize
4a1a83559e
9 změnil soubory, kde provedl 1002 přidání a 55 odebrání
  1. 78 0
      alnn_rewrite.log
  2. 12 1
      config.toml
  3. 164 46
      main.py
  4. 1 0
      pyproject.toml
  5. 108 2
      tasks/__init__.py
  6. 523 0
      tasks/analysis.py
  7. 69 0
      tasks/templates/model_report.typ
  8. 20 6
      util/screens.py
  9. 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!

+ 12 - 1
config.toml

@@ -1,9 +1,20 @@
 # 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)
 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"

+ 164 - 46
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.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
     # ==========================================
@@ -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)

+ 1 - 0
pyproject.toml

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

+ 108 - 2
tasks/__init__.py

@@ -11,19 +11,35 @@ 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
 
-PIPELINE_TASKS = {
+
+class TaskEntry(TypedDict):
+    task_name: str
+    task_func: Callable[..., None]
+
+
+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 +68,93 @@ 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,
+    },
 }
 
-SCENARIOS = {
+# --------------------------------------------------------------------------
+# 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)",
+    },
+    "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",),
+}
+
+
+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 +186,10 @@ SCENARIOS = {
             "evaluate_bayesian",
         ],
     },
+    "scen_analyze": {
+        "label": "4. Analyze Existing Evaluations",
+        "tasks": [
+            "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()

+ 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]]

+ 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" }