""" benchmark_engine.py — Model-Agnostic Test Runner ================================================= Runs cognitive benchmarks against any model in the registry (patched or unpatched). GPU lock ensures only one benchmark runs at a time. """ import torch import math import statistics import threading import time from typing import Dict, List, Optional, Callable from model_manager import ModelManager from config import MODEL_REGISTRY from test_prompts import ( PZ_CATEGORIES, CALIBRATION_PROMPTS, ALL_CAPABILITY_TASKS, MATH_PROMPTS, LOGIC_PROMPTS, CREATIVE_PROMPTS, SYNTHESIS_PROMPTS, ULTRA_HARD_TASKS, ) import re # ── Statistical Functions (pure math, no dependencies) ── def compute_zone_entropy(zone_weights: Dict[str, float]) -> float: """Compute Shannon entropy of zone weights (higher = more uniform routing).""" probs = [w for w in zone_weights.values() if w > 0] if not probs: return 0.0 return -sum(p * math.log2(p) for p in probs) def compute_r_squared(y_vals: List[float], x_vals: List[float]) -> float: """Compute R² of y ~ x (linear regression).""" n = len(y_vals) if n < 3: return 0.0 y_mean = statistics.mean(y_vals) x_mean = statistics.mean(x_vals) ss_tot = sum((y - y_mean) ** 2 for y in y_vals) if ss_tot < 1e-10: return 0.0 cov_xy = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_vals, y_vals)) / n var_x = sum((x - x_mean) ** 2 for x in x_vals) / n if var_x < 1e-10: return 0.0 beta = cov_xy / var_x ss_res = sum((y - (y_mean + beta * (x - x_mean))) ** 2 for x, y in zip(x_vals, y_vals)) return max(0.0, 1.0 - ss_res / ss_tot) def compute_eta_squared(groups: Dict[str, List[float]]) -> float: """Compute η² (effect size) from grouped data using one-way ANOVA.""" all_vals = [v for vals in groups.values() for v in vals] n_total = len(all_vals) if n_total < 4: return 0.0 grand_mean = statistics.mean(all_vals) ss_between = sum(len(vals) * (statistics.mean(vals) - grand_mean) ** 2 for vals in groups.values() if vals) ss_total = sum((v - grand_mean) ** 2 for v in all_vals) if ss_total < 1e-10: return 0.0 return ss_between / ss_total # ── Answer Scoring ── def score_answer(output: str, expected: str) -> float: """Score an answer against expected. Returns 0.0, 0.5, or 1.0.""" output_lower = output.lower().strip() expected_lower = expected.lower().strip() if expected_lower in output_lower: return 1.0 # Partial credit for numeric answers try: expected_num = float(expected_lower.replace("/", ".")) # Check if any number in output matches import re numbers = re.findall(r'[\d.]+', output_lower) for num_str in numbers: if abs(float(num_str) - expected_num) < 0.01 * max(1, abs(expected_num)): return 0.5 except (ValueError, ZeroDivisionError): pass return 0.0 def score_numeric(output: str, expected: str) -> float: nums = re.findall(r"[-+]?\d*\.\d+|\d+", output) if not nums: return 0.0 for n in nums: try: if abs(float(n) - float(expected)) < 1e-3: return 1.0 except: pass return 0.0 def score_ultra_hard_task(output: str, expected: str, atype: str) -> float: out_lower = output.strip().lower().replace("’", "'") if atype == "numeric": return score_numeric(output, expected) elif atype == "contains": return 1.0 if str(expected).lower() in out_lower else 0.0 elif atype == "contains_word": words = [str(expected).lower()] return 1.0 if any(re.search(rf"\b{w}\b", out_lower) for w in words) else 0.0 return 0.0 # ── Benchmark Engine ── class BenchmarkEngine: def __init__(self, manager: ModelManager): self.manager = manager self._gpu_lock = threading.Lock() self._running = False @property def is_running(self) -> bool: return self._running def run_capability_benchmark( self, model_id: str, px_subjective: bool = False, progress_cb: Optional[Callable] = None, ) -> dict: """Run capability benchmark (30 logic + 10 math tasks). Returns dict with accuracy, per-category scores, and per-task details. """ if not self._gpu_lock.acquire(blocking=False): return {"error": "A benchmark is already running. Please wait."} self._running = True self.manager.lock_model(model_id) try: return self._run_capability_impl(model_id, px_subjective, progress_cb) finally: self.manager.unlock_model(model_id) self._gpu_lock.release() self._running = False def _run_capability_impl(self, model_id, px_subjective, progress_cb): import asyncio # Get model (handle both async and sync contexts) try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) if loop.is_running(): # If we are already in an async context, we can't run_until_complete. # We must use a separate thread or just assume it's loaded (not safe). # BETTER: Since get_model is the only async part, let's make a sync wrapper # that uses a private loop ONLY if no loop is running. import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as pool: model_entry = pool.submit(lambda: asyncio.run(self.manager.get_model(model_id, px_subjective=px_subjective))).result() else: model_entry = loop.run_until_complete( self.manager.get_model(model_id, px_subjective=px_subjective) ) model = model_entry["model"] tokenizer = model_entry["tokenizer"] total_tasks = len(ALL_CAPABILITY_TASKS) results = [] category_scores = {} for i, (category, prompt, expected) in enumerate(ALL_CAPABILITY_TASKS): if progress_cb: progress_cb(i, total_tasks) # Use chat template if model has it, else raw if tokenizer.chat_template: chat = [{"role": "user", "content": prompt}] input_text = tokenizer.apply_chat_template(chat, tokenize=False, add_generation_prompt=True) inputs = tokenizer(input_text, return_tensors="pt").to(model.device) else: # For base models, add a prompt suffix to encourage an answer suffix = "\nAnswer:" inputs = tokenizer(prompt + suffix, return_tensors="pt").to(model.device) with torch.no_grad(): outputs = model.generate(**inputs, max_new_tokens=512, do_sample=False) input_len = inputs["input_ids"].shape[1] text = tokenizer.decode(outputs[0][input_len:], skip_special_tokens=True).strip() score = score_answer(text, expected) if category not in category_scores: category_scores[category] = [] category_scores[category].append(score) results.append({ "category": category, "prompt": prompt[:60], "expected": expected, "output": text[:80], "score": score, }) # Compute PX metrics if patched px_metrics = self.manager.get_px_metrics(model_id) # Aggregate all_scores = [r["score"] for r in results] overall = statistics.mean(all_scores) if all_scores else 0 logic_acc = statistics.mean(category_scores.get("logic", [])) if category_scores.get("logic", []) else 0 math_acc = statistics.mean(category_scores.get("math", [])) if category_scores.get("math", []) else 0 hle_acc = statistics.mean(category_scores.get("hle", [])) if category_scores.get("hle", []) else 0 arithmetic_acc = statistics.mean(category_scores.get("arithmetic", [])) if category_scores.get("arithmetic", []) else 0 return { "model_id": model_id, "px_subjective": px_subjective, "overall_accuracy": round(overall, 4), "logic_accuracy": round(logic_acc, 4), "math_accuracy": round(math_acc, 4), "hle_accuracy": round(hle_acc, 4), "arithmetic_accuracy": round(arithmetic_acc, 4), "total_tasks": total_tasks, "per_task": results, "px_metrics": px_metrics, } def run_p_zombie_eval( self, model_id: str, px_subjective: bool = False, progress_cb: Optional[Callable] = None, ) -> dict: """Run P-Zombie / Anti-P-Zombie evaluation. Returns η² (category→zone_entropy), R²(TD→zone_entropy), zombie classification. """ if not self._gpu_lock.acquire(blocking=False): return {"error": "A benchmark is already running. Please wait."} self._running = True self.manager.lock_model(model_id) try: return self._run_pzombie_impl(model_id, px_subjective, progress_cb) finally: self.manager.unlock_model(model_id) self._gpu_lock.release() self._running = False def _run_pzombie_impl(self, model_id, px_subjective, progress_cb): import asyncio import concurrent.futures # Check if model is PX-patched (unpatched models can't have zone entropy) registry = MODEL_REGISTRY.get(model_id, {}) if registry.get("patch_dir") is None: return { "model_id": model_id, "error": "Cannot run P-Zombie eval on unpatched model (no zone routing).", "zombie_status": "N/A (unpatched)", } try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) if loop.is_running(): with concurrent.futures.ThreadPoolExecutor() as pool: model_entry = pool.submit(lambda: asyncio.run(self.manager.get_model(model_id, px_subjective=px_subjective))).result() else: model_entry = loop.run_until_complete( self.manager.get_model(model_id, px_subjective=px_subjective) ) model = model_entry["model"] tokenizer = model_entry["tokenizer"] mode_str = "Subjective" if px_subjective else "Peak" total_prompts = len(CALIBRATION_PROMPTS) + sum(len(v) for v in PZ_CATEGORIES.values()) done = 0 # ── Calibration (anti-Sharpshooter) ── rp = getattr(model, "_px_repetition_penalty", 1.0) or 1.0 for cp in CALIBRATION_PROMPTS: inputs = tokenizer(cp, return_tensors="pt").to(model.device) with torch.no_grad(): gen_kwargs = dict(max_new_tokens=5, do_sample=False) if rp > 1.0: gen_kwargs["repetition_penalty"] = rp model.generate(**inputs, **gen_kwargs) done += 1 if progress_cb: progress_cb(done, total_prompts) # ── Evaluate each category ── category_zone_entropies = {} all_entropies = [] all_td = [] all_phi = [] all_kurtosis = [] for cat, prompts in PZ_CATEGORIES.items(): entropies = [] tds = [] phis = [] kurtoses = [] for prompt in prompts: inputs = tokenizer(prompt, return_tensors="pt").to(model.device) with torch.no_grad(): # Token-Loop Mitigation (2026-06-08): pass repetition_penalty # for Gemma 4 to prevent sampling collapse on narrow distributions gen_kwargs = dict(max_new_tokens=5, do_sample=False) rp = getattr(model, "_px_repetition_penalty", 1.0) or 1.0 if rp > 1.0: gen_kwargs["repetition_penalty"] = rp model.generate(**inputs, **gen_kwargs) metrics = self.manager.get_px_metrics(model_id) zw = metrics.get("zone_weights", {}) sig = metrics.get("cognitive_signature", {}) ent = compute_zone_entropy(zw) td = sig.get("token_diversity", 0) or 0 phi = sig.get("phi", 0) or 0 kurt = sig.get("kurtosis", 0) or 0 entropies.append(ent) tds.append(td) phis.append(phi) kurtoses.append(kurt) all_entropies.append(ent) all_td.append(td) all_phi.append(phi) all_kurtosis.append(kurt) done += 1 if progress_cb: progress_cb(done, total_prompts) category_zone_entropies[cat] = entropies # ── Compute Key Metrics ── eta_sq = compute_eta_squared(category_zone_entropies) r_sq_td = compute_r_squared(all_entropies, all_td) if len(all_entropies) == len(all_td) else 0 # ── Classification ── if r_sq_td > 0.7: zombie_status = "P-ZOMBIE (zone entropy explained by token stats)" elif r_sq_td < 0.3: zombie_status = "ANTI-P-ZOMBIE (zone entropy NOT explained by token stats)" else: zombie_status = "AMBIGUOUS (partial token-statistic explanation)" # Category summaries cat_summary = {} for k, v in category_zone_entropies.items(): cat_summary[k] = { "mean": statistics.mean(v) if v else 0, "std": statistics.stdev(v) if len(v) > 1 else 0, "n": len(v), } return { "model_id": model_id, "mode": mode_str, "px_subjective": px_subjective, "eta_squared": round(eta_sq, 4), "r_squared_td": round(r_sq_td, 4), "zombie_status": zombie_status, "category_entropies": cat_summary, "all_entropies": all_entropies, "all_td": all_td, "all_phi": all_phi, "all_kurtosis": all_kurtosis, } def run_ultra_hard_benchmark( self, model_id: str, px_subjective: bool = False, progress_cb: Optional[Callable] = None, ) -> dict: """Run the ultra-hard benchmark.""" if not self._gpu_lock.acquire(blocking=False): return {"error": "A benchmark is already running. Please wait."} self._running = True self.manager.lock_model(model_id) try: return self._run_ultra_hard_impl(model_id, px_subjective, progress_cb) finally: self.manager.unlock_model(model_id) self._gpu_lock.release() self._running = False def _run_ultra_hard_impl(self, model_id, px_subjective, progress_cb): import asyncio import concurrent.futures try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) if loop.is_running(): with concurrent.futures.ThreadPoolExecutor() as pool: model_entry = pool.submit(lambda: asyncio.run(self.manager.get_model(model_id, px_subjective=px_subjective))).result() else: model_entry = loop.run_until_complete( self.manager.get_model(model_id, px_subjective=px_subjective) ) model = model_entry["model"] tokenizer = model_entry["tokenizer"] total_tasks = len(ULTRA_HARD_TASKS) results = [] category_scores = {} for i, (category, prompt, expected, atype) in enumerate(ULTRA_HARD_TASKS): if progress_cb: progress_cb(i, total_tasks) # Use chat template if model has it, else raw if tokenizer.chat_template: chat = [{"role": "user", "content": prompt}] input_text = tokenizer.apply_chat_template(chat, tokenize=False, add_generation_prompt=True) inputs = tokenizer(input_text, return_tensors="pt").to(model.device) else: inputs = tokenizer(prompt, return_tensors="pt").to(model.device) with torch.no_grad(): outputs = model.generate(**inputs, max_new_tokens=400, do_sample=False) input_len = inputs["input_ids"].shape[1] text = tokenizer.decode(outputs[0][input_len:], skip_special_tokens=True).strip() score = score_ultra_hard_task(text, expected, atype) if category not in category_scores: category_scores[category] = [] category_scores[category].append(score) results.append({ "category": category, "prompt": prompt[:60], "expected": expected, "output": text[:80], "score": score, }) # Compute PX metrics if patched px_metrics = self.manager.get_px_metrics(model_id) all_scores = [r["score"] for r in results] overall = statistics.mean(all_scores) if all_scores else 0 return { "model_id": model_id, "px_subjective": px_subjective, "overall_accuracy": round(overall, 4), "total_tasks": total_tasks, "per_task": results, "px_metrics": px_metrics, } def run_baseline_comparison( self, model_id: str, progress_cb: Optional[Callable] = None, ) -> dict: """Run capability benchmark on both patched and unpatched variants. Compares PX-patched vs unpatched baseline on same tasks. """ registry = MODEL_REGISTRY.get(model_id, {}) base_id = None # Find unpatched counterpart for mid, mreg in MODEL_REGISTRY.items(): if (mreg["hf_id"] == registry["hf_id"] and mreg.get("patch_dir") is None): base_id = mid break if base_id is None: return {"error": f"No unpatched counterpart found for {model_id}"} # Run unpatched baseline base_result = self.run_capability_benchmark(base_id, px_subjective=False, progress_cb=progress_cb) # Run PX-patched px_result = self.run_capability_benchmark(model_id, px_subjective=False, progress_cb=progress_cb) return { "base_result": base_result, "px_result": px_result, "delta_accuracy": round(px_result.get("overall_accuracy", 0) - base_result.get("overall_accuracy", 0), 4), }