"""Outer-CV training and evaluation pipeline."""

from __future__ import annotations

import json
from collections import Counter
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from hashlib import sha256
from math import comb
from pathlib import Path
from threading import Lock
from typing import Any

import numpy as np
import optuna
import polars as pl
from optuna.samplers import TPESampler
from scipy.stats import rankdata
from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    average_precision_score,
    balanced_accuracy_score,
    brier_score_loss,
    log_loss,
    matthews_corrcoef,
    roc_auc_score,
)
from sklearn.model_selection import GroupKFold, LeaveOneGroupOut
from sklearn.preprocessing import StandardScaler
from sklearn.svm import LinearSVC

from phenoradar.config import AppConfig
from phenoradar.interpret import (
    InterpretationError,
    ModelFeatureEntry,
    build_interpretation_tables,
)
from phenoradar.model_selection import (
    Candidate,
    ContinuousSpec,
    ModelSelectionError,
    discrete_candidate_space_size,
    expanded_search_space,
    generate_candidates,
)

try:
    from threadpoolctl import (  # type: ignore[import-untyped]
        ThreadpoolController,
        threadpool_limits,
    )
except ImportError:  # pragma: no cover - available via scikit-learn dependency.
    ThreadpoolController = None
    threadpool_limits = None

_THREADPOOL_CONTROLLER: ThreadpoolController | None = None
_THREADPOOL_CONTROLLER_LOCK = Lock()


class CVError(ValueError):
    """Raised when CV training or evaluation cannot proceed."""


@dataclass(frozen=True)
class CVArtifacts:
    """Outputs generated by the outer-CV stage."""

    metrics_cv: pl.DataFrame
    loss_by_split_cv: pl.DataFrame
    thresholds: pl.DataFrame
    oof_predictions: pl.DataFrame
    feature_importance: pl.DataFrame
    coefficients: pl.DataFrame
    ensemble_model_probs: pl.DataFrame | None
    model_selection_selected: pl.DataFrame | None
    model_selection_trials: pl.DataFrame | None
    model_selection_trials_summary: pl.DataFrame | None
    feature_filter_counts: pl.DataFrame
    feature_filter_counts_summary: pl.DataFrame
    model_sparsity: pl.DataFrame
    model_sparsity_summary: pl.DataFrame
    warnings: list[str]


@dataclass(frozen=True)
class FinalRefitArtifacts:
    """Outputs generated by final refit on the full training/validation pool."""

    pred_external_test: pl.DataFrame
    pred_inference: pl.DataFrame
    loss_by_split_final_refit: pl.DataFrame
    model_selection_selected: pl.DataFrame | None
    feature_filter_counts: pl.DataFrame
    feature_filter_counts_summary: pl.DataFrame
    model_sparsity: pl.DataFrame
    model_sparsity_summary: pl.DataFrame
    warnings: list[str]
    ensemble_size: int
    feature_names: list[str]
    scaler: StandardScaler
    models: list[LogisticRegression | CalibratedClassifierCV | RandomForestClassifier]
    model_entries: list[FinalModelEntry] = field(default_factory=list)


@dataclass(frozen=True)
class SelectedCandidate:
    """Candidate selected for fitting with optional selection score."""

    candidate: Candidate
    score: float | None


@dataclass(frozen=True)
class SourceSelectionResult:
    """Selection outputs for one source sample_set_id within a training scope."""

    selected_candidates: list[SelectedCandidate]
    n_available_candidates: int
    n_scored_candidates: int
    selected_candidate_count_requested: int | None
    selected_candidate_count_effective: int
    trial_rows: list[dict[str, Any]]


@dataclass(frozen=True)
class OuterFoldResult:
    """Fold-local outputs returned from one outer-CV fold execution."""

    fold_metrics: dict[str, float]
    metric_rows: list[dict[str, float | int | str | None]]
    loss_rows: list[dict[str, float | str]]
    oof_rows: list[dict[str, float | int | str]]
    interpretation_entries: list[ModelFeatureEntry]
    ensemble_model_prob_rows: list[dict[str, float | int | str]]
    model_selection_selected_rows: list[dict[str, Any]]
    model_selection_trial_rows: list[dict[str, Any]]
    feature_filter_count_rows: list[dict[str, Any]]
    model_sparsity_rows: list[dict[str, Any]]
    fold_model_count: int
    n_features_before_preprocess: int
    n_features_after_low_prevalence: int
    n_features_after_low_variance: int
    n_features_after_correlation: int
    n_features_after_preprocess: int
    warnings: list[str]


@dataclass(frozen=True)
class OuterCvMatrixCache:
    """Shared raw expression matrix for all outer-CV folds."""

    matrix: np.ndarray
    feature_names: list[str]
    species_to_index: dict[str, int]


@dataclass(frozen=True)
class FeatureFilterCounts:
    """Feature counts after each preprocessing filter stage."""

    n_features_before: int
    n_features_after_low_prevalence: int
    n_features_after_low_variance: int
    n_features_after_correlation: int
    n_features_after_all: int


@dataclass(frozen=True)
class OuterSampleSetFitResult:
    """Fold-local fit/predict outputs for one sampled set."""

    model_probs: list[np.ndarray]
    train_model_probs: list[np.ndarray]
    fold_models: list[LogisticRegression | CalibratedClassifierCV | RandomForestClassifier]
    interpretation_entries: list[ModelFeatureEntry]
    ensemble_model_prob_rows: list[dict[str, float | int | str]]
    model_sparsity_rows: list[dict[str, Any]]
    selected_features: list[str]
    filter_counts: FeatureFilterCounts
    model_count: int


@dataclass(frozen=True)
class FinalSampleSetFitResult:
    """Final-refit fit/predict outputs for one sampled set."""

    model_probs: list[np.ndarray]
    train_model_probs: list[np.ndarray]
    fitted_models: list[LogisticRegression | CalibratedClassifierCV | RandomForestClassifier]
    model_sparsity_rows: list[dict[str, Any]]
    selected_features: list[str]
    scaler: StandardScaler
    filter_counts: FeatureFilterCounts
    model_count: int


@dataclass(frozen=True)
class FinalModelEntry:
    """One final-refit model with its model-local preprocessing state."""

    feature_names: list[str]
    scaler: StandardScaler
    model: LogisticRegression | CalibratedClassifierCV | RandomForestClassifier


_FEATURE_FILTER_STAGE_COLUMNS = [
    "n_features_before",
    "n_features_after_low_prevalence",
    "n_features_after_low_variance",
    "n_features_after_correlation",
    "n_features_after_all",
]
_FEATURE_FILTER_STAGE_ORDER = {
    "n_features_before": 0,
    "n_features_after_low_prevalence": 1,
    "n_features_after_low_variance": 2,
    "n_features_after_correlation": 3,
    "n_features_after_all": 4,
}
_NONZERO_TOLERANCE = 1e-12


def _empty_feature_filter_counts() -> pl.DataFrame:
    return pl.DataFrame(
        schema={
            "scope": pl.String,
            "fold_id": pl.String,
            "sample_set_id": pl.Int64,
            "n_features_before": pl.Int64,
            "n_features_after_low_prevalence": pl.Int64,
            "n_features_after_low_variance": pl.Int64,
            "n_features_after_correlation": pl.Int64,
            "n_features_after_all": pl.Int64,
        }
    )


def _empty_feature_filter_counts_summary() -> pl.DataFrame:
    return pl.DataFrame(
        schema={
            "scope": pl.String,
            "stage": pl.String,
            "n_records": pl.Int64,
            "n_features_min": pl.Int64,
            "n_features_median": pl.Float64,
            "n_features_mean": pl.Float64,
            "n_features_max": pl.Int64,
            "retained_ratio_min": pl.Float64,
            "retained_ratio_median": pl.Float64,
            "retained_ratio_mean": pl.Float64,
            "retained_ratio_max": pl.Float64,
        }
    )


def _feature_filter_count_row(
    *,
    scope: str,
    fold_id: str,
    sample_set_id: int,
    counts: FeatureFilterCounts,
) -> dict[str, Any]:
    return {
        "scope": scope,
        "fold_id": fold_id,
        "sample_set_id": int(sample_set_id),
        "n_features_before": int(counts.n_features_before),
        "n_features_after_low_prevalence": int(counts.n_features_after_low_prevalence),
        "n_features_after_low_variance": int(counts.n_features_after_low_variance),
        "n_features_after_correlation": int(counts.n_features_after_correlation),
        "n_features_after_all": int(counts.n_features_after_all),
    }


def _build_feature_filter_counts(rows: list[dict[str, Any]]) -> pl.DataFrame:
    if not rows:
        return _empty_feature_filter_counts()
    return pl.DataFrame(rows).sort(["scope", "fold_id", "sample_set_id"])


def _summarize_feature_filter_counts(feature_filter_counts: pl.DataFrame) -> pl.DataFrame:
    if feature_filter_counts.height == 0:
        return _empty_feature_filter_counts_summary()
    long = feature_filter_counts.unpivot(
        on=_FEATURE_FILTER_STAGE_COLUMNS,
        index=["scope", "fold_id", "sample_set_id", "n_features_before"],
        variable_name="stage",
        value_name="n_features",
    ).with_columns(
        (pl.col("n_features") / pl.col("n_features_before")).alias("retained_ratio"),
    )
    summary = long.group_by(["scope", "stage"]).agg(
        [
            pl.len().alias("n_records"),
            pl.col("n_features").min().alias("n_features_min"),
            pl.col("n_features").median().alias("n_features_median"),
            pl.col("n_features").mean().alias("n_features_mean"),
            pl.col("n_features").max().alias("n_features_max"),
            pl.col("retained_ratio").min().alias("retained_ratio_min"),
            pl.col("retained_ratio").median().alias("retained_ratio_median"),
            pl.col("retained_ratio").mean().alias("retained_ratio_mean"),
            pl.col("retained_ratio").max().alias("retained_ratio_max"),
        ]
    )
    return summary.with_columns(
        pl.col("stage").replace(_FEATURE_FILTER_STAGE_ORDER).alias("_stage_order"),
    ).sort(["scope", "_stage_order"]).drop("_stage_order")


def _empty_model_sparsity() -> pl.DataFrame:
    return pl.DataFrame(
        schema={
            "scope": pl.String,
            "fold_id": pl.String,
            "sample_set_id": pl.Int64,
            "model_index": pl.Int64,
            "model_name": pl.String,
            "n_features_after_all": pl.Int64,
            "n_nonzero_features": pl.Int64,
            "nonzero_ratio": pl.Float64,
            "count_method": pl.String,
            "reason": pl.String,
        }
    )


def _empty_model_sparsity_summary() -> pl.DataFrame:
    return pl.DataFrame(
        schema={
            "scope": pl.String,
            "model_name": pl.String,
            "n_models": pl.Int64,
            "n_models_with_nonzero_count": pl.Int64,
            "n_nonzero_min": pl.Int64,
            "n_nonzero_median": pl.Float64,
            "n_nonzero_mean": pl.Float64,
            "n_nonzero_max": pl.Int64,
            "nonzero_ratio_min": pl.Float64,
            "nonzero_ratio_median": pl.Float64,
            "nonzero_ratio_mean": pl.Float64,
            "nonzero_ratio_max": pl.Float64,
        }
    )


def _coef_nonzero_count(coefficients: np.ndarray, *, tolerance: float) -> int:
    abs_coef = np.abs(np.asarray(coefficients, dtype=float))
    per_feature = abs_coef if abs_coef.ndim == 1 else np.max(abs_coef, axis=0)
    return int(np.count_nonzero(per_feature > tolerance))


def _model_nonzero_count(
    model: LogisticRegression | CalibratedClassifierCV | RandomForestClassifier,
) -> tuple[int | None, str, str]:
    if isinstance(model, LogisticRegression):
        count = _coef_nonzero_count(model.coef_, tolerance=_NONZERO_TOLERANCE)
        return count, "coef_abs_gt_tol", "ok"

    if isinstance(model, CalibratedClassifierCV):
        calibrated_entries = getattr(model, "calibrated_classifiers_", None)
        if not calibrated_entries:
            return None, "coef_abs_gt_tol_union", "no_calibrated_estimators"
        per_estimator: list[np.ndarray] = []
        for entry in calibrated_entries:
            estimator = getattr(entry, "estimator", None)
            if estimator is None:
                estimator = getattr(entry, "base_estimator", None)
            if estimator is None or not hasattr(estimator, "coef_"):
                continue
            coef = np.asarray(estimator.coef_, dtype=float)
            abs_coef = np.abs(coef)
            if abs_coef.ndim == 1:
                per_estimator.append(abs_coef)
            else:
                per_estimator.append(np.max(abs_coef, axis=0))
        if not per_estimator:
            return None, "coef_abs_gt_tol_union", "coef_not_available"
        stacked = np.vstack(per_estimator)
        per_feature = np.max(stacked, axis=0)
        count = int(np.count_nonzero(per_feature > _NONZERO_TOLERANCE))
        return count, "coef_abs_gt_tol_union", "ok"

    if isinstance(model, RandomForestClassifier):
        if not hasattr(model, "feature_importances_"):
            return None, "feature_importance_gt_tol", "importance_not_available"
        importance = np.asarray(model.feature_importances_, dtype=float)
        count = int(np.count_nonzero(importance > _NONZERO_TOLERANCE))
        return count, "feature_importance_gt_tol", "ok"

    return None, "unsupported", "unsupported_model"


def _model_sparsity_row(
    *,
    scope: str,
    fold_id: str,
    sample_set_id: int,
    model_index: int,
    model_name: str,
    n_features_after_all: int,
    model: LogisticRegression | CalibratedClassifierCV | RandomForestClassifier,
) -> dict[str, Any]:
    n_nonzero, count_method, reason = _model_nonzero_count(model)
    if n_nonzero is None:
        nonzero_ratio: float | None = None
    else:
        denominator = max(1, int(n_features_after_all))
        nonzero_ratio = float(n_nonzero) / float(denominator)
    return {
        "scope": scope,
        "fold_id": fold_id,
        "sample_set_id": int(sample_set_id),
        "model_index": int(model_index),
        "model_name": model_name,
        "n_features_after_all": int(n_features_after_all),
        "n_nonzero_features": n_nonzero,
        "nonzero_ratio": nonzero_ratio,
        "count_method": count_method,
        "reason": reason,
    }


def _build_model_sparsity(rows: list[dict[str, Any]]) -> pl.DataFrame:
    if not rows:
        return _empty_model_sparsity()
    return pl.DataFrame(rows).sort(["scope", "fold_id", "sample_set_id", "model_index"])


def _summarize_model_sparsity(model_sparsity: pl.DataFrame) -> pl.DataFrame:
    if model_sparsity.height == 0:
        return _empty_model_sparsity_summary()
    return model_sparsity.group_by(["scope", "model_name"]).agg(
        [
            pl.len().alias("n_models"),
            pl.col("n_nonzero_features").count().alias("n_models_with_nonzero_count"),
            pl.col("n_nonzero_features").min().alias("n_nonzero_min"),
            pl.col("n_nonzero_features").median().alias("n_nonzero_median"),
            pl.col("n_nonzero_features").mean().alias("n_nonzero_mean"),
            pl.col("n_nonzero_features").max().alias("n_nonzero_max"),
            pl.col("nonzero_ratio").min().alias("nonzero_ratio_min"),
            pl.col("nonzero_ratio").median().alias("nonzero_ratio_median"),
            pl.col("nonzero_ratio").mean().alias("nonzero_ratio_mean"),
            pl.col("nonzero_ratio").max().alias("nonzero_ratio_max"),
        ]
    ).sort(["scope", "model_name"])


def _deterministic_int_seed(seed_text: str) -> int:
    seed_hash = sha256(seed_text.encode("utf-8")).hexdigest()[:16]
    return int(seed_hash, 16) % (2**31 - 1)


def _ensemble_seed(runtime_seed: int, training_scope_id: str, model_index: int) -> int:
    return _deterministic_int_seed(f"{runtime_seed}|{training_scope_id}|{model_index}")


def _runtime_n_jobs(config: AppConfig) -> int:
    value = int(config.runtime.n_jobs)
    if value < 1:
        raise CVError("runtime.n_jobs must be >= 1")
    return value


def _with_native_thread_limit[T](
    n_jobs: int, func: Callable[..., T], /, *args: Any, **kwargs: Any
) -> T:
    resolved_n_jobs = int(n_jobs)
    if resolved_n_jobs < 1:
        raise CVError("native thread limit must be >= 1")
    controller = _get_threadpool_controller()
    if controller is not None:
        with controller.limit(limits=resolved_n_jobs):
            return func(*args, **kwargs)
    if threadpool_limits is None:
        return func(*args, **kwargs)
    with threadpool_limits(limits=resolved_n_jobs):
        return func(*args, **kwargs)


def _get_threadpool_controller() -> ThreadpoolController | None:
    if ThreadpoolController is None:
        return None
    global _THREADPOOL_CONTROLLER
    if _THREADPOOL_CONTROLLER is not None:
        return _THREADPOOL_CONTROLLER
    # Reuse one controller to avoid repeated dl_iterate_phdr scans under high fold concurrency.
    with _THREADPOOL_CONTROLLER_LOCK:
        if _THREADPOOL_CONTROLLER is None:
            _THREADPOOL_CONTROLLER = ThreadpoolController()
    return _THREADPOOL_CONTROLLER


def _with_native_thread_limit_for_config[T](
    config: AppConfig, func: Callable[..., T], /, *args: Any, **kwargs: Any
) -> T:
    return _with_native_thread_limit(_runtime_n_jobs(config), func, *args, **kwargs)


def _polars_thread_pool_warning(config: AppConfig) -> str | None:
    runtime_n_jobs = _runtime_n_jobs(config)
    polars_threads = int(pl.thread_pool_size())
    if polars_threads <= runtime_n_jobs:
        return None
    return (
        "Polars thread pool exceeds runtime.n_jobs "
        f"(POLARS_MAX_THREADS={polars_threads}, runtime.n_jobs={runtime_n_jobs}). "
        "Set POLARS_MAX_THREADS before process start for a strict cap."
    )


def _selection_parallel_plan(config: AppConfig, candidate_count: int) -> tuple[int, int]:
    runtime_n_jobs = _runtime_n_jobs(config)
    if candidate_count <= 1 or runtime_n_jobs == 1:
        return 1, runtime_n_jobs
    worker_count = min(runtime_n_jobs, candidate_count)
    per_estimator_n_jobs = max(1, runtime_n_jobs // worker_count)
    return worker_count, per_estimator_n_jobs


def _outer_cv_parallel_plan(config: AppConfig, fold_count: int) -> tuple[int, int]:
    runtime_n_jobs = _runtime_n_jobs(config)
    if fold_count <= 1 or runtime_n_jobs == 1:
        return 1, runtime_n_jobs
    fold_workers = min(runtime_n_jobs, fold_count)
    per_fold_n_jobs = max(1, runtime_n_jobs // fold_workers)
    return fold_workers, per_fold_n_jobs


def _sample_set_parallel_plan(config: AppConfig, sample_count: int) -> tuple[int, int]:
    runtime_n_jobs = _runtime_n_jobs(config)
    if sample_count <= 1 or runtime_n_jobs == 1:
        return 1, runtime_n_jobs
    sample_workers = min(runtime_n_jobs, sample_count)
    per_sample_n_jobs = max(1, runtime_n_jobs // sample_workers)
    return sample_workers, per_sample_n_jobs


def _config_with_runtime_n_jobs(config: AppConfig, n_jobs: int) -> AppConfig:
    resolved_n_jobs = int(n_jobs)
    if resolved_n_jobs < 1:
        raise CVError("runtime.n_jobs must be >= 1")
    if int(config.runtime.n_jobs) == resolved_n_jobs:
        return config
    return config.model_copy(
        update={
            "runtime": config.runtime.model_copy(update={"n_jobs": resolved_n_jobs}),
        }
    )


class ExpressionMatrixBuilder:
    """Build species x orthogroup matrix from long-format expression data."""

    def __init__(self, config: AppConfig) -> None:
        self._tpm_path = Path(config.data.tpm_path)
        self._species_col = config.data.species_col
        self._feature_col = config.data.feature_col
        self._value_col = config.data.value_col
        self._max_pivot_cells = int(config.preprocess.max_pivot_cells)

        try:
            self._scan = pl.scan_csv(self._tpm_path, separator="\t")
        except FileNotFoundError as exc:
            raise CVError(f"Input file not found: {self._tpm_path}") from exc

        schema_columns = set(self._scan.collect_schema().names())
        required = {self._species_col, self._feature_col, self._value_col}
        missing = sorted(required - schema_columns)
        if missing:
            missing_str = ", ".join(missing)
            raise CVError(f"Missing required columns in expression data: {missing_str}")

    def build_matrix(self, species_order: list[str]) -> tuple[np.ndarray, list[str]]:
        if not species_order:
            raise CVError("No species were provided for matrix construction")

        unique_species = list(dict.fromkeys(species_order))
        long_scan = (
            self._scan.filter(
                pl.col(self._species_col).cast(pl.String, strict=False).str.strip_chars().is_in(
                    unique_species
                )
            )
            .select(
                pl.col(self._species_col)
                .cast(pl.String, strict=False)
                .str.strip_chars()
                .alias("__species"),
                pl.col(self._feature_col)
                .cast(pl.String, strict=False)
                .str.strip_chars()
                .alias("__feature"),
                pl.col(self._value_col).cast(pl.Float64, strict=False).fill_null(0.0).alias(
                    "__value"
                ),
            )
            .filter(
                pl.col("__species").is_not_null()
                & (pl.col("__species") != "")
                & pl.col("__feature").is_not_null()
                & (pl.col("__feature") != "")
            )
            .group_by(["__species", "__feature"])
            .agg(pl.col("__value").sum())
        )

        present_species = {
            str(value)
            for value in long_scan.select("__species").unique().collect().to_series().to_list()
        }
        if not present_species:
            raise CVError("Expression matrix is empty for the selected species")
        missing_species = sorted(set(unique_species) - set(str(v) for v in present_species))
        if missing_species:
            preview = ", ".join(missing_species[:10])
            raise CVError(f"Expression data is missing selected species: {preview}")

        feature_names = [
            str(value)
            for value in (
                long_scan.select("__feature")
                .unique()
                .sort("__feature")
                .collect()
                .to_series()
                .to_list()
            )
        ]
        if not feature_names:
            raise CVError("No features were available after pivoting expression data")

        ordering_df = pl.DataFrame(
            {"__species": species_order, "__row_idx": list(range(len(species_order)))}
        )
        estimated_cells = len(unique_species) * len(feature_names)
        if estimated_cells <= self._max_pivot_cells:
            long_df = long_scan.collect()
            pivot = long_df.pivot(
                index="__species",
                on="__feature",
                values="__value",
                aggregate_function="sum",
            )
            ordered = (
                ordering_df.join(pivot, on="__species", how="left")
                .fill_null(0.0)
                .sort("__row_idx")
                .select(feature_names)
            )
            return ordered.to_numpy().astype(float, copy=False), feature_names

        feature_chunk_size = max(1, self._max_pivot_cells // max(1, len(unique_species)))
        matrix = np.zeros((len(species_order), len(feature_names)), dtype=float)
        for start in range(0, len(feature_names), feature_chunk_size):
            stop = min(start + feature_chunk_size, len(feature_names))
            chunk_features = feature_names[start:stop]
            chunk_df = long_scan.filter(pl.col("__feature").is_in(chunk_features)).collect()
            chunk_pivot = chunk_df.pivot(
                index="__species",
                on="__feature",
                values="__value",
                aggregate_function="sum",
            )
            ordered_chunk = (
                ordering_df.join(chunk_pivot, on="__species", how="left")
                .fill_null(0.0)
                .sort("__row_idx")
                .select(chunk_features)
            )
            chunk_matrix = ordered_chunk.to_numpy().astype(float, copy=False)
            expected_shape = (len(species_order), len(chunk_features))
            if chunk_matrix.shape != expected_shape:
                raise CVError("Expression matrix chunking produced inconsistent chunk shape")
            matrix[:, start:stop] = chunk_matrix

        return matrix, feature_names


def _select_feature_indices_with_counts(
    config: AppConfig, x_train_log: np.ndarray, feature_names: list[str]
) -> tuple[np.ndarray, FeatureFilterCounts]:
    selected = np.arange(x_train_log.shape[1], dtype=int)
    n_features_before = int(selected.size)

    if config.preprocess.low_prevalence_filter.enabled:
        min_species = config.preprocess.low_prevalence_filter.min_species_per_feature
        if min_species is None:
            raise CVError("low_prevalence_filter is enabled but min_species_per_feature is missing")
        prevalence = np.count_nonzero(x_train_log[:, selected] > 0.0, axis=0)
        selected = selected[prevalence >= int(min_species)]
    n_features_after_low_prevalence = int(selected.size)

    if config.preprocess.low_variance_filter.enabled:
        min_variance = config.preprocess.low_variance_filter.min_variance
        if min_variance is None:
            raise CVError("low_variance_filter is enabled but min_variance is missing")
        variances = np.var(x_train_log[:, selected], axis=0)
        selected = selected[variances >= float(min_variance)]
    n_features_after_low_variance = int(selected.size)

    if config.preprocess.correlation_filter.enabled and selected.size > 1:
        selected = _apply_correlation_filter(config, x_train_log, selected, feature_names)
    n_features_after_correlation = int(selected.size)

    if selected.size == 0:
        raise CVError("Preprocessing removed all features in a fold")
    return selected, FeatureFilterCounts(
        n_features_before=n_features_before,
        n_features_after_low_prevalence=n_features_after_low_prevalence,
        n_features_after_low_variance=n_features_after_low_variance,
        n_features_after_correlation=n_features_after_correlation,
        n_features_after_all=int(selected.size),
    )


def _select_feature_indices(
    config: AppConfig, x_train_log: np.ndarray, feature_names: list[str]
) -> np.ndarray:
    selected, _counts = _select_feature_indices_with_counts(config, x_train_log, feature_names)
    return selected


def _apply_correlation_filter(
    config: AppConfig,
    x_train_log: np.ndarray,
    selected: np.ndarray,
    feature_names: list[str],
) -> np.ndarray:
    max_abs_corr = config.preprocess.correlation_filter.max_abs_correlation
    if max_abs_corr is None:
        raise CVError("correlation_filter is enabled but max_abs_correlation is missing")

    train_selected = x_train_log[:, selected]
    if config.preprocess.correlation_filter.method == "spearman":
        ranked = np.apply_along_axis(rankdata, 0, train_selected)
        corr = np.corrcoef(ranked, rowvar=False)
    else:
        corr = np.corrcoef(train_selected, rowvar=False)
    corr = np.nan_to_num(corr, nan=0.0, posinf=0.0, neginf=0.0)

    local_variances = np.var(train_selected, axis=0)
    order = sorted(
        range(selected.size),
        key=lambda idx: (-local_variances[idx], feature_names[selected[idx]]),
    )

    kept_local: list[int] = []
    for local_idx in order:
        if not kept_local:
            kept_local.append(local_idx)
            continue
        max_corr = float(np.max(np.abs(corr[local_idx, kept_local])))
        if max_corr < float(max_abs_corr):
            kept_local.append(local_idx)

    kept_global = selected[np.array(kept_local, dtype=int)]
    kept_global.sort()
    return np.asarray(kept_global, dtype=int)


def _preprocess_fold_with_counts(
    config: AppConfig,
    x_train_raw: np.ndarray,
    x_valid_raw: np.ndarray,
    feature_names: list[str],
) -> tuple[np.ndarray, np.ndarray, list[str], FeatureFilterCounts]:
    if np.any(x_train_raw < 0) or np.any(x_valid_raw < 0):
        raise CVError("TPM values must be non-negative before log1p transform")

    x_train_log = np.log1p(x_train_raw)
    x_valid_log = np.log1p(x_valid_raw)

    selected, counts = _select_feature_indices_with_counts(config, x_train_log, feature_names)
    selected_features = [feature_names[idx] for idx in selected]

    x_train_selected = x_train_log[:, selected]
    x_valid_selected = x_valid_log[:, selected]

    scaler = StandardScaler()
    x_train_scaled = np.asarray(scaler.fit_transform(x_train_selected), dtype=float)
    x_valid_scaled = np.asarray(scaler.transform(x_valid_selected), dtype=float)
    return x_train_scaled, x_valid_scaled, selected_features, counts


def _preprocess_fold(
    config: AppConfig,
    x_train_raw: np.ndarray,
    x_valid_raw: np.ndarray,
    feature_names: list[str],
) -> tuple[np.ndarray, np.ndarray, list[str]]:
    x_train_scaled, x_valid_scaled, selected_features, _counts = _preprocess_fold_with_counts(
        config,
        x_train_raw,
        x_valid_raw,
        feature_names,
    )
    return x_train_scaled, x_valid_scaled, selected_features


def _sample_training_sets(
    config: AppConfig,
    y_train: np.ndarray,
    groups_train: np.ndarray,
    training_scope_id: str,
    warnings: list[str],
) -> list[np.ndarray]:
    if config.sampling.strategy == "all_samples":
        return [np.arange(y_train.shape[0], dtype=int)]

    unique_groups = sorted(set(str(group) for group in groups_train.tolist()))
    group_specs: list[tuple[str, np.ndarray, np.ndarray, int]] = []
    max_sets = 1
    for group in unique_groups:
        group_indices = np.where(groups_train.astype(str) == group)[0]
        label0_idx = group_indices[y_train[group_indices] == 0]
        label1_idx = group_indices[y_train[group_indices] == 1]
        if label0_idx.size == 0 or label1_idx.size == 0:
            raise CVError(
                "group_balanced sampling requires both labels per group; "
                f"offending group={group}"
            )

        if config.sampling.max_samples_per_label_per_group is None:
            k_value = int(min(label0_idx.size, label1_idx.size))
        else:
            k_value = int(
                min(
                    label0_idx.size,
                    label1_idx.size,
                    config.sampling.max_samples_per_label_per_group,
                )
            )
        if k_value < 1:
            raise CVError(f"group_balanced sampling produced k=0 for group={group}")

        group_specs.append((group, label0_idx, label1_idx, k_value))
        max_sets *= comb(int(label0_idx.size), k_value) * comb(int(label1_idx.size), k_value)

    requested = int(config.sampling.sampled_set_count)
    effective = min(requested, max_sets)
    if effective < requested:
        warnings.append(
            "sampling.sampled_set_count exceeded the number of unique group-balanced "
            f"sample sets; capped from {requested} to {effective}"
        )

    sampled_sets: list[np.ndarray] = []
    seen: set[tuple[int, ...]] = set()
    for sample_set_id in range(effective):
        salt = 0
        while True:
            chosen: list[int] = []
            for group, label0_idx, label1_idx, k_value in group_specs:
                seed_text = (
                    f"{config.runtime.seed}|{training_scope_id}|{sample_set_id}|{salt}|{group}"
                )
                rng = np.random.default_rng(_deterministic_int_seed(seed_text))
                chosen.extend(rng.choice(label0_idx, size=k_value, replace=False).tolist())
                chosen.extend(rng.choice(label1_idx, size=k_value, replace=False).tolist())

            chosen_tuple = tuple(sorted(chosen))
            if chosen_tuple not in seen:
                seen.add(chosen_tuple)
                sampled_sets.append(np.array(chosen_tuple, dtype=int))
                break

            salt += 1
            if salt > 10_000:
                raise CVError(
                    "Failed to generate deterministic unique sampled sets for group_balanced "
                    "sampling"
                )

    return sampled_sets


def _group_label_inverse_weights(y: np.ndarray, groups: np.ndarray) -> np.ndarray:
    group_counts = Counter(groups.tolist())
    group_label_counts = Counter((groups[idx], int(y[idx])) for idx in range(y.shape[0]))

    raw = np.array(
        [
            1.0 / (group_counts[groups[idx]] * group_label_counts[(groups[idx], int(y[idx]))])
            for idx in range(y.shape[0])
        ],
        dtype=float,
    )
    raw_mean = float(np.mean(raw))
    if raw_mean <= 0:
        raise CVError("Invalid sample weights: non-positive mean encountered")
    return raw / raw_mean


def _fit_sample_weights(config: AppConfig, y: np.ndarray, groups: np.ndarray) -> np.ndarray | None:
    if config.sampling.weighting == "none":
        return None
    return _group_label_inverse_weights(y, groups)


def _validate_model_params(model_name: str, model_params: dict[str, Any]) -> None:
    allowed: dict[str, set[str]] = {
        "logistic_elasticnet": {"C", "l1_ratio", "max_iter"},
        "linear_svm": {"C", "max_iter"},
        "random_forest": {"n_estimators", "max_depth", "min_samples_split", "min_samples_leaf"},
    }
    disallowed = sorted(set(model_params.keys()) - allowed[model_name])
    if disallowed:
        disallowed_text = ", ".join(disallowed)
        raise CVError(
            f"Unsupported model_selection.search_space parameter(s) for {model_name}: "
            f"{disallowed_text}"
        )


def _build_estimator(
    config: AppConfig,
    model_seed: int,
    y_train: np.ndarray,
    model_params: dict[str, Any] | None = None,
    rf_n_jobs: int | None = None,
) -> LogisticRegression | CalibratedClassifierCV | RandomForestClassifier:
    params = {} if model_params is None else dict(model_params)
    _validate_model_params(config.model.name, params)

    if config.model.name == "logistic_elasticnet":
        c_value = float(params.get("C", 1.0))
        l1_ratio = float(params.get("l1_ratio", 0.5))
        max_iter = int(params.get("max_iter", 5000))
        return LogisticRegression(
            solver="saga",
            l1_ratio=l1_ratio,
            C=c_value,
            max_iter=max_iter,
            random_state=model_seed,
        )

    if config.model.name == "linear_svm":
        c_value = float(params.get("C", 1.0))
        max_iter = int(params.get("max_iter", 5000))
        class_counts = np.bincount(y_train.astype(int), minlength=2)
        min_class_count = int(class_counts.min())
        if min_class_count < 2:
            raise CVError(
                "linear_svm calibration requires at least 2 "
                "samples per class in train fold"
            )
        calibrate_cv = 3 if min_class_count >= 3 else 2
        return CalibratedClassifierCV(
            estimator=LinearSVC(C=c_value, max_iter=max_iter, random_state=model_seed),
            method="sigmoid",
            cv=calibrate_cv,
        )

    n_estimators = int(params.get("n_estimators", 300))
    max_depth_raw = params.get("max_depth", None)
    max_depth = None if max_depth_raw is None else int(max_depth_raw)
    min_samples_split = int(params.get("min_samples_split", 2))
    min_samples_leaf = int(params.get("min_samples_leaf", 1))
    resolved_rf_n_jobs = _runtime_n_jobs(config) if rf_n_jobs is None else int(rf_n_jobs)
    if resolved_rf_n_jobs < 1:
        raise CVError("random_forest n_jobs must be >= 1")
    return RandomForestClassifier(
        n_estimators=n_estimators,
        max_depth=max_depth,
        min_samples_split=min_samples_split,
        min_samples_leaf=min_samples_leaf,
        random_state=model_seed,
        n_jobs=resolved_rf_n_jobs,
    )


def _fit_estimator(
    estimator: LogisticRegression | CalibratedClassifierCV | RandomForestClassifier,
    x_train: np.ndarray,
    y_train: np.ndarray,
    sample_weight: np.ndarray | None,
) -> None:
    if sample_weight is None:
        estimator.fit(x_train, y_train)
        return
    try:
        estimator.fit(x_train, y_train, sample_weight=sample_weight)
    except TypeError:
        estimator.fit(x_train, y_train)


def _predict_positive_probability(
    estimator: LogisticRegression | CalibratedClassifierCV | RandomForestClassifier,
    x_valid: np.ndarray,
) -> np.ndarray:
    probabilities = np.asarray(estimator.predict_proba(x_valid), dtype=float)
    if probabilities.ndim != 2 or probabilities.shape[1] < 2:
        raise CVError("predict_proba returned unexpected shape")
    return probabilities[:, 1].astype(float, copy=False)


def _probability_shape(probs: list[np.ndarray]) -> tuple[int, ...]:
    if not probs:
        raise CVError("Probability aggregation received an empty model probability list")
    first_shape = probs[0].shape
    for array in probs[1:]:
        if array.shape != first_shape:
            raise CVError("Probability aggregation received inconsistent probability array shapes")
    return first_shape


def _mean_probability(probs: list[np.ndarray]) -> np.ndarray:
    shape = _probability_shape(probs)
    mean = np.zeros(shape, dtype=float)
    for idx, raw in enumerate(probs, start=1):
        values = np.asarray(raw, dtype=float)
        mean += (values - mean) / idx
    return mean


def _population_std_probability(probs: list[np.ndarray]) -> np.ndarray | None:
    shape = _probability_shape(probs)
    if len(probs) <= 1:
        return None

    mean = np.zeros(shape, dtype=float)
    m2 = np.zeros(shape, dtype=float)
    for idx, raw in enumerate(probs, start=1):
        values = np.asarray(raw, dtype=float)
        delta = values - mean
        mean += delta / idx
        delta2 = values - mean
        m2 += delta * delta2
    return np.sqrt(m2 / len(probs))


def _aggregate_probabilities(probs: list[np.ndarray], aggregation: str) -> np.ndarray:
    if aggregation == "mean":
        return _mean_probability(probs)
    if aggregation == "median":
        _probability_shape(probs)
        stacked = np.vstack(probs)
        return np.asarray(np.median(stacked, axis=0), dtype=float)
    raise CVError(f"Unsupported ensemble.probability_aggregation: {aggregation}")


def _selection_metric_from_probability(
    config: AppConfig, y_true: np.ndarray, prob: np.ndarray
) -> float:
    metric_name = config.model_selection.selection_metric
    if metric_name == "log_loss":
        return _binary_log_loss(y_true, prob)

    threshold = float(config.report.fixed_probability_threshold)
    pred = (prob >= threshold).astype(int)
    if metric_name == "mcc":
        return float(matthews_corrcoef(y_true, pred))
    if metric_name == "balanced_accuracy":
        return float(balanced_accuracy_score(y_true, pred))
    raise CVError(f"Unsupported model_selection.selection_metric: {metric_name}")


def _selection_metric_higher_is_better(metric_name: str) -> bool:
    return metric_name != "log_loss"


def _selection_metric_optuna_direction(metric_name: str) -> str:
    return "maximize" if _selection_metric_higher_is_better(metric_name) else "minimize"


def _selection_metric_nan_sentinel(metric_name: str) -> float:
    return -1e12 if _selection_metric_higher_is_better(metric_name) else 1e12


def _rank_selected_candidates(
    scored: list[SelectedCandidate], metric_name: str
) -> list[SelectedCandidate]:
    def _score_for_sort(value: float | None) -> float:
        if value is None:
            return -np.inf if _selection_metric_higher_is_better(metric_name) else np.inf
        return float(value)

    if _selection_metric_higher_is_better(metric_name):
        return sorted(
            scored,
            key=lambda item: (-_score_for_sort(item.score), item.candidate.candidate_index),
        )
    return sorted(
        scored,
        key=lambda item: (_score_for_sort(item.score), item.candidate.candidate_index),
    )


def _inner_cv_splits(
    config: AppConfig, y: np.ndarray, groups: np.ndarray
) -> list[tuple[np.ndarray, np.ndarray, str]]:
    if config.model_selection.inner_cv_strategy is None:
        raise CVError("model_selection.inner_cv_strategy is required when selection is active")

    indices = np.arange(y.shape[0], dtype=int)
    split_iter: Any
    if config.model_selection.inner_cv_strategy == "logo":
        split_iter = LeaveOneGroupOut().split(indices, y, groups)
    else:
        n_splits = config.model_selection.inner_cv_n_splits
        if n_splits is None:
            raise CVError(
                "model_selection.inner_cv_n_splits is required for inner_cv_strategy=group_kfold"
            )
        split_iter = GroupKFold(n_splits=n_splits).split(indices, y, groups)

    rows: list[tuple[np.ndarray, np.ndarray, str]] = []
    try:
        for inner_fold_index, (train_idx, valid_idx) in enumerate(split_iter):
            train_idx_array = np.asarray(train_idx, dtype=int)
            valid_idx_array = np.asarray(valid_idx, dtype=int)
            if train_idx_array.size == 0 or valid_idx_array.size == 0:
                raise CVError("Inner CV produced an empty train/validation split")
            rows.append((train_idx_array, valid_idx_array, str(inner_fold_index)))
    except ValueError as exc:
        raise CVError(f"Inner CV split error: {exc}") from exc

    if not rows:
        raise CVError("Inner CV produced zero folds")
    return rows


def _score_candidate_inner_cv(
    *,
    config: AppConfig,
    training_scope_id: str,
    source_sample_set_id: int,
    candidate: Candidate,
    x_source_raw: np.ndarray,
    y_source: np.ndarray,
    groups_source: np.ndarray,
    feature_names: list[str],
    estimator_n_jobs: int | None = None,
) -> tuple[float, list[dict[str, Any]]]:
    resolved_estimator_n_jobs = (
        _runtime_n_jobs(config) if estimator_n_jobs is None else int(estimator_n_jobs)
    )
    if resolved_estimator_n_jobs < 1:
        raise CVError("estimator_n_jobs must be >= 1")

    def _score() -> tuple[float, list[dict[str, Any]]]:
        fold_scores: list[float] = []
        trial_rows: list[dict[str, Any]] = []
        params_json = json.dumps(candidate.params, ensure_ascii=True, sort_keys=True)

        for train_idx, valid_idx, inner_fold_id in _inner_cv_splits(
            config, y_source, groups_source
        ):
            x_train_raw = x_source_raw[train_idx, :]
            x_valid_raw = x_source_raw[valid_idx, :]
            y_train = y_source[train_idx]
            y_valid = y_source[valid_idx]
            groups_train = groups_source[train_idx]

            x_train, x_valid, _selected = _preprocess_fold(
                config, x_train_raw, x_valid_raw, feature_names
            )
            sample_weight = _fit_sample_weights(config, y_train, groups_train)
            seed = _deterministic_int_seed(
                f"{config.runtime.seed}|{training_scope_id}|source_{source_sample_set_id}|"
                f"candidate_{candidate.candidate_index}|inner_{inner_fold_id}"
            )
            estimator = _build_estimator(
                config,
                seed,
                y_train,
                model_params=candidate.params,
                rf_n_jobs=resolved_estimator_n_jobs,
            )
            _fit_estimator(estimator, x_train, y_train, sample_weight)
            prob = _predict_positive_probability(estimator, x_valid)
            score = _selection_metric_from_probability(config, y_valid, prob)
            fold_scores.append(score)
            trial_rows.append(
                {
                    "candidate_index": candidate.candidate_index,
                    "inner_fold_id": inner_fold_id,
                    "metric_name": config.model_selection.selection_metric,
                    "metric_value": score,
                    "params_json": params_json,
                }
            )

        if not fold_scores:
            return np.nan, trial_rows
        return float(np.nanmean(np.asarray(fold_scores, dtype=float))), trial_rows

    return _with_native_thread_limit(resolved_estimator_n_jobs, _score)


def _tpe_trial_params(
    trial: optuna.Trial,
    *,
    discrete_values: dict[str, list[Any]],
    continuous_values: dict[str, ContinuousSpec],
) -> dict[str, Any]:
    params: dict[str, Any] = {}
    for param_name in sorted(discrete_values.keys()):
        params[param_name] = trial.suggest_categorical(param_name, discrete_values[param_name])
    for param_name in sorted(continuous_values.keys()):
        spec = continuous_values[param_name]
        if hasattr(spec, "start") and hasattr(spec, "stop"):
            params[param_name] = trial.suggest_float(param_name, spec.start, spec.stop)
        else:
            exponent_name = f"{param_name}__exp"
            exponent = trial.suggest_float(exponent_name, spec.start_exp, spec.stop_exp)
            params[param_name] = float(spec.base**exponent)
    return params


def _prepare_source_selection_tpe(
    *,
    config: AppConfig,
    training_scope_id: str,
    source_sample_set_id: int,
    sampled_idx: np.ndarray,
    x_train_raw: np.ndarray,
    y_train: np.ndarray,
    groups_train: np.ndarray,
    feature_names: list[str],
    warnings: list[str],
    progress_callback: Callable[[str, str | None], None] | None = None,
) -> SourceSelectionResult:
    requested = config.model_selection.selected_candidate_count
    if requested is None:
        raise CVError("selected_candidate_count is required for TPE selection flow")
    if config.model_selection.trial_count is None:
        raise CVError("trial_count is required for TPE strategy")
    runtime_seed = int(config.runtime.seed)
    estimator_n_jobs = _runtime_n_jobs(config)

    x_source_raw = x_train_raw[sampled_idx, :]
    y_source = y_train[sampled_idx]
    groups_source = groups_train[sampled_idx]
    if np.unique(y_source).size < 2:
        raise CVError(
            "Selection source sampled set became single-class; "
            f"source_sample_set_id={source_sample_set_id}"
        )

    trial_count = int(config.model_selection.trial_count)
    discrete_values, continuous_values = expanded_search_space(config.model_selection.search_space)
    effective_trials = trial_count
    if not continuous_values:
        full_space_size = discrete_candidate_space_size(discrete_values)
        effective_trials = min(trial_count, full_space_size)
        if effective_trials < trial_count:
            warnings.append(
                "model_selection.trial_count exceeded discrete candidate space; "
                f"capped from {trial_count} to {effective_trials} "
                f"for source sample_set_id={source_sample_set_id}"
            )

    local_seed = _deterministic_int_seed(
        f"{runtime_seed}|{training_scope_id}|source_{source_sample_set_id}|tpe_selection"
    )
    metric_name = config.model_selection.selection_metric
    study = optuna.create_study(
        direction=_selection_metric_optuna_direction(metric_name),
        sampler=TPESampler(seed=local_seed),
    )
    nan_sentinel = _selection_metric_nan_sentinel(metric_name)
    trial_rows: list[dict[str, Any]] = []

    def _objective(trial: optuna.Trial) -> float:
        params = _tpe_trial_params(
            trial,
            discrete_values=discrete_values,
            continuous_values=continuous_values,
        )
        candidate = Candidate(candidate_index=trial.number, params=params)
        mean_score, rows = _score_candidate_inner_cv(
            config=config,
            training_scope_id=training_scope_id,
            source_sample_set_id=source_sample_set_id,
            candidate=candidate,
            x_source_raw=x_source_raw,
            y_source=y_source,
            groups_source=groups_source,
            feature_names=feature_names,
            estimator_n_jobs=estimator_n_jobs,
        )
        trial_rows.extend(rows)
        if progress_callback is not None:
            score_repr = "NA" if np.isnan(mean_score) else f"{float(mean_score):.6f}"
            progress_callback(
                "selection_candidate_done",
                (
                    f"source_sample_set_id={source_sample_set_id}, "
                    f"candidate_index={candidate.candidate_index}, "
                    f"candidate_progress={trial.number + 1}/{effective_trials}, "
                    f"metric_value={score_repr}"
                ),
            )
        trial.set_user_attr("candidate_params", params)
        if np.isnan(mean_score):
            trial.set_user_attr("valid_metric_value", False)
            return nan_sentinel
        trial.set_user_attr("valid_metric_value", True)
        return float(mean_score)

    study.optimize(_objective, n_trials=effective_trials)
    ordered_trials = sorted(study.trials, key=lambda item: item.number)
    scored: list[SelectedCandidate] = []
    for trial in ordered_trials:
        params_raw = trial.user_attrs.get("candidate_params")
        if not isinstance(params_raw, dict):
            raise CVError("TPE trial is missing candidate_params user attribute")
        score_value: float | None = None
        if trial.value is not None and bool(trial.user_attrs.get("valid_metric_value", False)):
            score_value = float(trial.value)
        scored.append(
            SelectedCandidate(
                candidate=Candidate(candidate_index=trial.number, params=dict(params_raw)),
                score=score_value,
            )
        )

    available = len(scored)
    if requested is None:
        raise CVError("selected_candidate_count resolved to null unexpectedly")
    requested_count = int(requested)
    effective_count = min(requested_count, available)
    if effective_count < requested_count:
        warnings.append(
            "model_selection.selected_candidate_count exceeded available candidates; "
            f"capped from {requested_count} to {effective_count} "
            f"for source sample_set_id={source_sample_set_id}"
        )

    ranked = _rank_selected_candidates(scored, metric_name)
    return SourceSelectionResult(
        selected_candidates=ranked[:effective_count],
        n_available_candidates=available,
        n_scored_candidates=available,
        selected_candidate_count_requested=requested_count,
        selected_candidate_count_effective=effective_count,
        trial_rows=trial_rows,
    )


def _prepare_source_selection(
    *,
    config: AppConfig,
    training_scope_id: str,
    source_sample_set_id: int,
    sampled_idx: np.ndarray,
    x_train_raw: np.ndarray,
    y_train: np.ndarray,
    groups_train: np.ndarray,
    feature_names: list[str],
    warnings: list[str],
    progress_callback: Callable[[str, str | None], None] | None = None,
) -> SourceSelectionResult:
    requested = config.model_selection.selected_candidate_count
    selection_active = requested is not None
    if selection_active and config.model_selection.search_strategy == "tpe":
        return _prepare_source_selection_tpe(
            config=config,
            training_scope_id=training_scope_id,
            source_sample_set_id=source_sample_set_id,
            sampled_idx=sampled_idx,
            x_train_raw=x_train_raw,
            y_train=y_train,
            groups_train=groups_train,
            feature_names=feature_names,
            warnings=warnings,
            progress_callback=progress_callback,
        )

    try:
        candidates = generate_candidates(
            config=config,
            training_scope_id=training_scope_id,
            source_sample_set_id=source_sample_set_id,
            warnings=warnings,
        )
    except ModelSelectionError as exc:
        raise CVError(str(exc)) from exc

    if not candidates:
        raise CVError("Model-selection candidate generation produced zero candidates")

    available = len(candidates)

    if not selection_active:
        selected_candidates = [SelectedCandidate(candidate=item, score=None) for item in candidates]
        return SourceSelectionResult(
            selected_candidates=selected_candidates,
            n_available_candidates=available,
            n_scored_candidates=0,
            selected_candidate_count_requested=None,
            selected_candidate_count_effective=available,
            trial_rows=[],
        )

    x_source_raw = x_train_raw[sampled_idx, :]
    y_source = y_train[sampled_idx]
    groups_source = groups_train[sampled_idx]
    if np.unique(y_source).size < 2:
        raise CVError(
            "Selection source sampled set became single-class; "
            f"source_sample_set_id={source_sample_set_id}"
        )

    worker_count, estimator_n_jobs = _selection_parallel_plan(config, available)
    scored: list[SelectedCandidate] = []
    trial_rows: list[dict[str, Any]] = []
    if worker_count == 1:
        scored_rows: list[tuple[float, list[dict[str, Any]]]] = []
        for candidate_progress, candidate in enumerate(candidates, start=1):
            mean_score, rows = _score_candidate_inner_cv(
                config=config,
                training_scope_id=training_scope_id,
                source_sample_set_id=source_sample_set_id,
                candidate=candidate,
                x_source_raw=x_source_raw,
                y_source=y_source,
                groups_source=groups_source,
                feature_names=feature_names,
                estimator_n_jobs=estimator_n_jobs,
            )
            scored_rows.append((mean_score, rows))
            if progress_callback is not None:
                score_repr = "NA" if np.isnan(mean_score) else f"{float(mean_score):.6f}"
                progress_callback(
                    "selection_candidate_done",
                    (
                        f"source_sample_set_id={source_sample_set_id}, "
                        f"candidate_index={candidate.candidate_index}, "
                        f"candidate_progress={candidate_progress}/{available}, "
                        f"metric_value={score_repr}"
                    ),
                )
    else:
        scored_rows_by_index: dict[int, tuple[float, list[dict[str, Any]]]] = {}
        with ThreadPoolExecutor(max_workers=worker_count) as executor:
            futures = {
                executor.submit(
                    _score_candidate_inner_cv,
                    config=config,
                    training_scope_id=training_scope_id,
                    source_sample_set_id=source_sample_set_id,
                    candidate=candidate,
                    x_source_raw=x_source_raw,
                    y_source=y_source,
                    groups_source=groups_source,
                    feature_names=feature_names,
                    estimator_n_jobs=estimator_n_jobs,
                ): candidate
                for candidate in candidates
            }
            for candidate_progress, future in enumerate(as_completed(futures), start=1):
                candidate = futures[future]
                mean_score, rows = future.result()
                scored_rows_by_index[candidate.candidate_index] = (mean_score, rows)
                if progress_callback is not None:
                    score_repr = "NA" if np.isnan(mean_score) else f"{float(mean_score):.6f}"
                    progress_callback(
                        "selection_candidate_done",
                        (
                            f"source_sample_set_id={source_sample_set_id}, "
                            f"candidate_index={candidate.candidate_index}, "
                            f"candidate_progress={candidate_progress}/{available}, "
                            f"metric_value={score_repr}"
                        ),
                    )
        scored_rows = [scored_rows_by_index[candidate.candidate_index] for candidate in candidates]

    for candidate, (mean_score, rows) in zip(candidates, scored_rows, strict=True):
        score_value = None if np.isnan(mean_score) else float(mean_score)
        scored.append(SelectedCandidate(candidate=candidate, score=score_value))
        trial_rows.extend(rows)

    if requested is None:
        raise CVError("selected_candidate_count resolved to null unexpectedly")
    requested_count = int(requested)
    effective_count = min(requested_count, available)
    if effective_count < requested_count:
        warnings.append(
            "model_selection.selected_candidate_count exceeded available candidates; "
            f"capped from {requested_count} to {effective_count} "
            f"for source sample_set_id={source_sample_set_id}"
        )

    ranked = _rank_selected_candidates(scored, config.model_selection.selection_metric)
    return SourceSelectionResult(
        selected_candidates=ranked[:effective_count],
        n_available_candidates=available,
        n_scored_candidates=available,
        selected_candidate_count_requested=requested_count,
        selected_candidate_count_effective=effective_count,
        trial_rows=trial_rows,
    )


def _compute_fold_metrics(
    y_true: np.ndarray, prob: np.ndarray, threshold: float
) -> dict[str, float]:
    y_pred = (prob >= threshold).astype(int)
    metrics: dict[str, float] = {}

    try:
        metrics["roc_auc"] = float(roc_auc_score(y_true, prob))
    except (IndexError, ValueError):
        metrics["roc_auc"] = np.nan

    try:
        metrics["pr_auc"] = float(average_precision_score(y_true, prob))
    except (IndexError, ValueError):
        metrics["pr_auc"] = np.nan

    try:
        metrics["balanced_accuracy"] = float(balanced_accuracy_score(y_true, y_pred))
    except (IndexError, ValueError):
        metrics["balanced_accuracy"] = np.nan

    try:
        metrics["mcc"] = float(matthews_corrcoef(y_true, y_pred))
    except (IndexError, ValueError):
        metrics["mcc"] = np.nan

    try:
        metrics["brier"] = float(brier_score_loss(y_true, prob))
    except (IndexError, ValueError):
        metrics["brier"] = np.nan

    return metrics


def _binary_log_loss(y_true: np.ndarray, prob: np.ndarray) -> float:
    try:
        return float(log_loss(y_true, prob, labels=[0, 1]))
    except (IndexError, ValueError):
        return np.nan


def _summarize_model_selection_trials(model_selection_trials: pl.DataFrame) -> pl.DataFrame:
    scored = model_selection_trials.with_columns(
        pl.when(pl.col("metric_value").is_nan())
        .then(None)
        .otherwise(pl.col("metric_value"))
        .alias("_metric_value_valid")
    )
    return scored.group_by(
        ["fold_id", "sample_set_id", "candidate_index", "metric_name", "params_json"]
    ).agg(
        [
            pl.len().alias("n_inner_folds"),
            pl.col("_metric_value_valid").count().alias("n_valid_inner_folds"),
            pl.col("_metric_value_valid").mean().alias("metric_value_mean"),
            pl.col("_metric_value_valid").std(ddof=0).alias("metric_value_std"),
        ]
    ).sort(["fold_id", "sample_set_id", "candidate_index"])


def _derive_cv_threshold(
    config: AppConfig, y_true: np.ndarray, prob: np.ndarray
) -> tuple[float, str | None]:
    if prob.size == 0:
        raise CVError("Cannot derive CV threshold from empty prediction set")

    metric_name = config.report.auto_threshold_selection_metric
    candidates = np.unique(np.concatenate([np.array([0.0, 1.0]), prob]))
    best_threshold = 0.5
    best_score = -np.inf

    for threshold in candidates:
        pred = (prob >= float(threshold)).astype(int)
        if metric_name == "mcc":
            score = float(matthews_corrcoef(y_true, pred))
        else:
            score = float(balanced_accuracy_score(y_true, pred))
        if np.isnan(score):
            continue
        if score > best_score or (
            np.isclose(score, best_score) and float(threshold) < best_threshold
        ):
            best_score = score
            best_threshold = float(threshold)

    if np.isinf(best_score):
        return 0.5, "cv_derived_threshold fallback to 0.5 because all candidate scores were NaN"
    return best_threshold, None


def _fold_ids(split_manifest: pl.DataFrame) -> list[str]:
    fold_values = (
        split_manifest.filter((pl.col("pool") == "validation") & (pl.col("fold_id") != "NA"))
        .select("fold_id")
        .unique()
        .to_series()
        .to_list()
    )
    return [str(v) for v in sorted(int(v) for v in fold_values)]


def _outer_cv_species(split_manifest: pl.DataFrame) -> list[str]:
    species_values = (
        split_manifest.filter(pl.col("pool").is_in(["train", "validation"]))
        .select("species")
        .unique()
        .sort("species")
        .to_series()
        .to_list()
    )
    return [str(value) for value in species_values]


def _build_outer_cv_matrix_cache(
    config: AppConfig, split_manifest: pl.DataFrame
) -> OuterCvMatrixCache:
    species_order = _outer_cv_species(split_manifest)
    if not species_order:
        raise CVError("No species available for outer CV train/validation pool")

    matrix_builder = ExpressionMatrixBuilder(config)
    matrix, feature_names = _with_native_thread_limit_for_config(
        config,
        matrix_builder.build_matrix,
        species_order,
    )
    matrix.setflags(write=False)
    species_to_index = {species: idx for idx, species in enumerate(species_order)}
    if len(species_to_index) != len(species_order):
        raise CVError("Outer CV species list contains duplicate identifiers")
    return OuterCvMatrixCache(
        matrix=matrix,
        feature_names=feature_names,
        species_to_index=species_to_index,
    )


def _slice_outer_cv_matrix(
    cache: OuterCvMatrixCache,
    *,
    fold_id: str,
    train_species: list[str],
    valid_species: list[str],
) -> tuple[np.ndarray, np.ndarray]:
    try:
        train_row_idx = np.array(
            [cache.species_to_index[species] for species in train_species],
            dtype=int,
        )
        valid_row_idx = np.array(
            [cache.species_to_index[species] for species in valid_species],
            dtype=int,
        )
    except KeyError as exc:
        raise CVError(
            "Fold references species absent from shared outer-CV matrix; "
            f"fold_id={fold_id}, species={exc.args[0]}"
        ) from exc

    return cache.matrix[train_row_idx, :], cache.matrix[valid_row_idx, :]


def _metric_rows(
    fold_id: str | None,
    aggregate_scope: str,
    metrics: dict[str, float],
    n_pos: int,
    n_neg: int,
    n_valid_folds: int | dict[str, int] | None,
) -> list[dict[str, float | int | str | None]]:
    rows: list[dict[str, float | int | str | None]] = []
    for metric_name, metric_value in metrics.items():
        metric_valid_folds: int | None
        if isinstance(n_valid_folds, dict):
            metric_valid_folds = n_valid_folds.get(metric_name)
        else:
            metric_valid_folds = n_valid_folds
        rows.append(
            {
                "aggregate_scope": aggregate_scope,
                "fold_id": "NA" if fold_id is None else fold_id,
                "metric": metric_name,
                "metric_value": metric_value,
                "n_pos": n_pos,
                "n_neg": n_neg,
                "n_valid_folds": metric_valid_folds,
            }
        )
    return rows


def _preprocess_train_and_target(
    config: AppConfig,
    x_train_raw: np.ndarray,
    x_target_raw: np.ndarray,
    feature_names: list[str],
) -> tuple[np.ndarray, np.ndarray, list[str], StandardScaler]:
    x_train_scaled, x_target_scaled, selected_features, scaler, _counts = (
        _preprocess_train_and_target_with_counts(
            config,
            x_train_raw,
            x_target_raw,
            feature_names,
        )
    )
    return x_train_scaled, x_target_scaled, selected_features, scaler


def _preprocess_train_and_target_with_counts(
    config: AppConfig,
    x_train_raw: np.ndarray,
    x_target_raw: np.ndarray,
    feature_names: list[str],
) -> tuple[np.ndarray, np.ndarray, list[str], StandardScaler, FeatureFilterCounts]:
    if np.any(x_train_raw < 0) or np.any(x_target_raw < 0):
        raise CVError("TPM values must be non-negative before log1p transform")

    x_train_log = np.log1p(x_train_raw)
    x_target_log = np.log1p(x_target_raw)

    selected, counts = _select_feature_indices_with_counts(config, x_train_log, feature_names)
    selected_features = [feature_names[idx] for idx in selected]

    x_train_selected = x_train_log[:, selected]
    x_target_selected = x_target_log[:, selected]

    scaler = StandardScaler()
    x_train_scaled = np.asarray(scaler.fit_transform(x_train_selected), dtype=float)
    if x_target_selected.shape[0] == 0:
        x_target_scaled = np.empty((0, x_train_selected.shape[1]), dtype=float)
    else:
        x_target_scaled = np.asarray(scaler.transform(x_target_selected), dtype=float)
    return x_train_scaled, x_target_scaled, selected_features, scaler, counts


def _build_prediction_table(
    species: list[str],
    prob: np.ndarray,
    fixed_threshold: float,
    cv_threshold: float,
    uncertainty_std: np.ndarray | None,
    true_label: np.ndarray | None = None,
    include_true_label_column: bool = False,
) -> pl.DataFrame:
    if len(species) != int(prob.shape[0]):
        raise CVError("Prediction species/probability length mismatch")
    if uncertainty_std is not None and len(species) != int(uncertainty_std.shape[0]):
        raise CVError("Prediction species/uncertainty length mismatch")
    if true_label is not None and len(species) != int(true_label.shape[0]):
        raise CVError("Prediction species/true_label length mismatch")

    payload: dict[str, list[Any]] = {
        "species": species,
        "prob": prob.astype(float, copy=False).tolist(),
        "pred_label_fixed_threshold": (prob >= fixed_threshold).astype(int).tolist(),
        "pred_label_cv_derived_threshold": (prob >= cv_threshold).astype(int).tolist(),
    }
    if include_true_label_column:
        payload["true_label"] = [None] * len(species)
    if true_label is not None:
        payload["true_label"] = true_label.astype(int, copy=False).tolist()
    if uncertainty_std is not None:
        payload["uncertainty_std"] = uncertainty_std.astype(float, copy=False).tolist()

    return pl.DataFrame(payload).sort("species")


def _fit_outer_sample_set(
    *,
    config: AppConfig,
    fold_id: str,
    sample_set_id: int,
    sampled_idx: np.ndarray,
    source_result: SourceSelectionResult,
    base_model_index: int,
    x_train_raw: np.ndarray,
    y_train: np.ndarray,
    groups_train: np.ndarray,
    x_valid_raw: np.ndarray,
    valid_species: list[str],
    feature_names: list[str],
) -> OuterSampleSetFitResult:
    x_sampled_raw = x_train_raw[sampled_idx, :]
    y_sampled = y_train[sampled_idx]
    groups_sampled = groups_train[sampled_idx]
    if np.unique(y_sampled).size < 2:
        raise CVError(f"Fold {fold_id} sampled training set became single-class")
    try:
        x_sampled, x_valid, selected_features, filter_counts = _preprocess_fold_with_counts(
            config,
            x_sampled_raw,
            x_valid_raw,
            feature_names,
        )
    except CVError as exc:
        raise CVError(
            f"Fold {fold_id} sample_set_id={sample_set_id} preprocessing failed: {exc}"
        ) from exc
    sample_weight = _fit_sample_weights(config, y_sampled, groups_sampled)

    model_probs: list[np.ndarray] = []
    train_model_probs: list[np.ndarray] = []
    fold_models: list[LogisticRegression | CalibratedClassifierCV | RandomForestClassifier] = []
    interpretation_entries: list[ModelFeatureEntry] = []
    ensemble_model_prob_rows: list[dict[str, float | int | str]] = []
    model_sparsity_rows: list[dict[str, Any]] = []
    for selected_offset, selected in enumerate(source_result.selected_candidates):
        model_index = base_model_index + selected_offset
        model_seed = _ensemble_seed(
            config.runtime.seed,
            f"fold_{fold_id}|sample_set_{sample_set_id}",
            model_index,
        )
        estimator = _build_estimator(
            config,
            model_seed,
            y_sampled,
            model_params=selected.candidate.params,
        )
        _fit_estimator(estimator, x_sampled, y_sampled, sample_weight)
        fold_models.append(estimator)
        model_sparsity_rows.append(
            _model_sparsity_row(
                scope="outer_fold",
                fold_id=fold_id,
                sample_set_id=sample_set_id,
                model_index=model_index,
                model_name=config.model.name,
                n_features_after_all=len(selected_features),
                model=estimator,
            )
        )
        train_model_probs.append(_predict_positive_probability(estimator, x_sampled))
        interpretation_entries.append(
            ModelFeatureEntry(
                feature_names=selected_features,
                model=estimator,
            )
        )
        model_prob = _predict_positive_probability(estimator, x_valid)
        model_probs.append(model_prob)
        for species_name, prob_value in zip(
            valid_species,
            model_prob.tolist(),
            strict=True,
        ):
            ensemble_model_prob_rows.append(
                {
                    "fold_id": fold_id,
                    "model_index": model_index,
                    "species": species_name,
                    "prob": float(prob_value),
                }
            )

    return OuterSampleSetFitResult(
        model_probs=model_probs,
        train_model_probs=train_model_probs,
        fold_models=fold_models,
        interpretation_entries=interpretation_entries,
        ensemble_model_prob_rows=ensemble_model_prob_rows,
        model_sparsity_rows=model_sparsity_rows,
        selected_features=selected_features,
        filter_counts=filter_counts,
        model_count=len(source_result.selected_candidates),
    )


def _fit_final_refit_sample_set(
    *,
    config: AppConfig,
    sample_set_id: int,
    sampled_idx: np.ndarray,
    source_result: SourceSelectionResult,
    base_model_index: int,
    x_train_raw: np.ndarray | None = None,
    y_train: np.ndarray,
    groups_train: np.ndarray,
    x_target_raw: np.ndarray | None = None,
    feature_names: list[str] | None = None,
    target_count: int,
    x_train: np.ndarray | None = None,
    x_target: np.ndarray | None = None,
) -> FinalSampleSetFitResult:
    resolved_x_train_raw = x_train_raw if x_train_raw is not None else x_train
    resolved_x_target_raw = x_target_raw if x_target_raw is not None else x_target
    if resolved_x_train_raw is None or resolved_x_target_raw is None:
        raise CVError("Final refit sample-set fit requires train/target arrays")
    if feature_names is None:
        feature_names = [f"feature_{idx}" for idx in range(int(resolved_x_train_raw.shape[1]))]

    x_sampled_raw = resolved_x_train_raw[sampled_idx, :]
    y_sampled = y_train[sampled_idx]
    groups_sampled = groups_train[sampled_idx]
    if np.unique(y_sampled).size < 2:
        raise CVError("Final refit sampled training set became single-class")
    (
        x_sampled,
        x_target,
        selected_features,
        scaler,
        filter_counts,
    ) = _preprocess_train_and_target_with_counts(
        config,
        x_sampled_raw,
        resolved_x_target_raw,
        feature_names,
    )
    sample_weight = _fit_sample_weights(config, y_sampled, groups_sampled)

    selected_count = len(source_result.selected_candidates)
    model_workers, per_model_n_jobs = _sample_set_parallel_plan(config, selected_count)
    model_config = _config_with_runtime_n_jobs(config, per_model_n_jobs)

    def _fit_one(
        selected_offset: int,
        selected: SelectedCandidate,
    ) -> tuple[
        int,
        LogisticRegression | CalibratedClassifierCV | RandomForestClassifier,
        np.ndarray,
        np.ndarray,
    ]:
        def _fit_one_limited() -> tuple[
            int,
            LogisticRegression | CalibratedClassifierCV | RandomForestClassifier,
            np.ndarray,
            np.ndarray,
        ]:
            model_index = base_model_index + selected_offset
            model_seed = _ensemble_seed(config.runtime.seed, "final_refit", model_index)
            estimator = _build_estimator(
                model_config,
                model_seed,
                y_sampled,
                model_params=selected.candidate.params,
            )
            _fit_estimator(estimator, x_sampled, y_sampled, sample_weight)
            if target_count == 0:
                model_prob = np.array([], dtype=float)
            else:
                model_prob = _predict_positive_probability(estimator, x_target)
            train_model_prob = _predict_positive_probability(estimator, x_sampled)
            return selected_offset, estimator, model_prob, train_model_prob

        return _with_native_thread_limit_for_config(model_config, _fit_one_limited)

    results: list[
        tuple[
            int,
            LogisticRegression | CalibratedClassifierCV | RandomForestClassifier,
            np.ndarray,
            np.ndarray,
        ]
    ] = []
    if model_workers == 1:
        for selected_offset, selected in enumerate(source_result.selected_candidates):
            results.append(_fit_one(selected_offset, selected))
    else:
        with ThreadPoolExecutor(max_workers=model_workers) as executor:
            futures = {
                selected_offset: executor.submit(_fit_one, selected_offset, selected)
                for selected_offset, selected in enumerate(source_result.selected_candidates)
            }
            for selected_offset in range(selected_count):
                results.append(futures[selected_offset].result())

    ordered_results = sorted(results, key=lambda item: item[0])
    fitted_models: list[LogisticRegression | CalibratedClassifierCV | RandomForestClassifier] = []
    model_probs: list[np.ndarray] = []
    train_model_probs: list[np.ndarray] = []
    model_sparsity_rows: list[dict[str, Any]] = []
    for selected_offset, model, prob, train_prob in ordered_results:
        model_index = base_model_index + selected_offset
        fitted_models.append(model)
        model_sparsity_rows.append(
            _model_sparsity_row(
                scope="final_refit",
                fold_id="NA",
                sample_set_id=sample_set_id,
                model_index=model_index,
                model_name=config.model.name,
                n_features_after_all=len(selected_features),
                model=model,
            )
        )
        model_probs.append(prob)
        train_model_probs.append(train_prob)

    return FinalSampleSetFitResult(
        model_probs=model_probs,
        train_model_probs=train_model_probs,
        fitted_models=fitted_models,
        model_sparsity_rows=model_sparsity_rows,
        selected_features=selected_features,
        scaler=scaler,
        filter_counts=filter_counts,
        model_count=selected_count,
    )


def run_final_refit(
    config: AppConfig,
    split_manifest: pl.DataFrame,
    cv_threshold: float,
) -> FinalRefitArtifacts:
    """Refit final model(s) on full training pool and predict external/inference pools."""
    warnings: list[str] = []
    polars_warning = _polars_thread_pool_warning(config)
    if polars_warning is not None:
        warnings.append(polars_warning)
    matrix_builder = ExpressionMatrixBuilder(config)

    train_pool = (
        split_manifest.filter(pl.col("pool").is_in(["train", "validation"]))
        .group_by("species")
        .agg(
            pl.col("label").drop_nulls().first().alias("label"),
            pl.col("group_id").drop_nulls().first().alias("group_id"),
        )
        .sort("species")
    )
    if train_pool.height == 0:
        raise CVError("No species available for final refit training pool")
    if train_pool.filter(pl.col("label").is_null() | pl.col("group_id").is_null()).height > 0:
        raise CVError("Final refit training pool contains null label/group values")

    external_pool = (
        split_manifest.filter(pl.col("pool") == "external_test")
        .group_by("species")
        .agg(pl.col("label").drop_nulls().first().alias("label"))
        .sort("species")
    )
    inference_pool = (
        split_manifest.filter(pl.col("pool") == "discovery_inference")
        .select("species")
        .unique()
        .sort("species")
    )

    train_species = [str(v) for v in train_pool.select("species").to_series().to_list()]
    external_species = [str(v) for v in external_pool.select("species").to_series().to_list()]
    external_true_label = np.array(
        external_pool.select("label").to_series().to_list(), dtype=int
    )
    inference_species = [str(v) for v in inference_pool.select("species").to_series().to_list()]
    target_species = external_species + inference_species

    x_all_raw, feature_names = _with_native_thread_limit_for_config(
        config, matrix_builder.build_matrix, train_species + target_species
    )
    train_count = len(train_species)
    target_count = len(target_species)
    external_count = len(external_species)

    x_train_raw = x_all_raw[:train_count, :]
    x_target_raw = (
        x_all_raw[train_count:, :]
        if target_count > 0
        else np.empty((0, x_all_raw.shape[1]), dtype=float)
    )

    y_train = np.array(train_pool.select("label").to_series().to_list(), dtype=int)
    groups_train = np.array(train_pool.select("group_id").to_series().to_list(), dtype=str)
    sampled_sets = _sample_training_sets(
        config=config,
        y_train=y_train,
        groups_train=groups_train,
        training_scope_id="final_refit",
        warnings=warnings,
    )

    model_probs: list[np.ndarray] = []
    fitted_models: list[LogisticRegression | CalibratedClassifierCV | RandomForestClassifier] = []
    selection_active = config.model_selection.selected_candidate_count is not None
    model_selection_selected_rows: list[dict[str, Any]] = []

    source_sample_set_ids = sorted(
        {
            (
                sample_set_id
                if config.model_selection.candidate_source_policy == "per_sample_set"
                else 0
            )
            for sample_set_id in range(len(sampled_sets))
        }
    )
    source_workers, per_source_n_jobs = _sample_set_parallel_plan(
        config, len(source_sample_set_ids)
    )
    source_config = _config_with_runtime_n_jobs(config, per_source_n_jobs)

    def _prepare_source(source_sample_set_id: int) -> tuple[int, SourceSelectionResult, list[str]]:
        local_warnings: list[str] = []
        source_result = _prepare_source_selection(
            config=source_config,
            training_scope_id="final_refit",
            source_sample_set_id=source_sample_set_id,
            sampled_idx=sampled_sets[source_sample_set_id],
            x_train_raw=x_train_raw,
            y_train=y_train,
            groups_train=groups_train,
            feature_names=feature_names,
            warnings=local_warnings,
        )
        return source_sample_set_id, source_result, local_warnings

    source_results: dict[int, SourceSelectionResult] = {}
    if source_workers == 1:
        for source_sample_set_id in source_sample_set_ids:
            source_id, source_result, local_warnings = _prepare_source(source_sample_set_id)
            source_results[source_id] = source_result
            warnings.extend(local_warnings)
    else:
        with ThreadPoolExecutor(max_workers=source_workers) as executor:
            futures = {
                source_sample_set_id: executor.submit(_prepare_source, source_sample_set_id)
                for source_sample_set_id in source_sample_set_ids
            }
            for source_sample_set_id in source_sample_set_ids:
                source_id, source_result, local_warnings = futures[source_sample_set_id].result()
                source_results[source_id] = source_result
                warnings.extend(local_warnings)

    for sample_set_id, _sampled_idx in enumerate(sampled_sets):
        source_sample_set_id = (
            sample_set_id
            if config.model_selection.candidate_source_policy == "per_sample_set"
            else 0
        )
        source_result = source_results[source_sample_set_id]
        if not source_result.selected_candidates:
            raise CVError("No candidates were selected for final_refit")
        if selection_active:
            for rank, selected in enumerate(source_result.selected_candidates, start=1):
                score_value = np.nan if selected.score is None else float(selected.score)
                model_selection_selected_rows.append(
                    {
                        "selection_scope": "final_refit",
                        "fold_id": "NA",
                        "sample_set_id": sample_set_id,
                        "selection_source_sample_set_id": source_sample_set_id,
                        "rank": rank,
                        "candidate_index": selected.candidate.candidate_index,
                        "metric_name": config.model_selection.selection_metric,
                        "metric_value": score_value,
                        "n_available_candidates": source_result.n_available_candidates,
                        "n_scored_candidates": source_result.n_scored_candidates,
                        "selected_candidate_count_requested": (
                            source_result.selected_candidate_count_requested
                        ),
                        "selected_candidate_count_effective": (
                            source_result.selected_candidate_count_effective
                        ),
                        "params_json": json.dumps(
                            selected.candidate.params,
                            ensure_ascii=True,
                            sort_keys=True,
                        ),
                    }
                )

    model_index_offsets: dict[int, int] = {}
    model_index = 0
    for sample_set_id, _sampled_idx in enumerate(sampled_sets):
        source_sample_set_id = (
            sample_set_id
            if config.model_selection.candidate_source_policy == "per_sample_set"
            else 0
        )
        model_index_offsets[sample_set_id] = model_index
        model_index += len(source_results[source_sample_set_id].selected_candidates)

    sample_workers, per_sample_n_jobs = _sample_set_parallel_plan(config, len(sampled_sets))
    sample_config = _config_with_runtime_n_jobs(config, per_sample_n_jobs)

    def _fit_sample_set(
        sample_set_id: int,
        sampled_idx: np.ndarray,
    ) -> tuple[int, FinalSampleSetFitResult]:
        source_sample_set_id = (
            sample_set_id
            if config.model_selection.candidate_source_policy == "per_sample_set"
            else 0
        )
        fit_result = _with_native_thread_limit_for_config(
            sample_config,
            _fit_final_refit_sample_set,
            config=sample_config,
            sample_set_id=sample_set_id,
            sampled_idx=sampled_idx,
            source_result=source_results[source_sample_set_id],
            base_model_index=model_index_offsets[sample_set_id],
            x_train_raw=x_train_raw,
            y_train=y_train,
            groups_train=groups_train,
            x_target_raw=x_target_raw,
            feature_names=feature_names,
            target_count=target_count,
        )
        return sample_set_id, fit_result

    sample_results: dict[int, FinalSampleSetFitResult] = {}
    if sample_workers == 1:
        for sample_set_id, sampled_idx in enumerate(sampled_sets):
            result_id, fit_result = _fit_sample_set(sample_set_id, sampled_idx)
            sample_results[result_id] = fit_result
    else:
        with ThreadPoolExecutor(max_workers=sample_workers) as executor:
            sample_futures = {
                sample_set_id: executor.submit(_fit_sample_set, sample_set_id, sampled_idx)
                for sample_set_id, sampled_idx in enumerate(sampled_sets)
            }
            for sample_set_id in range(len(sampled_sets)):
                result_id, fit_result = sample_futures[sample_set_id].result()
                sample_results[result_id] = fit_result

    first_sample_result = sample_results.get(0)
    if first_sample_result is None:
        raise CVError("Final refit produced zero sampled-set fit results")
    selected_features = first_sample_result.selected_features
    scaler = first_sample_result.scaler

    model_entries: list[FinalModelEntry] = []
    feature_filter_count_rows: list[dict[str, Any]] = []
    model_sparsity_rows: list[dict[str, Any]] = []
    sampled_train_loss_values: list[float] = []
    sampled_external_loss_values: list[float] = []
    for sample_set_id in range(len(sampled_sets)):
        fit_result = sample_results[sample_set_id]
        feature_filter_count_rows.append(
            _feature_filter_count_row(
                scope="final_refit",
                fold_id="NA",
                sample_set_id=sample_set_id,
                counts=fit_result.filter_counts,
            )
        )
        model_sparsity_rows.extend(fit_result.model_sparsity_rows)
        fitted_models.extend(fit_result.fitted_models)
        model_probs.extend(fit_result.model_probs)
        sampled_idx = sampled_sets[sample_set_id]
        y_sampled = y_train[sampled_idx]
        sampled_train_prob = _aggregate_probabilities(
            probs=fit_result.train_model_probs,
            aggregation=config.ensemble.probability_aggregation,
        )
        sampled_train_loss_values.append(_binary_log_loss(y_sampled, sampled_train_prob))
        if external_count > 0:
            sampled_target_prob = _aggregate_probabilities(
                probs=fit_result.model_probs,
                aggregation=config.ensemble.probability_aggregation,
            )
            sampled_external_prob = sampled_target_prob[:external_count]
            sampled_external_loss_values.append(
                _binary_log_loss(external_true_label, sampled_external_prob)
            )
        for model in fit_result.fitted_models:
            model_entries.append(
                FinalModelEntry(
                    feature_names=fit_result.selected_features,
                    scaler=fit_result.scaler,
                    model=model,
                )
            )

    sampled_train_loss_array = np.asarray(sampled_train_loss_values, dtype=float)
    if np.all(np.isnan(sampled_train_loss_array)):
        train_loss = np.nan
    else:
        train_loss = float(np.nanmean(sampled_train_loss_array))
    loss_rows: list[dict[str, float | str]] = [
        {
            "split": "train",
            "metric": "log_loss",
            "metric_value": train_loss,
        }
    ]
    if external_count > 0:
        sampled_external_loss_array = np.asarray(sampled_external_loss_values, dtype=float)
        if np.all(np.isnan(sampled_external_loss_array)):
            external_loss = np.nan
        else:
            external_loss = float(np.nanmean(sampled_external_loss_array))
        loss_rows.append(
            {
                "split": "external_test",
                "metric": "log_loss",
                "metric_value": external_loss,
            }
        )
    loss_by_split_final_refit = pl.DataFrame(loss_rows)

    if target_count == 0:
        mean_prob = np.array([], dtype=float)
        uncertainty_std = np.array([], dtype=float) if len(model_probs) > 1 else None
    else:
        mean_prob = _aggregate_probabilities(
            probs=model_probs,
            aggregation=config.ensemble.probability_aggregation,
        )
        uncertainty_std = _population_std_probability(model_probs)

    external_prob = mean_prob[:external_count]
    inference_prob = mean_prob[external_count:]
    external_uncertainty = (
        None if uncertainty_std is None else uncertainty_std[:external_count]
    )
    inference_uncertainty = (
        None if uncertainty_std is None else uncertainty_std[external_count:]
    )

    fixed_threshold = float(config.report.fixed_probability_threshold)
    pred_external = _build_prediction_table(
        species=external_species,
        prob=external_prob,
        fixed_threshold=fixed_threshold,
        cv_threshold=cv_threshold,
        uncertainty_std=external_uncertainty,
        true_label=external_true_label,
        include_true_label_column=True,
    )
    pred_inference = _build_prediction_table(
        species=inference_species,
        prob=inference_prob,
        fixed_threshold=fixed_threshold,
        cv_threshold=cv_threshold,
        uncertainty_std=inference_uncertainty,
        include_true_label_column=True,
    )
    model_selection_selected: pl.DataFrame | None = None
    if model_selection_selected_rows:
        model_selection_selected = pl.DataFrame(model_selection_selected_rows).sort(
            [
                "selection_scope",
                "fold_id",
                "sample_set_id",
                "rank",
                "candidate_index",
            ]
        )
    feature_filter_counts = _build_feature_filter_counts(feature_filter_count_rows)
    feature_filter_counts_summary = _summarize_feature_filter_counts(feature_filter_counts)
    model_sparsity = _build_model_sparsity(model_sparsity_rows)
    model_sparsity_summary = _summarize_model_sparsity(model_sparsity)

    return FinalRefitArtifacts(
        pred_external_test=pred_external,
        pred_inference=pred_inference,
        loss_by_split_final_refit=loss_by_split_final_refit,
        model_selection_selected=model_selection_selected,
        feature_filter_counts=feature_filter_counts,
        feature_filter_counts_summary=feature_filter_counts_summary,
        model_sparsity=model_sparsity,
        model_sparsity_summary=model_sparsity_summary,
        warnings=warnings,
        ensemble_size=len(model_probs),
        feature_names=selected_features,
        scaler=scaler,
        models=fitted_models,
        model_entries=model_entries,
    )


def _run_outer_fold(
    *,
    config: AppConfig,
    split_manifest: pl.DataFrame,
    outer_matrix_cache: OuterCvMatrixCache,
    fold_id: str,
    fixed_threshold: float,
    selection_active: bool,
    progress_callback: Callable[[str], None] | None = None,
) -> OuterFoldResult:
    warnings: list[str] = []

    def _emit_fold_progress(stage: str, details: str | None = None) -> None:
        if progress_callback is None:
            return
        if details is None:
            progress_callback(f"Outer CV fold stage (fold_id={fold_id}, stage={stage}).")
            return
        progress_callback(
            f"Outer CV fold stage (fold_id={fold_id}, stage={stage}, {details})."
        )

    train_df = split_manifest.filter(
        (pl.col("pool") == "train") & (pl.col("fold_id") == fold_id)
    ).sort("species")
    valid_df = split_manifest.filter(
        (pl.col("pool") == "validation") & (pl.col("fold_id") == fold_id)
    ).sort("species")
    if train_df.height == 0 or valid_df.height == 0:
        raise CVError(f"Fold {fold_id} has empty train/validation split")

    train_species = [str(v) for v in train_df.select("species").to_series().to_list()]
    valid_species = [str(v) for v in valid_df.select("species").to_series().to_list()]
    y_train = np.array(train_df.select("label").to_series().to_list(), dtype=int)
    y_valid = np.array(valid_df.select("label").to_series().to_list(), dtype=int)
    groups_train = np.array(train_df.select("group_id").to_series().to_list(), dtype=str)
    _emit_fold_progress(
        "start",
        (
            f"n_train_species={len(train_species)}, "
            f"n_valid_species={len(valid_species)}"
        ),
    )

    x_train_raw, x_valid_raw = _slice_outer_cv_matrix(
        outer_matrix_cache,
        fold_id=fold_id,
        train_species=train_species,
        valid_species=valid_species,
    )
    feature_names = outer_matrix_cache.feature_names
    _emit_fold_progress("matrix_ready", f"n_features_raw={len(feature_names)}")

    sampled_sets = _sample_training_sets(
        config=config,
        y_train=y_train,
        groups_train=groups_train,
        training_scope_id=f"fold_{fold_id}",
        warnings=warnings,
    )
    _emit_fold_progress("sampling_done", f"sampled_set_count={len(sampled_sets)}")
    source_sample_set_ids = sorted(
        {
            (
                sample_set_id
                if config.model_selection.candidate_source_policy == "per_sample_set"
                else 0
            )
            for sample_set_id in range(len(sampled_sets))
        }
    )
    source_workers, per_source_n_jobs = _sample_set_parallel_plan(
        config, len(source_sample_set_ids)
    )
    source_config = _config_with_runtime_n_jobs(config, per_source_n_jobs)
    total_selection_sources = len(source_sample_set_ids)
    if selection_active:
        _emit_fold_progress(
            "selection_start",
            (
                f"selection_active={selection_active}, "
                f"selection_source_count={total_selection_sources}, "
                f"source_workers={source_workers}"
            ),
        )

    def _prepare_source(source_sample_set_id: int) -> tuple[int, SourceSelectionResult, list[str]]:
        local_warnings: list[str] = []
        source_result = _prepare_source_selection(
            config=source_config,
            training_scope_id=f"outer_fold_{fold_id}",
            source_sample_set_id=source_sample_set_id,
            sampled_idx=sampled_sets[source_sample_set_id],
            x_train_raw=x_train_raw,
            y_train=y_train,
            groups_train=groups_train,
            feature_names=feature_names,
            warnings=local_warnings,
            progress_callback=_emit_fold_progress,
        )
        return source_sample_set_id, source_result, local_warnings

    source_results: dict[int, SourceSelectionResult] = {}
    if source_workers == 1:
        for source_progress, source_sample_set_id in enumerate(source_sample_set_ids, start=1):
            if selection_active:
                _emit_fold_progress(
                    "selection_source_start",
                    (
                        f"source_sample_set_id={source_sample_set_id}, "
                        f"source_progress={source_progress}/{total_selection_sources}"
                    ),
                )
            source_id, source_result, local_warnings = _prepare_source(source_sample_set_id)
            source_results[source_id] = source_result
            warnings.extend(local_warnings)
            if selection_active:
                _emit_fold_progress(
                    "selection_source_done",
                    (
                        f"source_sample_set_id={source_id}, "
                        f"source_progress={source_progress}/{total_selection_sources}, "
                        f"n_available_candidates={source_result.n_available_candidates}, "
                        f"n_scored_candidates={source_result.n_scored_candidates}, "
                        f"selected_candidate_count={len(source_result.selected_candidates)}"
                    ),
                )
    else:
        with ThreadPoolExecutor(max_workers=source_workers) as executor:
            futures = {
                executor.submit(_prepare_source, source_sample_set_id): source_sample_set_id
                for source_sample_set_id in source_sample_set_ids
            }
            for source_progress, future in enumerate(as_completed(futures), start=1):
                source_id, source_result, local_warnings = future.result()
                source_results[source_id] = source_result
                warnings.extend(local_warnings)
                if selection_active:
                    _emit_fold_progress(
                        "selection_source_done",
                        (
                            f"source_sample_set_id={source_id}, "
                            f"source_progress={source_progress}/{total_selection_sources}, "
                            f"n_available_candidates={source_result.n_available_candidates}, "
                            f"n_scored_candidates={source_result.n_scored_candidates}, "
                            f"selected_candidate_count={len(source_result.selected_candidates)}"
                        ),
                    )

    if selection_active:
        _emit_fold_progress(
            "selection_done",
            (
                f"selection_active={selection_active}, "
                f"selection_source_count={len(source_sample_set_ids)}"
            ),
        )

    model_selection_trial_rows: list[dict[str, Any]] = []
    if selection_active:
        for source_sample_set_id in source_sample_set_ids:
            source_result = source_results[source_sample_set_id]
            for row in source_result.trial_rows:
                model_selection_trial_rows.append(
                    {
                        "fold_id": fold_id,
                        "sample_set_id": source_sample_set_id,
                        "candidate_index": row["candidate_index"],
                        "inner_fold_id": row["inner_fold_id"],
                        "metric_name": row["metric_name"],
                        "metric_value": row["metric_value"],
                        "params_json": row["params_json"],
                    }
                )

    model_selection_selected_rows: list[dict[str, Any]] = []
    for sample_set_id, _sampled_idx in enumerate(sampled_sets):
        source_sample_set_id = (
            sample_set_id
            if config.model_selection.candidate_source_policy == "per_sample_set"
            else 0
        )
        source_result = source_results[source_sample_set_id]
        if not source_result.selected_candidates:
            raise CVError(
                "No candidates were selected for " f"fold={fold_id}, sample_set_id={sample_set_id}"
            )
        if selection_active:
            for rank, selected in enumerate(source_result.selected_candidates, start=1):
                selected_score = np.nan if selected.score is None else float(selected.score)
                model_selection_selected_rows.append(
                    {
                        "selection_scope": "outer_fold",
                        "fold_id": fold_id,
                        "sample_set_id": sample_set_id,
                        "selection_source_sample_set_id": source_sample_set_id,
                        "rank": rank,
                        "candidate_index": selected.candidate.candidate_index,
                        "metric_name": config.model_selection.selection_metric,
                        "metric_value": selected_score,
                        "n_available_candidates": source_result.n_available_candidates,
                        "n_scored_candidates": source_result.n_scored_candidates,
                        "selected_candidate_count_requested": (
                            source_result.selected_candidate_count_requested
                        ),
                        "selected_candidate_count_effective": (
                            source_result.selected_candidate_count_effective
                        ),
                        "params_json": json.dumps(
                            selected.candidate.params,
                            ensure_ascii=True,
                            sort_keys=True,
                        ),
                    }
                )

    model_index_offsets: dict[int, int] = {}
    model_index = 0
    for sample_set_id, _sampled_idx in enumerate(sampled_sets):
        source_sample_set_id = (
            sample_set_id
            if config.model_selection.candidate_source_policy == "per_sample_set"
            else 0
        )
        model_index_offsets[sample_set_id] = model_index
        model_index += len(source_results[source_sample_set_id].selected_candidates)

    sample_workers, per_sample_n_jobs = _sample_set_parallel_plan(config, len(sampled_sets))
    sample_config = _config_with_runtime_n_jobs(config, per_sample_n_jobs)

    def _fit_sample_set(
        sample_set_id: int, sampled_idx: np.ndarray
    ) -> tuple[int, OuterSampleSetFitResult]:
        source_sample_set_id = (
            sample_set_id
            if config.model_selection.candidate_source_policy == "per_sample_set"
            else 0
        )
        fit_result = _with_native_thread_limit_for_config(
            sample_config,
            _fit_outer_sample_set,
            config=sample_config,
            fold_id=fold_id,
            sample_set_id=sample_set_id,
            sampled_idx=sampled_idx,
            source_result=source_results[source_sample_set_id],
            base_model_index=model_index_offsets[sample_set_id],
            x_train_raw=x_train_raw,
            y_train=y_train,
            groups_train=groups_train,
            x_valid_raw=x_valid_raw,
            valid_species=valid_species,
            feature_names=feature_names,
        )
        return sample_set_id, fit_result

    sample_results: dict[int, OuterSampleSetFitResult] = {}
    feature_filter_count_rows: list[dict[str, Any]] = []
    model_sparsity_rows: list[dict[str, Any]] = []

    def _record_sample_fit_result(sample_set_id: int, fit_result: OuterSampleSetFitResult) -> None:
        sample_results[sample_set_id] = fit_result
        counts = fit_result.filter_counts
        feature_filter_count_rows.append(
            _feature_filter_count_row(
                scope="outer_fold",
                fold_id=fold_id,
                sample_set_id=sample_set_id,
                counts=counts,
            )
        )
        model_sparsity_rows.extend(fit_result.model_sparsity_rows)
        _emit_fold_progress(
            "preprocess_sample_set_done",
            (
                f"sample_set_id={sample_set_id}, "
                f"features_before={counts.n_features_before}, "
                f"features_after_low_prevalence={counts.n_features_after_low_prevalence}, "
                f"features_after_low_variance={counts.n_features_after_low_variance}, "
                f"features_after_correlation={counts.n_features_after_correlation}, "
                f"features_after={counts.n_features_after_all}"
            ),
        )

    if sample_workers == 1:
        for sample_set_id, sampled_idx in enumerate(sampled_sets):
            result_id, fit_result = _fit_sample_set(sample_set_id, sampled_idx)
            _record_sample_fit_result(result_id, fit_result)
    else:
        with ThreadPoolExecutor(max_workers=sample_workers) as executor:
            sample_futures = {
                sample_set_id: executor.submit(_fit_sample_set, sample_set_id, sampled_idx)
                for sample_set_id, sampled_idx in enumerate(sampled_sets)
            }
            for sample_set_id in range(len(sampled_sets)):
                result_id, fit_result = sample_futures[sample_set_id].result()
                _record_sample_fit_result(result_id, fit_result)

    first_sample_result = sample_results.get(0)
    if first_sample_result is None:
        raise CVError(f"Fold {fold_id} produced zero sampled-set fit results")
    first_filter_counts = first_sample_result.filter_counts
    n_features_before_preprocess = first_filter_counts.n_features_before
    n_features_after_low_prevalence = first_filter_counts.n_features_after_low_prevalence
    n_features_after_low_variance = first_filter_counts.n_features_after_low_variance
    n_features_after_correlation = first_filter_counts.n_features_after_correlation
    n_features_after_preprocess = first_filter_counts.n_features_after_all
    _emit_fold_progress("preprocess_done")

    model_probs: list[np.ndarray] = []
    fold_models: list[LogisticRegression | CalibratedClassifierCV | RandomForestClassifier] = []
    interpretation_entries: list[ModelFeatureEntry] = []
    ensemble_model_prob_rows: list[dict[str, float | int | str]] = []
    fold_model_count = 0
    for sample_set_id in range(len(sampled_sets)):
        fit_result = sample_results[sample_set_id]
        model_probs.extend(fit_result.model_probs)
        fold_models.extend(fit_result.fold_models)
        interpretation_entries.extend(fit_result.interpretation_entries)
        ensemble_model_prob_rows.extend(fit_result.ensemble_model_prob_rows)
        fold_model_count += fit_result.model_count

    _emit_fold_progress(
        "fit_done",
        f"ensemble_model_count={fold_model_count}",
    )

    mean_prob = _aggregate_probabilities(
        probs=model_probs,
        aggregation=config.ensemble.probability_aggregation,
    )
    uncertainty_std = _population_std_probability(model_probs)
    fold_metrics = _compute_fold_metrics(y_valid, mean_prob, fixed_threshold)
    sampled_train_loss_values: list[float] = []
    sampled_valid_loss_values: list[float] = []
    for sample_set_id, sampled_idx in enumerate(sampled_sets):
        fit_result = sample_results[sample_set_id]
        y_sampled = y_train[sampled_idx]
        sampled_train_prob = _aggregate_probabilities(
            probs=fit_result.train_model_probs,
            aggregation=config.ensemble.probability_aggregation,
        )
        sampled_valid_prob = _aggregate_probabilities(
            probs=fit_result.model_probs,
            aggregation=config.ensemble.probability_aggregation,
        )
        sampled_train_loss_values.append(_binary_log_loss(y_sampled, sampled_train_prob))
        sampled_valid_loss_values.append(_binary_log_loss(y_valid, sampled_valid_prob))

    sampled_train_loss_array = np.asarray(sampled_train_loss_values, dtype=float)
    sampled_valid_loss_array = np.asarray(sampled_valid_loss_values, dtype=float)
    if np.all(np.isnan(sampled_train_loss_array)):
        train_split_loss = np.nan
    else:
        train_split_loss = float(np.nanmean(sampled_train_loss_array))
    if np.all(np.isnan(sampled_valid_loss_array)):
        valid_split_loss = np.nan
    else:
        valid_split_loss = float(np.nanmean(sampled_valid_loss_array))
    loss_rows: list[dict[str, float | str]] = [
        {
            "fold_id": fold_id,
            "split": "train",
            "metric": "log_loss",
            "metric_value": train_split_loss,
        },
        {
            "fold_id": fold_id,
            "split": "validation",
            "metric": "log_loss",
            "metric_value": valid_split_loss,
        },
    ]

    fold_n_pos = int(np.sum(y_valid == 1))
    fold_n_neg = int(np.sum(y_valid == 0))
    metric_rows = _metric_rows(
        fold_id=fold_id,
        aggregate_scope="NA",
        metrics=fold_metrics,
        n_pos=fold_n_pos,
        n_neg=fold_n_neg,
        n_valid_folds=None,
    )

    oof_rows: list[dict[str, float | int | str]] = []
    uncertainty_values = (
        None if uncertainty_std is None else uncertainty_std.astype(float, copy=False).tolist()
    )
    row_iter = zip(
        valid_species,
        y_valid.tolist(),
        mean_prob.tolist(),
        strict=True,
    )
    if uncertainty_values is None:
        for species, label, prob in row_iter:
            oof_rows.append(
                {
                    "fold_id": fold_id,
                    "species": species,
                    "label": int(label),
                    "prob": float(prob),
                }
            )
    else:
        for (species, label, prob), uncertainty in zip(
            row_iter,
            uncertainty_values,
            strict=True,
        ):
            oof_rows.append(
                {
                    "fold_id": fold_id,
                    "species": species,
                    "label": int(label),
                    "prob": float(prob),
                    "uncertainty_std": float(uncertainty),
                }
            )

    return OuterFoldResult(
        fold_metrics=fold_metrics,
        metric_rows=metric_rows,
        loss_rows=loss_rows,
        oof_rows=oof_rows,
        interpretation_entries=interpretation_entries,
        ensemble_model_prob_rows=ensemble_model_prob_rows,
        model_selection_selected_rows=model_selection_selected_rows,
        model_selection_trial_rows=model_selection_trial_rows,
        feature_filter_count_rows=feature_filter_count_rows,
        model_sparsity_rows=model_sparsity_rows,
        fold_model_count=fold_model_count,
        n_features_before_preprocess=n_features_before_preprocess,
        n_features_after_low_prevalence=n_features_after_low_prevalence,
        n_features_after_low_variance=n_features_after_low_variance,
        n_features_after_correlation=n_features_after_correlation,
        n_features_after_preprocess=n_features_after_preprocess,
        warnings=warnings,
    )


def run_outer_cv(
    config: AppConfig,
    split_manifest: pl.DataFrame,
    progress_callback: Callable[[str], None] | None = None,
) -> CVArtifacts:
    """Execute outer CV using split manifest and return evaluation artifacts."""
    warnings: list[str] = []
    polars_warning = _polars_thread_pool_warning(config)
    if polars_warning is not None:
        warnings.append(polars_warning)
    fixed_threshold = float(config.report.fixed_probability_threshold)
    selection_active = config.model_selection.selected_candidate_count is not None

    fold_metric_list: list[dict[str, float]] = []
    metric_rows: list[dict[str, float | int | str | None]] = []
    loss_rows: list[dict[str, float | str]] = []
    oof_rows: list[dict[str, float | int | str]] = []
    interpretation_entries: list[ModelFeatureEntry] = []
    ensemble_model_prob_rows: list[dict[str, float | int | str]] = []
    model_selection_selected_rows: list[dict[str, Any]] = []
    model_selection_trial_rows: list[dict[str, Any]] = []
    feature_filter_count_rows: list[dict[str, Any]] = []
    model_sparsity_rows: list[dict[str, Any]] = []
    max_fold_ensemble_size = 0

    fold_ids = _fold_ids(split_manifest)
    outer_matrix_cache = _build_outer_cv_matrix_cache(config, split_manifest)
    fold_workers, per_fold_n_jobs = _outer_cv_parallel_plan(config, len(fold_ids))
    fold_config = _config_with_runtime_n_jobs(config, per_fold_n_jobs)
    progress_lock = Lock()

    def _emit_progress(message: str) -> None:
        if progress_callback is None:
            return
        with progress_lock:
            progress_callback(message)

    total_folds = len(fold_ids)
    if total_folds > 0:
        _emit_progress(
            "Outer CV fold execution started "
            f"(total_folds={total_folds}, fold_workers={fold_workers})."
        )
    completed_count = 0

    def _consume_fold_result(fold_result: OuterFoldResult) -> None:
        nonlocal max_fold_ensemble_size
        warnings.extend(fold_result.warnings)
        fold_metric_list.append(fold_result.fold_metrics)
        metric_rows.extend(fold_result.metric_rows)
        loss_rows.extend(fold_result.loss_rows)
        oof_rows.extend(fold_result.oof_rows)
        interpretation_entries.extend(fold_result.interpretation_entries)
        ensemble_model_prob_rows.extend(fold_result.ensemble_model_prob_rows)
        model_selection_selected_rows.extend(fold_result.model_selection_selected_rows)
        model_selection_trial_rows.extend(fold_result.model_selection_trial_rows)
        feature_filter_count_rows.extend(fold_result.feature_filter_count_rows)
        model_sparsity_rows.extend(fold_result.model_sparsity_rows)
        max_fold_ensemble_size = max(max_fold_ensemble_size, fold_result.fold_model_count)

    if fold_workers == 1:
        for fold_id in fold_ids:
            fold_result = _run_outer_fold(
                config=fold_config,
                split_manifest=split_manifest,
                outer_matrix_cache=outer_matrix_cache,
                fold_id=fold_id,
                fixed_threshold=fixed_threshold,
                selection_active=selection_active,
                progress_callback=_emit_progress,
            )
            _consume_fold_result(fold_result)
            completed_count += 1
            _emit_progress(
                f"Outer CV fold completed (fold_id={fold_id}, "
                f"progress={completed_count}/{total_folds})."
            )
    else:
        with ThreadPoolExecutor(max_workers=fold_workers) as executor:
            futures = {
                executor.submit(
                    _run_outer_fold,
                    config=fold_config,
                    split_manifest=split_manifest,
                    outer_matrix_cache=outer_matrix_cache,
                    fold_id=fold_id,
                    fixed_threshold=fixed_threshold,
                    selection_active=selection_active,
                    progress_callback=_emit_progress,
                ): fold_id
                for fold_id in fold_ids
            }
            for future in as_completed(futures):
                fold_id = futures[future]
                fold_result = future.result()
                _consume_fold_result(fold_result)
                completed_count += 1
                _emit_progress(
                    f"Outer CV fold completed (fold_id={fold_id}, "
                    f"progress={completed_count}/{total_folds})."
                )

    if not oof_rows:
        raise CVError("No out-of-fold predictions were generated")

    oof_df = pl.DataFrame(oof_rows).sort(["fold_id", "species"])
    oof_y = np.array(oof_df.select("label").to_series().to_list(), dtype=int)
    oof_prob = np.array(oof_df.select("prob").to_series().to_list(), dtype=float)

    micro_metrics = _compute_fold_metrics(oof_y, oof_prob, fixed_threshold)
    macro_metrics: dict[str, float] = {}
    valid_fold_counts: dict[str, int] = {}
    for metric_name in micro_metrics:
        fold_values = np.array([m[metric_name] for m in fold_metric_list], dtype=float)
        valid_fold_counts[metric_name] = int(np.count_nonzero(~np.isnan(fold_values)))
        if np.all(np.isnan(fold_values)):
            macro_metrics[metric_name] = np.nan
        else:
            macro_metrics[metric_name] = float(np.nanmean(fold_values))

    total_pos = int(np.sum(oof_y == 1))
    total_neg = int(np.sum(oof_y == 0))

    metric_rows.extend(
        _metric_rows(
            fold_id=None,
            aggregate_scope="macro",
            metrics=macro_metrics,
            n_pos=total_pos,
            n_neg=total_neg,
            n_valid_folds=valid_fold_counts,
        )
    )
    metric_rows.extend(
        _metric_rows(
            fold_id=None,
            aggregate_scope="micro",
            metrics=micro_metrics,
            n_pos=total_pos,
            n_neg=total_neg,
            n_valid_folds=valid_fold_counts,
        )
    )

    metrics_df = pl.DataFrame(metric_rows).sort(["aggregate_scope", "fold_id", "metric"])
    loss_by_split_df = pl.DataFrame(loss_rows).sort(["fold_id", "split"])
    cv_threshold, threshold_warning = _derive_cv_threshold(config, oof_y, oof_prob)
    if threshold_warning is not None:
        warnings.append(threshold_warning)
    thresholds_df = pl.DataFrame(
        [
            {
                "threshold_name": "fixed_probability_threshold",
                "threshold_value": fixed_threshold,
                "source": "config",
                "selection_metric": "NA",
                "selection_scope": "NA",
            },
            {
                "threshold_name": "cv_derived_threshold",
                "threshold_value": cv_threshold,
                "source": "oof_predictions",
                "selection_metric": config.report.auto_threshold_selection_metric,
                "selection_scope": "outer_cv",
            },
        ]
    ).sort("threshold_name")
    try:
        interpretation_artifacts = _with_native_thread_limit_for_config(
            config, build_interpretation_tables, interpretation_entries
        )
    except InterpretationError as exc:
        raise CVError(str(exc)) from exc
    warnings.extend(interpretation_artifacts.warnings)

    ensemble_model_probs: pl.DataFrame | None = None
    if max_fold_ensemble_size > 1:
        ensemble_model_probs = pl.DataFrame(ensemble_model_prob_rows).sort(
            ["fold_id", "model_index", "species"]
        )
    model_selection_selected: pl.DataFrame | None = None
    if model_selection_selected_rows:
        model_selection_selected = pl.DataFrame(model_selection_selected_rows).sort(
            [
                "selection_scope",
                "fold_id",
                "sample_set_id",
                "rank",
                "candidate_index",
            ]
        )
    model_selection_trials: pl.DataFrame | None = None
    model_selection_trials_summary: pl.DataFrame | None = None
    if model_selection_trial_rows:
        model_selection_trials = pl.DataFrame(model_selection_trial_rows).sort(
            ["fold_id", "sample_set_id", "candidate_index", "inner_fold_id"]
        )
        model_selection_trials_summary = _summarize_model_selection_trials(model_selection_trials)
    feature_filter_counts = _build_feature_filter_counts(feature_filter_count_rows)
    feature_filter_counts_summary = _summarize_feature_filter_counts(feature_filter_counts)
    model_sparsity = _build_model_sparsity(model_sparsity_rows)
    model_sparsity_summary = _summarize_model_sparsity(model_sparsity)

    return CVArtifacts(
        metrics_cv=metrics_df,
        loss_by_split_cv=loss_by_split_df,
        thresholds=thresholds_df,
        oof_predictions=oof_df,
        feature_importance=interpretation_artifacts.feature_importance,
        coefficients=interpretation_artifacts.coefficients,
        ensemble_model_probs=ensemble_model_probs,
        model_selection_selected=model_selection_selected,
        model_selection_trials=model_selection_trials,
        model_selection_trials_summary=model_selection_trials_summary,
        feature_filter_counts=feature_filter_counts,
        feature_filter_counts_summary=feature_filter_counts_summary,
        model_sparsity=model_sparsity,
        model_sparsity_summary=model_sparsity_summary,
        warnings=warnings,
    )
