csai / code /run_a1_bootstrap.py
Mohith202's picture
Refactor code structure for improved readability and maintainability and also added the mask ROI
0dac2bf
Raw
History Blame Contribute Delete
28.2 kB
#!/usr/bin/env python
"""Bootstrap A1 baseline with manifest QC and Protocol C split artifacts.
This command currently implements:
- Phase 1: strict derivatives manifest and integrity checks
- Phase 2: canonical-grid symmetric analysis mask construction
- Phase 3 (core): ROI-preservation QC for 7 core language regions
- Phase 4: annotation harmonization to unified run/condition/speaker event tables
- Phase 5: optional frozen feature extraction wrappers and caching
- Phase 6: optional TR-level HRF regressor + z-score alignment input caching
- Phase 7 prep: Protocol C (cross-subject) split table
"""
from __future__ import annotations
import argparse
import json
import random
from pathlib import Path
from typing import Any
import nibabel as nib
import pandas as pd
from a1_pipeline.alignment import build_and_cache_alignment_inputs
from a1_pipeline.annotations import build_harmonized_annotation_tables
from a1_pipeline.constants import DEFAULT_ALLOWED_RUNS, DEFAULT_MODEL_IDS, DEFAULT_TIME_SCALE_SECONDS
from a1_pipeline.features import extract_and_cache_run_level_features, resolve_feature_num_workers
from a1_pipeline.io_utils import ensure_directory, write_json
from a1_pipeline.manifest import build_derivatives_manifest
from a1_pipeline.model_config import load_model_ids_from_config
from a1_pipeline.spatial import build_symmetric_analysis_mask
from a1_pipeline.targets import evaluate_core_roi_preservation, load_core_roi_masks
def _parse_allowed_runs(raw: str) -> tuple[int, ...]:
tokens = [token.strip() for token in raw.split(",") if token.strip()]
runs = []
for token in tokens:
value = int(token)
if value <= 0:
raise ValueError(f"Run must be positive: {value}")
runs.append(value)
if not runs:
raise ValueError("At least one allowed run is required")
return tuple(sorted(set(runs)))
def _parse_subject_list(raw: str) -> list[str]:
raw = raw.strip().lower()
if raw == "all":
return []
tokens = [token.strip() for token in raw.split(",") if token.strip()]
return sorted(set(tokens))
def _parse_layer_indices(raw: str) -> list[int] | None:
raw = raw.strip().lower()
if raw == "all":
return None
tokens = [token.strip() for token in raw.split(",") if token.strip()]
if not tokens:
raise ValueError("--feature-layer-indices must be 'all' or a comma-separated list")
indices = sorted({int(token) for token in tokens})
if any(idx < 0 for idx in indices):
raise ValueError("Layer indices must be non-negative")
return indices
def _build_protocol_c_folds(
manifest_df: pd.DataFrame,
n_test_subjects: int,
n_folds: int,
seed: int,
) -> pd.DataFrame:
"""Build Protocol C folds using repeated 21:3 subject holdout splits."""
canonical_labels = {
1: "single_female",
2: "single_male",
3: "mixed_female",
4: "mixed_male",
}
subjects = sorted(manifest_df["subject"].astype(str).unique().tolist())
n_subjects = len(subjects)
if n_subjects != 24:
raise ValueError(
"Protocol C 21:3 split requires exactly 24 accepted subjects. "
f"Found {n_subjects}."
)
if n_test_subjects != 3:
raise ValueError(
"Protocol C is fixed to 21:3, so --protocol-c-test-subjects must be 3"
)
if n_folds <= 0:
raise ValueError("--protocol-c-num-folds must be positive")
rng = random.Random(int(seed))
shuffled_subjects = list(subjects)
rng.shuffle(shuffled_subjects)
rows: list[dict[str, Any]] = []
for fold_idx in range(int(n_folds)):
start = (fold_idx * n_test_subjects) % n_subjects
test_subjects = [
shuffled_subjects[(start + offset) % n_subjects]
for offset in range(n_test_subjects)
]
test_subject_set = set(test_subjects)
train_subjects = [subject for subject in subjects if subject not in test_subject_set]
if len(train_subjects) != 21:
raise ValueError(
"Protocol C expected 21 train subjects per fold, got "
f"{len(train_subjects)}"
)
for canonical_run, condition_label in canonical_labels.items():
rows.append(
{
"fold_id": f"cv2_run{canonical_run}_fold{fold_idx + 1}",
"canonical_run": int(canonical_run),
"condition_label": str(condition_label),
"train_subjects": ",".join(train_subjects),
"test_subjects": ",".join(test_subjects),
}
)
return pd.DataFrame(rows).sort_values(["canonical_run", "fold_id"]).reset_index(drop=True)
def _build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Bootstrap A1 manifest and split artifacts")
parser.add_argument(
"--base-dir",
type=str,
default=".",
help="Dataset repository root",
)
parser.add_argument(
"--derivatives-dir",
type=str,
default=None,
help="Override derivatives directory (default: <base-dir>/derivatives)",
)
parser.add_argument(
"--data-dir",
type=str,
default=None,
help="Override raw BIDS data directory (default: <base-dir>/data)",
)
parser.add_argument(
"--annotation-dir",
type=str,
default=None,
help="Override annotation directory (default: <base-dir>/data/annotation)",
)
parser.add_argument(
"--output-dir",
type=str,
default=None,
help="Output folder for bootstrap artifacts (default: <base-dir>/outputs/a1_bootstrap)",
)
parser.add_argument(
"--roi-mask-dir",
type=str,
default=None,
help=(
"Core ROI mask directory (default resolution: "
"<base-dir>/code/assets/roi_masks, "
"fallback: <base-dir>/llms_brain_lateralization/roi_masks)"
),
)
parser.add_argument(
"--allowed-runs",
type=str,
default=",".join(str(value) for value in DEFAULT_ALLOWED_RUNS),
help="Comma-separated run whitelist",
)
parser.add_argument(
"--exclude-subjects",
type=str,
default="sub-03,sub-18",
help="Comma-separated subject IDs to exclude from all phases",
)
parser.add_argument(
"--deep-nifti-integrity-check",
action="store_true",
help="Force-read tail volumes to catch truncated .nii.gz files early (slower)",
)
parser.add_argument(
"--disable-mixed-fallback",
action="store_true",
help="Disable mixed-run condition fallback to generic 'mixed' label",
)
parser.add_argument(
"--protocol-c-test-subjects",
type=int,
default=3,
help="Protocol C test subject count per fold (fixed to 3 for 21:3)",
)
parser.add_argument(
"--protocol-c-num-folds",
type=int,
default=2,
help="Protocol C repeated holdout fold count",
)
parser.add_argument(
"--protocol-c-seed",
type=int,
default=0,
help="Random seed for Protocol C fold construction",
)
parser.add_argument(
"--run-mask-method",
type=str,
default="first_volume_nonzero",
choices=["first_volume_nonzero", "temporal_any_nonzero"],
help="Method for deriving per-run spatial mask from 4D BOLD",
)
parser.add_argument(
"--run-mask-epsilon",
type=float,
default=1e-6,
help="Absolute threshold used when binarizing run mask from BOLD values",
)
parser.add_argument(
"--min-core-roi-voxels",
type=int,
default=20,
help="Minimum post-mask voxel count required per core ROI and subject-run",
)
parser.add_argument(
"--allow-core-roi-failures",
action="store_true",
help="Do not fail command even if core ROI preservation gate fails",
)
parser.add_argument(
"--time-scale-seconds",
type=float,
default=DEFAULT_TIME_SCALE_SECONDS,
help="Scale converting annotation onset/offset units to seconds",
)
parser.add_argument(
"--run-feature-extraction",
action="store_true",
help="Execute Phase 5 feature extraction and cache run-level hidden states",
)
parser.add_argument(
"--feature-dry-run",
action="store_true",
help="Use deterministic random features instead of loading real HF models",
)
parser.add_argument(
"--feature-dry-run-n-layers",
type=int,
default=3,
help="Number of synthetic layers when --feature-dry-run is enabled",
)
parser.add_argument(
"--feature-dry-run-hidden-dim",
type=int,
default=128,
help="Synthetic hidden dimension when --feature-dry-run is enabled",
)
parser.add_argument(
"--feature-layer-indices",
type=str,
default="all",
help="Feature layers to cache: 'all' or comma-separated indices",
)
parser.add_argument(
"--max-words-per-chunk",
type=int,
default=256,
help="Word chunk size used during feature extraction",
)
parser.add_argument(
"--feature-device",
type=str,
default="auto",
help="Feature extraction device: auto, cpu, cuda, cuda:0, ...",
)
parser.add_argument(
"--feature-num-workers",
type=str,
default="auto",
help=(
"Number of GPU workers for feature extraction. 'auto' uses every "
"visible CUDA device (recommended on 4xA10G). Use 1 to force "
"single-GPU/serial extraction."
),
)
parser.add_argument(
"--feature-local-files-only",
action="store_true",
help="Do not access network when loading HF models/tokenizers",
)
parser.add_argument(
"--feature-overwrite",
action="store_true",
help="Overwrite existing cached feature files",
)
parser.add_argument(
"--run-alignment",
action="store_true",
help="Execute Phase 6 HRF/z-score alignment input caching",
)
parser.add_argument(
"--alignment-subjects",
type=str,
default="all",
help="Alignment BOLD subject subset: 'all' or comma-separated subject IDs",
)
parser.add_argument(
"--alignment-trim-start-tr",
type=int,
default=0,
help="Trim this many TRs from start for both regressors and BOLD",
)
parser.add_argument(
"--alignment-trim-end-tr",
type=int,
default=0,
help="Trim this many TRs from end for both regressors and BOLD",
)
parser.add_argument(
"--alignment-hrf-model",
type=str,
default="glover",
help="HRF model passed to nilearn.compute_regressor",
)
parser.add_argument(
"--alignment-overwrite",
action="store_true",
help="Overwrite existing cached alignment arrays",
)
parser.add_argument(
"--model-config",
type=str,
default=None,
help="JSON model profile config (default: <base-dir>/code/config/model_profiles.json)",
)
parser.add_argument(
"--model-profile",
type=str,
default=None,
help="Profile name in --model-config (default: active_profile from config)",
)
return parser
def _resolve_paths(args: argparse.Namespace) -> tuple[Path, Path, Path, Path, Path, Path]:
base_dir = Path(args.base_dir).resolve()
derivatives_dir = Path(args.derivatives_dir).resolve() if args.derivatives_dir else base_dir / "derivatives"
data_dir = Path(args.data_dir).resolve() if args.data_dir else base_dir / "data"
annotation_dir = Path(args.annotation_dir).resolve() if args.annotation_dir else data_dir / "annotation"
output_dir = Path(args.output_dir).resolve() if args.output_dir else base_dir / "outputs" / "a1_bootstrap"
if args.roi_mask_dir:
roi_mask_dir = Path(args.roi_mask_dir).resolve()
else:
roi_candidates = [
base_dir / "code" / "assets" / "roi_masks",
base_dir / "llms_brain_lateralization" / "roi_masks",
]
roi_mask_dir = roi_candidates[0]
for candidate in roi_candidates:
if candidate.exists():
roi_mask_dir = candidate
break
return base_dir, derivatives_dir, data_dir, annotation_dir, output_dir, roi_mask_dir
def _validate_required_dirs(
derivatives_dir: Path,
data_dir: Path,
annotation_dir: Path,
roi_mask_dir: Path,
) -> None:
if not derivatives_dir.exists():
raise FileNotFoundError(f"Derivatives directory not found: {derivatives_dir}")
if not data_dir.exists():
raise FileNotFoundError(f"Data directory not found: {data_dir}")
if not annotation_dir.exists():
raise FileNotFoundError(f"Annotation directory not found: {annotation_dir}")
if not roi_mask_dir.exists():
raise FileNotFoundError(f"ROI mask directory not found: {roi_mask_dir}")
def _resolve_requested_models(args: argparse.Namespace, base_dir: Path) -> tuple[list[str], dict[str, Any]]:
default_model_config_path = (base_dir / "code" / "config" / "model_profiles.json").resolve()
model_config_path = Path(args.model_config).resolve() if args.model_config else default_model_config_path
if model_config_path.exists():
return load_model_ids_from_config(
config_path=model_config_path,
profile=args.model_profile,
)
if args.model_config:
raise FileNotFoundError(f"Model config not found: {model_config_path}")
if args.model_profile:
raise FileNotFoundError(
"--model-profile was provided but no model config file was found. "
"Create code/config/model_profiles.json or pass --model-config."
)
return list(DEFAULT_MODEL_IDS), {
"source": "constants_default",
"config_path": None,
"profile": None,
"description": "Fallback to DEFAULT_MODEL_IDS",
}
def main() -> None:
parser = _build_arg_parser()
args = parser.parse_args()
allowed_runs = _parse_allowed_runs(args.allowed_runs)
excluded_subjects = _parse_subject_list(args.exclude_subjects)
alignment_subjects = _parse_subject_list(args.alignment_subjects)
feature_layer_indices = _parse_layer_indices(args.feature_layer_indices)
mixed_fallback_enabled = not bool(args.disable_mixed_fallback)
base_dir, derivatives_dir, data_dir, annotation_dir, output_dir, roi_mask_dir = _resolve_paths(args)
requested_models, model_selection = _resolve_requested_models(args=args, base_dir=base_dir)
_validate_required_dirs(
derivatives_dir=derivatives_dir,
data_dir=data_dir,
annotation_dir=annotation_dir,
roi_mask_dir=roi_mask_dir,
)
ensure_directory(output_dir)
manifest_df, rejected_df, manifest_qc = build_derivatives_manifest(
derivatives_dir=derivatives_dir,
data_dir=data_dir,
allowed_runs=allowed_runs,
enable_mixed_fallback=mixed_fallback_enabled,
excluded_subjects=excluded_subjects,
deep_nifti_integrity_check=bool(args.deep_nifti_integrity_check),
)
analysis_mask_img, run_mask_map, run_mask_qc_df, mask_qc = build_symmetric_analysis_mask(
manifest_df=manifest_df,
run_mask_method=str(args.run_mask_method),
epsilon=float(args.run_mask_epsilon),
)
core_roi_masks, core_roi_pre_mask_voxels = load_core_roi_masks(
roi_mask_dir=roi_mask_dir,
reference_img=analysis_mask_img,
)
core_roi_coverage_df, core_roi_failures_df, core_roi_qc = evaluate_core_roi_preservation(
manifest_df=manifest_df,
run_mask_map=run_mask_map,
core_roi_masks=core_roi_masks,
reference_affine=analysis_mask_img.affine,
min_voxels_required=int(args.min_core_roi_voxels),
)
core_roi_qc["core_roi_pre_mask_voxels"] = core_roi_pre_mask_voxels
core_roi_gate_passed = len(core_roi_failures_df) == 0
run_events_df, unified_events_df, annotation_source_resolution_df, annotation_qc = build_harmonized_annotation_tables(
manifest_df=manifest_df,
annotation_dir=annotation_dir,
time_scale_seconds=float(args.time_scale_seconds),
enable_mixed_fallback=mixed_fallback_enabled,
)
accepted_manifest_path = output_dir / "accepted_manifest.csv"
rejected_manifest_path = output_dir / "rejected_manifest.csv"
protocol_c_path = output_dir / "protocol_c_cross_subject_folds.csv"
analysis_mask_path = output_dir / "analysis_mask.nii.gz"
run_mask_qc_path = output_dir / "run_mask_qc.csv"
analysis_mask_qc_path = output_dir / "analysis_mask_qc.json"
core_roi_coverage_path = output_dir / "core_roi_coverage.csv"
core_roi_failures_path = output_dir / "core_roi_failures.csv"
core_roi_qc_path = output_dir / "core_roi_qc.json"
run_events_path = output_dir / "run_event_templates.csv"
unified_events_path = output_dir / "unified_word_events.csv"
annotation_source_resolution_path = output_dir / "annotation_source_resolution.csv"
annotation_qc_path = output_dir / "annotation_qc.json"
manifest_qc_path = output_dir / "manifest_qc.json"
bootstrap_summary_path = output_dir / "bootstrap_summary.json"
feature_output_dir = output_dir / "features"
feature_summary_path = output_dir / "feature_extraction_summary.csv"
feature_qc_path = output_dir / "feature_extraction_qc.json"
alignment_output_dir = output_dir / "alignment_inputs"
alignment_bold_summary_path = output_dir / "alignment_bold_summary.csv"
alignment_regressor_summary_path = output_dir / "alignment_regressor_summary.csv"
alignment_qc_path = output_dir / "alignment_qc.json"
manifest_df.to_csv(accepted_manifest_path, index=False)
rejected_df.to_csv(rejected_manifest_path, index=False)
protocol_c_df = _build_protocol_c_folds(
manifest_df=manifest_df,
n_test_subjects=int(args.protocol_c_test_subjects),
n_folds=int(args.protocol_c_num_folds),
seed=int(args.protocol_c_seed),
)
protocol_c_df.to_csv(protocol_c_path, index=False)
run_mask_qc_df.to_csv(run_mask_qc_path, index=False)
core_roi_coverage_df.to_csv(core_roi_coverage_path, index=False)
core_roi_failures_df.to_csv(core_roi_failures_path, index=False)
run_events_df.to_csv(run_events_path, index=False)
unified_events_df.to_csv(unified_events_path, index=False)
annotation_source_resolution_df.to_csv(annotation_source_resolution_path, index=False)
nib.save(analysis_mask_img, str(analysis_mask_path))
split_summary = {
"n_manifest_rows": int(len(manifest_df)),
"n_subjects_manifest": int(manifest_df["subject"].nunique()) if not manifest_df.empty else 0,
"n_protocol_a_folds": 0,
"n_protocol_b_splits": 0,
"n_protocol_b_skipped": 0,
"n_protocol_c_folds": int(len(protocol_c_df)),
}
write_json(manifest_qc_path, manifest_qc)
write_json(analysis_mask_qc_path, mask_qc)
write_json(core_roi_qc_path, core_roi_qc)
write_json(annotation_qc_path, annotation_qc)
feature_summary_df = None
feature_qc: dict[str, Any] | None = None
if bool(args.run_feature_extraction):
feature_output_dir.mkdir(parents=True, exist_ok=True)
resolved_workers = resolve_feature_num_workers(
requested=args.feature_num_workers,
device=str(args.feature_device),
)
if resolved_workers > 1:
print(
f"[bootstrap] Feature extraction will use {resolved_workers} GPU workers "
f"(requested={args.feature_num_workers}, device={args.feature_device}).",
flush=True,
)
feature_summary_df, feature_qc = extract_and_cache_run_level_features(
run_events_df=run_events_df,
model_ids=requested_models,
output_dir=feature_output_dir,
layer_indices=feature_layer_indices,
max_words_per_chunk=int(args.max_words_per_chunk),
dry_run=bool(args.feature_dry_run),
dry_run_n_layers=int(args.feature_dry_run_n_layers),
dry_run_hidden_dim=int(args.feature_dry_run_hidden_dim),
device=str(args.feature_device),
local_files_only=bool(args.feature_local_files_only),
overwrite=bool(args.feature_overwrite),
num_workers=resolved_workers,
)
feature_summary_df.to_csv(feature_summary_path, index=False)
if feature_qc is None:
feature_qc = {}
write_json(feature_qc_path, feature_qc)
if feature_summary_df is None and bool(args.run_alignment):
if feature_summary_path.exists():
feature_summary_df = pd.read_csv(feature_summary_path)
if feature_qc_path.exists():
with feature_qc_path.open("r", encoding="utf-8") as handle:
feature_qc = json.load(handle)
else:
raise RuntimeError(
"Alignment requested but no feature summary is available. "
"Run with --run-feature-extraction first or provide existing cached summary."
)
alignment_bold_df = None
alignment_regressor_df = None
alignment_qc: dict[str, Any] | None = None
if bool(args.run_alignment):
assert feature_summary_df is not None
alignment_output_dir.mkdir(parents=True, exist_ok=True)
alignment_bold_df, alignment_regressor_df, alignment_qc = build_and_cache_alignment_inputs(
manifest_df=manifest_df,
run_events_df=run_events_df,
feature_summary_df=feature_summary_df,
analysis_mask_path=analysis_mask_path,
output_dir=alignment_output_dir,
trim_start_tr=int(args.alignment_trim_start_tr),
trim_end_tr=int(args.alignment_trim_end_tr),
hrf_model=str(args.alignment_hrf_model),
overwrite=bool(args.alignment_overwrite),
alignment_subjects=alignment_subjects,
)
alignment_bold_df.to_csv(alignment_bold_summary_path, index=False)
alignment_regressor_df.to_csv(alignment_regressor_summary_path, index=False)
if alignment_qc is None:
alignment_qc = {}
write_json(alignment_qc_path, alignment_qc)
bootstrap_summary: dict[str, Any] = {
"base_dir": str(base_dir),
"derivatives_dir": str(derivatives_dir),
"data_dir": str(data_dir),
"annotation_dir": str(annotation_dir),
"roi_mask_dir": str(roi_mask_dir),
"output_dir": str(output_dir),
"allowed_runs": list(allowed_runs),
"excluded_subjects": excluded_subjects,
"deep_nifti_integrity_check": bool(args.deep_nifti_integrity_check),
"mixed_fallback_enabled": mixed_fallback_enabled,
"models_locked": requested_models,
"model_selection": model_selection,
"time_scale_seconds": float(args.time_scale_seconds),
"feature_extraction_config": {
"run_feature_extraction": bool(args.run_feature_extraction),
"dry_run": bool(args.feature_dry_run),
"dry_run_n_layers": int(args.feature_dry_run_n_layers),
"dry_run_hidden_dim": int(args.feature_dry_run_hidden_dim),
"layer_indices": feature_layer_indices,
"max_words_per_chunk": int(args.max_words_per_chunk),
"device": str(args.feature_device),
"local_files_only": bool(args.feature_local_files_only),
"overwrite": bool(args.feature_overwrite),
},
"alignment_config": {
"run_alignment": bool(args.run_alignment),
"alignment_subjects": alignment_subjects,
"trim_start_tr": int(args.alignment_trim_start_tr),
"trim_end_tr": int(args.alignment_trim_end_tr),
"hrf_model": str(args.alignment_hrf_model),
"overwrite": bool(args.alignment_overwrite),
},
"run_mask_config": {
"method": str(args.run_mask_method),
"epsilon": float(args.run_mask_epsilon),
},
"core_roi_config": {
"min_voxels_required": int(args.min_core_roi_voxels),
"allow_core_roi_failures": bool(args.allow_core_roi_failures),
},
"protocol_c_config": {
"n_test_subjects": int(args.protocol_c_test_subjects),
"n_train_subjects": 21,
"n_folds": int(args.protocol_c_num_folds),
"seed": int(args.protocol_c_seed),
},
"manifest_qc": manifest_qc,
"analysis_mask_qc": mask_qc,
"core_roi_qc": core_roi_qc,
"annotation_qc": annotation_qc,
"feature_qc": feature_qc,
"alignment_qc": alignment_qc,
"core_roi_gate_passed": core_roi_gate_passed,
"split_summary": split_summary,
"artifacts": {
"accepted_manifest": str(accepted_manifest_path),
"rejected_manifest": str(rejected_manifest_path),
"manifest_qc": str(manifest_qc_path),
"analysis_mask": str(analysis_mask_path),
"run_mask_qc": str(run_mask_qc_path),
"analysis_mask_qc": str(analysis_mask_qc_path),
"core_roi_coverage": str(core_roi_coverage_path),
"core_roi_failures": str(core_roi_failures_path),
"core_roi_qc": str(core_roi_qc_path),
"run_event_templates": str(run_events_path),
"unified_word_events": str(unified_events_path),
"annotation_source_resolution": str(annotation_source_resolution_path),
"annotation_qc": str(annotation_qc_path),
"feature_output_dir": str(feature_output_dir),
"feature_summary": str(feature_summary_path) if feature_summary_path.exists() else None,
"feature_qc": str(feature_qc_path) if feature_qc_path.exists() else None,
"alignment_output_dir": str(alignment_output_dir) if alignment_output_dir.exists() else None,
"alignment_bold_summary": str(alignment_bold_summary_path)
if alignment_bold_summary_path.exists()
else None,
"alignment_regressor_summary": str(alignment_regressor_summary_path)
if alignment_regressor_summary_path.exists()
else None,
"alignment_qc": str(alignment_qc_path) if alignment_qc_path.exists() else None,
"protocol_c_cross_subject": str(protocol_c_path),
},
}
write_json(bootstrap_summary_path, bootstrap_summary)
print("=" * 72)
print("A1 bootstrap complete")
print(f"Accepted manifest rows: {len(manifest_df)}")
print(f"Rejected candidate rows: {len(rejected_df)}")
print(f"Protocol C folds: {len(protocol_c_df)}")
print(f"Core ROI fail rows: {len(core_roi_failures_df)}")
print(f"Core ROI gate passed: {core_roi_gate_passed}")
print(f"Run-level event rows: {len(run_events_df)}")
print(f"Unified event rows: {len(unified_events_df)}")
print(f"Model selection source: {model_selection.get('source')}")
print(f"Model profile: {model_selection.get('profile')}")
print(f"Locked models: {', '.join(requested_models)}")
if feature_summary_df is not None:
print(f"Feature cache rows: {len(feature_summary_df)}")
if alignment_regressor_df is not None and alignment_bold_df is not None:
print(f"Alignment regressor rows: {len(alignment_regressor_df)}")
print(f"Alignment BOLD rows: {len(alignment_bold_df)}")
print(f"Output directory: {output_dir}")
print("=" * 72)
if not core_roi_gate_passed and not bool(args.allow_core_roi_failures):
raise RuntimeError(
"Core ROI preservation gate failed. "
"Inspect core_roi_failures.csv or rerun with --allow-core-roi-failures."
)
if __name__ == "__main__":
main()