td-toolkit / hugging /td_fuse /td_loop.py
td-builder's picture
Upload 163 files
0cb2e84 verified
Raw
History Blame Contribute Delete
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
# ============================================================
@dataclass
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
# ============================================================
@dataclass
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()