#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import re import subprocess import sys from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any DEFAULT_MODELS = ["gpt-4o", "gpt-5.4"] DEFAULT_CASES = [ "CASE-A-001", "CASE-A-002", "CASE-A-003", "CASE-A-004", "CASE-B-001", "CASE-B-002", "CASE-B-003", "CASE-B-004", "CASE-B-005", "CASE-C-001", "CASE-C-002", "CASE-C-003", "CASE-C-004", "CASE-D-001", "CASE-D-002", "CASE-D-003", "CASE-D-004", "CASE-D-005", "CASE-D-006", "CASE-E-001", "CASE-E-002", ] PASS_THRESHOLD = 0.85 DEFAULT_TIMEOUT_SECONDS = 1200 START_RE = re.compile(r"^\[START\]\s+task=(\S+)\s+env=(\S+)\s+model=(\S+)\s*$") END_RE = re.compile( r"^\[END\]\s+success=(true|false)\s+steps=(\d+)\s+(?:score=([0-9]+\.[0-9]+)\s+)?rewards=(.*)\s*$" ) API_CALLS_RE = re.compile(r"^Total API calls:\s*(\d+)\s*$") @dataclass class ModelStats: model: str average_score: float success_rate: float min_score: float max_score: float failed_cases: list[str] case_scores: dict[str, float] api_calls: int debug_artifact_dir: str = "" model_profile: dict[str, Any] = field(default_factory=dict) def _model_dirname(model: str) -> str: cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", model.strip()) return cleaned or "model" def _score_from_end(score_field: str | None, rewards_field: str) -> float: if score_field: try: return float(score_field) except ValueError: pass parts = [p.strip() for p in rewards_field.split(",") if p.strip()] if not parts: return 0.0 try: return float(parts[-1]) except ValueError: return 0.0 def _base_model_score(model_name: str) -> float: normalized = re.sub(r"\s+", " ", model_name.strip().lower()) if "gpt-4o" in normalized: base = 4.6 else: match = re.search(r"gpt-([0-9]+(?:\.[0-9]+)?)", normalized) if match: base = float(match.group(1)) elif "gpt-4" in normalized: base = 4.0 elif "gpt-3.5" in normalized: base = 3.5 else: base = 4.0 if "pro" in normalized: base += 0.5 if "latest" in normalized: base += 0.2 if "mini" in normalized: base -= 0.7 if "nano" in normalized: base -= 1.1 if "turbo" in normalized: base -= 0.3 return base def _fallback_model_profile(model_name: str) -> dict[str, Any]: score = _base_model_score(model_name) if score >= 5.0: return { "model_name": model_name, "capability_score": score, "tier": "elite", "plan_mode": "coverage", "repair_level": "grounded", "investigation_budget_bonus": 2, "intervention_budget_bonus": 2, "decision_token_budget": 1536, "planning_token_budget": 640, } if score >= 4.5: return { "model_name": model_name, "capability_score": score, "tier": "strong", "plan_mode": "hybrid", "repair_level": "partial", "investigation_budget_bonus": 1, "intervention_budget_bonus": 1, "decision_token_budget": 1280, "planning_token_budget": 512, } return { "model_name": model_name, "capability_score": score, "tier": "standard", "plan_mode": "llm", "repair_level": "none", "investigation_budget_bonus": 0, "intervention_budget_bonus": 0, "decision_token_budget": 1024, "planning_token_budget": 384, } def _model_profile_for(model_name: str) -> dict[str, Any]: try: from inference_llm_powered import get_model_capability_profile return asdict(get_model_capability_profile(model_name)) except Exception: return _fallback_model_profile(model_name) def _load_model_profile_from_debug_artifacts(debug_artifact_dir: Path | None) -> dict[str, Any]: if debug_artifact_dir is None or not debug_artifact_dir.exists(): return {} for artifact_path in sorted(debug_artifact_dir.glob("*.json")): try: payload = json.loads(artifact_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): continue profile = payload.get("model_profile") if isinstance(profile, dict) and profile: return profile return {} def _display_path(path: Path | None) -> str: if path is None: return "" return path.as_posix() def _capability_score(stats: ModelStats) -> float: value = stats.model_profile.get("capability_score", 0.0) try: return float(value) except (TypeError, ValueError): return 0.0 def build_capability_summary(results: list[ModelStats]) -> dict[str, Any]: ordered = sorted(results, key=lambda item: (_capability_score(item), item.model)) ordered_models = [item.model for item in ordered] pairwise: list[dict[str, Any]] = [] violations: list[dict[str, Any]] = [] for weaker, stronger in zip(ordered, ordered[1:]): avg_gap = round(stronger.average_score - weaker.average_score, 4) success_gap = round(stronger.success_rate - weaker.success_rate, 4) ordering_ok = avg_gap >= 0.0 and success_gap >= 0.0 comparison = { "weaker_model": weaker.model, "stronger_model": stronger.model, "weaker_tier": weaker.model_profile.get("tier", "unknown"), "stronger_tier": stronger.model_profile.get("tier", "unknown"), "average_score_gap": avg_gap, "success_rate_gap": success_gap, "ordering_ok": ordering_ok, } pairwise.append(comparison) if not ordering_ok: violations.append(comparison) return { "ordered_models": ordered_models, "monotonic_by_capability": not violations, "pairwise": pairwise, "violations": violations, } def build_output_payload( results: list[ModelStats], *, cases: list[str], pass_threshold: float, api_base_url: str, env_url: str, ) -> dict[str, Any]: return { "generated_at_utc": datetime.now(timezone.utc).isoformat(), "models": [r.model for r in results], "cases": cases, "case_count": len(cases), "pass_threshold": pass_threshold, "runtime_context": { "python_version": sys.version.split()[0], "python_executable": sys.executable, "api_base_url": api_base_url, "env_url": env_url, }, "capability_order": build_capability_summary(results), "results": [ { "model": r.model, "model_profile": r.model_profile, "average_score": round(r.average_score, 4), "success_rate": round(r.success_rate, 4), "min_score": round(r.min_score, 4), "max_score": round(r.max_score, 4), "api_calls": r.api_calls, "debug_artifact_dir": r.debug_artifact_dir, "failed_cases": r.failed_cases, "case_scores": {k: round(v, 4) for k, v in r.case_scores.items()}, } for r in results ], } def run_one_model( model: str, *, repo_root: Path, api_base_url: str, env_url: str, api_key: str, cases: list[str], timeout_seconds: int, pass_threshold: float, debug_artifact_dir: Path | None = None, ) -> ModelStats: env = os.environ.copy() env["MODEL_NAME"] = model env["API_BASE_URL"] = api_base_url env["ENV_URL"] = env_url env["OPENAI_API_KEY"] = api_key cmd = [sys.executable, "inference_llm_powered.py", "--cases", *cases] if debug_artifact_dir is not None: cmd.extend(["--debug-artifact-dir", str(debug_artifact_dir)]) proc = subprocess.run( cmd, cwd=str(repo_root), env=env, capture_output=True, text=True, timeout=timeout_seconds, check=False, ) if proc.returncode != 0: raise RuntimeError(f"{model} run failed (exit {proc.returncode}):\n{proc.stderr}") current_case: str | None = None case_scores: dict[str, float] = {} api_calls = -1 for raw_line in proc.stdout.splitlines(): line = raw_line.strip() start_match = START_RE.match(line) if start_match: current_case = start_match.group(1) continue end_match = END_RE.match(line) if end_match and current_case: case_scores[current_case] = _score_from_end(end_match.group(3), end_match.group(4)) current_case = None continue api_calls_match = API_CALLS_RE.match(line) if api_calls_match: api_calls = int(api_calls_match.group(1)) if not case_scores: raise RuntimeError(f"{model} run did not produce parseable case scores.\nSTDOUT:\n{proc.stdout}") if api_calls == 0: raise RuntimeError( f"{model} produced 0 API calls. The run likely fell back to heuristic logic instead of live model inference." ) scores = list(case_scores.values()) avg_score = sum(scores) / len(scores) failed = [case for case, score in case_scores.items() if score < pass_threshold] success_rate = (len(scores) - len(failed)) / len(scores) model_profile = _load_model_profile_from_debug_artifacts(debug_artifact_dir) or _model_profile_for(model) return ModelStats( model=model, average_score=avg_score, success_rate=success_rate, min_score=min(scores), max_score=max(scores), failed_cases=failed, case_scores=case_scores, api_calls=api_calls, debug_artifact_dir=_display_path(debug_artifact_dir), model_profile=model_profile, ) def _fmt_failed(cases: list[str]) -> str: if not cases: return "0" labels = ", ".join(cases) return f"{len(cases)} ({labels})" def print_table(results: list[ModelStats]) -> None: if not results: return metric_width = 18 col_width = max(20, min(36, max(len(item.model) for item in results) + 6)) def row(metric: str, values: list[str]) -> str: return f"{metric:<{metric_width}}" + "".join(f"{value:<{col_width}}" for value in values) print("\n" + "=" * (metric_width + col_width * len(results))) print(row("Metric", [item.model for item in results])) print("-" * (metric_width + col_width * len(results))) print(row("Tier", [str(item.model_profile.get("tier", "unknown")) for item in results])) print(row("Capability", [f"{_capability_score(item):.1f}" for item in results])) print(row("Average Score", [f"{item.average_score:.4f}" for item in results])) print(row("Success Rate", [f"{item.success_rate * 100:.1f}%" for item in results])) print(row("Min Score", [f"{item.min_score:.2f}" for item in results])) print(row("Max Score", [f"{item.max_score:.2f}" for item in results])) print(row("API Calls", [str(item.api_calls) for item in results])) print(row("Failed Cases", [_fmt_failed(item.failed_cases) for item in results])) print("=" * (metric_width + col_width * len(results)) + "\n") def main() -> int: parser = argparse.ArgumentParser(description="Live model-vs-model comparison for LedgerShield.") parser.add_argument("--models", default=",".join(DEFAULT_MODELS), help="Comma-separated model names.") parser.add_argument("--cases", default=",".join(DEFAULT_CASES), help="Comma-separated case ids.") parser.add_argument("--output", default="live_model_comparison.json", help="Output JSON path.") parser.add_argument( "--timeout-seconds", type=int, default=DEFAULT_TIMEOUT_SECONDS, help="Per-model subprocess timeout for inference_llm_powered.py.", ) args = parser.parse_args() models = [m.strip() for m in args.models.split(",") if m.strip()] cases = [case.strip() for case in args.cases.split(",") if case.strip()] if len(models) < 2: print("Need at least 2 models in --models", file=sys.stderr) return 2 if not cases: print("Need at least 1 case in --cases", file=sys.stderr) return 2 api_key = os.getenv("OPENAI_API_KEY") if not api_key: print("OPENAI_API_KEY is required", file=sys.stderr) return 2 api_base_url = os.getenv("API_BASE_URL", "https://api.openai.com/v1") env_url = os.getenv("ENV_URL", "http://127.0.0.1:8000") repo_root = Path(__file__).resolve().parent out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) debug_root = out_path.with_name(f"{out_path.stem}_debug") results: list[ModelStats] = [] for model in models: print(f"Running {model} ...") stats = run_one_model( model, repo_root=repo_root, api_base_url=api_base_url, env_url=env_url, api_key=api_key, cases=cases, timeout_seconds=args.timeout_seconds, pass_threshold=PASS_THRESHOLD, debug_artifact_dir=debug_root / _model_dirname(model), ) results.append(stats) output_payload = build_output_payload( results, cases=cases, pass_threshold=PASS_THRESHOLD, api_base_url=api_base_url, env_url=env_url, ) out_path.write_text(json.dumps(output_payload, indent=2), encoding="utf-8") print_table(results) capability_summary = output_payload["capability_order"] print( "Capability ordering check:", "PASS" if capability_summary["monotonic_by_capability"] else "VIOLATION", ) print(f"Saved detailed output to: {out_path}") print(f"Saved debug case traces to: {debug_root}") return 0 if __name__ == "__main__": raise SystemExit(main())