text_to_solver_program/benchmark/benchmark.py
dschlueter 6ecfdf6ae0 feat: Zielwert-Zerlegung als Reparaturgrund, SOLVER_TIMEOUT, Summenzeilen-Fehlalarm behoben
- check_objective_decomposition: bei LP/MILP/CP muss eine objective_column den Zielwert
  belegen, eine abweichende Beitragsspalte ist überall ein Befund -> Solver-Reparatur,
  bleibt es: Warnung. 90 von 90 linearen Läufen seit 17.09. hätten bestanden.
- Summenzeilen: „Gesamt…“/„Nutzen…“ nur noch in der letzten Zeile, einzelne Zeile nie,
  Zwischensummen nicht gegen die Gesamtsumme. Vorher 15 Rucksack-Läufe mit falschem
  „Summe (0) weicht vom Zielwert (51) ab“; von 17 Abweichungen bleibt 1 echte.
- SOLVER_TIMEOUT (Default 60) für alle Programmläufe und als {ZEITLIMIT} im Solver-Prompt
- Benchmark: dirty zählt nur versionierte Dateien; SOLVER_TIMEOUT in der Konfiguration

Ändert die Prompt-Kennung (Solver-Prompt.txt).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:24:46 +02:00

251 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Benchmark: Referenzaufgaben mit bekanntem Optimum durch die Pipeline schicken und Modelle vergleichen.
Beispiel:
.venv/bin/python benchmark.py --models deepseek/deepseek-v4-flash-0731 deepseek/deepseek-v4-pro-0813 --runs 2
"""
import argparse
import hashlib
import json
import os
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from datetime import datetime
from pathlib import Path
from core.llm import DEFAULT_MODEL, is_fatal_error
from TextToModel import run_pipeline
BENCHMARK_DIR = Path(__file__).parent
ROOT = BENCHMARK_DIR.parent
CASES_DIR = BENCHMARK_DIR / "cases"
RESULTS_DIR = BENCHMARK_DIR / "results"
# Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten
quota_exhausted = threading.Event()
# Dateien, die das LLM-Verhalten steuern: die System-Prompts und die Feldbeschreibungen
# (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung).
PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "understanding/Abbildungs-Prompt.txt",
"modeling/Routing-und-Extraktions-Prompt.txt", "checks/Pruef-Prompt.txt",
"solving/Solver-Prompt.txt",
"core/model_schema.py")
# Umgebungsvariablen, die das Verhalten ändern – ohne sie ließe sich ein A/B-Lauf nicht zuordnen
CONFIG_VARS = ("LLM_MODEL", "LLM_MODEL_EXTRACTION", "LLM_MODEL_REVIEW", "LLM_MODEL_REVISION", "LLM_MODEL_SOLVER",
"UNDERSTAND_RETRIES", "MAX_REVISIONS", "SOLVER_RETRIES", "MAX_RESTARTS", "INFEASIBLE_RECHECK",
"LLM_REASONING_EFFORT", "SOLVER_SANDBOX", "SOLVER_TIMEOUT")
def prompt_digest(paths=None) -> str:
"""Kurzer Hash über alle Dateien, die das LLM-Verhalten steuern."""
digest = hashlib.sha256()
for path in paths if paths is not None else [ROOT / n for n in PROMPT_FILES]:
digest.update(Path(path).read_bytes())
return digest.hexdigest()[:12]
@lru_cache(maxsize=1)
def prompt_state() -> dict:
"""Womit wurde gemessen? Ohne diese Angaben lassen sich zwei Ergebnisdateien nicht vergleichen.
Beobachtet: die Messlatte 19/22 ließ sich keinem Prompt-Stand zuordnen, weil nur Fall, Modell und
Laufnummer gespeichert wurden – jede Prompt-Änderung entwertete die Vergleichbarkeit still.
"""
state = {"prompts": prompt_digest()}
gesetzt = {name: os.environ[name] for name in CONFIG_VARS if os.environ.get(name)}
if gesetzt:
state["config"] = gesetzt
def git(*args):
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True,
text=True, timeout=10).stdout.strip()
try:
state["commit"] = git("rev-parse", "--short", "HEAD") or "unbekannt"
# nur versionierte Dateien: eine abgelegte Aufgabe in tasks/ ändert nichts am gemessenen Stand
state["dirty"] = bool(git("status", "--porcelain", "--untracked-files=no"))
except (OSError, subprocess.SubprocessError):
state["commit"], state["dirty"] = "unbekannt", None
return state
def load_cases(names=None):
cases = []
for case_dir in sorted(p for p in CASES_DIR.iterdir() if p.is_dir()):
if names and case_dir.name not in names:
continue
cases.append({
"name": case_dir.name,
"task": (case_dir / "task.txt").read_text(encoding="utf-8"),
"expected": json.loads((case_dir / "expected.json").read_text(encoding="utf-8")),
# Alles außer task.txt/expected.json gilt als beigelegte Datendatei (--daten)
"data_files": sorted(str(f) for f in case_dir.iterdir()
if f.is_file() and f.name not in ("task.txt", "expected.json")),
})
return cases
def result_correct(solver_run, expected: dict) -> bool:
"""Status muss passen (Default optimal); bei optimal zusätzlich der Zielwert."""
expected_status = expected.get("status", "optimal")
if solver_run is None or solver_run.status.value != expected_status:
return False
return expected_status != "optimal" or objective_matches(solver_run.objective_value, expected)
def objective_matches(actual, expected: dict) -> bool:
if actual is None:
return False
# Toleranz relativ, mindestens absolut (für Zielwerte < 1)
return abs(actual - expected["objective_value"]) <= expected["tolerance"] * max(1.0, abs(expected["objective_value"]))
def run_case(case: dict, model: str, run_no: int) -> dict:
expected = case["expected"]
row = {"case": case["name"], "model": model, "run": run_no, **prompt_state()}
if quota_exhausted.is_set():
row.update(seconds=0, error="übersprungen: fataler API-Fehler in früherem Lauf")
return row
start = time.monotonic()
try:
result = run_pipeline(case["task"], model=model, data_files=case.get("data_files") or ())
except Exception as exc: # API-/Validierungsfehler sollen den Benchmark nicht abbrechen
message = f"{type(exc).__name__}: {exc}"
if is_fatal_error(message):
quota_exhausted.set()
row.update(seconds=round(time.monotonic() - start), error=message[:500])
return row
spec, solver_run = result.specification, result.solver_run
if spec is None: # abgelehnt: keine Optimierungsaufgabe erkannt
fragen = len(result.understanding.sub_questions) if result.understanding else 0
row.update(seconds=round(time.monotonic() - start), problem_class=None, target_stack=None,
class_ok=False, stack_ok=False, check_errors=0, check_warnings=len(result.checks),
revisions=0, review_ok=None, data_assumptions=0, solver_status="abgelehnt", solver_attempts=0,
objective_value=None, objective_ok=False, questions=fragen, answered=0, answer_findings=0,
task_ok=False, solver_error="; ".join(result.reliability_issues), reliable=False, restarts=0,
providers=result.usage.providers if result.usage else {},
responded_models=result.usage.responded_models if result.usage else {},
llm_calls=result.usage.calls if result.usage else 0,
reasoning_tokens=result.usage.reasoning_tokens if result.usage else 0,
cost_usd=result.usage.cost_usd if result.usage else None,
result=json.loads(result.model_dump_json()))
return row
# "Aufgabe erfüllt" statt nur "Zahl richtig": alle Teilfragen beantwortet, keine unbelegte Zahl
fragen = {q.id for q in result.understanding.sub_questions} if result.understanding else set()
antworten = {a.question_id for a in solver_run.answers} if solver_run else set()
abnahme = [w for w in (solver_run.warnings if solver_run else []) if "Antwort zu" in w or "Teilfragen ohne" in w]
objective_ok = result.reliable and result_correct(solver_run, expected)
row.update(
seconds=round(time.monotonic() - start),
problem_class=spec.problem_class.value,
target_stack=spec.target_stack.value,
class_ok=spec.problem_class.value == expected.get("problem_class", spec.problem_class.value),
stack_ok=spec.target_stack.value == expected.get("target_stack", spec.target_stack.value),
check_errors=sum(1 for i in result.checks if i.severity.value == "error"),
check_warnings=sum(1 for i in result.checks if i.severity.value == "warning"),
revisions=result.revisions,
review_ok=not result.review.needs_revision,
data_assumptions=len(result.solver_code.data_assumptions) if result.solver_code else 0,
solver_status=solver_run.status.value if solver_run else "nicht gestartet",
solver_attempts=solver_run.attempts if solver_run else 0,
objective_value=solver_run.objective_value if solver_run else None,
objective_ok=objective_ok,
questions=len(fragen),
answered=len(fragen & antworten),
answer_findings=len(abnahme),
task_ok=bool(objective_ok and fragen and fragen <= antworten and not abnahme
and len(fragen) >= expected.get("min_sub_questions", 1)),
solver_error=solver_run.error if solver_run else "; ".join(result.reliability_issues),
reliable=result.reliable,
restarts=result.restarts,
providers=result.usage.providers,
responded_models=result.usage.responded_models,
llm_calls=result.usage.calls,
reasoning_tokens=result.usage.reasoning_tokens,
cost_usd=result.usage.cost_usd,
result=json.loads(result.model_dump_json()),
)
return row
def mark(value) -> str:
return {True: "ja", False: "NEIN", None: "-"}.get(value, str(value))
def print_table(rows):
header = ["Fall", "Modell", "#", "Klasse", "Stack", "ChkErr", "Rev", "Review", "Solver", "Zielwert", "Zuverl.", "Neust.", "Treffer", "Fragen", "Antw.", "Erfüllt", "Datenann.", "Aufrufe", "Reasoning", "USD", "s"]
lines = [header]
for r in rows:
if "error" in r:
lines.append([r["case"], r["model"], str(r["run"]), "-", "-", "-", "-", "-", "ABBRUCH", "-", "-", "-", "NEIN", "-", "-", "NEIN", "-", "-", "-", "-", str(r["seconds"])])
continue
objective = "-" if r["objective_value"] is None else f"{r['objective_value']:.6g}"
lines.append([
r["case"], r["model"], str(r["run"]), mark(r["class_ok"]), mark(r["stack_ok"]), str(r["check_errors"]),
str(r["revisions"]), mark(r["review_ok"]), r["solver_status"], objective, mark(r["reliable"]), str(r["restarts"]), mark(r["objective_ok"]),
str(r.get("questions", 0)), str(r.get("answered", 0)), mark(r.get("task_ok")),
str(r["data_assumptions"]), str(r["llm_calls"]), str(r["reasoning_tokens"]),
"-" if r["cost_usd"] is None else f"{r['cost_usd']:.4f}", str(r["seconds"]),
])
widths = [max(len(line[i]) for line in lines) for i in range(len(header))]
for n, line in enumerate(lines):
print(" ".join(cell.ljust(w) for cell, w in zip(line, widths)))
if n == 0:
print(" ".join("-" * w for w in widths))
stand = prompt_state()
geaendert = " + nicht eingecheckte Änderungen" if stand.get("dirty") else ""
print(f"\nGemessener Stand: Commit {stand['commit']}{geaendert}, Prompt-Kennung {stand['prompts']} "
f"(über {', '.join(PROMPT_FILES)})")
if stand.get("config"):
print("Abweichende Einstellungen: " + ", ".join(f"{k}={v}" for k, v in stand["config"].items()))
print("\nZusammenfassung je Modell:")
for model in dict.fromkeys(r["model"] for r in rows):
mine = [r for r in rows if r["model"] == model]
hits = sum(1 for r in mine if r.get("objective_ok"))
erfuellt = sum(1 for r in mine if r.get("task_ok"))
routing = sum(1 for r in mine if r.get("class_ok") and r.get("stack_ok"))
seconds = sum(r["seconds"] for r in mine) / len(mine)
cost = sum(r.get("cost_usd") or 0 for r in mine)
print(f" {model}: Ergebnis korrekt {hits}/{len(mine)}, Aufgabe erfüllt {erfuellt}/{len(mine)}, Routing korrekt {routing}/{len(mine)}, "
f"Ø {seconds:.0f} s pro Lauf, Kosten gesamt ${cost:.4f} (ohne abgebrochene Läufe)")
antwortend = {}
for r in mine:
for name, n in (r.get("responded_models") or {}).items():
antwortend[name] = antwortend.get(name, 0) + n
if antwortend and set(antwortend) != {model}:
print(f" Modell laut Antwort: {antwortend}")
for r in rows:
if r.get("error") or r.get("solver_error"):
print(f"\n[{r['case']} | {r['model']} | #{r['run']}] {r.get('error') or r.get('solver_error')}")
if quota_exhausted.is_set():
print("\nABBRUCH: fataler API-Fehler (Kontingent, Guthaben oder Key) – restliche Läufe wurden übersprungen.")
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--models", nargs="+", default=[DEFAULT_MODEL])
parser.add_argument("--runs", type=int, default=1, help="Läufe je Fall und Modell (Streuung sichtbar machen)")
parser.add_argument("--cases", nargs="+", help="nur diese Fälle (Verzeichnisnamen)")
parser.add_argument("--jobs", type=int, default=1, help="parallele Läufe (Gemini Free Tier: 1)")
args = parser.parse_args()
cases = load_cases(args.cases)
jobs = [(case, model, run_no) for model in args.models for case in cases for run_no in range(1, args.runs + 1)]
with ThreadPoolExecutor(max_workers=args.jobs) as pool:
rows = list(pool.map(lambda job: run_case(*job), jobs))
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
out_file = RESULTS_DIR / f"{datetime.now():%Y%m%d-%H%M%S}.json"
out_file.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
print()
print_table(rows)
print(f"\nDetails: {out_file}")
if __name__ == "__main__":
main()