Download hugging/td_fuse/td_loop.py from td-builder/td-toolkit: direct link, hf CLI and curl.
- Browser
- Download file 42.4 kB
-
https://huggingface.co/td-builder/td-toolkit/resolve/main/hugging/td_fuse/td_loop.py
- Command line
-
hf download hf://td-builder/td-toolkit/hugging/td_fuse/td_loop.py
-
curl -L -o td_loop.py https://huggingface.co/td-builder/td-toolkit/resolve/main/hugging/td_fuse/td_loop.py
42.4 kB
| """ | |
| TD Self-Improvement Loop v1 — The Full Autonomous Pipeline | |
| Ties everything together into an endless self-improvement loop: | |
| Step 1: Test (benchmark + weight health) | |
| Step 2: Diagnose (weakness finder) | |
| Step 3: Find data (dataset finder) | |
| Step 4: Train + Repair (selfimprove.py) | |
| Step 5: Verify (re-benchmark) | |
| → Loop | |
| This is the script you start and walk away from. | |
| Usage: | |
| python td_loop.py # Start from latest checkpoint | |
| python td_loop.py --model path/to/model # Start from specific model | |
| python td_loop.py --max-cycles 10 # Stop after 10 cycles | |
| python td_loop.py --dry-run # Just test+diagnose, don't train | |
| """ | |
| import json | |
| import time | |
| import shutil | |
| import gc | |
| import sys | |
| import argparse | |
| from pathlib import Path | |
| from typing import Dict, List, Optional, Tuple | |
| from dataclasses import dataclass | |
| # ============================================================ | |
| # LOOP CONFIGURATION | |
| # ============================================================ | |
| class LoopConfig: | |
| """Configuration for the self-improvement loop.""" | |
| # Model | |
| model_path: str = "" # Auto-detected if empty | |
| reference_model: str = "Qwen/Qwen3-VL-8B-Instruct" | |
| # Directories | |
| output_dir: str = "td_fuse_outputs/self_improve" | |
| benchmark_dir: str = "td_fuse_outputs/benchmarks" | |
| weakness_dir: str = "td_fuse_outputs/weakness_reports" | |
| data_dir: str = "td_fuse_outputs/training_data" | |
| # Loop settings | |
| max_cycles: int = 0 # 0 = infinite | |
| dry_run: bool = False # Test + diagnose only | |
| training_budget: int = 8000 # Samples per cycle | |
| # Score targets (when to stop) | |
| targets: Dict = None | |
| # HuggingFace | |
| hf_token: str = "" | |
| # Regression protection | |
| max_regression_pct: float = 0.05 # 5% drop = rollback | |
| max_consecutive_regressions: int = 2 # 2 in a row = stop | |
| # Quick test mode (compressed run to verify code paths) | |
| quick_test: bool = False | |
| # Safety limits | |
| max_training_hours: float = 4.0 # Warn if training exceeds this (hours per cycle) | |
| def __post_init__(self): | |
| if self.targets is None: | |
| self.targets = { | |
| "math": 0.80, | |
| "code": 0.70, | |
| "reasoning": 0.75, | |
| "creativity": 0.65, | |
| "knowledge": 0.80, | |
| "instruction_following": 0.85, | |
| } | |
| # ============================================================ | |
| # CYCLE STATE TRACKING | |
| # ============================================================ | |
| class CycleResult: | |
| """Results from one improvement cycle.""" | |
| cycle_num: int | |
| benchmark_scores: Dict[str, float] | |
| weight_health_summary: Dict | |
| weaknesses_found: int | |
| data_collected: int | |
| training_loss: float | |
| improved: bool # Overall, did we improve? | |
| regressions: List[str] # Categories that got worse | |
| duration_seconds: float | |
| model_path: str | |
| def load_cycle_history(output_dir: str) -> List[CycleResult]: | |
| """Load previous cycle results. Returns empty list if file is corrupt.""" | |
| history_file = Path(output_dir) / "loop_history.json" | |
| if not history_file.exists(): | |
| return [] | |
| try: | |
| with open(history_file) as f: | |
| data = json.load(f) | |
| return [CycleResult(**c) for c in data] | |
| except (json.JSONDecodeError, TypeError, KeyError) as e: | |
| print(f" WARNING: History file corrupt ({e}) — starting fresh") | |
| # Back up the corrupt file | |
| backup = history_file.with_suffix(".json.bak") | |
| try: | |
| shutil.copy2(str(history_file), str(backup)) | |
| print(f" Backed up corrupt history to {backup}") | |
| except Exception: | |
| pass | |
| return [] | |
| def save_cycle_history(history: List[CycleResult], output_dir: str): | |
| """Save cycle history to disk. Atomic write to prevent corruption.""" | |
| history_file = Path(output_dir) / "loop_history.json" | |
| tmp_file = Path(output_dir) / "loop_history.json.tmp" | |
| Path(output_dir).mkdir(parents=True, exist_ok=True) | |
| data = [] | |
| for c in history: | |
| d = { | |
| "cycle_num": c.cycle_num, | |
| "benchmark_scores": c.benchmark_scores, | |
| "weight_health_summary": c.weight_health_summary, | |
| "weaknesses_found": c.weaknesses_found, | |
| "data_collected": c.data_collected, | |
| "training_loss": c.training_loss, | |
| "improved": c.improved, | |
| "regressions": c.regressions, | |
| "duration_seconds": c.duration_seconds, | |
| "model_path": c.model_path, | |
| } | |
| data.append(d) | |
| try: | |
| # Write to temp file first, then atomic rename | |
| with open(tmp_file, "w") as f: | |
| json.dump(data, f, indent=2) | |
| # Atomic rename — if this fails, the old file is still intact | |
| tmp_file.replace(history_file) | |
| except OSError as e: | |
| print(f" WARNING: Failed to save history ({e}). Disk may be full!") | |
| # Try to clean up temp file | |
| try: | |
| tmp_file.unlink(missing_ok=True) | |
| except Exception: | |
| pass | |
| # ============================================================ | |
| # THE LOOP | |
| # ============================================================ | |
| def _setup_logging(output_dir: str): | |
| """ | |
| Tee all stdout/stderr to a log file so diagnostics survive terminal disconnects. | |
| Returns the log file path. | |
| """ | |
| import sys as _sys | |
| Path(output_dir).mkdir(parents=True, exist_ok=True) | |
| log_path = Path(output_dir) / f"loop_{time.strftime('%Y%m%d_%H%M%S')}.log" | |
| class Tee: | |
| def __init__(self, original, log_file): | |
| self.original = original | |
| self.log_file = log_file | |
| def write(self, data): | |
| self.original.write(data) | |
| try: | |
| self.log_file.write(data) | |
| self.log_file.flush() | |
| except Exception: | |
| pass # Don't crash if log write fails | |
| def flush(self): | |
| self.original.flush() | |
| try: | |
| self.log_file.flush() | |
| except Exception: | |
| pass | |
| log_fh = open(log_path, "a") | |
| _sys.stdout = Tee(_sys.stdout, log_fh) | |
| _sys.stderr = Tee(_sys.stderr, log_fh) | |
| return str(log_path) | |
| def _acquire_lockfile(output_dir: str) -> Optional[str]: | |
| """ | |
| Prevent two td_loop instances from running simultaneously. | |
| Returns lockfile path if acquired, None if another instance is running. | |
| """ | |
| import os | |
| lock_path = Path(output_dir) / "td_loop.lock" | |
| Path(output_dir).mkdir(parents=True, exist_ok=True) | |
| if lock_path.exists(): | |
| # Check if the PID in the lockfile is still alive | |
| try: | |
| with open(lock_path) as f: | |
| old_pid = int(f.read().strip()) | |
| os.kill(old_pid, 0) # Signal 0 = check if alive | |
| # Process is alive — another instance is running | |
| return None | |
| except (ValueError, ProcessLookupError, PermissionError, OSError): | |
| # PID invalid or dead — stale lockfile, safe to remove | |
| print(f" Removing stale lockfile (PID {lock_path.read_text().strip()} is dead)") | |
| with open(lock_path, "w") as f: | |
| f.write(str(os.getpid())) | |
| return str(lock_path) | |
| def _release_lockfile(output_dir: str): | |
| """Release the lockfile.""" | |
| lock_path = Path(output_dir) / "td_loop.lock" | |
| try: | |
| lock_path.unlink(missing_ok=True) | |
| except Exception: | |
| pass | |
| def run_loop(cfg: LoopConfig): | |
| """ | |
| The main self-improvement loop. | |
| Start it and walk away. It will: | |
| 1. Test the model (benchmark + weight health) | |
| 2. Find weaknesses | |
| 3. Search for training data | |
| 4. Train + repair | |
| 5. Verify improvement | |
| 6. Repeat | |
| """ | |
| # ── LOCKFILE: Prevent duplicate instances ── | |
| lock = _acquire_lockfile(cfg.output_dir) | |
| if lock is None: | |
| print("ERROR: Another td_loop instance is already running!") | |
| print(f" Lock file: {Path(cfg.output_dir) / 'td_loop.lock'}") | |
| print(" If this is wrong (crashed process), delete the lock file and retry.") | |
| return | |
| # ── LOG TEE: Save all output to file ── | |
| log_path = _setup_logging(cfg.output_dir) | |
| # ── SIGINT HANDLER: Graceful shutdown ── | |
| import signal | |
| shutdown_requested = [False] # Mutable container for closure | |
| def _signal_handler(signum, frame): | |
| if shutdown_requested[0]: | |
| print("\n FORCED SHUTDOWN — exiting immediately!") | |
| _release_lockfile(cfg.output_dir) | |
| raise SystemExit(1) | |
| shutdown_requested[0] = True | |
| print(f"\n SHUTDOWN REQUESTED (Ctrl+C) — will stop after current step completes.") | |
| print(f" Press Ctrl+C again to force quit.") | |
| signal.signal(signal.SIGINT, _signal_handler) | |
| signal.signal(signal.SIGTERM, _signal_handler) | |
| print("\n" + "=" * 60) | |
| print("TD SELF-IMPROVEMENT LOOP v1") | |
| print("=" * 60) | |
| print(f" Max cycles: {'infinite' if cfg.max_cycles == 0 else cfg.max_cycles}") | |
| print(f" Training budget: {cfg.training_budget} samples/cycle") | |
| print(f" Dry run: {cfg.dry_run}") | |
| print(f" Started: {time.strftime('%Y-%m-%d %H:%M:%S')}") | |
| print(f" Log file: {log_path}") | |
| print("=" * 60) | |
| try: | |
| _run_loop_inner(cfg, shutdown_requested) | |
| finally: | |
| _release_lockfile(cfg.output_dir) | |
| print(f"\n Lock released. Log saved to: {log_path}") | |
| def _run_loop_inner(cfg: LoopConfig, shutdown_requested: list): | |
| """Inner loop logic, separated so run_loop can handle cleanup.""" | |
| # ── GPU HEALTH CHECK ── | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| gpu_name = torch.cuda.get_device_name(0) | |
| gpu_mem = torch.cuda.get_device_properties(0).total_memory / 1e9 | |
| print(f"\n GPU: {gpu_name} ({gpu_mem:.1f} GB)") | |
| if gpu_mem < 20: | |
| print(f" WARNING: GPU has only {gpu_mem:.1f} GB — Qwen3-VL-8B needs ~24 GB for training!") | |
| # Quick allocation test — try to allocate 1GB tensor | |
| test = torch.empty(256, 1024, 1024, dtype=torch.bfloat16, device="cuda") | |
| del test | |
| torch.cuda.empty_cache() | |
| print(f" GPU health: OK (allocation test passed)") | |
| else: | |
| print("\n WARNING: No CUDA GPU detected! Training will be extremely slow or fail.") | |
| except Exception as e: | |
| print(f"\n WARNING: GPU check failed: {e}") | |
| # ── DISK HEALTH CHECK ── | |
| try: | |
| disk = shutil.disk_usage(cfg.output_dir if Path(cfg.output_dir).exists() else ".") | |
| free_gb = disk.free / 1e9 | |
| print(f" Disk: {free_gb:.1f} GB free") | |
| if free_gb < 50: | |
| print(f" WARNING: Low disk space! Need ~25 GB per cycle for checkpoints.") | |
| except Exception: | |
| pass | |
| # Load history | |
| history = load_cycle_history(cfg.output_dir) | |
| cycle_num = len(history) + 1 | |
| cycles_run_this_session = 0 | |
| consecutive_regressions = 0 | |
| # Auto-detect model if not specified | |
| if not cfg.model_path: | |
| cfg.model_path = _auto_detect_model() | |
| print(f"\n Starting model: {cfg.model_path}") | |
| print(f" Starting at cycle: {cycle_num}") | |
| # The loop | |
| while True: | |
| # ── GRACEFUL SHUTDOWN CHECK ── | |
| if shutdown_requested[0]: | |
| print(f"\n Shutdown requested — stopping before cycle {cycle_num}") | |
| _print_final_summary(history) | |
| return | |
| if cfg.max_cycles > 0 and cycles_run_this_session >= cfg.max_cycles: | |
| print(f"\n Reached max cycles ({cfg.max_cycles}). Stopping.") | |
| break | |
| cycle_start = time.time() | |
| print(f"\n{'#' * 60}") | |
| print(f"# CYCLE {cycle_num}") | |
| print(f"# {time.strftime('%Y-%m-%d %H:%M:%S')}") | |
| print(f"{'#' * 60}") | |
| # ============================================ | |
| # STEP 1: TEST (benchmark + weight health) | |
| # ============================================ | |
| print(f"\n{'='*50}") | |
| print(f"STEP 1/{5 if not cfg.dry_run else 2}: TEST MODEL") | |
| print(f"{'='*50}") | |
| # 1A: Run benchmark (with crash recovery) | |
| try: | |
| benchmark_scores = _run_benchmark(cfg.model_path, cfg.benchmark_dir, cycle_num) | |
| except Exception as e: | |
| print(f"\n CRITICAL: Benchmark crashed: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| # Can't proceed without benchmark scores — try to clean GPU and retry once | |
| gc.collect() | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except ImportError: | |
| pass | |
| try: | |
| print(" Retrying benchmark after GPU cleanup...") | |
| benchmark_scores = _run_benchmark(cfg.model_path, cfg.benchmark_dir, cycle_num) | |
| except Exception as e2: | |
| print(f" Benchmark failed twice: {e2}") | |
| print(f" Skipping cycle {cycle_num}") | |
| consecutive_regressions += 1 | |
| if consecutive_regressions >= cfg.max_consecutive_regressions: | |
| print(f"\n {consecutive_regressions} consecutive failures — STOPPING LOOP") | |
| break | |
| cycle_num += 1 | |
| continue | |
| # 1B: Analyze weight health | |
| health_data = _analyze_weights(cfg.model_path, cfg.reference_model, cfg.output_dir) | |
| # ============================================ | |
| # STEP 2: DIAGNOSE (find weaknesses) | |
| # ============================================ | |
| print(f"\n{'='*50}") | |
| print(f"STEP 2/{5 if not cfg.dry_run else 2}: DIAGNOSE WEAKNESSES") | |
| print(f"{'='*50}") | |
| try: | |
| weakness_report = _find_weaknesses( | |
| benchmark_path=str(Path(cfg.benchmark_dir) / f"cycle{cycle_num}" / "results.json"), | |
| health_data=health_data, | |
| targets=cfg.targets, | |
| budget=cfg.training_budget, | |
| output_dir=cfg.weakness_dir, | |
| cycle_num=cycle_num, | |
| ) | |
| except Exception as e: | |
| print(f"\n WARNING: Weakness analysis crashed: {e}") | |
| print(f" Using fallback: train all categories equally") | |
| weakness_report = { | |
| "search_plan": [{"category": c, "budget": cfg.training_budget // 6, | |
| "keywords": [f"{c} training dataset"], | |
| "hf_tags": [], "priority": 0.5, "score": 0.5, | |
| "target": 0.75, "gap": 0.25} | |
| for c in ["math", "code", "reasoning", "creativity", | |
| "knowledge", "instruction_following"]], | |
| "data_allocation": {c: cfg.training_budget // 6 | |
| for c in ["math", "code", "reasoning", "creativity", | |
| "knowledge", "instruction_following"]}, | |
| "lora_target_layers": list(range(36)), | |
| "norms_to_repair": [], | |
| "total_weaknesses": 6, | |
| } | |
| if cfg.dry_run: | |
| print("\n DRY RUN — stopping after diagnosis.") | |
| result = CycleResult( | |
| cycle_num=cycle_num, | |
| benchmark_scores=benchmark_scores, | |
| weight_health_summary=health_data.get("summary", {}), | |
| weaknesses_found=weakness_report.get("total_weaknesses", 0), | |
| data_collected=0, | |
| training_loss=0.0, | |
| improved=False, | |
| regressions=[], | |
| duration_seconds=time.time() - cycle_start, | |
| model_path=cfg.model_path, | |
| ) | |
| history.append(result) | |
| save_cycle_history(history, cfg.output_dir) | |
| break | |
| # ============================================ | |
| # STEP 3: FIND TRAINING DATA | |
| # ============================================ | |
| print(f"\n{'='*50}") | |
| print(f"STEP 3/5: FIND TRAINING DATA") | |
| print(f"{'='*50}") | |
| # Guard: If no weaknesses found, skip data search + training | |
| if not weakness_report.get("search_plan"): | |
| print("\n No weaknesses found — all targets met! Skipping training.") | |
| print(" (This shouldn't happen unless the model is already great)") | |
| training_data = {"total_samples": 0, "categories_covered": [], "categories_missing": []} | |
| else: | |
| try: | |
| training_data = _find_training_data( | |
| weakness_report=weakness_report, | |
| output_dir=cfg.data_dir, | |
| hf_token=cfg.hf_token, | |
| cycle_num=cycle_num, | |
| ) | |
| except Exception as e: | |
| print(f"\n WARNING: Data finder crashed: {e}") | |
| print(f" Continuing with generic datasets (selfimprove.py fallback)") | |
| training_data = {"total_samples": 0, "categories_covered": [], "categories_missing": []} | |
| # ============================================ | |
| # STEP 4: TRAIN + REPAIR | |
| # ============================================ | |
| print(f"\n{'='*50}") | |
| print(f"STEP 4/5: TRAIN + REPAIR") | |
| print(f"{'='*50}") | |
| # ── DISK SPACE PRE-CHECK ── | |
| try: | |
| disk = shutil.disk_usage(cfg.output_dir if Path(cfg.output_dir).exists() else ".") | |
| free_gb = disk.free / 1e9 | |
| if free_gb < 10: | |
| print(f"\n CRITICAL: Only {free_gb:.1f} GB disk free — skipping training!") | |
| print(f" Need at least 10 GB to safely save a checkpoint.") | |
| consecutive_regressions += 1 | |
| cycle_num += 1 | |
| continue | |
| except Exception: | |
| pass # Can't check disk — proceed anyway | |
| try: | |
| new_model_path, training_loss = _train_model( | |
| model_path=cfg.model_path, | |
| training_data_path=str(Path(cfg.data_dir) / f"cycle{cycle_num}" / "training_data.jsonl"), | |
| lora_targets=weakness_report.get("lora_target_layers", list(range(36))), | |
| norms_to_repair=weakness_report.get("norms_to_repair", []), | |
| reference_model=cfg.reference_model, | |
| output_dir=cfg.output_dir, | |
| cycle_num=cycle_num, | |
| quick_test=cfg.quick_test, | |
| ) | |
| except Exception as e: | |
| print(f"\n CRITICAL: Training crashed: {e}") | |
| print(f" Skipping this cycle and retrying with current model...") | |
| import traceback | |
| traceback.print_exc() | |
| # Save partial results and continue to next cycle | |
| cycle_duration = time.time() - cycle_start | |
| result = CycleResult( | |
| cycle_num=cycle_num, | |
| benchmark_scores=benchmark_scores, | |
| weight_health_summary=health_data.get("summary", {}), | |
| weaknesses_found=weakness_report.get("total_weaknesses", 0), | |
| data_collected=training_data.get("total_samples", 0), | |
| training_loss=0.0, | |
| improved=False, | |
| regressions=["TRAINING_CRASH"], | |
| duration_seconds=cycle_duration, | |
| model_path=cfg.model_path, | |
| ) | |
| history.append(result) | |
| save_cycle_history(history, cfg.output_dir) | |
| consecutive_regressions += 1 | |
| if consecutive_regressions >= cfg.max_consecutive_regressions: | |
| print(f"\n {consecutive_regressions} consecutive failures — STOPPING LOOP") | |
| break | |
| cycle_num += 1 | |
| # Free GPU memory before retry | |
| gc.collect() | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except ImportError: | |
| pass | |
| continue | |
| # ── TRAINING DURATION WATCHDOG ── | |
| train_elapsed_h = (time.time() - cycle_start) / 3600 | |
| if train_elapsed_h > cfg.max_training_hours: | |
| print(f"\n WARNING: Training took {train_elapsed_h:.1f}h (limit: {cfg.max_training_hours}h)") | |
| print(f" This may indicate a stuck process or oversized dataset.") | |
| # ============================================ | |
| # STEP 5: VERIFY | |
| # ============================================ | |
| print(f"\n{'='*50}") | |
| print(f"STEP 5/5: VERIFY IMPROVEMENT") | |
| print(f"{'='*50}") | |
| try: | |
| new_scores = _run_benchmark(new_model_path, cfg.benchmark_dir, cycle_num, suffix="_post") | |
| except Exception as e: | |
| print(f"\n CRITICAL: Post-training benchmark crashed: {e}") | |
| print(f" Cannot verify improvement — treating as regression for safety.") | |
| import traceback | |
| traceback.print_exc() | |
| new_scores = {} # Empty = all categories missing = treated as regression | |
| # Compare scores | |
| improved, regressions = _compare_scores(benchmark_scores, new_scores, cfg.max_regression_pct) | |
| if improved: | |
| print(f"\n ✓ IMPROVEMENT CONFIRMED — cycle {cycle_num} successful!") | |
| consecutive_regressions = 0 | |
| cfg.model_path = new_model_path # Use improved model next cycle | |
| else: | |
| print(f"\n ✗ REGRESSION DETECTED in: {', '.join(regressions)}") | |
| consecutive_regressions += 1 | |
| if consecutive_regressions >= cfg.max_consecutive_regressions: | |
| print(f"\n {consecutive_regressions} consecutive regressions — STOPPING LOOP") | |
| print(f" The model may have hit its improvement ceiling.") | |
| break | |
| # Still use the new model — small regressions can recover | |
| cfg.model_path = new_model_path | |
| # Save cycle results | |
| cycle_duration = time.time() - cycle_start | |
| result = CycleResult( | |
| cycle_num=cycle_num, | |
| benchmark_scores=new_scores, | |
| weight_health_summary=health_data.get("summary", {}), | |
| weaknesses_found=weakness_report.get("total_weaknesses", 0), | |
| data_collected=training_data.get("total_samples", 0), | |
| training_loss=training_loss, | |
| improved=improved, | |
| regressions=regressions, | |
| duration_seconds=cycle_duration, | |
| model_path=new_model_path, | |
| ) | |
| history.append(result) | |
| save_cycle_history(history, cfg.output_dir) | |
| # Print cycle summary | |
| _print_cycle_summary(result, history) | |
| # Check if all targets met | |
| if _all_targets_met(new_scores, cfg.targets): | |
| print(f"\n{'='*60}") | |
| print(f"TARGET PERFORMANCE REACHED AFTER {cycle_num} CYCLES!") | |
| print(f"{'='*60}") | |
| break | |
| # ── PERIODIC FULL GC between cycles ── | |
| # After a full cycle, Python has accumulated lots of garbage | |
| # (deleted models, tokenizers, datasets). Force collection. | |
| gc.collect() | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| # Print GPU memory status for debugging | |
| allocated = torch.cuda.memory_allocated() / 1e9 | |
| reserved = torch.cuda.memory_reserved() / 1e9 | |
| print(f"\n GPU memory: {allocated:.1f} GB allocated, {reserved:.1f} GB reserved") | |
| except ImportError: | |
| pass | |
| # ── SHUTDOWN CHECK before starting next cycle ── | |
| cycles_run_this_session += 1 | |
| if shutdown_requested[0]: | |
| print(f"\n Shutdown requested — stopping after cycle {cycle_num}") | |
| break | |
| cycle_num += 1 | |
| # Final summary | |
| _print_final_summary(history) | |
| # ============================================================ | |
| # STEP IMPLEMENTATIONS | |
| # ============================================================ | |
| def _auto_detect_model() -> str: | |
| """Find the best starting model.""" | |
| base = Path("td_fuse_outputs/self_improve") | |
| # Check for highest cycle output | |
| if base.exists(): | |
| for n in range(50, 0, -1): | |
| cycle_dir = base / f"improved_cycle{n}" | |
| if cycle_dir.exists() and list(cycle_dir.glob("*.safetensors")): | |
| print(f" Found cycle {n} output at {cycle_dir}") | |
| return str(cycle_dir) | |
| # Check for old "improved" folder | |
| old_improved = base / "improved" | |
| if old_improved.exists() and list(old_improved.glob("*.safetensors")): | |
| return str(old_improved) | |
| # Fall back to reasoning_healed | |
| healed = Path("td_fuse_outputs/reasoning_healed") | |
| if healed.exists() and list(healed.glob("*.safetensors")): | |
| return str(healed) | |
| raise FileNotFoundError("No model found! Need a starting model checkpoint.") | |
| def _run_benchmark( | |
| model_path: str, | |
| benchmark_dir: str, | |
| cycle_num: int, | |
| suffix: str = "", | |
| ) -> Dict[str, float]: | |
| """Run td_benchmark.py and return scores per category.""" | |
| from td_benchmark import BenchmarkConfig, run_benchmark | |
| output_dir = str(Path(benchmark_dir) / f"cycle{cycle_num}{suffix}") | |
| results_path = str(Path(output_dir) / "results.json") | |
| Path(output_dir).mkdir(parents=True, exist_ok=True) | |
| # Validate checkpoint before loading | |
| mp = Path(model_path) | |
| if mp.is_dir() and not (mp / "config.json").exists(): | |
| raise FileNotFoundError(f"No config.json in {model_path} — stale or corrupt checkpoint") | |
| config = BenchmarkConfig( | |
| model_path=model_path, | |
| output_dir=output_dir, | |
| output_path=results_path, | |
| disable_thinking=True, # SPEED: Skip thinking for benchmark | |
| ) | |
| print(f" Running benchmark on {model_path}...") | |
| results = run_benchmark(config) | |
| # Guard: results might be None or not a dict if benchmark crashed internally | |
| if not isinstance(results, dict): | |
| print(f" WARNING: Benchmark returned {type(results)} instead of dict — treating as empty") | |
| results = {} | |
| # Extract scores per category (already 0.0-1.0 after our fix) | |
| scores = {} | |
| for category, data in results.get("categories", {}).items(): | |
| if isinstance(data, dict): | |
| scores[category] = data.get("score", 0.0) | |
| else: | |
| scores[category] = 0.0 | |
| print(f" Benchmark scores:") | |
| for cat, score in sorted(scores.items()): | |
| print(f" {cat}: {score:.0%}") | |
| # Clean up model from GPU memory | |
| gc.collect() | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except ImportError: | |
| pass | |
| return scores | |
| def _analyze_weights( | |
| model_path: str, | |
| reference_model: str, | |
| output_dir: str, | |
| ) -> Dict: | |
| """Run weight health analysis from selfimprove.py.""" | |
| try: | |
| from selfimprove import ( | |
| analyze_model_weights, | |
| compute_health_scores, | |
| compute_damage_scores, | |
| compute_coherence_scores, | |
| detect_clogged_norms, | |
| ) | |
| print(f" Analyzing model weights...") | |
| model_analysis = analyze_model_weights(model_path, "TD model") | |
| health_scores = compute_health_scores(model_analysis) | |
| coherence_scores = compute_coherence_scores(model_analysis) | |
| clogged_norms = detect_clogged_norms(health_scores) | |
| # Reference comparison — skip if no reference cached (saves ~5 min) | |
| # selfimprove.py downloads it during training anyway | |
| damage_scores = {} | |
| ref_path = Path(output_dir) / "reference_weights" | |
| if ref_path.exists() and list(ref_path.glob("*.safetensors")): | |
| print(f" Found cached reference at {ref_path}, comparing...") | |
| ref_analysis = analyze_model_weights(str(ref_path), "Reference") | |
| damage_scores = compute_damage_scores(model_analysis, ref_analysis) | |
| else: | |
| print(f" No cached reference — skipping damage comparison (saves time)") | |
| # Build summary | |
| unhealthy = sum(1 for h in health_scores.values() if h["health_score"] > 0.2) | |
| summary = { | |
| "total_tensors": len(health_scores), | |
| "unhealthy_tensors": unhealthy, | |
| "clogged_norms": len(clogged_norms), | |
| "coherence_issues": sum(1 for s in coherence_scores.values() if s > 0.3), | |
| } | |
| print(f" Weight health: {unhealthy}/{len(health_scores)} tensors with issues") | |
| print(f" Clogged norms: {len(clogged_norms)}") | |
| # Clean up | |
| gc.collect() | |
| return { | |
| "health_scores": health_scores, | |
| "damage_scores": damage_scores, | |
| "coherence_scores": coherence_scores, | |
| "clogged_norms": clogged_norms, | |
| "summary": summary, | |
| } | |
| except Exception as e: | |
| print(f" Warning: Weight analysis failed: {e}") | |
| return {"summary": {}, "health_scores": {}, "clogged_norms": []} | |
| def _find_weaknesses( | |
| benchmark_path: str, | |
| health_data: Dict, | |
| targets: Dict, | |
| budget: int, | |
| output_dir: str, | |
| cycle_num: int, | |
| ) -> Dict: | |
| """Run td_weakness.py analysis.""" | |
| from td_weakness import run_weakness_analysis | |
| output_path = str(Path(output_dir) / f"cycle{cycle_num}" / "weakness_report.json") | |
| Path(output_path).parent.mkdir(parents=True, exist_ok=True) | |
| report = run_weakness_analysis( | |
| benchmark_path=benchmark_path, | |
| health_scores=health_data.get("health_scores") or {}, | |
| damage_scores=health_data.get("damage_scores") or {}, | |
| coherence_scores=health_data.get("coherence_scores") or {}, | |
| clogged_norms=health_data.get("clogged_norms") or [], | |
| targets=targets, | |
| total_budget=budget, | |
| output_path=output_path, | |
| ) | |
| return { | |
| "search_plan": report.search_plan, | |
| "data_allocation": report.data_allocation, | |
| "lora_target_layers": report.lora_target_layers, | |
| "norms_to_repair": report.norms_to_repair, | |
| "total_weaknesses": len(report.answer_weaknesses) + len(report.weight_weaknesses), | |
| } | |
| def _find_training_data( | |
| weakness_report: Dict, | |
| output_dir: str, | |
| hf_token: str, | |
| cycle_num: int, | |
| ) -> Dict: | |
| """Run td_data_finder.py to search for and download data.""" | |
| from td_data_finder import find_training_data | |
| cycle_data_dir = str(Path(output_dir) / f"cycle{cycle_num}") | |
| result = find_training_data( | |
| search_plan=weakness_report["search_plan"], | |
| data_allocation=weakness_report["data_allocation"], | |
| output_dir=cycle_data_dir, | |
| hf_token=hf_token if hf_token else None, | |
| ) | |
| return { | |
| "total_samples": result.total_samples, | |
| "categories_covered": result.categories_covered, | |
| "categories_missing": result.categories_missing, | |
| } | |
| def _train_model( | |
| model_path: str, | |
| training_data_path: str, | |
| lora_targets: List[int], | |
| norms_to_repair: List[str], | |
| reference_model: str, | |
| output_dir: str, | |
| cycle_num: int, | |
| quick_test: bool = False, | |
| ) -> Tuple[str, float]: | |
| """ | |
| Run training using selfimprove.py's training pipeline. | |
| Returns: (new_model_path, final_training_loss) | |
| """ | |
| from selfimprove import SelfImproveConfig, run_cycle | |
| cfg = SelfImproveConfig() | |
| # CRITICAL: Set model_path BEFORE run_cycle so auto_detect_model_path | |
| # doesn't pick a different checkpoint | |
| cfg.model_path = model_path | |
| cfg.reference_model = reference_model | |
| cfg.output_dir = output_dir | |
| if quick_test: | |
| # ── COMPRESSED TEST MODE ── | |
| # Exercises every code path but finishes in ~30-45 min | |
| print(" *** QUICK TEST: Compressed training settings ***") | |
| cfg.mega_mode = True # Still mega mode so norm unclogging runs | |
| cfg.top_n_layers = 8 # Only 8 layers (not 36) — 4x faster LoRA setup | |
| cfg.min_improvement_score = 0.0 | |
| cfg.lora_r = 32 # Smaller LoRA — 4x less trainable params | |
| cfg.lora_alpha = 64 | |
| cfg.train_batch = 4 | |
| cfg.train_grad_accum = 2 # Effective batch 8 (not 16) | |
| cfg.num_train_samples = 500 # 16x fewer samples | |
| cfg.train_epochs = 1 # 1 epoch (not 3) — 3x faster | |
| cfg.learning_rate = 1e-5 | |
| cfg.norm_learning_rate = 1e-3 | |
| cfg.save_steps = 9999 # No mid-training saves — saves ~2 min | |
| else: | |
| # ── FULL MEGA MODE ── | |
| cfg.mega_mode = True | |
| cfg.top_n_layers = 36 | |
| cfg.min_improvement_score = 0.0 | |
| cfg.lora_r = 128 | |
| cfg.lora_alpha = 256 | |
| cfg.train_batch = 4 | |
| cfg.train_grad_accum = 4 | |
| cfg.num_train_samples = 8000 | |
| cfg.learning_rate = 1e-5 | |
| cfg.norm_learning_rate = 1e-3 | |
| cfg.save_steps = 200 | |
| # Pass targeted training data from td_data_finder | |
| # selfimprove.py will use this INSTEAD of its hardcoded datasets | |
| training_data_file = Path(training_data_path) | |
| if training_data_file.exists() and training_data_file.stat().st_size > 0: | |
| cfg.custom_data_path = str(training_data_file) | |
| print(f" Using targeted training data: {training_data_file}") | |
| else: | |
| print(f" No targeted data found — selfimprove.py will use generic datasets") | |
| # Figure out what cycle_num selfimprove.py will ACTUALLY use | |
| # so we know where the output will be saved | |
| import re as _re | |
| si_match = _re.search(r'improved_cycle(\d+)', model_path) | |
| if si_match: | |
| si_cycle_num = int(si_match.group(1)) + 1 | |
| elif "improve" in model_path: | |
| si_cycle_num = 2 | |
| else: | |
| si_cycle_num = 1 | |
| print(f" Starting training (loop cycle {cycle_num}, selfimprove cycle {si_cycle_num})...") | |
| print(f" Model: {model_path}") | |
| print(f" Output will be: {output_dir}/improved_cycle{si_cycle_num}") | |
| run_cycle(cfg) | |
| # Find the output model — use selfimprove's cycle numbering | |
| new_model_path = str(Path(output_dir) / f"improved_cycle{si_cycle_num}") | |
| if not Path(new_model_path).exists(): | |
| # Fallback: try loop's cycle number | |
| alt_path = str(Path(output_dir) / f"improved_cycle{cycle_num}") | |
| if Path(alt_path).exists(): | |
| new_model_path = alt_path | |
| else: | |
| # Last resort: scan for any improved_cycle* dir | |
| base = Path(output_dir) | |
| found = False | |
| for n in range(50, 0, -1): | |
| check = base / f"improved_cycle{n}" | |
| if check.exists() and list(check.glob("*.safetensors")): | |
| new_model_path = str(check) | |
| print(f" Output path mismatch — found model at {check}") | |
| found = True | |
| break | |
| if not found: | |
| old_improved = base / "improved" | |
| if old_improved.exists(): | |
| new_model_path = str(old_improved) | |
| else: | |
| # Training produced nothing — return original model | |
| print(f" ERROR: No output model found! Training may have failed silently.") | |
| print(f" Returning original model: {model_path}") | |
| return model_path, 0.0 | |
| # Validate the output model has actual weights | |
| out_p = Path(new_model_path) | |
| if out_p.is_dir(): | |
| st_files = list(out_p.glob("*.safetensors")) | |
| if not st_files: | |
| print(f" ERROR: Output model has no safetensors files! Returning original.") | |
| return model_path, 0.0 | |
| total_sz = sum(f.stat().st_size for f in st_files) / 1e9 | |
| if total_sz < 1.0: | |
| print(f" ERROR: Output model only {total_sz:.2f} GB — likely corrupt. Returning original.") | |
| return model_path, 0.0 | |
| # Try to get final loss from training log | |
| training_loss = 0.0 | |
| log_file = Path(new_model_path) / "training_log.json" | |
| if log_file.exists(): | |
| try: | |
| with open(log_file) as f: | |
| log = json.load(f) | |
| training_loss = log.get("final_loss", 0.0) | |
| except Exception: | |
| pass | |
| return new_model_path, training_loss | |
| def _compare_scores( | |
| old_scores: Dict[str, float], | |
| new_scores: Dict[str, float], | |
| max_regression: float, | |
| ) -> Tuple[bool, List[str]]: | |
| """ | |
| Compare benchmark scores before and after training. | |
| Returns: (overall_improved, list_of_regressed_categories) | |
| """ | |
| regressions = [] | |
| improvements = 0 | |
| for category in old_scores: | |
| if category not in new_scores: | |
| print(f" ? {category}: MISSING from new scores — treating as regression!") | |
| regressions.append(category) | |
| continue | |
| old = old_scores[category] | |
| new = new_scores[category] | |
| diff = new - old | |
| if diff > 0.01: # Meaningful improvement (>1%) | |
| improvements += 1 | |
| print(f" ↑ {category}: {old:.1%} → {new:.1%} (+{diff:.1%})") | |
| elif diff < -max_regression: # Regression beyond threshold | |
| regressions.append(category) | |
| print(f" ↓ {category}: {old:.1%} → {new:.1%} ({diff:.1%}) REGRESSION!") | |
| else: | |
| print(f" = {category}: {old:.1%} → {new:.1%} (stable)") | |
| # Improved if: at least one category improved AND improvements outnumber regressions. | |
| # Old logic (improvements > 0 AND regressions == 0) was too strict — | |
| # with 6 categories, some random fluctuation is expected. If math goes | |
| # up 5% but creativity dips 2%, that's still a net win. | |
| overall_improved = improvements > len(regressions) | |
| return overall_improved, regressions | |
| def _all_targets_met(scores: Dict[str, float], targets: Dict[str, float]) -> bool: | |
| """Check if all scores meet or exceed targets.""" | |
| for category, target in targets.items(): | |
| if category not in scores: | |
| return False | |
| if scores[category] < target: | |
| return False | |
| return True | |
| # ============================================================ | |
| # SUMMARIES | |
| # ============================================================ | |
| def _print_cycle_summary(result: CycleResult, history: List[CycleResult]): | |
| """Print a summary of the completed cycle.""" | |
| try: | |
| hours = result.duration_seconds / 3600 | |
| print(f"\n{'='*50}") | |
| print(f"CYCLE {result.cycle_num} SUMMARY") | |
| print(f"{'='*50}") | |
| print(f" Duration: {hours:.1f} hours") | |
| print(f" Weaknesses found: {result.weaknesses_found}") | |
| print(f" Data collected: {result.data_collected}") | |
| print(f" Training loss: {result.training_loss:.4f}") | |
| print(f" Improved: {'Yes' if result.improved else 'No'}") | |
| if result.regressions: | |
| print(f" Regressions: {', '.join(result.regressions)}") | |
| print(f" Model saved: {result.model_path}") | |
| # Score history | |
| if len(history) > 1: | |
| print(f"\n Score progression:") | |
| for h in history[-5:]: # Last 5 cycles | |
| scores = h.benchmark_scores or {} | |
| avg = sum(scores.values()) / max(len(scores), 1) if scores else 0.0 | |
| print(f" Cycle {h.cycle_num}: avg {avg:.1%}") | |
| except Exception as e: | |
| print(f" (Could not print cycle summary: {e})") | |
| def _print_final_summary(history: List[CycleResult]): | |
| """Print final summary after loop ends.""" | |
| if not history: | |
| return | |
| try: | |
| total_hours = sum(r.duration_seconds for r in history) / 3600 | |
| improved_cycles = sum(1 for r in history if r.improved) | |
| print(f"\n{'#' * 60}") | |
| print(f"# LOOP COMPLETE") | |
| print(f"{'#' * 60}") | |
| print(f" Total cycles: {len(history)}") | |
| print(f" Improved cycles: {improved_cycles}/{len(history)}") | |
| print(f" Total time: {total_hours:.1f} hours") | |
| if len(history) >= 2: | |
| first = history[0] | |
| last = history[-1] | |
| first_scores = first.benchmark_scores or {} | |
| last_scores = last.benchmark_scores or {} | |
| if first_scores and last_scores: | |
| print(f"\n Score changes (first → last):") | |
| for cat in sorted(first_scores.keys()): | |
| if cat in last_scores: | |
| old = first_scores[cat] | |
| new = last_scores[cat] | |
| diff = new - old | |
| arrow = "↑" if diff > 0 else "↓" if diff < 0 else "=" | |
| print(f" {arrow} {cat}: {old:.1%} → {new:.1%} ({diff:+.1%})") | |
| print(f"\n Final model: {history[-1].model_path}") | |
| except Exception as e: | |
| print(f" (Could not print final summary: {e})") | |
| # ============================================================ | |
| # CLI | |
| # ============================================================ | |
| def main(): | |
| parser = argparse.ArgumentParser(description="TD Self-Improvement Loop") | |
| parser.add_argument("--model", default="", help="Starting model path") | |
| parser.add_argument("--max-cycles", type=int, default=0, help="Max cycles (0=infinite)") | |
| parser.add_argument("--budget", type=int, default=8000, help="Training samples per cycle") | |
| parser.add_argument("--dry-run", action="store_true", help="Test + diagnose only") | |
| parser.add_argument("--quick-test", action="store_true", | |
| help="Compressed test run (~30-45 min): 500 samples, 1 epoch, LoRA r=32, 8 layers. " | |
| "Tests every code path but finishes fast.") | |
| parser.add_argument("--hf-token", default="", help="HuggingFace token") | |
| args = parser.parse_args() | |
| cfg = LoopConfig( | |
| model_path=args.model, | |
| max_cycles=args.max_cycles, | |
| training_budget=args.budget, | |
| dry_run=args.dry_run, | |
| hf_token=args.hf_token, | |
| ) | |
| # Quick test mode: override to compressed settings | |
| if args.quick_test: | |
| cfg.quick_test = True | |
| cfg.max_cycles = 1 | |
| cfg.training_budget = 500 | |
| print("\n *** QUICK TEST MODE ***") | |
| print(" 500 samples, 1 epoch, LoRA r=32, 8 layers") | |
| print(" Expected time: ~30-45 minutes") | |
| print(" Purpose: verify every code path works before real run\n") | |
| run_loop(cfg) | |
| if __name__ == "__main__": | |
| main() | |