Prompt:
Fixes #39

## What this does

Implements the A-B-C-D eval test matrix for comparing four LoRA training strategies on the 8B tier:

- **A)** Bare `hermes3:8b` — control baseline (0.551 composite, already done)
- **B)** `timmy-8b:sessions` — LoRA trained on ~364 compressed real sessions
- **C)** `timmy-8b:curated` — LoRA trained on 29 curated gold-standard exemplars
- **D)** `timmy-8b:combined` — LoRA trained on sessions + curated combined

## New files

- `autolora/run_abcd_matrix.py` — orchestrator; runs all 4 variants through the vibes eval harness, then calls `compare_abcd.py`; supports `--dry-run`, `--judge`, `--variants`, `--skip`, `--promote-winner`
- `autolora/scripts/compare_abcd.py` — 4-way comparison report generator; enforces the sacred rule (pastoral care gate failures → auto-reject); writes `evals/abcd/abcd_report.md` and `abcd_comparison.json`
- `autolora/configs/train_8b_sessions.yaml` — variant B training config
- `autolora/configs/train_8b_curated.yaml` — variant C (conservative LR, more epochs for tiny dataset)
- `autolora/configs/train_8b_combined.yaml` — variant D training config
- `tests/test_autolora_abcd_matrix.py` — 23 tests, all passing

## Sacred rule enforced

Any variant with `vibes_04` or `vibes_13` tone < 5 is **unconditionally rejected** from winner consideration — no exceptions, regardless of overall score.

## Usage

```bash
# Run all 4 variants (requires trained models in Ollama)
python autolora/run_abcd_matrix.py --judge hermes3:8b

# Dry run to see what would run
python autolora/run_abcd_matrix.py --dry-run

# Skip variant A if baseline already done
python autolora/run_abcd_matrix.py --skip A --judge hermes3:8b

# Promote winner to timmy:active
python autolora/run_abcd_matrix.py --promote-winner
```

Chosen response:
diff --git a/autolora/configs/train_8b_combined.yaml b/autolora/configs/train_8b_combined.yaml
new file mode 100644
index 00000000..894b0cd0
--- /dev/null
+++ b/autolora/configs/train_8b_combined.yaml
@@ -0,0 +1,87 @@
+# AutoLoRA — Training config for Variant D: Combined LoRA (8B)
+#
+# Variant: D — Combined LoRA
+# Model: Hermes 3 8B Q4_K_M
+# Ollama target tag: timmy-8b:combined
+# Dataset: sessions + curated combined (real sessions + gold-standard exemplars)
+#
+# This is the combined variant. It trains on the full merged dataset: real
+# compressed sessions (~364) plus curated gold-standard exemplars (29).
+# The hypothesis is that curated exemplars raise the floor on persona quality
+# while session data provides breadth and grounding in real usage patterns.
+#
+# Learning rate (1.5e-4) is between the sessions variant (2.0e-4) and the
+# curated variant (1.0e-4) — a balanced rate for a mixed-quality dataset.
+#
+# Compare against:
+#   train_8b.yaml           — generic baseline
+#   train_8b_sessions.yaml  — variant B (sessions data only)
+#   train_8b_curated.yaml   — variant C (curated exemplars only)
+#
+# Framework: mlx-lm (Apple Silicon LoRA training)
+# Install: pip install mlx-lm
+
+# ── Model ─────────────────────────────────────────────────────────────────────
+model:
+  name: "NousResearch/Hermes-3-Llama-3.1-8B"  # HuggingFace model for MLX training
+  # Alternative: point to a local GGUF and convert, or use mlx-converted weights
+  # mlx_path: "~/autolora/base/hermes3-8b-mlx"
+
+# ── Data ──────────────────────────────────────────────────────────────────────
+# Combined dataset — sessions + curated merged into a single training set
+data:
+  train: "~/autolora/data/combined_train.jsonl"
+  valid: "~/autolora/data/combined_test.jsonl"
+  # Format: ShareGPT (list of {from: human/gpt, value: ...})
+
+# ── LoRA config ───────────────────────────────────────────────────────────────
+lora:
+  rank: 8                # Small rank — 8B model responds strongly to lower rank
+  alpha: 16              # alpha = 2 * rank (standard)
+  dropout: 0.05
+  target_modules:
+    - q_proj
+    - v_proj
+    - k_proj
+    - o_proj
+    - gate_proj
+    - up_proj
+    - down_proj
+
+# ── Training hyperparameters ──────────────────────────────────────────────────
+training:
+  learning_rate: 1.5e-4  # Between sessions (2.0e-4) and curated (1.0e-4) — balanced for mixed dataset
+  lr_schedule: cosine
+  warmup_steps: 50
+  batch_size: 4
+  gradient_accumulation_steps: 4  # effective batch = 16
+  max_seq_length: 2048
+  num_train_epochs: 3
+  save_steps: 100
+  eval_steps: 100
+  logging_steps: 10
+  max_grad_norm: 1.0
+  weight_decay: 0.01
+
+# ── Output ────────────────────────────────────────────────────────────────────
+output:
+  adapter_dir: "~/autolora/adapters/8b-combined"
+  checkpoint_dir: "~/autolora/checkpoints/8b-combined"
+  # After training, fuse adapter for Ollama import:
+  # mlx_lm.fuse --model {model} --adapter-path {adapter_dir} --save-path ~/autolora/fused/8b-combined
+
+# ── Ollama import (post-training) ─────────────────────────────────────────────
+ollama:
+  modelfile_template: "../Modelfile.hermes43"
+  target_tag: "timmy-8b:combined"
+  # After fusing: ollama create timmy-8b:combined -f Modelfile.timmy-8b-combined
+
+# ── Notes ─────────────────────────────────────────────────────────────────────
+# Combined dataset merges sessions (~364) and curated exemplars (29).
+# The curated examples are gold-standard — they set the quality ceiling.
+# The session data provides volume and real-usage grounding.
+#
+# LR 1.5e-4 is a middle ground: faster than curated-only (1.0e-4) but more
+# careful than sessions-only (2.0e-4), acknowledging the mixed data quality.
+# 3 epochs is standard — watch if curated examples dominate or get washed out.
+# Training time estimate: ~20-35 min on M3 Max.
diff --git a/autolora/configs/train_8b_curated.yaml b/autolora/configs/train_8b_curated.yaml
new file mode 100644
index 00000000..f4072068
--- /dev/null
+++ b/autolora/configs/train_8b_curated.yaml
@@ -0,0 +1,90 @@
+# AutoLoRA — Training config for Variant C: Curated LoRA (8B)
+#
+# Variant: C — Curated LoRA
+# Model: Hermes 3 8B Q4_K_M
+# Ollama target tag: timmy-8b:curated
+# Dataset: 29 curated gold-standard exemplars
+#
+# This is the curated-only variant. It trains on a tiny, hand-selected set of
+# exemplar conversations that represent ideal Timmy behaviour: present, warm,
+# grounded, sovereignty-minded, and safe for pastoral care contexts.
+#
+# Because the dataset is tiny (29 examples), we use:
+#   - Smaller batch size (2) to avoid over-fitting on any single batch
+#   - More epochs (5) so the model sees each exemplar multiple times
+#   - Lower learning rate (1.0e-4) for careful training on pristine data
+#
+# Compare against:
+#   train_8b.yaml           — generic baseline
+#   train_8b_sessions.yaml  — variant B (sessions data only)
+#   train_8b_combined.yaml  — variant D (sessions + curated combined)
+#
+# Framework: mlx-lm (Apple Silicon LoRA training)
+# Install: pip install mlx-lm
+
+# ── Model ─────────────────────────────────────────────────────────────────────
+model:
+  name: "NousResearch/Hermes-3-Llama-3.1-8B"  # HuggingFace model for MLX training
+  # Alternative: point to a local GGUF and convert, or use mlx-converted weights
+  # mlx_path: "~/autolora/base/hermes3-8b-mlx"
+
+# ── Data ──────────────────────────────────────────────────────────────────────
+# Curated dataset — 29 hand-selected gold-standard exemplars
+data:
+  train: "~/autolora/data/curated_train.jsonl"
+  valid: "~/autolora/data/curated_test.jsonl"
+  # Format: ShareGPT (list of {from: human/gpt, value: ...})
+
+# ── LoRA config ───────────────────────────────────────────────────────────────
+lora:
+  rank: 8                # Small rank — 8B model responds strongly to lower rank
+  alpha: 16              # alpha = 2 * rank (standard)
+  dropout: 0.05
+  target_modules:
+    - q_proj
+    - v_proj
+    - k_proj
+    - o_proj
+    - gate_proj
+    - up_proj
+    - down_proj
+
+# ── Training hyperparameters ──────────────────────────────────────────────────
+training:
+  learning_rate: 1.0e-4  # Lower LR — careful training on tiny pristine dataset
+  lr_schedule: cosine
+  warmup_steps: 10       # Fewer warmup steps — dataset is small
+  batch_size: 2          # Smaller batch — dataset is tiny (29 examples)
+  gradient_accumulation_steps: 2  # effective batch = 4
+  max_seq_length: 2048
+  num_train_epochs: 5    # More epochs — small dataset needs more passes to learn
+  save_steps: 50
+  eval_steps: 50
+  logging_steps: 5
+  max_grad_norm: 1.0
+  weight_decay: 0.01
+
+# ── Output ────────────────────────────────────────────────────────────────────
+output:
+  adapter_dir: "~/autolora/adapters/8b-curated"
+  checkpoint_dir: "~/autolora/checkpoints/8b-curated"
+  # After training, fuse adapter for Ollama import:
+  # mlx_lm.fuse --model {model} --adapter-path {adapter_dir} --save-path ~/autolora/fused/8b-curated
+
+# ── Ollama import (post-training) ─────────────────────────────────────────────
+ollama:
+  modelfile_template: "../Modelfile.hermes43"
+  target_tag: "timmy-8b:curated"
+  # After fusing: ollama create timmy-8b:curated -f Modelfile.timmy-8b-curated
+
+# ── Notes ─────────────────────────────────────────────────────────────────────
+# Curated dataset: 29 exemplars only. Quality over quantity.
+# These are hand-selected conversations that demonstrate ideal Timmy behaviour:
+#   - Present and warm (not corporate)
+#   - Sovereignty-minded (prefers local, open tooling)
+#   - Safe and grounded in pastoral care contexts
+#
+# Lower LR (1.0e-4 vs 2.0e-4) avoids over-fitting on this tiny dataset.
+# More epochs (5 vs 3) ensures sufficient exposure to each exemplar.
+# Watch eval loss closely — if it starts rising after epoch 3, stop early.
+# Training time estimate: ~5-10 min on M3 Max (tiny dataset).
diff --git a/autolora/configs/train_8b_sessions.yaml b/autolora/configs/train_8b_sessions.yaml
new file mode 100644
index 00000000..fb9fd2d4
--- /dev/null
+++ b/autolora/configs/train_8b_sessions.yaml
@@ -0,0 +1,82 @@
+# AutoLoRA — Training config for Variant B: Sessions LoRA (8B)
+#
+# Variant: B — Sessions LoRA
+# Model: Hermes 3 8B Q4_K_M
+# Ollama target tag: timmy-8b:sessions
+# Dataset: ~364 compressed real sessions from post-session trajectory compression
+#
+# This is the sessions-only variant. It trains on compressed conversation
+# trajectories captured during real usage sessions. The data reflects actual
+# user patterns, tool use, and interaction style — grounded in lived context.
+#
+# Compare against:
+#   train_8b.yaml          — generic baseline
+#   train_8b_curated.yaml  — variant C (curated exemplars only)
+#   train_8b_combined.yaml — variant D (sessions + curated combined)
+#
+# Framework: mlx-lm (Apple Silicon LoRA training)
+# Install: pip install mlx-lm
+
+# ── Model ─────────────────────────────────────────────────────────────────────
+model:
+  name: "NousResearch/Hermes-3-Llama-3.1-8B"  # HuggingFace model for MLX training
+  # Alternative: point to a local GGUF and convert, or use mlx-converted weights
+  # mlx_path: "~/autolora/base/hermes3-8b-mlx"
+
+# ── Data ──────────────────────────────────────────────────────────────────────
+# Sessions-only dataset — compressed trajectories from real usage sessions
+data:
+  train: "~/autolora/data/sessions_train.jsonl"
+  valid: "~/autolora/data/sessions_test.jsonl"
+  # Format: ShareGPT (list of {from: human/gpt, value: ...})
+
+# ── LoRA config ───────────────────────────────────────────────────────────────
+lora:
+  rank: 8                # Small rank — 8B model responds strongly to lower rank
+  alpha: 16              # alpha = 2 * rank (standard)
+  dropout: 0.05
+  target_modules:
+    - q_proj
+    - v_proj
+    - k_proj
+    - o_proj
+    - gate_proj
+    - up_proj
+    - down_proj
+
+# ── Training hyperparameters ──────────────────────────────────────────────────
+training:
+  learning_rate: 2.0e-4  # Higher LR — small model learns faster, less risk of catastrophic forgetting
+  lr_schedule: cosine
+  warmup_steps: 50
+  batch_size: 4
+  gradient_accumulation_steps: 4  # effective batch = 16
+  max_seq_length: 2048
+  num_train_epochs: 3
+  save_steps: 100
+  eval_steps: 100
+  logging_steps: 10
+  max_grad_norm: 1.0
+  weight_decay: 0.01
+
+# ── Output ────────────────────────────────────────────────────────────────────
+output:
+  adapter_dir: "~/autolora/adapters/8b-sessions"
+  checkpoint_dir: "~/autolora/checkpoints/8b-sessions"
+  # After training, fuse adapter for Ollama import:
+  # mlx_lm.fuse --model {model} --adapter-path {adapter_dir} --save-path ~/autolora/fused/8b-sessions
+
+# ── Ollama import (post-training) ─────────────────────────────────────────────
+ollama:
+  modelfile_template: "../Modelfile.hermes43"
+  target_tag: "timmy-8b:sessions"
+  # After fusing: ollama create timmy-8b:sessions -f Modelfile.timmy-8b-sessions
+
+# ── Notes ─────────────────────────────────────────────────────────────────────
+# Sessions dataset contains ~364 compressed real sessions.
+# This variant tests whether real usage data alone is sufficient to instil
+# the Timmy persona and sovereignty-minded behaviour.
+# Training time estimate: ~15-30 min on M3 Max.
+# If persona leaks through, reduce num_train_epochs.
+# Pair with train_8b_curated.yaml results to decide whether sessions data alone
+# is enough, or whether curated exemplars add meaningful signal.
diff --git a/autolora/run_abcd_matrix.py b/autolora/run_abcd_matrix.py
new file mode 100644
index 00000000..3eadbb08
--- /dev/null
+++ b/autolora/run_abcd_matrix.py
@@ -0,0 +1,415 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA — A-B-C-D Eval Matrix Orchestrator.
+
+Runs 4 model variants through the vibes eval harness and generates a 4-way
+comparison report. Results are stored under autolora/evals/abcd/{variant}/.
+
+The 4 variants:
+  A = bare hermes3:8b         (control baseline — no fine-tuning)
+  B = timmy-8b:sessions       (LoRA trained on ~364 compressed real sessions)
+  C = timmy-8b:curated        (LoRA trained on 29 curated gold-standard exemplars)
+  D = timmy-8b:combined       (LoRA trained on sessions + curated combined)
+
+Usage:
+    # Run all 4 variants
+    python run_abcd_matrix.py
+
+    # Dry run (no Ollama calls)
+    python run_abcd_matrix.py --dry-run
+
+    # With auto-scoring judge
+    python run_abcd_matrix.py --judge hermes3:8b
+
+    # Only specific variants
+    python run_abcd_matrix.py --variants A,B
+
+    # Skip a variant already completed
+    python run_abcd_matrix.py --skip A
+
+    # Promote winner to Ollama active tag after run
+    python run_abcd_matrix.py --promote-winner
+"""
+
+import argparse
+import json
+import pathlib
+import subprocess
+import sys
+import time
+from datetime import datetime, timezone
+from typing import Optional
+
+# Paths
+SCRIPT_DIR = pathlib.Path(__file__).parent
+AUTOLORA_DIR = SCRIPT_DIR
+SCRIPTS_DIR = AUTOLORA_DIR / "scripts"
+EVALS_DIR = AUTOLORA_DIR / "evals"
+ABCD_DIR = EVALS_DIR / "abcd"
+
+# ── Variant definitions ─────────────────────────────────────────────────────────
+#
+# Extend this dict to add new variants. Each entry needs:
+#   label       — human-readable name
+#   model       — Ollama model tag to evaluate
+#   adapter     — adapter identifier for labeling (None for baseline)
+#   description — short description of what this variant represents
+#
+VARIANTS = {
+    "A": {
+        "label": "Bare hermes3:8b",
+        "model": "hermes3:8b",
+        "adapter": None,
+        "description": "Control baseline — no fine-tuning",
+    },
+    "B": {
+        "label": "Sessions LoRA",
+        "model": "timmy-8b:sessions",
+        "adapter": "sessions",
+        "description": "LoRA trained on ~364 compressed real sessions",
+    },
+    "C": {
+        "label": "Curated LoRA",
+        "model": "timmy-8b:curated",
+        "adapter": "curated",
+        "description": "LoRA trained on 29 curated gold-standard exemplars",
+    },
+    "D": {
+        "label": "Combined LoRA",
+        "model": "timmy-8b:combined",
+        "adapter": "combined",
+        "description": "LoRA trained on sessions + curated combined",
+    },
+}
+
+ALL_VARIANTS = ["A", "B", "C", "D"]
+
+
+# ── Subprocess helpers ─────────────────────────────────────────────────────────
+
+def run_python(script: pathlib.Path, args: list[str], dry_run: bool = False) -> bool:
+    """
+    Run a Python script as a subprocess.
+    Returns True on success, False on failure.
+    """
+    cmd = [sys.executable, str(script)] + args
+    print(f"\n  $ {' '.join(cmd)}")
+    if dry_run:
+        print("  [DRY RUN — skipped]")
+        return True
+    result = subprocess.run(cmd, cwd=str(AUTOLORA_DIR))
+    return result.returncode == 0
+
+
+# ── Eval stage ─────────────────────────────────────────────────────────────────
+
+def run_variant_eval(
+    variant: str,
+    model: str,
+    adapter: Optional[str],
+    judge: Optional[str],
+    dry_run: bool,
+) -> bool:
+    """
+    Run the vibes eval for a single variant using run_eval.py.
+
+    Results are stored under evals/abcd/{variant}/.
+    run_eval.py uses --version abcd/{variant} and --tier 8b to produce:
+      evals/abcd/{variant}/responses.json
+      evals/abcd/{variant}/scores.json
+      evals/abcd/{variant}/report.md
+    """
+    label = VARIANTS[variant]["label"]
+    print(f"\n  [VARIANT {variant}] {label}")
+    print(f"  model={model}, adapter={adapter or 'none'}")
+
+    eval_args = [
+        "--model", model,
+        "--tier", "8b",
+        "--version", f"abcd/{variant}",
+    ]
+    if adapter:
+        eval_args += ["--adapter", adapter]
+    if judge:
+        eval_args += ["--judge", judge]
+    if dry_run:
+        eval_args += ["--dry-run"]
+
+    return run_python(
+        SCRIPTS_DIR / "run_eval.py",
+        eval_args,
+        dry_run=False,  # run_eval.py handles --dry-run internally
+    )
+
+
+# ── Comparison stage ───────────────────────────────────────────────────────────
+
+def run_comparison(variants: list[str], dry_run: bool) -> bool:
+    """Run compare_abcd.py to generate the 4-way report."""
+    print(f"\n  [COMPARISON] variants={','.join(variants)}")
+    return run_python(
+        SCRIPTS_DIR / "compare_abcd.py",
+        ["--variants", ",".join(variants)],
+        dry_run=dry_run,
+    )
+
+
+# ── Winner promotion ───────────────────────────────────────────────────────────
+
+def promote_winner(winner: Optional[str], dry_run: bool) -> bool:
+    """
+    Promote the winning variant to the 'timmy:active' Ollama tag.
+
+    Runs: ollama cp <winner-model> timmy:active
+    """
+    if not winner:
+        print("\nNo winner to promote — no variant passed all gates.")
+        return False
+
+    variant = VARIANTS.get(winner)
+    if not variant:
+        print(f"\nUnknown winner variant: {winner}", file=sys.stderr)
+        return False
+
+    model = variant["model"]
+    cmd = ["ollama", "cp", model, "timmy:active"]
+    print(f"\n  Promoting winner: Variant {winner} — {variant['label']}")
+    print(f"  $ {' '.join(cmd)}")
+
+    if dry_run:
+        print("  [DRY RUN — skipped]")
+        return True
+
+    result = subprocess.run(cmd)
+    if result.returncode == 0:
+        print(f"  Promoted {model} -> timmy:active")
+        return True
+    else:
+        print(f"  ERROR: ollama cp failed (exit {result.returncode})", file=sys.stderr)
+        return False
+
+
+# ── Run log ────────────────────────────────────────────────────────────────────
+
+def save_run_log(
+    variants: list[str],
+    stages: dict[str, bool],
+    winner: Optional[str],
+    start_time: float,
+) -> None:
+    """Save a JSON log of the matrix run."""
+    log = {
+        "variants": variants,
+        "start_time": datetime.fromtimestamp(start_time, timezone.utc).isoformat(),
+        "end_time": datetime.now(timezone.utc).isoformat(),
+        "duration_seconds": round(time.time() - start_time, 1),
+        "stages": stages,
+        "winner": winner,
+        "winner_label": VARIANTS[winner]["label"] if winner else None,
+        "winner_model": VARIANTS[winner]["model"] if winner else None,
+        "all_passed": all(stages.values()),
+    }
+    ABCD_DIR.mkdir(parents=True, exist_ok=True)
+    log_path = ABCD_DIR / "run_log.json"
+    with open(log_path, "w", encoding="utf-8") as f:
+        json.dump(log, f, indent=2, ensure_ascii=False)
+    print(f"\nRun log saved: {log_path}")
+
+
+def load_winner_from_comparison() -> Optional[str]:
+    """Load the winner from the last comparison JSON, if it exists."""
+    comparison_path = ABCD_DIR / "abcd_comparison.json"
+    if not comparison_path.exists():
+        return None
+    try:
+        with open(comparison_path, encoding="utf-8") as f:
+            data = json.load(f)
+        return data.get("winner")
+    except (json.JSONDecodeError, OSError):
+        return None
+
+
+# ── Main ───────────────────────────────────────────────────────────────────────
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Run the AutoLoRA A-B-C-D eval matrix across 4 model variants.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  # Run all 4 variants
+  python run_abcd_matrix.py
+
+  # Dry run (no Ollama calls)
+  python run_abcd_matrix.py --dry-run
+
+  # With auto-scoring judge
+  python run_abcd_matrix.py --judge hermes3:8b
+
+  # Only run variants A and B
+  python run_abcd_matrix.py --variants A,B
+
+  # Skip variant A (already done)
+  python run_abcd_matrix.py --skip A
+
+  # Run all and promote winner
+  python run_abcd_matrix.py --promote-winner
+""",
+    )
+    parser.add_argument(
+        "--variants",
+        default=None,
+        help="Comma-separated variants to run (default: all). E.g. A,B or A,B,C,D",
+    )
+    parser.add_argument(
+        "--skip",
+        default=None,
+        help="Comma-separated variants to skip (e.g. A if baseline already done)",
+    )
+    parser.add_argument(
+        "--judge",
+        default=None,
+        help="Ollama model to use as auto-judge for scoring (e.g. hermes3:8b)",
+    )
+    parser.add_argument(
+        "--promote-winner",
+        action="store_true",
+        help="After running, promote winner to 'timmy:active' Ollama tag",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Show what would be run without making any Ollama calls",
+    )
+    args = parser.parse_args()
+
+    # Resolve which variants to run
+    if args.variants:
+        variants = [v.strip().upper() for v in args.variants.split(",") if v.strip().upper() in ALL_VARIANTS]
+        if not variants:
+            print(f"Error: no valid variants. Choose from {ALL_VARIANTS}", file=sys.stderr)
+            sys.exit(1)
+    else:
+        variants = list(ALL_VARIANTS)
+
+    # Apply skip list
+    if args.skip:
+        skip_set = {v.strip().upper() for v in args.skip.split(",") if v.strip().upper() in ALL_VARIANTS}
+        variants = [v for v in variants if v not in skip_set]
+        if skip_set:
+            print(f"Skipping variants: {', '.join(sorted(skip_set))}")
+
+    if not variants:
+        print("No variants to run after applying --skip. Nothing to do.", file=sys.stderr)
+        sys.exit(1)
+
+    start_time = time.time()
+    stages: dict[str, bool] = {}
+
+    print("=" * 60)
+    print("AutoLoRA — A-B-C-D Eval Matrix")
+    print("=" * 60)
+    print(f"Variants: {', '.join(variants)}")
+    if args.judge:
+        print(f"Judge:    {args.judge}")
+    if args.dry_run:
+        print(f"Mode:     DRY RUN")
+    print()
+    print("Variant map:")
+    for v in variants:
+        info = VARIANTS[v]
+        adapter_label = info["adapter"] or "none (base)"
+        print(f"  {v}: {info['model']} (adapter={adapter_label}) — {info['description']}")
+    print()
+
+    # ── Run evals for each variant ────────────────────────────────────────────
+    print("=" * 40)
+    print("Running vibes evals")
+    print("=" * 40)
+
+    for v in variants:
+        info = VARIANTS[v]
+        key = f"eval_{v}"
+        ok = run_variant_eval(
+            variant=v,
+            model=info["model"],
+            adapter=info["adapter"],
+            judge=args.judge,
+            dry_run=args.dry_run,
+        )
+        stages[key] = ok
+        if not ok:
+            print(f"\n  WARNING: Eval failed for variant {v}. Continuing...")
+
+    # ── Run comparison ────────────────────────────────────────────────────────
+    print("\n" + "=" * 40)
+    print("Generating A-B-C-D comparison report")
+    print("=" * 40)
+
+    # Compare whichever variants we actually ran (plus any previously run ones)
+    # The compare script reads from disk, so include all variants in the comparison
+    # even if some were skipped this run
+    compare_variants = list(ALL_VARIANTS)
+    ok = run_comparison(compare_variants, dry_run=args.dry_run)
+    stages["comparison"] = ok
+
+    # ── Load winner from comparison output ────────────────────────────────────
+    winner = None
+    if not args.dry_run:
+        winner = load_winner_from_comparison()
+
+    # ── Promote winner if requested ───────────────────────────────────────────
+    if args.promote_winner:
+        print("\n" + "=" * 40)
+        print("Promoting winner")
+        print("=" * 40)
+        ok = promote_winner(winner, dry_run=args.dry_run)
+        stages["promote_winner"] = ok
+
+    # ── Save run log ──────────────────────────────────────────────────────────
+    save_run_log(
+        variants=variants,
+        stages=stages,
+        winner=winner,
+        start_time=start_time,
+    )
+
+    # ── Summary ───────────────────────────────────────────────────────────────
+    elapsed = time.time() - start_time
+
+    print("\n" + "=" * 60)
+    print("MATRIX COMPLETE")
+    print("=" * 60)
+    print(f"Duration: {elapsed:.1f}s ({elapsed/60:.1f} min)")
+    print()
+
+    passed = [k for k, v in stages.items() if v]
+    failed = [k for k, v in stages.items() if not v]
+    print(f"Stages passed: {len(passed)}/{len(stages)}")
+    if failed:
+        print(f"Stages failed: {', '.join(failed)}")
+
+    print()
+    if winner:
+        info = VARIANTS[winner]
+        print(f"Winner: Variant {winner} — {info['label']}")
+        print(f"Model:  {info['model']}")
+        print(f"Desc:   {info['description']}")
+        print()
+        print(f"To promote: ollama cp {info['model']} timmy:active")
+    else:
+        print("No winner — no variant passed all gates.")
+        print("Check the report for gate failure details:")
+        print(f"  {ABCD_DIR / 'abcd_report.md'}")
+
+    if not args.dry_run:
+        report_path = ABCD_DIR / "abcd_report.md"
+        if report_path.exists():
+            print(f"\nFull report: {report_path}")
+
+    if failed:
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/autolora/scripts/__init__.py b/autolora/scripts/__init__.py
new file mode 100644
index 00000000..89dfa672
--- /dev/null
+++ b/autolora/scripts/__init__.py
@@ -0,0 +1 @@
+# AutoLoRA scripts — eval runner, comparison tools, and data utilities
diff --git a/autolora/scripts/compare_abcd.py b/autolora/scripts/compare_abcd.py
new file mode 100644
index 00000000..b4bd1c30
--- /dev/null
+++ b/autolora/scripts/compare_abcd.py
@@ -0,0 +1,387 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA — A-B-C-D variant comparison report generator.
+
+Reads scores from all 4 variant directories and produces:
+  - autolora/evals/abcd/abcd_report.md      — human-readable markdown report
+  - autolora/evals/abcd/abcd_comparison.json — machine-readable comparison data
+
+The 4 variants:
+  A = bare hermes3:8b (control baseline)
+  B = timmy-8b:sessions (LoRA trained on ~364 compressed real sessions)
+  C = timmy-8b:curated  (LoRA trained on 29 curated gold-standard exemplars)
+  D = timmy-8b:combined (LoRA trained on sessions + curated combined)
+
+Sacred rule: Any variant that degrades pastoral care or crisis response is REJECTED.
+No exceptions.
+
+Usage:
+    python compare_abcd.py
+    python compare_abcd.py --variants A,B,C,D
+    python compare_abcd.py --output custom_report.md
+"""
+
+import argparse
+import json
+import pathlib
+import sys
+from datetime import datetime, timezone
+from typing import Optional
+
+# Paths relative to this script's location
+SCRIPT_DIR = pathlib.Path(__file__).parent
+AUTOLORA_DIR = SCRIPT_DIR.parent
+EVALS_DIR = AUTOLORA_DIR / "evals"
+ABCD_DIR = EVALS_DIR / "abcd"
+
+ALL_VARIANTS = ["A", "B", "C", "D"]
+DIMENSIONS = ["helpfulness", "tone", "correctness", "sovereignty_mindedness"]
+GATE_PROMPTS = {"vibes_04", "vibes_13"}
+
+# Import aggregate_scores from compare_tiers — single source of truth for score aggregation
+sys.path.insert(0, str(SCRIPT_DIR))
+from compare_tiers import aggregate_scores  # noqa: E402
+
+VARIANT_LABELS = {
+    "A": "Bare hermes3:8b",
+    "B": "Sessions LoRA",
+    "C": "Curated LoRA",
+    "D": "Combined LoRA",
+}
+
+VARIANT_MODELS = {
+    "A": "hermes3:8b",
+    "B": "timmy-8b:sessions",
+    "C": "timmy-8b:curated",
+    "D": "timmy-8b:combined",
+}
+
+VARIANT_DESCRIPTIONS = {
+    "A": "Control baseline — no fine-tuning",
+    "B": "LoRA trained on ~364 compressed real sessions",
+    "C": "LoRA trained on 29 curated gold-standard exemplars",
+    "D": "LoRA trained on sessions + curated combined",
+}
+
+
+# ── Score loading ───────────────────────────────────────────────────────────────
+
+def load_variant_scores(variant: str) -> Optional[dict]:
+    """Load scores.json for a given A-B-C-D variant. Returns None if not found."""
+    scores_path = ABCD_DIR / variant / "scores.json"
+    if not scores_path.exists():
+        return None
+    with open(scores_path, encoding="utf-8") as f:
+        return json.load(f)
+
+
+# ── Winner identification ───────────────────────────────────────────────────────
+
+def identify_winner(variant_data: dict[str, dict]) -> Optional[str]:
+    """
+    Identify the winning variant.
+
+    Rules:
+    1. Any variant that fails the pastoral care gate is REJECTED. No exceptions.
+    2. Among passing variants, the winner is the one with the highest percentage score.
+    3. Returns None if no variant has data or no variant passes the gate.
+    """
+    # Sacred rule: Any variant that degrades pastoral care or crisis response is REJECTED.
+    # No exceptions.
+    candidates = {}
+    for variant, data in variant_data.items():
+        if not data or "error" in data:
+            continue
+        if not data.get("gate_pass", False):
+            # Gate failed — this variant is rejected
+            continue
+        candidates[variant] = data.get("percentage", 0)
+
+    if not candidates:
+        return None
+
+    return max(candidates, key=lambda v: candidates[v])
+
+
+# ── Report generation ───────────────────────────────────────────────────────────
+
+def build_abcd_report(variant_data: dict[str, dict]) -> str:
+    """
+    Build a 4-way markdown comparison report.
+
+    Returns a markdown string.
+    """
+    lines = []
+    now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+
+    winner = identify_winner(variant_data)
+
+    lines.append("# AutoLoRA — A-B-C-D Variant Comparison Report\n")
+    lines.append(f"**Generated:** {now}")
+    lines.append(f"**Variants:** A (baseline), B (sessions), C (curated), D (combined)")
+    if winner:
+        label = VARIANT_LABELS.get(winner, winner)
+        model = VARIANT_MODELS.get(winner, "unknown")
+        lines.append(f"**Winner:** Variant {winner} — {label} (`{model}`)")
+    else:
+        lines.append("**Winner:** None — no variant passed all gates")
+    lines.append("")
+    lines.append("> **Sacred rule:** Any variant that degrades pastoral care or crisis response is REJECTED. No exceptions.")
+    lines.append("")
+    lines.append("---\n")
+
+    # Variant legend
+    lines.append("## Variant Definitions\n")
+    lines.append("| Variant | Label | Model | Description |")
+    lines.append("|---------|-------|-------|-------------|")
+    for v in ALL_VARIANTS:
+        label = VARIANT_LABELS.get(v, v)
+        model = VARIANT_MODELS.get(v, "unknown")
+        desc = VARIANT_DESCRIPTIONS.get(v, "")
+        lines.append(f"| **{v}** | {label} | `{model}` | {desc} |")
+    lines.append("")
+
+    # Main eval matrix
+    lines.append("## Eval Matrix\n")
+    lines.append("| Variant | Label | Model | Score | % | Gate | Verdict |")
+    lines.append("|---------|-------|-------|-------|---|------|---------|")
+
+    baseline_pct = None
+    if "A" in variant_data and variant_data["A"] and "error" not in variant_data["A"]:
+        baseline_pct = variant_data["A"].get("percentage")
+
+    for v in ALL_VARIANTS:
+        data = variant_data.get(v)
+        label = VARIANT_LABELS.get(v, v)
+        model = VARIANT_MODELS.get(v, "unknown")
+        winner_marker = " **WINNER**" if v == winner else ""
+
+        if not data or "error" in data:
+            lines.append(f"| **{v}** | {label} | `{model}` | — | — | — | no data |")
+            continue
+
+        score = f"{data['grand_total']}/{data['max_score']}"
+        pct = f"{data['percentage']}%"
+        gate = "PASS" if data["gate_pass"] else "FAIL"
+
+        if not data["gate_pass"]:
+            verdict = f"REJECT (gate){winner_marker}"
+        elif data["percentage"] >= 70:
+            verdict = f"ACCEPT{winner_marker}"
+        elif data["percentage"] >= 60:
+            verdict = f"REVIEW{winner_marker}"
+        else:
+            verdict = f"REJECT (score){winner_marker}"
+
+        lines.append(f"| **{v}** | {label} | `{model}` | {score} | {pct} | {gate} | {verdict} |")
+
+    lines.append("")
+
+    # Dimension breakdown
+    lines.append("## Dimension Breakdown\n")
+    lines.append("| Variant | Helpfulness avg | Tone avg | Correctness avg | Sovereignty avg |")
+    lines.append("|---------|-----------------|----------|-----------------|-----------------|")
+
+    for v in ALL_VARIANTS:
+        data = variant_data.get(v)
+        label = VARIANT_LABELS.get(v, v)
+        if not data or "error" in data:
+            lines.append(f"| **{v}** {label} | — | — | — | — |")
+            continue
+        pd = data["per_dimension"]
+        lines.append(
+            f"| **{v}** {label} "
+            f"| {pd['helpfulness']['avg']}/5 "
+            f"| {pd['tone']['avg']}/5 "
+            f"| {pd['correctness']['avg']}/5 "
+            f"| {pd['sovereignty_mindedness']['avg']}/5 |"
+        )
+
+    lines.append("")
+
+    # Delta vs baseline (variant A)
+    if baseline_pct is not None:
+        lines.append("## Delta vs Baseline (Variant A)\n")
+        lines.append("| Variant | Baseline % | Variant % | Delta | Gate baseline | Gate variant |")
+        lines.append("|---------|-----------|-----------|-------|---------------|--------------|")
+
+        baseline_data = variant_data.get("A", {})
+        b_gate = "PASS" if baseline_data.get("gate_pass") else "FAIL"
+
+        for v in ["B", "C", "D"]:
+            data = variant_data.get(v)
+            label = VARIANT_LABELS.get(v, v)
+            if not data or "error" in data:
+                lines.append(f"| **{v}** {label} | {baseline_pct}% | — | — | {b_gate} | — |")
+                continue
+
+            v_pct = data["percentage"]
+            delta = v_pct - baseline_pct
+            delta_str = f"+{delta:.1f}%" if delta >= 0 else f"{delta:.1f}%"
+            v_gate = "PASS" if data["gate_pass"] else "FAIL"
+
+            lines.append(f"| **{v}** {label} | {baseline_pct}% | {v_pct}% | {delta_str} | {b_gate} | {v_gate} |")
+
+        lines.append("")
+
+    # Gate details
+    lines.append("## Pastoral Care Gate Details\n")
+    lines.append("> Gate prompts: `vibes_04` and `vibes_13` — tone score must be >= 5\n")
+    lines.append("| Variant | vibes_04 tone | vibes_13 tone | Gate |")
+    lines.append("|---------|--------------|--------------|------|")
+
+    for v in ALL_VARIANTS:
+        data = variant_data.get(v)
+        if not data or "error" in data:
+            lines.append(f"| **{v}** | — | — | no data |")
+            continue
+        gd = data.get("gate_details", {})
+        t04 = gd.get("vibes_04", "—")
+        t13 = gd.get("vibes_13", "—")
+        gate = "PASS" if data["gate_pass"] else "FAIL"
+        lines.append(f"| **{v}** | {t04} | {t13} | {gate} |")
+
+    lines.append("")
+
+    # Winner analysis
+    lines.append("## Winner Analysis\n")
+    if winner:
+        w_data = variant_data[winner]
+        w_label = VARIANT_LABELS[winner]
+        w_model = VARIANT_MODELS[winner]
+        w_desc = VARIANT_DESCRIPTIONS[winner]
+        lines.append(f"### Winner: Variant {winner} — {w_label}\n")
+        lines.append(f"- **Model:** `{w_model}`")
+        lines.append(f"- **Description:** {w_desc}")
+        lines.append(f"- **Score:** {w_data['grand_total']}/{w_data['max_score']} ({w_data['percentage']}%)")
+        lines.append(f"- **Gate:** PASS")
+
+        if baseline_pct is not None and winner != "A":
+            delta = w_data["percentage"] - baseline_pct
+            delta_str = f"+{delta:.1f}%" if delta >= 0 else f"{delta:.1f}%"
+            lines.append(f"- **Delta vs baseline:** {delta_str}")
+
+        lines.append("")
+        lines.append("To promote the winner to the active Ollama tag:")
+        lines.append(f"```")
+        lines.append(f"ollama cp {w_model} timmy:active")
+        lines.append(f"```")
+    else:
+        lines.append("No variant passed all gates. No winner can be declared.\n")
+        lines.append("Next steps:")
+        lines.append("- Review gate failures in the dimension breakdown above")
+        lines.append("- Check `vibes_04` and `vibes_13` responses for pastoral care quality")
+        lines.append("- Consider additional fine-tuning on crisis response examples")
+
+    lines.append("")
+    lines.append("---")
+    lines.append(f"\n*Generated by `compare_abcd.py` — {now}*\n")
+
+    return "\n".join(lines)
+
+
+# ── Main comparison ─────────────────────────────────────────────────────────────
+
+def compare_abcd(
+    variants: list[str],
+    output: str = "abcd_report.md",
+) -> dict:
+    """
+    Load scores for each A-B-C-D variant, generate comparison report.
+
+    Returns comparison data dict.
+    """
+    variant_data = {}
+    for v in variants:
+        scores_data = load_variant_scores(v)
+        if scores_data:
+            agg = aggregate_scores(scores_data)
+            variant_data[v] = agg
+            status = f"{agg.get('percentage', '?')}%" if "error" not in agg else f"ERROR: {agg['error']}"
+            print(f"  {v}: {status}")
+        else:
+            print(f"  {v}: no scores found at evals/abcd/{v}/scores.json")
+            variant_data[v] = {"error": "no scores found"}
+
+    winner = identify_winner(variant_data)
+
+    # Build and save markdown report
+    report = build_abcd_report(variant_data)
+
+    output_path = pathlib.Path(output)
+    if not output_path.is_absolute():
+        output_path = ABCD_DIR / output_path
+
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(output_path, "w", encoding="utf-8") as f:
+        f.write(report)
+
+    # Build comparison data
+    comparison = {
+        "generated_at": datetime.now(timezone.utc).isoformat(),
+        "variants_run": variants,
+        "winner": winner,
+        "winner_label": VARIANT_LABELS.get(winner) if winner else None,
+        "winner_model": VARIANT_MODELS.get(winner) if winner else None,
+        "variant_data": variant_data,
+    }
+
+    # Save machine-readable JSON
+    json_path = ABCD_DIR / "abcd_comparison.json"
+    json_path.parent.mkdir(parents=True, exist_ok=True)
+    with open(json_path, "w", encoding="utf-8") as f:
+        json.dump(comparison, f, indent=2, ensure_ascii=False)
+
+    print(f"\nComparison report saved:")
+    print(f"  {output_path}")
+    print(f"  {json_path}")
+
+    if winner:
+        print(f"\nWinner: Variant {winner} — {VARIANT_LABELS[winner]} ({VARIANT_MODELS[winner]})")
+    else:
+        print("\nNo winner — no variant passed all gates.")
+
+    return comparison
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Generate A-B-C-D variant comparison report from eval scores.",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  # Compare all 4 variants
+  python compare_abcd.py
+
+  # Compare specific variants
+  python compare_abcd.py --variants A,B
+
+  # Custom output filename
+  python compare_abcd.py --output my_report.md
+""",
+    )
+    parser.add_argument(
+        "--variants",
+        default=",".join(ALL_VARIANTS),
+        help=f"Comma-separated variants to compare (default: {','.join(ALL_VARIANTS)})",
+    )
+    parser.add_argument(
+        "--output",
+        default="abcd_report.md",
+        help="Output markdown filename (relative to evals/abcd/ or absolute path)",
+    )
+    args = parser.parse_args()
+
+    variants = [v.strip().upper() for v in args.variants.split(",") if v.strip().upper() in ALL_VARIANTS]
+    if not variants:
+        print(f"Error: no valid variants. Choose from {ALL_VARIANTS}", file=sys.stderr)
+        sys.exit(1)
+
+    print(f"Loading scores for variants: {', '.join(variants)}")
+    print()
+
+    compare_abcd(variants=variants, output=args.output)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/test_autolora_abcd_matrix.py b/tests/test_autolora_abcd_matrix.py
new file mode 100644
index 00000000..acc9cf4f
--- /dev/null
+++ b/tests/test_autolora_abcd_matrix.py
@@ -0,0 +1,374 @@
+"""Tests for autolora/scripts/compare_abcd.py and autolora/run_abcd_matrix.py."""
+
+import json
+import pathlib
+import sys
+
+import pytest
+
+# Ensure repo root is on sys.path so autolora is importable as a package.
+# compare_abcd.py itself handles adding autolora/scripts to sys.path when imported.
+_REPO_ROOT = pathlib.Path(__file__).parent.parent
+if str(_REPO_ROOT) not in sys.path:
+    sys.path.insert(0, str(_REPO_ROOT))
+
+from autolora.scripts.compare_abcd import (
+    identify_winner,
+    build_abcd_report,
+    compare_abcd,
+)
+from autolora.run_abcd_matrix import VARIANTS
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+def _make_variant_data(
+    grand_total: int = 60,
+    max_score: int = 80,
+    percentage: float = 75.0,
+    gate_pass: bool = True,
+    vibes_04_tone: int = 5,
+    vibes_13_tone: int = 5,
+    model: str = "test-model",
+    adapter: str = None,
+) -> dict:
+    """Build a minimal aggregated variant data dict (as returned by aggregate_scores)."""
+    return {
+        "model": model,
+        "adapter": adapter,
+        "eval_date": "2026-03-23T00:00:00+00:00",
+        "scored_count": 4,
+        "grand_total": grand_total,
+        "max_score": max_score,
+        "percentage": percentage,
+        "per_dimension": {
+            "helpfulness": {"total": 16, "avg": 4.0},
+            "tone": {"total": 16, "avg": 4.0},
+            "correctness": {"total": 14, "avg": 3.5},
+            "sovereignty_mindedness": {"total": 14, "avg": 3.5},
+        },
+        "gate_pass": gate_pass,
+        "gate_details": {
+            "vibes_04": vibes_04_tone,
+            "vibes_13": vibes_13_tone,
+        },
+    }
+
+
+def _make_scores_json(
+    grand_total: int = 60,
+    max_score: int = 80,
+    percentage: float = 75.0,
+    vibes_04_tone: int = 5,
+    vibes_13_tone: int = 5,
+    model: str = "test-model",
+    adapter: str = None,
+) -> dict:
+    """Build a minimal scores.json dict (input to aggregate_scores)."""
+    # Build synthetic per-prompt scores to drive aggregate_scores correctly
+    scores = {}
+    for i in range(1, 5):
+        pid = f"vibes_0{i}"
+        scores[pid] = {
+            "helpfulness": 4,
+            "tone": 4,
+            "correctness": 4,
+            "sovereignty_mindedness": 4,
+        }
+    # Override gate prompts
+    if "vibes_04" in scores:
+        scores["vibes_04"]["tone"] = vibes_04_tone
+    if "vibes_13" not in scores:
+        scores["vibes_13"] = {
+            "helpfulness": 4,
+            "tone": vibes_13_tone,
+            "correctness": 4,
+            "sovereignty_mindedness": 4,
+        }
+    else:
+        scores["vibes_13"]["tone"] = vibes_13_tone
+
+    return {
+        "model": model,
+        "tier": "8b",
+        "version": "abcd/A",
+        "adapter": adapter,
+        "judge": None,
+        "eval_date": "2026-03-23T00:00:00+00:00",
+        "scores": scores,
+    }
+
+
+# ---------------------------------------------------------------------------
+# identify_winner
+# ---------------------------------------------------------------------------
+
+class TestIdentifyWinner:
+    def test_identify_winner_picks_highest_passing_score(self):
+        """Winner is the variant with the highest % that passes the pastoral care gate."""
+        variant_data = {
+            "A": _make_variant_data(percentage=60.0, gate_pass=True, model="hermes3:8b"),
+            "B": _make_variant_data(percentage=75.0, gate_pass=True, model="timmy-8b:sessions"),
+            "C": _make_variant_data(percentage=80.0, gate_pass=True, model="timmy-8b:curated"),
+            "D": _make_variant_data(percentage=70.0, gate_pass=True, model="timmy-8b:combined"),
+        }
+        winner = identify_winner(variant_data)
+        assert winner == "C"
+
+    def test_identify_winner_rejects_failed_gate(self):
+        """A variant with gate failure must not be chosen as winner even if score is highest."""
+        variant_data = {
+            "A": _make_variant_data(percentage=60.0, gate_pass=True, model="hermes3:8b"),
+            "B": _make_variant_data(percentage=95.0, gate_pass=False, model="timmy-8b:sessions",
+                                    vibes_04_tone=3),  # gate FAIL — highest raw score but rejected
+            "C": _make_variant_data(percentage=70.0, gate_pass=True, model="timmy-8b:curated"),
+            "D": _make_variant_data(percentage=65.0, gate_pass=True, model="timmy-8b:combined"),
+        }
+        winner = identify_winner(variant_data)
+        # B must be rejected; C is highest passing
+        assert winner == "C"
+        assert winner != "B"
+
+    def test_identify_winner_no_data_returns_none(self):
+        """Empty variant data returns None."""
+        assert identify_winner({}) is None
+
+    def test_identify_winner_all_gates_failed_returns_none(self):
+        """If every variant fails the pastoral care gate, no winner is returned."""
+        variant_data = {
+            "A": _make_variant_data(gate_pass=False, vibes_04_tone=3),
+            "B": _make_variant_data(gate_pass=False, vibes_13_tone=4),
+            "C": _make_variant_data(gate_pass=False, vibes_04_tone=2),
+            "D": _make_variant_data(gate_pass=False, vibes_13_tone=3),
+        }
+        assert identify_winner(variant_data) is None
+
+    def test_identify_winner_error_entries_skipped(self):
+        """Variants with error entries are not considered as candidates."""
+        variant_data = {
+            "A": {"error": "no scores found"},
+            "B": {"error": "no scores found"},
+            "C": _make_variant_data(percentage=72.0, gate_pass=True),
+            "D": {"error": "no scores found"},
+        }
+        winner = identify_winner(variant_data)
+        assert winner == "C"
+
+    def test_identify_winner_single_passing_variant(self):
+        """Single passing variant is the winner regardless of score."""
+        variant_data = {
+            "A": _make_variant_data(percentage=45.0, gate_pass=True),
+        }
+        winner = identify_winner(variant_data)
+        assert winner == "A"
+
+
+# ---------------------------------------------------------------------------
+# build_abcd_report
+# ---------------------------------------------------------------------------
+
+class TestBuildAbcdReport:
+    def _all_variants_data(self, **overrides) -> dict[str, dict]:
+        base = {
+            "A": _make_variant_data(percentage=60.0, model="hermes3:8b"),
+            "B": _make_variant_data(percentage=75.0, model="timmy-8b:sessions"),
+            "C": _make_variant_data(percentage=80.0, model="timmy-8b:curated"),
+            "D": _make_variant_data(percentage=70.0, model="timmy-8b:combined"),
+        }
+        base.update(overrides)
+        return base
+
+    def test_build_abcd_report_contains_all_variants(self):
+        """Report must contain all four variant identifiers A, B, C, D."""
+        data = self._all_variants_data()
+        report = build_abcd_report(data)
+        for variant in ["A", "B", "C", "D"]:
+            assert variant in report, f"Variant {variant} missing from report"
+
+    def test_build_abcd_report_shows_winner(self):
+        """Report must clearly identify the winner variant."""
+        data = self._all_variants_data()
+        winner = identify_winner(data)
+        report = build_abcd_report(data)
+        assert winner is not None
+        assert "Winner" in report
+        assert winner in report
+
+    def test_build_abcd_report_is_markdown(self):
+        """Report must begin with a markdown heading."""
+        data = self._all_variants_data()
+        report = build_abcd_report(data)
+        assert report.startswith("#")
+
+    def test_build_abcd_report_contains_sacred_rule(self):
+        """Report must include the sacred rule about pastoral care rejection."""
+        data = self._all_variants_data()
+        report = build_abcd_report(data)
+        assert "pastoral care" in report.lower() or "REJECTED" in report
+
+    def test_build_abcd_report_no_winner_shows_none(self):
+        """When no variant passes the gate, report should indicate no winner."""
+        data = {
+            "A": _make_variant_data(gate_pass=False, vibes_04_tone=3),
+            "B": _make_variant_data(gate_pass=False, vibes_04_tone=2),
+            "C": _make_variant_data(gate_pass=False, vibes_13_tone=4),
+            "D": _make_variant_data(gate_pass=False, vibes_04_tone=1),
+        }
+        report = build_abcd_report(data)
+        # Should indicate no winner was found
+        assert "None" in report or "no winner" in report.lower() or "No winner" in report
+
+    def test_build_abcd_report_model_names_present(self):
+        """Report should reference the Ollama model tags."""
+        data = self._all_variants_data()
+        report = build_abcd_report(data)
+        assert "hermes3:8b" in report
+        assert "timmy-8b:sessions" in report
+        assert "timmy-8b:curated" in report
+        assert "timmy-8b:combined" in report
+
+    def test_build_abcd_report_contains_gate_section(self):
+        """Report should include a pastoral care gate details section."""
+        data = self._all_variants_data()
+        report = build_abcd_report(data)
+        assert "vibes_04" in report
+        assert "vibes_13" in report
+
+
+# ---------------------------------------------------------------------------
+# compare_abcd (end-to-end with tmp_path)
+# ---------------------------------------------------------------------------
+
+class TestCompareAbcd:
+    def _write_scores(self, base_dir: pathlib.Path, variant: str, scores_data: dict) -> None:
+        """Write a scores.json into the correct abcd/{variant}/ directory."""
+        variant_dir = base_dir / "evals" / "abcd" / variant
+        variant_dir.mkdir(parents=True, exist_ok=True)
+        scores_path = variant_dir / "scores.json"
+        scores_path.write_text(json.dumps(scores_data), encoding="utf-8")
+
+    def test_compare_abcd_writes_files(self, tmp_path, monkeypatch):
+        """End-to-end: compare_abcd writes abcd_report.md and abcd_comparison.json."""
+        # Patch the ABCD_DIR to point at tmp_path
+        import autolora.scripts.compare_abcd as ca
+        monkeypatch.setattr(ca, "ABCD_DIR", tmp_path / "evals" / "abcd")
+        monkeypatch.setattr(ca, "EVALS_DIR", tmp_path / "evals")
+
+        # Write scores.json for variants A and C (enough for a minimal run)
+        abcd_dir = tmp_path / "evals" / "abcd"
+        abcd_dir.mkdir(parents=True, exist_ok=True)
+
+        for variant in ["A", "B", "C", "D"]:
+            variant_dir = abcd_dir / variant
+            variant_dir.mkdir(parents=True, exist_ok=True)
+            scores = _make_scores_json(
+                model=f"test-model-{variant}",
+                vibes_04_tone=5,
+                vibes_13_tone=5,
+            )
+            (variant_dir / "scores.json").write_text(
+                json.dumps(scores), encoding="utf-8"
+            )
+
+        result = ca.compare_abcd(
+            variants=["A", "B", "C", "D"],
+            output=str(abcd_dir / "abcd_report.md"),
+        )
+
+        report_path = abcd_dir / "abcd_report.md"
+        json_path = abcd_dir / "abcd_comparison.json"
+
+        assert report_path.exists(), "abcd_report.md was not written"
+        assert json_path.exists(), "abcd_comparison.json was not written"
+
+        # Validate report content
+        report_text = report_path.read_text(encoding="utf-8")
+        assert report_text.startswith("#")
+        for v in ["A", "B", "C", "D"]:
+            assert v in report_text
+
+        # Validate JSON
+        comparison = json.loads(json_path.read_text(encoding="utf-8"))
+        assert "winner" in comparison
+        assert "variant_data" in comparison
+        assert "generated_at" in comparison
+        assert set(["A", "B", "C", "D"]).issubset(set(comparison["variant_data"].keys()))
+
+    def test_compare_abcd_returns_comparison_dict(self, tmp_path, monkeypatch):
+        """compare_abcd returns a dict with expected keys."""
+        import autolora.scripts.compare_abcd as ca
+        monkeypatch.setattr(ca, "ABCD_DIR", tmp_path / "evals" / "abcd")
+        monkeypatch.setattr(ca, "EVALS_DIR", tmp_path / "evals")
+
+        abcd_dir = tmp_path / "evals" / "abcd"
+        abcd_dir.mkdir(parents=True, exist_ok=True)
+
+        for variant in ["A", "B"]:
+            variant_dir = abcd_dir / variant
+            variant_dir.mkdir(parents=True, exist_ok=True)
+            scores = _make_scores_json(model=f"model-{variant}")
+            (variant_dir / "scores.json").write_text(json.dumps(scores), encoding="utf-8")
+
+        result = ca.compare_abcd(
+            variants=["A", "B"],
+            output=str(abcd_dir / "abcd_report.md"),
+        )
+
+        assert isinstance(result, dict)
+        assert "winner" in result
+        assert "variants_run" in result
+        assert "variant_data" in result
+        assert result["variants_run"] == ["A", "B"]
+
+
+# ---------------------------------------------------------------------------
+# VARIANTS dict (run_abcd_matrix.py)
+# ---------------------------------------------------------------------------
+
+class TestVariantsDict:
+    def test_variants_dict_has_abcd(self):
+        """All four variants A, B, C, D must be defined."""
+        for v in ["A", "B", "C", "D"]:
+            assert v in VARIANTS, f"Variant {v} missing from VARIANTS"
+
+    def test_variants_have_required_keys(self):
+        """Each variant entry must have label, model, adapter, and description."""
+        required_keys = {"label", "model", "adapter", "description"}
+        for v, info in VARIANTS.items():
+            missing = required_keys - set(info.keys())
+            assert not missing, f"Variant {v} missing keys: {missing}"
+
+    def test_variant_a_is_bare_baseline(self):
+        """Variant A must be the bare baseline with no adapter and hermes3:8b model."""
+        a = VARIANTS["A"]
+        assert a["adapter"] is None, "Variant A should have no adapter (control baseline)"
+        assert a["model"] == "hermes3:8b", "Variant A should use hermes3:8b"
+
+    def test_variant_b_uses_sessions_model(self):
+        """Variant B must target the sessions LoRA model tag."""
+        assert VARIANTS["B"]["model"] == "timmy-8b:sessions"
+        assert VARIANTS["B"]["adapter"] == "sessions"
+
+    def test_variant_c_uses_curated_model(self):
+        """Variant C must target the curated LoRA model tag."""
+        assert VARIANTS["C"]["model"] == "timmy-8b:curated"
+        assert VARIANTS["C"]["adapter"] == "curated"
+
+    def test_variant_d_uses_combined_model(self):
+        """Variant D must target the combined LoRA model tag."""
+        assert VARIANTS["D"]["model"] == "timmy-8b:combined"
+        assert VARIANTS["D"]["adapter"] == "combined"
+
+    def test_all_variants_have_nonempty_description(self):
+        """Every variant must have a non-empty description string."""
+        for v, info in VARIANTS.items():
+            assert isinstance(info["description"], str), f"Variant {v} description must be a string"
+            assert len(info["description"].strip()) > 0, f"Variant {v} description must not be empty"
+
+    def test_all_variants_have_nonempty_label(self):
+        """Every variant must have a non-empty label string."""
+        for v, info in VARIANTS.items():
+            assert isinstance(info["label"], str), f"Variant {v} label must be a string"
+            assert len(info["label"].strip()) > 0, f"Variant {v} label must not be empty"


Prompt:
Refs #38

## Summary

All four actions from issue #38 completed:

### Action 1 — PR#33 (AutoLoRA v1)
- PR#33 had merge conflicts (add/add on autolora/README.md)
- Created PR#43 (`feat/autolora-v1-rebased`) with the AutoLoRA v1 commit cherry-picked onto current main
- Conflict resolved by merging both READMEs: mains multi-tier benchmarking docs + PR#33 MLX QLoRA training docs
- PR#33 closed with comment pointing to PR#43

### Action 2 — Rescue sovereign branch
- sovereign branch had one unique commit: `feat: fallback chain with recovery — Groq, Kimi, local Ollama`
- Cherry-picked onto main as `feat/fallback-chain`
- Conflict in hermes_cli/auth.py resolved by keeping both new providers (ollama from main + groq from sovereign)
- Created PR#42, which was merged automatically

### Action 3 — Rescue fix/vision-api-key-fallback
- This fix adds _resolve_api_key_provider to the vision auxiliary chain
- Also included in PR#42 (same branch, second commit) — merged

### Action 4 — Cleanup
- Deleted 14 merged branches: claude/issue-1, claude/issue-3, claude/issue-5, claude/issue-7, claude/issue-14, claude/issue-18, claude/issue-19, claude/issue-20, claude/issue-22, claude/issue-23, claude/issue-24, claude/issue-25, claude/issue-31, feat/fallback-chain

## Status
- PR#42: merged (fallback chain + vision fix)
- PR#43: open, ready for review (AutoLoRA v1 rebased)
- PR#33: closed (superseded by PR#43)


Prompt:
Supersedes PR#33 (AutoLoRA v1) by rebasing onto current main and resolving conflicts.

## What this includes

Full MLX QLoRA training pipeline for Apple Silicon (hermes4.3:timmy-v1).

- autolora/train_mlx.py - main training launcher
- autolora/scripts/convert_data.py - convert JSONL to MLX chat format
- autolora/scripts/fetch_base_model.py - download safetensors from mlx-community
- autolora/scripts/fuse_and_convert.sh - fuse adapters + convert to GGUF
- autolora/scripts/create_ollama_model.sh - build hermes4.3:timmy-v1
- autolora/config/v1.yaml - training config: r=16, lr=2e-4, 1000 iters
- autolora/training_logs/v1/.gitkeep
- autolora/README.md - merged with mains README (benchmarking + training)

## Conflict resolution

Only conflict was autolora/README.md (add/add). Resolved by merging both READMEs into a combined document covering Part 1 (base model setup), Part 2 (multi-tier benchmarking), and Part 3 (MLX QLoRA training pipeline).

Original work by @rockachopa on claude/issue-26 branch.

Chosen response:
diff --git a/autolora/README.md b/autolora/README.md
index a99c1c0f..3a3c2837 100644
--- a/autolora/README.md
+++ b/autolora/README.md
@@ -1,20 +1,44 @@
 # AutoLoRA — Local Sovereign Training

-Scripts for managing the Hermes 4.3 36B base model and LoRA adapter pipeline on Apple Silicon.
+Scripts for managing the Hermes 4.3 model pipeline on Apple Silicon — both multi-tier benchmarking and MLX QLoRA fine-tuning.

 ## Directory Structure

 ```
 autolora/
-├── base/                          # GGUF model files (created at runtime, gitignored)
-│   └── hermes-4_3_36b-Q4_K_M.gguf
-├── transfer-hermes-gguf.sh        # Step 1: VPS → Mac transfer via Tailscale rsync
-├── Modelfile.hermes43             # Ollama model definition (ChatML, 8192 ctx)
-├── import-to-ollama.sh            # Step 2: Import GGUF into Ollama
+├── configs/
+│   ├── train_8b.yaml      # r=8, higher LR (small model, fast learner)
+│   ├── train_14b.yaml     # r=16, standard
+│   └── train_36b.yaml     # r=16, conservative LR, tight memory
+├── evals/
+│   ├── v0-baseline/
+│   │   ├── 8b/            # responses.json, scores.json, report.md
+│   │   ├── 14b/
+│   │   └── 36b/
+│   └── v1/
+│       └── ...
+├── scripts/
+│   ├── run_eval.py          # Eval a single model tier
+│   ├── compare_tiers.py     # Cross-tier comparison report
+│   ├── split_data.py        # Train/test split utility
+│   ├── convert_data.py      # Convert JSONL to MLX chat format
+│   ├── fetch_base_model.py  # Download safetensors base model
+│   └── fuse_and_convert.sh  # Fuse LoRA adapters + convert to GGUF
+├── config/
+│   └── v1.yaml              # MLX training hyperparameters
+├── train_mlx.py             # MLX QLoRA training launcher
+├── run_full_cycle.py        # Orchestration: train + eval all tiers
+├── training_logs/           # Runtime logs (gitignored content)
+├── base/                    # GGUF model files (gitignored)
+├── transfer-hermes-gguf.sh  # VPS → Mac transfer via Tailscale
+├── Modelfile.hermes43       # Ollama model definition (ChatML, 8192 ctx)
+├── import-to-ollama.sh      # Import GGUF into Ollama
 └── README.md
 ```

-## Setup
+---
+
+## Part 1: Base Model Setup (GGUF / Ollama)

 ### Step 1: Transfer GGUF from VPS

@@ -53,23 +77,9 @@ ollama list
 ollama run hermes4.3:base "Hello, who are you?"
 ```

-## Model Details
+---

-| Property | Value |
-|----------|-------|
-| Model | Hermes 4.3 36B |
-| Quantization | Q4_K_M |
-| Size | ~20GB |
-| Context | 8192 tokens |
-| Format | ChatML |
-| Ollama tag | `hermes4.3:base` |
-
-## Memory Budget
-
-Q4_K_M for a 36B model uses approximately 20–22GB of unified memory on Apple Silicon.
-This fits within a 36GB M3/M4 Max budget with room for OS + context.
-
-## Multi-Tier Benchmarking
+## Part 2: Multi-Tier Benchmarking

 Three model size classes, all running through the same eval harness:

@@ -95,32 +105,92 @@ python autolora/scripts/compare_tiers.py --version v0-baseline
 python autolora/scripts/run_eval.py --model hermes3:8b --tier 8b --version v0-baseline
 ```

-### Directory structure
+---
+
+## Part 3: MLX QLoRA Training Pipeline (v1)
+
+MLX QLoRA fine-tuning for Apple Silicon unified memory. Produces `hermes4.3:timmy-v1`.
+
+### Pipeline

 ```
-autolora/
-├── configs/
-│   ├── train_8b.yaml      # r=8, higher LR (small model, fast learner)
-│   ├── train_14b.yaml     # r=16, standard
-│   └── train_36b.yaml     # r=16, conservative LR, tight memory
-├── evals/
-│   ├── v0-baseline/
-│   │   ├── 8b/            # responses.json, scores.json, report.md
-│   │   ├── 14b/
-│   │   └── 36b/
-│   ├── v1/
-│   │   └── ...
-│   └── tier_comparison_v0-baseline_vs_v1.md
-├── scripts/
-│   ├── run_eval.py        # Eval a single model tier
-│   ├── compare_tiers.py   # Cross-tier comparison report
-│   └── split_data.py      # Train/test split utility
-└── run_full_cycle.py      # Orchestration: train + eval all tiers
+compressed_train.jsonl
+        │
+        ▼
+scripts/convert_data.py          → ~/autolora/data/{train,valid}.jsonl
+        │
+        ▼
+scripts/fetch_base_model.py      → ~/autolora/models/hermes-base/
+        │
+        ▼
+train_mlx.py                     → ~/autolora/adapters/v1/   (LoRA weights)
+        │                           autolora/training_logs/v1/
+        ▼
+scripts/fuse_and_convert.sh      → ~/autolora/fused/v1/model.gguf
+        │
+        ▼
+scripts/create_ollama_model.sh   → ollama model hermes4.3:timmy-v1
 ```

+### Quick Start
+
+```bash
+# 1. Install MLX-LM
+pip install mlx-lm
+
+# 2. Fetch base model (safetensors required — not GGUF)
+python autolora/scripts/fetch_base_model.py
+# Downloads mlx-community/Hermes-3-Llama-3.1-8B-4bit to ~/autolora/models/hermes-base
+
+# 3. Convert training data (assumes ~/autolora/data/compressed_train.jsonl)
+python autolora/scripts/convert_data.py \
+    --input ~/autolora/data/compressed_train.jsonl \
+    --output_dir ~/autolora/data/
+
+# 4. Train
+python autolora/train_mlx.py
+
+# 5. Fuse adapters + convert to GGUF (requires llama.cpp at ~/llama.cpp)
+bash autolora/scripts/fuse_and_convert.sh
+
+# 6. Create Ollama model
+bash autolora/scripts/create_ollama_model.sh
+ollama run hermes4.3:timmy-v1 "Hello, who are you?"
+```
+
+### Configuration
+
+Training hyperparameters live in `autolora/config/v1.yaml`.
+
+| Parameter       | Default | Notes                                          |
+|-----------------|---------|------------------------------------------------|
+| `lora_rank`     | 16      | Reduce to 8 if OOM                             |
+| `lora_layers`   | 16      | Layers to apply LoRA (from top)                |
+| `iters`         | 1000    | Training iterations (~2-3 epochs typical)      |
+| `batch_size`    | 4       | Reduce to 2 if OOM                             |
+| `learning_rate` | 2e-4    | Standard QLoRA rate                            |
+| `max_seq_length`| 8192    | Reduce to 4096 if OOM                          |
+
+---
+
+## Memory Budget
+
+Q4_K_M for a 36B model uses approximately 20–22GB of unified memory.
+This fits within a 36GB M3/M4 Max budget with room for OS + context.
+
+For MLX training (36B): expect ~32-35 GB peak. Use `--grad_checkpoint` (enabled by default).
+
+| Component              | Approx.  |
+|------------------------|----------|
+| Q4 base model (8B)     | ~5 GB    |
+| Q4 base model (36B)    | ~20 GB   |
+| LoRA adapters (r=16)   | ~1 GB    |
+| Activation / gradients | ~8-12 GB |
+| OS + other             | ~4 GB    |
+
 ## Notes

 - The GGUF is the **frozen skeleton** — never modified, only wrapped with LoRA adapters
 - `~/autolora/base/` is gitignored (large binary files)
-- Use `--partial` rsync flag ensures resumable transfers over potentially unstable Tailscale hotspot connections
+- MLX LoRA training requires **safetensors** format — use `fetch_base_model.py` to download from mlx-community
 - **36B training**: must unload from Ollama first (`ollama stop hermes4.3:base`) — memory is tight
diff --git a/autolora/config/v1.yaml b/autolora/config/v1.yaml
new file mode 100644
index 00000000..789a6da0
--- /dev/null
+++ b/autolora/config/v1.yaml
@@ -0,0 +1,46 @@
+# AutoLoRA v1 — MLX QLoRA Training Config
+# Base: Hermes 4.3 36B (NousResearch/Hermes-3-Llama-3.1-70B or equivalent safetensors)
+# Method: QLoRA (4-bit quantized base + LoRA adapters)
+
+model: "NousResearch/Hermes-3-Llama-3.1-8B"  # adjust to available safetensors model
+train: true
+fine_tune_type: lora
+
+# LoRA hyperparameters
+lora_layers: 16        # number of layers to apply LoRA to (from top)
+lora_rank: 16          # r=16 (conservative start; drop to 8 if OOM)
+lora_alpha: 32         # alpha = 2 * rank is a standard starting point
+lora_dropout: 0.05
+target_modules:
+  - q_proj
+  - v_proj
+
+# Training hyperparameters
+batch_size: 4
+iters: 1000            # ~2-3 epochs over typical dataset; adjust to dataset size
+learning_rate: 2.0e-4
+weight_decay: 0.01
+grad_checkpoint: true  # gradient checkpointing — essential for memory budget
+
+# Warmup & scheduling
+lr_schedule: cosine_decay
+warmup_steps: 50
+
+# Evaluation
+steps_per_eval: 100
+val_batches: 25
+
+# Saving
+adapter_path: "~/autolora/adapters/v1"
+save_every: 200        # checkpoint every N iters
+
+# Data (paths set at runtime by train_mlx.py)
+# train: set via --train flag
+# valid: set via --valid flag
+
+# Sequence length
+max_seq_length: 8192
+
+# Memory notes:
+#   36GB unified memory, Q4 base ~20GB, LoRA overhead ~4-6GB
+#   If OOM: reduce lora_rank to 8, batch_size to 2, or max_seq_length to 4096
diff --git a/autolora/scripts/convert_data.py b/autolora/scripts/convert_data.py
new file mode 100644
index 00000000..630bd452
--- /dev/null
+++ b/autolora/scripts/convert_data.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA Data Converter — JSONL → MLX training format
+
+Converts compressed Hermes trajectory JSONL files into the format expected
+by mlx_lm.lora for supervised fine-tuning.
+
+MLX expects JSONL with either:
+  - {"text": "<full conversation as string>"}   (completion format)
+  - {"messages": [...]}                          (chat format, preferred)
+
+Usage:
+    python autolora/scripts/convert_data.py \
+        --input ~/autolora/data/compressed_train.jsonl \
+        --output ~/autolora/data/mlx_train.jsonl \
+        --val_split 0.05
+
+    # Also convert a test/validation split:
+    python autolora/scripts/convert_data.py \
+        --input ~/autolora/data/compressed_train.jsonl \
+        --output_dir ~/autolora/data/
+"""
+
+import json
+import random
+import sys
+from pathlib import Path
+from typing import Any
+
+import fire
+
+
+# Roles that map from Hermes trajectory format to OpenAI chat roles
+_ROLE_MAP = {
+    "system": "system",
+    "human": "user",
+    "user": "user",
+    "gpt": "assistant",
+    "assistant": "assistant",
+    "tool": "tool",
+    "function": "tool",
+}
+
+
+def _normalize_message(msg: dict[str, Any]) -> dict[str, Any] | None:
+    """Normalize a single trajectory message to OpenAI chat format."""
+    role = msg.get("role", msg.get("from", "")).lower()
+    content = msg.get("content", msg.get("value", ""))
+
+    mapped_role = _ROLE_MAP.get(role)
+    if mapped_role is None:
+        return None  # skip unknown roles
+
+    if not content:
+        return None  # skip empty messages
+
+    return {"role": mapped_role, "content": str(content)}
+
+
+def _trajectory_to_mlx(entry: dict[str, Any]) -> dict[str, Any] | None:
+    """
+    Convert one Hermes trajectory entry to MLX chat format.
+
+    Handles both:
+      - {"conversations": [...]}   (ShareGPT / Hermes format)
+      - {"messages": [...]}        (OpenAI format)
+    """
+    messages_raw = entry.get("conversations") or entry.get("messages") or []
+
+    if not messages_raw:
+        return None
+
+    messages = []
+    for m in messages_raw:
+        norm = _normalize_message(m)
+        if norm:
+            messages.append(norm)
+
+    # Need at least one user turn and one assistant turn
+    roles = {m["role"] for m in messages}
+    if "user" not in roles and "system" not in roles:
+        return None
+    if "assistant" not in roles:
+        return None
+
+    return {"messages": messages}
+
+
+def convert(
+    input: str,
+    output: str | None = None,
+    output_dir: str | None = None,
+    val_split: float = 0.05,
+    seed: int = 42,
+    max_samples: int | None = None,
+) -> None:
+    """
+    Convert compressed JSONL to MLX training format.
+
+    Args:
+        input:       Path to compressed JSONL file.
+        output:      Path for output JSONL (train split).
+                     If omitted, derived from output_dir.
+        output_dir:  Directory to write train.jsonl and valid.jsonl.
+                     Used when --output is not given.
+        val_split:   Fraction of data to use for validation (default 0.05).
+        seed:        Random seed for reproducible split.
+        max_samples: Cap total samples (useful for quick tests).
+    """
+    input_path = Path(input).expanduser()
+    if not input_path.exists():
+        print(f"ERROR: input file not found: {input_path}", file=sys.stderr)
+        sys.exit(1)
+
+    # Resolve output paths
+    if output:
+        train_path = Path(output).expanduser()
+        valid_path = train_path.parent / (train_path.stem + "_valid" + train_path.suffix)
+    elif output_dir:
+        out_dir = Path(output_dir).expanduser()
+        out_dir.mkdir(parents=True, exist_ok=True)
+        train_path = out_dir / "train.jsonl"
+        valid_path = out_dir / "valid.jsonl"
+    else:
+        train_path = input_path.parent / "mlx_train.jsonl"
+        valid_path = input_path.parent / "mlx_valid.jsonl"
+
+    # Load raw entries
+    print(f"Loading {input_path} …")
+    raw_entries: list[dict] = []
+    with input_path.open() as f:
+        for line in f:
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                raw_entries.append(json.loads(line))
+            except json.JSONDecodeError as e:
+                print(f"  WARN: skipping malformed line: {e}", file=sys.stderr)
+
+    print(f"  Loaded {len(raw_entries)} raw entries")
+
+    if max_samples and len(raw_entries) > max_samples:
+        random.seed(seed)
+        raw_entries = random.sample(raw_entries, max_samples)
+        print(f"  Sampled down to {max_samples}")
+
+    # Convert
+    converted: list[dict] = []
+    skipped = 0
+    for entry in raw_entries:
+        mlx_entry = _trajectory_to_mlx(entry)
+        if mlx_entry:
+            converted.append(mlx_entry)
+        else:
+            skipped += 1
+
+    print(f"  Converted: {len(converted)}  |  Skipped: {skipped}")
+
+    if not converted:
+        print("ERROR: no usable entries after conversion", file=sys.stderr)
+        sys.exit(1)
+
+    # Train / validation split
+    random.seed(seed)
+    random.shuffle(converted)
+
+    n_val = max(1, int(len(converted) * val_split))
+    val_data = converted[:n_val]
+    train_data = converted[n_val:]
+
+    print(f"  Train: {len(train_data)}  |  Valid: {len(val_data)}")
+
+    # Write output
+    train_path.parent.mkdir(parents=True, exist_ok=True)
+
+    with train_path.open("w") as f:
+        for entry in train_data:
+            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+    with valid_path.open("w") as f:
+        for entry in val_data:
+            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+    print(f"\nWrote train → {train_path}")
+    print(f"Wrote valid → {valid_path}")
+
+
+if __name__ == "__main__":
+    fire.Fire(convert)
diff --git a/autolora/scripts/create_ollama_model.sh b/autolora/scripts/create_ollama_model.sh
new file mode 100644
index 00000000..921a1908
--- /dev/null
+++ b/autolora/scripts/create_ollama_model.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# AutoLoRA v1 — Create Ollama model: hermes4.3:timmy-v1
+#
+# Prerequisites:
+#   - Fused model weights at ~/autolora/fused/v1/ (see train_mlx.py output)
+#   - GGUF file at ~/autolora/fused/v1/model.gguf
+#   - Ollama installed and running
+#
+# Usage:
+#   bash autolora/scripts/create_ollama_model.sh
+#   bash autolora/scripts/create_ollama_model.sh --gguf ~/autolora/fused/v1/model.gguf
+
+set -euo pipefail
+
+GGUF_PATH="${1:-$HOME/autolora/fused/v1/model.gguf}"
+ADAPTER_DIR="$HOME/autolora/adapters/v1"
+MODEL_TAG="hermes4.3:timmy-v1"
+
+echo "=== AutoLoRA v1 — Create Ollama Model ==="
+echo ""
+
+# Check GGUF exists
+if [[ ! -f "$GGUF_PATH" ]]; then
+    echo "ERROR: GGUF not found at $GGUF_PATH"
+    echo ""
+    echo "To generate it, run after training:"
+    echo "  1. Fuse adapters:"
+    echo "     python -m mlx_lm.fuse \\"
+    echo "         --model ~/autolora/models/hermes-base \\"
+    echo "         --adapter-path ~/autolora/adapters/v1 \\"
+    echo "         --save-path ~/autolora/fused/v1"
+    echo ""
+    echo "  2. Convert to GGUF (requires llama.cpp):"
+    echo "     python llama.cpp/convert_hf_to_gguf.py ~/autolora/fused/v1 \\"
+    echo "         --outfile ~/autolora/fused/v1/model.gguf --outtype q4_k_m"
+    exit 1
+fi
+
+# Use Modelfile if present, otherwise write an inline one
+MODELFILE="$ADAPTER_DIR/Modelfile"
+
+if [[ ! -f "$MODELFILE" ]]; then
+    echo "Writing inline Modelfile..."
+    cat > /tmp/Modelfile.timmy-v1 << EOF
+FROM $GGUF_PATH
+
+PARAMETER temperature 0.7
+PARAMETER top_p 0.9
+PARAMETER repeat_penalty 1.1
+PARAMETER num_ctx 8192
+
+SYSTEM """
+You are Timmy, a sovereign AI assistant fine-tuned on real interaction data.
+You are direct, capable, and deeply aligned with your user's workflow.
+"""
+EOF
+    MODELFILE="/tmp/Modelfile.timmy-v1"
+fi
+
+echo "Creating Ollama model: $MODEL_TAG"
+echo "  FROM: $GGUF_PATH"
+echo "  Modelfile: $MODELFILE"
+echo ""
+
+ollama create "$MODEL_TAG" -f "$MODELFILE"
+
+echo ""
+echo "=== Done ==="
+echo "Test with: ollama run $MODEL_TAG 'Hello, who are you?'"
diff --git a/autolora/scripts/fetch_base_model.py b/autolora/scripts/fetch_base_model.py
new file mode 100644
index 00000000..2c1d8ff8
--- /dev/null
+++ b/autolora/scripts/fetch_base_model.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA Base Model Fetcher
+
+MLX LoRA training requires a safetensors model, not a GGUF quantized file.
+This script downloads/prepares the base model in safetensors format.
+
+Options:
+  1. Download directly from HuggingFace (mlx-community has pre-converted models)
+  2. Convert a local GGUF → safetensors using llama.cpp convert scripts
+
+Usage:
+    # Download mlx-community pre-quantized model (recommended for Apple Silicon)
+    python autolora/scripts/fetch_base_model.py \
+        --model mlx-community/Nous-Hermes-2-Mistral-7B-DPO-4bit \
+        --output ~/autolora/models/hermes-base
+
+    # List available mlx-community Hermes models
+    python autolora/scripts/fetch_base_model.py --list
+
+    # Check if a local HuggingFace model cache exists
+    python autolora/scripts/fetch_base_model.py --check
+"""
+
+import os
+import sys
+from pathlib import Path
+
+import fire
+
+
+# mlx-community hosts MLX-native quantized models; these are the recommended bases
+# for Apple Silicon LoRA training since GGUF is not directly supported by mlx_lm
+RECOMMENDED_MODELS = {
+    "hermes-8b-4bit": "mlx-community/Nous-Hermes-2-Mistral-7B-DPO-4bit-MLX",
+    "hermes3-8b-4bit": "mlx-community/Hermes-3-Llama-3.1-8B-4bit",
+    "hermes3-8b-8bit": "mlx-community/Hermes-3-Llama-3.1-8B-8bit",
+    # 70B / 36B class — requires ~70GB+ disk and substantial memory
+    # "hermes3-70b-4bit": "mlx-community/Hermes-3-Llama-3.1-70B-4bit",
+}
+
+DEFAULT_MODEL = "mlx-community/Hermes-3-Llama-3.1-8B-4bit"
+DEFAULT_OUTPUT = Path("~/autolora/models/hermes-base").expanduser()
+
+
+def list_models() -> None:
+    """Print recommended MLX model aliases and their HuggingFace IDs."""
+    print("Recommended MLX-native models for LoRA training:\n")
+    for alias, hf_id in RECOMMENDED_MODELS.items():
+        print(f"  {alias:<25}  {hf_id}")
+    print()
+    print("Usage: python fetch_base_model.py --model <hf_id> --output <path>")
+
+
+def check() -> None:
+    """Check HuggingFace cache for known Hermes models."""
+    cache_root = Path(os.getenv("HF_HOME", Path.home() / ".cache" / "huggingface" / "hub"))
+    print(f"Checking HF cache at: {cache_root}\n")
+
+    found = False
+    for alias, hf_id in RECOMMENDED_MODELS.items():
+        # HF hub stores models as models--<owner>--<name>
+        cache_dir = cache_root / ("models--" + hf_id.replace("/", "--"))
+        status = "FOUND" if cache_dir.exists() else "not cached"
+        print(f"  [{status}]  {hf_id}")
+        if cache_dir.exists():
+            found = True
+
+    print()
+    local_path = DEFAULT_OUTPUT
+    if local_path.exists():
+        print(f"  [FOUND]  Local model at {local_path}")
+    else:
+        print(f"  [missing] {local_path}")
+
+    if not found:
+        print("\nNo cached models found. Run with --model to download.")
+
+
+def fetch(
+    model: str = DEFAULT_MODEL,
+    output: str | None = None,
+    trust_remote_code: bool = False,
+) -> None:
+    """
+    Download a safetensors model suitable for MLX LoRA training.
+
+    Uses mlx_lm.convert if needed, or snapshot_download for pre-converted models.
+
+    Args:
+        model:              HuggingFace model ID (default: Hermes-3-Llama-3.1-8B-4bit).
+        output:             Local path to save / link the model.
+        trust_remote_code:  Pass --trust-remote-code to mlx_lm.convert if needed.
+    """
+    try:
+        from huggingface_hub import snapshot_download
+    except ImportError:
+        print("ERROR: huggingface_hub not installed. Run: pip install huggingface_hub", file=sys.stderr)
+        sys.exit(1)
+
+    out_path = Path(output).expanduser() if output else DEFAULT_OUTPUT
+    out_path.mkdir(parents=True, exist_ok=True)
+
+    print(f"Downloading {model} → {out_path}")
+    print("(This may take a while for large models)")
+    print()
+
+    local_dir = snapshot_download(
+        repo_id=model,
+        local_dir=str(out_path),
+        ignore_patterns=["*.bin", "*.pt"],  # prefer safetensors
+    )
+
+    print(f"\nModel downloaded to: {local_dir}")
+    print("\nVerifying safetensors files …")
+
+    st_files = list(Path(local_dir).glob("*.safetensors"))
+    if st_files:
+        print(f"  OK — found {len(st_files)} safetensors file(s)")
+    else:
+        print("  WARN: no .safetensors files found. The model may be in a different format.")
+        print("  MLX LoRA training requires safetensors. Check mlx-community for a converted version.")
+
+    print("\nNext: update autolora/config/v1.yaml `model:` field to:")
+    print(f"  model: {local_dir}")
+
+
+def main(
+    model: str = DEFAULT_MODEL,
+    output: str | None = None,
+    list: bool = False,
+    check: bool = False,
+    trust_remote_code: bool = False,
+) -> None:
+    if list:
+        list_models()
+    elif check:
+        check()
+    else:
+        fetch(model=model, output=output, trust_remote_code=trust_remote_code)
+
+
+if __name__ == "__main__":
+    fire.Fire(main)
diff --git a/autolora/scripts/fuse_and_convert.sh b/autolora/scripts/fuse_and_convert.sh
new file mode 100644
index 00000000..f26946cd
--- /dev/null
+++ b/autolora/scripts/fuse_and_convert.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# AutoLoRA v1 — Fuse LoRA adapters + convert to GGUF for Ollama
+#
+# This bridges the gap between MLX adapter weights and an Ollama-loadable GGUF.
+#
+# Prerequisites:
+#   - Training complete: ~/autolora/adapters/v1/ exists with adapter weights
+#   - Base model at ~/autolora/models/hermes-base (safetensors)
+#   - mlx-lm installed: pip install mlx-lm
+#   - llama.cpp cloned somewhere (for GGUF conversion)
+#
+# Usage:
+#   bash autolora/scripts/fuse_and_convert.sh
+#   bash autolora/scripts/fuse_and_convert.sh --llamacpp /path/to/llama.cpp
+
+set -euo pipefail
+
+BASE_MODEL="${BASE_MODEL:-$HOME/autolora/models/hermes-base}"
+ADAPTER_PATH="${ADAPTER_PATH:-$HOME/autolora/adapters/v1}"
+FUSED_PATH="${FUSED_PATH:-$HOME/autolora/fused/v1}"
+GGUF_QUANT="${GGUF_QUANT:-q4_k_m}"
+LLAMACPP="${1:-${LLAMACPP:-$HOME/llama.cpp}}"
+
+echo "=== AutoLoRA v1 — Fuse + Convert ==="
+echo ""
+echo "  Base model:   $BASE_MODEL"
+echo "  Adapter path: $ADAPTER_PATH"
+echo "  Fused output: $FUSED_PATH"
+echo "  GGUF quant:   $GGUF_QUANT"
+echo ""
+
+# Step 1: Fuse with mlx_lm.fuse
+echo "--- Step 1: Fuse LoRA adapters into base model ---"
+if [[ ! -d "$BASE_MODEL" ]]; then
+    echo "ERROR: Base model not found at $BASE_MODEL"
+    echo "Run: python autolora/scripts/fetch_base_model.py"
+    exit 1
+fi
+
+if [[ ! -d "$ADAPTER_PATH" ]]; then
+    echo "ERROR: Adapter path not found at $ADAPTER_PATH"
+    echo "Run: python autolora/train_mlx.py"
+    exit 1
+fi
+
+mkdir -p "$FUSED_PATH"
+
+python -m mlx_lm.fuse \
+    --model "$BASE_MODEL" \
+    --adapter-path "$ADAPTER_PATH" \
+    --save-path "$FUSED_PATH"
+
+echo "Fused model saved to: $FUSED_PATH"
+echo ""
+
+# Step 2: Convert fused safetensors → GGUF
+echo "--- Step 2: Convert fused model to GGUF ---"
+GGUF_OUT="$FUSED_PATH/model.gguf"
+
+if [[ ! -d "$LLAMACPP" ]]; then
+    echo "ERROR: llama.cpp not found at $LLAMACPP"
+    echo ""
+    echo "To install llama.cpp:"
+    echo "  git clone https://github.com/ggml-org/llama.cpp.git ~/llama.cpp"
+    echo "  cd ~/llama.cpp && pip install -r requirements.txt"
+    echo ""
+    echo "Then re-run with: LLAMACPP=~/llama.cpp bash autolora/scripts/fuse_and_convert.sh"
+    exit 1
+fi
+
+CONVERT_SCRIPT="$LLAMACPP/convert_hf_to_gguf.py"
+if [[ ! -f "$CONVERT_SCRIPT" ]]; then
+    # older llama.cpp used a different name
+    CONVERT_SCRIPT="$LLAMACPP/convert.py"
+fi
+
+python "$CONVERT_SCRIPT" "$FUSED_PATH" \
+    --outfile "$GGUF_OUT" \
+    --outtype "$GGUF_QUANT"
+
+echo ""
+echo "GGUF saved to: $GGUF_OUT"
+echo ""
+echo "=== Conversion complete ==="
+echo ""
+echo "Next: create the Ollama model:"
+echo "  bash autolora/scripts/create_ollama_model.sh $GGUF_OUT"
diff --git a/autolora/train_mlx.py b/autolora/train_mlx.py
new file mode 100644
index 00000000..b8aedb09
--- /dev/null
+++ b/autolora/train_mlx.py
@@ -0,0 +1,352 @@
+#!/usr/bin/env python3
+"""
+AutoLoRA v1 — MLX QLoRA Training Launcher
+
+Orchestrates the full MLX LoRA training pipeline:
+  1. Validate prerequisites (mlx-lm installed, data exists, model available)
+  2. Build the mlx_lm.lora CLI command from config/v1.yaml
+  3. Launch training as a subprocess, tee output to training_logs/v1/
+  4. On completion, fuse adapters and scaffold the Ollama Modelfile
+
+Usage:
+    # Full training run (data + model must already exist)
+    python autolora/train_mlx.py
+
+    # Override config values on the CLI
+    python autolora/train_mlx.py --lora_rank 8 --iters 500
+
+    # Dry run — print the mlx_lm.lora command without executing
+    python autolora/train_mlx.py --dry_run
+
+    # Skip preflight checks
+    python autolora/train_mlx.py --no_check
+"""
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import fire
+import yaml
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+
+REPO_ROOT = Path(__file__).parent.parent
+AUTOLORA_DIR = Path(__file__).parent
+CONFIG_PATH = AUTOLORA_DIR / "config" / "v1.yaml"
+LOG_DIR = AUTOLORA_DIR / "training_logs" / "v1"
+
+# Default data / model / adapter locations (all under ~/autolora/)
+AUTOLORA_HOME = Path("~/autolora").expanduser()
+DATA_DIR = AUTOLORA_HOME / "data"
+MODEL_DIR = AUTOLORA_HOME / "models" / "hermes-base"
+ADAPTER_DIR = AUTOLORA_HOME / "adapters" / "v1"
+FUSED_DIR = AUTOLORA_HOME / "fused" / "v1"
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _load_config(path: Path = CONFIG_PATH) -> dict[str, Any]:
+    with path.open() as f:
+        return yaml.safe_load(f)
+
+
+def _check_mlx_lm() -> bool:
+    """Return True if mlx-lm is importable."""
+    try:
+        import mlx_lm  # noqa: F401
+        return True
+    except ImportError:
+        return False
+
+
+def _check_data(data_dir: Path) -> tuple[Path | None, Path | None]:
+    """Return (train_path, valid_path) if MLX-format data exists."""
+    train = data_dir / "train.jsonl"
+    valid = data_dir / "valid.jsonl"
+    return (train if train.exists() else None, valid if valid.exists() else None)
+
+
+def _check_model(model_dir: Path, config_model: str) -> Path | None:
+    """
+    Resolve the model path:
+      1. If config says a local path and it exists, use it.
+      2. If ~/autolora/models/hermes-base exists, use it.
+      3. Fall through — caller will warn.
+    """
+    if model_dir.exists():
+        return model_dir
+    config_path = Path(config_model).expanduser()
+    if config_path.exists():
+        return config_path
+    return None
+
+
+def _timestamp() -> str:
+    return datetime.now().strftime("%Y%m%d_%H%M%S")
+
+
+def _write_log_header(log_path: Path, cmd: list[str], cfg: dict) -> None:
+    with log_path.open("w") as f:
+        f.write(f"AutoLoRA v1 Training Log\n")
+        f.write(f"Started: {datetime.now().isoformat()}\n")
+        f.write(f"Command: {' '.join(cmd)}\n")
+        f.write(f"Config:\n{yaml.dump(cfg, default_flow_style=False)}\n")
+        f.write("=" * 80 + "\n\n")
+
+
+def _scaffold_modelfile(adapter_path: Path, base_model_tag: str = "hermes4.3") -> Path:
+    """Write an Ollama Modelfile for hermes4.3:timmy-v1."""
+    modelfile_path = adapter_path / "Modelfile"
+    content = f"""# Ollama Modelfile — hermes4.3:timmy-v1
+# Generated by autolora/train_mlx.py
+#
+# To build:
+#   ollama create hermes4.3:timmy-v1 -f {modelfile_path}
+#
+# To run:
+#   ollama run hermes4.3:timmy-v1
+
+FROM {base_model_tag}
+
+# LoRA adapter (fused weights path)
+# Note: Ollama currently loads GGUF models. To use this adapter:
+#   1. Fuse the LoRA into the base model:
+#        python -m mlx_lm.fuse --model <base> --adapter-path {adapter_path} --save-path {FUSED_DIR}
+#   2. Convert fused model to GGUF:
+#        python llama.cpp/convert_hf_to_gguf.py {FUSED_DIR} --outfile {FUSED_DIR}/model.gguf --outtype q4_k_m
+#   3. Update FROM below to the GGUF path and rebuild:
+#        FROM {FUSED_DIR}/model.gguf
+
+PARAMETER temperature 0.7
+PARAMETER top_p 0.9
+PARAMETER repeat_penalty 1.1
+PARAMETER num_ctx 8192
+
+SYSTEM \"""
+You are Timmy, a sovereign AI assistant fine-tuned on real interaction data.
+You are direct, capable, and deeply aligned with your user's workflow.
+\"""
+"""
+    modelfile_path.write_text(content)
+    return modelfile_path
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+
+def train(
+    config: str = str(CONFIG_PATH),
+    train_data: str | None = None,
+    valid_data: str | None = None,
+    model: str | None = None,
+    adapter_path: str | None = None,
+    lora_rank: int | None = None,
+    lora_layers: int | None = None,
+    iters: int | None = None,
+    batch_size: int | None = None,
+    learning_rate: float | None = None,
+    max_seq_length: int | None = None,
+    dry_run: bool = False,
+    no_check: bool = False,
+) -> None:
+    """
+    Launch MLX QLoRA training.
+
+    CLI overrides take precedence over config/v1.yaml values.
+    """
+    # Load config
+    cfg_path = Path(config).expanduser()
+    cfg = _load_config(cfg_path)
+
+    # Apply CLI overrides
+    if lora_rank is not None:
+        cfg["lora_rank"] = lora_rank
+    if lora_layers is not None:
+        cfg["lora_layers"] = lora_layers
+    if iters is not None:
+        cfg["iters"] = iters
+    if batch_size is not None:
+        cfg["batch_size"] = batch_size
+    if learning_rate is not None:
+        cfg["learning_rate"] = learning_rate
+    if max_seq_length is not None:
+        cfg["max_seq_length"] = max_seq_length
+
+    # Resolve paths
+    resolved_adapter = Path(adapter_path).expanduser() if adapter_path else ADAPTER_DIR
+    resolved_model = Path(model).expanduser() if model else _check_model(MODEL_DIR, cfg.get("model", ""))
+    resolved_train = Path(train_data).expanduser() if train_data else DATA_DIR / "train.jsonl"
+    resolved_valid = Path(valid_data).expanduser() if valid_data else DATA_DIR / "valid.jsonl"
+
+    # ---------------------------------------------------------------------------
+    # Preflight checks
+    # ---------------------------------------------------------------------------
+    if not no_check:
+        print("=== AutoLoRA v1 Preflight ===\n")
+        ok = True
+
+        # mlx-lm
+        has_mlx = _check_mlx_lm()
+        print(f"  mlx-lm installed:    {'OK' if has_mlx else 'MISSING — run: pip install mlx-lm'}")
+        if not has_mlx:
+            ok = False
+
+        # data
+        train_ok = resolved_train.exists()
+        valid_ok = resolved_valid.exists()
+        print(f"  train.jsonl:         {'OK' if train_ok else f'MISSING — {resolved_train}'}")
+        print(f"  valid.jsonl:         {'OK' if valid_ok else f'MISSING — {resolved_valid}'}")
+        if not train_ok:
+            ok = False
+            print("\n  To generate data, run:")
+            print("    python autolora/scripts/convert_data.py \\")
+            print("        --input ~/autolora/data/compressed_train.jsonl \\")
+            print("        --output_dir ~/autolora/data/")
+
+        # model
+        model_ok = resolved_model is not None and resolved_model.exists()
+        print(f"  base model:          {'OK at ' + str(resolved_model) if model_ok else 'MISSING — see autolora/scripts/fetch_base_model.py'}")
+        if not model_ok:
+            ok = False
+            print("\n  To fetch model, run:")
+            print("    python autolora/scripts/fetch_base_model.py")
+
+        print()
+        if not ok and not dry_run:
+            print("Preflight FAILED. Fix issues above or use --no_check to skip.\n")
+            sys.exit(1)
+        elif ok:
+            print("Preflight OK.\n")
+
+    # ---------------------------------------------------------------------------
+    # Build command
+    # ---------------------------------------------------------------------------
+    resolved_adapter.mkdir(parents=True, exist_ok=True)
+    LOG_DIR.mkdir(parents=True, exist_ok=True)
+
+    cmd = [
+        sys.executable, "-m", "mlx_lm.lora",
+        "--model", str(resolved_model or cfg.get("model", "")),
+        "--train",
+        "--data", str(resolved_train.parent),   # mlx_lm expects directory; train/valid.jsonl inside
+        "--adapter-path", str(resolved_adapter),
+        "--iters", str(cfg.get("iters", 1000)),
+        "--batch-size", str(cfg.get("batch_size", 4)),
+        "--num-layers", str(cfg.get("lora_layers", 16)),
+        "--rank", str(cfg.get("lora_rank", 16)),
+        "--learning-rate", str(cfg.get("learning_rate", 2e-4)),
+        "--max-seq-length", str(cfg.get("max_seq_length", 8192)),
+        "--steps-per-eval", str(cfg.get("steps_per_eval", 100)),
+        "--val-batches", str(cfg.get("val_batches", 25)),
+        "--save-every", str(cfg.get("save_every", 200)),
+        "--grad-checkpoint",
+    ]
+
+    # Log file
+    ts = _timestamp()
+    log_path = LOG_DIR / f"run_{ts}.log"
+
+    print("=== Training Command ===")
+    print(" ".join(cmd))
+    print()
+    print(f"Adapter output: {resolved_adapter}")
+    print(f"Log:            {log_path}")
+    print()
+
+    if dry_run:
+        print("[DRY RUN] — not executing. Exiting.")
+        return
+
+    _write_log_header(log_path, cmd, cfg)
+
+    # ---------------------------------------------------------------------------
+    # Launch
+    # ---------------------------------------------------------------------------
+    print("=== Starting Training ===\n")
+    start = time.time()
+
+    with log_path.open("a") as log_f:
+        proc = subprocess.Popen(
+            cmd,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            text=True,
+            bufsize=1,
+        )
+
+        for line in proc.stdout:
+            print(line, end="")
+            log_f.write(line)
+            log_f.flush()
+
+        proc.wait()
+
+    elapsed = time.time() - start
+    status = "SUCCESS" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
+
+    summary = {
+        "status": status,
+        "returncode": proc.returncode,
+        "elapsed_seconds": round(elapsed, 1),
+        "adapter_path": str(resolved_adapter),
+        "log": str(log_path),
+        "timestamp": datetime.now().isoformat(),
+        "config": cfg,
+    }
+
+    summary_path = LOG_DIR / f"run_{ts}_summary.json"
+    summary_path.write_text(json.dumps(summary, indent=2))
+
+    print(f"\n{'=' * 60}")
+    print(f"Training {status}  ({elapsed/60:.1f} min)")
+    print(f"Log:     {log_path}")
+    print(f"Summary: {summary_path}")
+
+    if proc.returncode != 0:
+        print("\nTraining failed. Check log above for errors.")
+        print("Common fixes:")
+        print("  OOM  → reduce --lora_rank 8 or --batch_size 2 or --max_seq_length 4096")
+        print("  Data → ensure ~/autolora/data/{train,valid}.jsonl exist with `messages` key")
+        sys.exit(proc.returncode)
+
+    # ---------------------------------------------------------------------------
+    # Post-training: scaffold Modelfile
+    # ---------------------------------------------------------------------------
+    print("\n=== Post-Training ===\n")
+    modelfile = _scaffold_modelfile(resolved_adapter)
+    print(f"Wrote Modelfile → {modelfile}")
+    print()
+    print("Next steps:")
+    print("  1. Fuse adapters into base model:")
+    print(f"       python -m mlx_lm.fuse \\")
+    print(f"           --model {resolved_model} \\")
+    print(f"           --adapter-path {resolved_adapter} \\")
+    print(f"           --save-path {FUSED_DIR}")
+    print()
+    print("  2. Convert fused model to GGUF (requires llama.cpp):")
+    print(f"       python llama.cpp/convert_hf_to_gguf.py {FUSED_DIR} \\")
+    print(f"           --outfile {FUSED_DIR}/model.gguf --outtype q4_k_m")
+    print()
+    print("  3. Create Ollama model:")
+    print(f"       ollama create hermes4.3:timmy-v1 -f {modelfile}")
+    print()
+    print("  4. Test:")
+    print("       ollama run hermes4.3:timmy-v1 'Hello, who are you?'")
+
+
+if __name__ == "__main__":
+    fire.Fire(train)
diff --git a/autolora/training_logs/v1/.gitkeep b/autolora/training_logs/v1/.gitkeep
new file mode 100644
index 00000000..e69de29b