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>
This commit is contained in:
parent
8dd5073cd1
commit
6ecfdf6ae0
11 changed files with 244 additions and 60 deletions
|
|
@ -446,6 +446,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
|
||||||
| `LLM_TIMEOUT` | Zeitlimit je LLM-Aufruf in Sekunden | 420 |
|
| `LLM_TIMEOUT` | Zeitlimit je LLM-Aufruf in Sekunden | 420 |
|
||||||
| `LLM_REASONING_EFFORT` | `low`, `medium` oder `high` (OpenRouter) | Modellvorgabe |
|
| `LLM_REASONING_EFFORT` | `low`, `medium` oder `high` (OpenRouter) | Modellvorgabe |
|
||||||
| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des Anbieters in Tokens/s | 75 |
|
| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des Anbieters in Tokens/s | 75 |
|
||||||
|
| `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` |
|
||||||
| `SOLVER_SANDBOX` | `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` (ungeschützt) | `auto` |
|
| `SOLVER_SANDBOX` | `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` (ungeschützt) | `auto` |
|
||||||
|
|
||||||
Beispiel für einen einzelnen Lauf mit geänderten Einstellungen:
|
Beispiel für einen einzelnen Lauf mit geänderten Einstellungen:
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ Exit-Codes: `0` Ergebnis zuverlässig, `2` Ergebnis vorhanden, aber unzuverläss
|
||||||
| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des OpenRouter-Anbieters (Tokens/s); Präferenz, kein harter Filter | 75 |
|
| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des OpenRouter-Anbieters (Tokens/s); Präferenz, kein harter Filter | 75 |
|
||||||
| `LLM_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 |
|
| `LLM_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 |
|
||||||
| `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe |
|
| `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe |
|
||||||
|
| `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` |
|
||||||
| `SOLVER_SANDBOX` | Isolierung des erzeugten Codes: `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` | `auto` |
|
| `SOLVER_SANDBOX` | Isolierung des erzeugten Codes: `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` | `auto` |
|
||||||
| `LLM_MODEL_UNDERSTANDING`, `LLM_MODEL_EXTRACTION`, `LLM_MODEL_REVIEW`, `LLM_MODEL_REVISION`, `LLM_MODEL_SOLVER` | Modell einzelner Pipeline-Stufen, z. B. ein schnelles für den Solver-Code; verschiedene Anbieter sind erlaubt | Modell aus `--model` |
|
| `LLM_MODEL_UNDERSTANDING`, `LLM_MODEL_EXTRACTION`, `LLM_MODEL_REVIEW`, `LLM_MODEL_REVISION`, `LLM_MODEL_SOLVER` | Modell einzelner Pipeline-Stufen, z. B. ein schnelles für den Solver-Code; verschiedene Anbieter sind erlaubt | Modell aus `--model` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ from data import data_profile
|
||||||
from modeling.extraction import revise_model, text_to_model
|
from modeling.extraction import revise_model, text_to_model
|
||||||
from reporting.report import write_report
|
from reporting.report import write_report
|
||||||
from solving.script_export import build_solver_script
|
from solving.script_export import build_solver_script
|
||||||
from solving.solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve
|
from solving.solver_stage import (SOLVER_TIMEOUT, check_feasibility, check_multistart, check_sensitivity,
|
||||||
|
run_solver_code, solve)
|
||||||
from understanding.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions
|
from understanding.figures import FigureError, normalize_figure_ids, split_figures, with_transcriptions
|
||||||
from understanding.understand import read_figures, understand_task
|
from understanding.understand import read_figures, understand_task
|
||||||
|
|
||||||
|
|
@ -374,7 +375,7 @@ def continue_pipeline(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = 60,
|
def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = SOLVER_TIMEOUT,
|
||||||
data_files=()) -> PipelineResult:
|
data_files=()) -> PipelineResult:
|
||||||
"""Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf.
|
"""Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ PROMPT_FILES = ("understanding/Verstehen-Prompt.txt", "understanding/Abbildungs-
|
||||||
# Umgebungsvariablen, die das Verhalten ändern – ohne sie ließe sich ein A/B-Lauf nicht zuordnen
|
# 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",
|
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",
|
"UNDERSTAND_RETRIES", "MAX_REVISIONS", "SOLVER_RETRIES", "MAX_RESTARTS", "INFEASIBLE_RECHECK",
|
||||||
"LLM_REASONING_EFFORT", "SOLVER_SANDBOX")
|
"LLM_REASONING_EFFORT", "SOLVER_SANDBOX", "SOLVER_TIMEOUT")
|
||||||
|
|
||||||
|
|
||||||
def prompt_digest(paths=None) -> str:
|
def prompt_digest(paths=None) -> str:
|
||||||
|
|
@ -63,7 +63,8 @@ def prompt_state() -> dict:
|
||||||
text=True, timeout=10).stdout.strip()
|
text=True, timeout=10).stdout.strip()
|
||||||
try:
|
try:
|
||||||
state["commit"] = git("rev-parse", "--short", "HEAD") or "unbekannt"
|
state["commit"] = git("rev-parse", "--short", "HEAD") or "unbekannt"
|
||||||
state["dirty"] = bool(git("status", "--porcelain"))
|
# 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):
|
except (OSError, subprocess.SubprocessError):
|
||||||
state["commit"], state["dirty"] = "unbekannt", None
|
state["commit"], state["dirty"] = "unbekannt", None
|
||||||
return state
|
return state
|
||||||
|
|
|
||||||
|
|
@ -256,12 +256,12 @@ def _chart_for_table(table):
|
||||||
return ""
|
return ""
|
||||||
col = table.columns.index(table.objective_column)
|
col = table.columns.index(table.objective_column)
|
||||||
labels, values, total = [], [], None
|
labels, values, total = [], [], None
|
||||||
for row in table.rows:
|
for n, row in enumerate(table.rows):
|
||||||
value = row[col] if col < len(row) else None
|
value = row[col] if col < len(row) else None
|
||||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
continue
|
continue
|
||||||
label = str(row[0]) if row else ""
|
label = str(row[0]) if row else ""
|
||||||
if _is_sum_row(row):
|
if _is_sum_row(row, last=n == len(table.rows) - 1):
|
||||||
total = (label, value)
|
total = (label, value)
|
||||||
else:
|
else:
|
||||||
labels.append(label)
|
labels.append(label)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ Ergebnisausgabe:
|
||||||
- sensitivity: Wirkung der bindenden Restriktionen, durch NEULÖSEN ermittelt (nicht aus Solver-Attributen ablesen). Für höchstens 8 bindende Restriktionen (linke Seite erreicht die Grenze genau), am besten die inhaltlich wichtigsten:
|
- sensitivity: Wirkung der bindenden Restriktionen, durch NEULÖSEN ermittelt (nicht aus Solver-Attributen ablesen). Für höchstens 8 bindende Restriktionen (linke Seite erreicht die Grenze genau), am besten die inhaltlich wichtigsten:
|
||||||
{"name": "<wie in constraints>", "delta": <Verschiebung > 0>, "objective_plus": <Zielwert mit rhs+delta oder null>, "objective_minus": <Zielwert mit rhs-delta oder null>, "unit": "€ je Einheit"}
|
{"name": "<wie in constraints>", "delta": <Verschiebung > 0>, "objective_plus": <Zielwert mit rhs+delta oder null>, "objective_minus": <Zielwert mit rhs-delta oder null>, "unit": "€ je Einheit"}
|
||||||
- delta klein wählen (etwa 1 % der rechten Seite, mindestens eine sinnvolle Einheit), das Modell je Richtung einmal neu lösen und den Zielwert eintragen; bei unlösbar null.
|
- delta klein wählen (etwa 1 % der rechten Seite, mindestens eine sinnvolle Einheit), das Modell je Richtung einmal neu lösen und den Zielwert eintragen; bei unlösbar null.
|
||||||
- Nur ausführen, wenn das Neulösen schnell geht (Gesamtlaufzeit unter 60 s), sonst leere Liste.
|
- Nur ausführen, wenn das Neulösen schnell geht (Gesamtlaufzeit unter {ZEITLIMIT} s), sonst leere Liste.
|
||||||
- relaxation_bound: bei ganzzahligen/binären Variablen der Zielwert der stetigen Relaxation (alle Ganzzahligkeitsforderungen aufgehoben), sonst null. Zeigt, was die Ganzzahligkeit kostet.
|
- relaxation_bound: bei ganzzahligen/binären Variablen der Zielwert der stetigen Relaxation (alle Ganzzahligkeitsforderungen aufgehoben), sonst null. Zeigt, was die Ganzzahligkeit kostet.
|
||||||
- answers: Die Rückprojektion auf die gestellte Aufgabe – genau ein Eintrag je Teilfrage aus der Liste im Auftrag, mit deren question_id. Das gilt auch dann, wenn es nur EINE Teilfrage gibt: answers bleibt nie leer.
|
- answers: Die Rückprojektion auf die gestellte Aufgabe – genau ein Eintrag je Teilfrage aus der Liste im Auftrag, mit deren question_id. Das gilt auch dann, wenn es nur EINE Teilfrage gibt: answers bleibt nie leer.
|
||||||
[{"question_id": "a", "text": "…"}, {"question_id": "b", "text": "…"}]
|
[{"question_id": "a", "text": "…"}, {"question_id": "b", "text": "…"}]
|
||||||
|
|
@ -94,7 +94,7 @@ Ergebnisausgabe:
|
||||||
- data_override enthält genau die Einträge von DATA, die sich ändern. Das Programm rechnet jedes Szenario selbst und trägt seinen objective_value ein.
|
- data_override enthält genau die Einträge von DATA, die sich ändern. Das Programm rechnet jedes Szenario selbst und trägt seinen objective_value ein.
|
||||||
- Die Pipeline führt dein Programm anschließend mit jedem data_override ERNEUT aus und vergleicht – abweichende Werte fallen auf. Kein LLM ist daran beteiligt. [→ verify_scenarios]
|
- Die Pipeline führt dein Programm anschließend mit jedem data_override ERNEUT aus und vergleicht – abweichende Werte fallen auf. Kein LLM ist daran beteiligt. [→ verify_scenarios]
|
||||||
- Damit das funktioniert, muss das Programm ausschließlich aus DATA lesen; höchstens fünf Szenarien werden nachgerechnet.
|
- Damit das funktioniert, muss das Programm ausschließlich aus DATA lesen; höchstens fünf Szenarien werden nachgerechnet.
|
||||||
- Jedes Szenario braucht einen objective_value – null nur, wenn es bewiesen unlösbar ist. Unterscheiden sich Szenarien nur in Zielgewichten, ist die Lösung eines Szenarios für die anderen zulässig: übergib sie als Startlösung, dann endet keines im Zeitlimit ohne Ergebnis. Zeitlimits so wählen, dass alle Läufe zusammen unter 60 s bleiben. [→ check_scenarios_solved]
|
- Jedes Szenario braucht einen objective_value – null nur, wenn es bewiesen unlösbar ist. Unterscheiden sich Szenarien nur in Zielgewichten, ist die Lösung eines Szenarios für die anderen zulässig: übergib sie als Startlösung, dann endet keines im Zeitlimit ohne Ergebnis. Zeitlimits so wählen, dass alle Läufe zusammen unter {ZEITLIMIT} s bleiben. [→ check_scenarios_solved]
|
||||||
- graph: NUR bei Graphenproblemen (Stack NetworkX), sonst null. Grundlage für die Zeichnung im Report:
|
- graph: NUR bei Graphenproblemen (Stack NetworkX), sonst null. Grundlage für die Zeichnung im Report:
|
||||||
{"nodes": ["0", "1", "2", "3"],
|
{"nodes": ["0", "1", "2", "3"],
|
||||||
"arcs": [{"source": "0", "target": "1", "weight": 8}, ...],
|
"arcs": [{"source": "0", "target": "1", "weight": 8}, ...],
|
||||||
|
|
@ -120,7 +120,7 @@ Ergebnisausgabe:
|
||||||
- Entscheidungswerte als formatierte Texte, Beiträge als Zahlen; Dezimalkomma im Text ist erlaubt.
|
- Entscheidungswerte als formatierte Texte, Beiträge als Zahlen; Dezimalkomma im Text ist erlaubt.
|
||||||
- unit: Einheit der objective_column für die Diagrammbeschriftung (z. B. " €", " %"), sonst weglassen.
|
- unit: Einheit der objective_column für die Diagrammbeschriftung (z. B. " €", " %"), sonst weglassen.
|
||||||
- note: ein Satz, wie die Tabelle zu lesen ist.
|
- note: ein Satz, wie die Tabelle zu lesen ist.
|
||||||
Erzeuge nach Möglichkeit diese drei Tabellen (in dieser Reihenfolge):
|
Erzeuge nach Möglichkeit diese drei Tabellen (in dieser Reihenfolge). Bei LP, MILP und CP ist die Zielwert-Zerlegung Pflicht (die Zielfunktion ist dort immer eine Summe), und jede objective_column muss sich zum Zielwert summieren – sonst wird das Programm zur Reparatur zurückgegeben. [→ check_objective_decomposition]
|
||||||
1. Die Objekttabelle wie oben beschrieben.
|
1. Die Objekttabelle wie oben beschrieben.
|
||||||
2. "Zielwert-Zerlegung": Zeilen für die Bestandteile der Zielfunktion (z. B. "Erwartete Rendite", "Risiko (λ·Varianz)", "Transaktionskosten") mit Spalten ["Bestandteil", "Wert", "Einheit"], Vorzeichen wie in der Zielfunktion (Abzüge negativ), objective_column "Wert", danach die Summenzeile "Zielwert". Alle Bestandteile müssen dieselbe Einheit haben; steht dort Unterschiedliches (z. B. "€" neben "dimensionslos"), ist das Modell fehlerhaft.
|
2. "Zielwert-Zerlegung": Zeilen für die Bestandteile der Zielfunktion (z. B. "Erwartete Rendite", "Risiko (λ·Varianz)", "Transaktionskosten") mit Spalten ["Bestandteil", "Wert", "Einheit"], Vorzeichen wie in der Zielfunktion (Abzüge negativ), objective_column "Wert", danach die Summenzeile "Zielwert". Alle Bestandteile müssen dieselbe Einheit haben; steht dort Unterschiedliches (z. B. "€" neben "dimensionslos"), ist das Modell fehlerhaft.
|
||||||
3. "Bindende Restriktionen": welche Grenzen die Lösung ausreizt, mit Spalten ["Restriktion", "Wert", "Grenze", "bindend"] und Werten aus der Lösung (z. B. "Höchstgewicht A02" | "30,00 %" | "30,00 %" | "ja"). Nur Tatsachen aus der Lösung, keine Bewertung; ohne objective_column. Die note erklärt „bindend" in Alltagssprache („die Lösung schöpft diese Grenze voll aus") – ohne Fachbegriffe. [→ check_text_quality]
|
3. "Bindende Restriktionen": welche Grenzen die Lösung ausreizt, mit Spalten ["Restriktion", "Wert", "Grenze", "bindend"] und Werten aus der Lösung (z. B. "Höchstgewicht A02" | "30,00 %" | "30,00 %" | "ja"). Nur Tatsachen aus der Lösung, keine Bewertung; ohne objective_column. Die note erklärt „bindend" in Alltagssprache („die Lösung schöpft diese Grenze voll aus") – ohne Fachbegriffe. [→ check_text_quality]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from core.llm import LLMSession
|
||||||
from checks.model_checks import numbers_in, unexplained_jargon
|
from checks.model_checks import numbers_in, unexplained_jargon
|
||||||
from core.model_schema import (
|
from core.model_schema import (
|
||||||
Answer,
|
Answer,
|
||||||
|
ProblemClass,
|
||||||
TargetStack,
|
TargetStack,
|
||||||
Scenario,
|
Scenario,
|
||||||
ConstraintValue,
|
ConstraintValue,
|
||||||
|
|
@ -40,6 +41,9 @@ RESULT_MARKER = "RESULT_JSON:"
|
||||||
# Isolierung des erzeugten Codes: "auto" nutzt bubblewrap, wenn vorhanden; "bwrap" verlangt es
|
# Isolierung des erzeugten Codes: "auto" nutzt bubblewrap, wenn vorhanden; "bwrap" verlangt es
|
||||||
# (Fehler, wenn es fehlt); "aus" führt ungeschützt aus (nur für Systeme ohne bubblewrap).
|
# (Fehler, wenn es fehlt); "aus" führt ungeschützt aus (nur für Systeme ohne bubblewrap).
|
||||||
SANDBOX = os.environ.get("SOLVER_SANDBOX", "auto").strip().lower()
|
SANDBOX = os.environ.get("SOLVER_SANDBOX", "auto").strip().lower()
|
||||||
|
# Zeitlimit je Programmlauf in Sekunden. Geht auch als Zeitbudget in den Solver-Prompt ({ZEITLIMIT}): ein höheres
|
||||||
|
# Limit nützt nur, wenn das Programm seine internen Solver-Zeitlimits danach wählt (SCIP-Szenarien, Portfolio Nr. 2).
|
||||||
|
SOLVER_TIMEOUT = int(os.environ.get("SOLVER_TIMEOUT", "60"))
|
||||||
|
|
||||||
ALLOWED_MODULES = {"json", "math", "itertools", "functools", "collections", "numpy", "scipy", "highspy", "ortools", "cvxpy", "pyscipopt", "cyipopt", "networkx", "pandas"}
|
ALLOWED_MODULES = {"json", "math", "itertools", "functools", "collections", "numpy", "scipy", "highspy", "ortools", "cvxpy", "pyscipopt", "cyipopt", "networkx", "pandas"}
|
||||||
FORBIDDEN_CALLS = {"__import__", "eval", "exec", "compile", "open", "input", "breakpoint"}
|
FORBIDDEN_CALLS = {"__import__", "eval", "exec", "compile", "open", "input", "breakpoint"}
|
||||||
|
|
@ -260,13 +264,21 @@ def _tail(text: str, limit: int = 2000) -> str:
|
||||||
|
|
||||||
|
|
||||||
SUM_ROW_LABELS = ("summe", "gesamt", "total", "σ", "sum", "zielwert", "ergebnis", "nutzen", "insgesamt")
|
SUM_ROW_LABELS = ("summe", "gesamt", "total", "σ", "sum", "zielwert", "ergebnis", "nutzen", "insgesamt")
|
||||||
|
# Vor der letzten Zeile zählt nur eine eindeutige Beschriftung: „Nutzen der eingepackten Gegenstände“ und
|
||||||
|
# „Gesamtnutzen …“ sind dort Bestandteile. Beobachtet: 15 Rucksack-Läufe meldeten „Summe (0) weicht vom
|
||||||
|
# Zielwert (51) ab“, weil der einzige Bestandteil als Summenzeile galt und aus der Summe fiel.
|
||||||
|
_INNER_SUM_ROW = re.compile(r"(zwischen)?summe\b.*|(gesamt|total|insgesamt|zielwert|σ|sum)\s*:?\s*", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _is_sum_row(row, value=None, others=None) -> bool:
|
def _is_sum_row(row, value=None, others=None, last: bool = True) -> bool:
|
||||||
"""Summenzeile: am Text erkennbar oder (letzte Zeile) am Wert = Summe der übrigen Zeilen."""
|
"""Summenzeile: am Text erkennbar oder (letzte Zeile) am Wert = Summe der übrigen Zeilen.
|
||||||
if row and isinstance(row[0], str) and row[0].strip().lower().startswith(SUM_ROW_LABELS):
|
|
||||||
|
Wortanfänge wie „Gesamt…“ oder „Nutzen…“ zählen nur in der letzten Zeile, davor nur eindeutige Beschriftungen.
|
||||||
|
"""
|
||||||
|
label = row[0].strip().lower() if row and isinstance(row[0], str) else ""
|
||||||
|
if label and (label.startswith(SUM_ROW_LABELS) if last else _INNER_SUM_ROW.fullmatch(label)):
|
||||||
return True
|
return True
|
||||||
if value is None or not others:
|
if not last or value is None or not others:
|
||||||
return False
|
return False
|
||||||
return _close(value, sum(others))
|
return _close(value, sum(others))
|
||||||
|
|
||||||
|
|
@ -286,6 +298,60 @@ def _close(a: float, b: float) -> bool:
|
||||||
return abs(a - b) <= 1e-3 * max(abs(a), abs(b)) + 1e-6
|
return abs(a - b) <= 1e-3 * max(abs(a), abs(b)) + 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def _is_number(value) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def check_table(table: ResultTable, objective_value) -> tuple:
|
||||||
|
"""Eine Tabelle gegen den Zielwert prüfen: (belegt, abweichung, Warnungen).
|
||||||
|
|
||||||
|
`belegt` heißt: die Beitragsspalte summiert sich zum gemeldeten Zielwert – der Zielwert ist damit aus
|
||||||
|
Bestandteilen nachgerechnet und nicht bloß behauptet. `abweichung` ist die Meldung, wenn sie es nicht tut.
|
||||||
|
"""
|
||||||
|
if not table.objective_column:
|
||||||
|
return False, None, []
|
||||||
|
if table.objective_column not in table.columns:
|
||||||
|
return False, None, [f"Tabelle '{table.title}': Spalte '{table.objective_column}' existiert nicht."]
|
||||||
|
col = table.columns.index(table.objective_column)
|
||||||
|
cells = [(row, row[col] if col < len(row) else None) for row in table.rows]
|
||||||
|
numeric = [v for _, v in cells if _is_number(v)]
|
||||||
|
letzte = len(cells) - 1
|
||||||
|
|
||||||
|
def summenzeile(n, row, v):
|
||||||
|
# letzte Zeile kann auch ohne Schlüsselwort die Summe sein (z. B. Beschriftung "Zielwert");
|
||||||
|
# eine einzige Zeile ist nie eine Summe („Gesamtnutzen …“ als einziger Bestandteil)
|
||||||
|
if letzte == 0:
|
||||||
|
return False
|
||||||
|
return _is_sum_row(row, v if n == letzte else None, numeric[:-1] if n == letzte else None, last=n == letzte)
|
||||||
|
|
||||||
|
values = [v for n, (row, v) in enumerate(cells) if not summenzeile(n, row, v)]
|
||||||
|
if not all(_is_number(v) for v in values):
|
||||||
|
return False, None, [f"Tabelle '{table.title}': Spalte '{table.objective_column}' enthält nicht nur "
|
||||||
|
f"Zahlen – Summe nicht prüfbar."]
|
||||||
|
total = sum(values)
|
||||||
|
if _is_comparison(values, len(values) == len(cells), total, objective_value):
|
||||||
|
return False, None, []
|
||||||
|
warnings, abweichung = [], None
|
||||||
|
belegt = objective_value is not None and bool(values) and _close(total, objective_value)
|
||||||
|
if objective_value is not None and not _close(total, objective_value):
|
||||||
|
abweichung = (f"Tabelle '{table.title}': Summe der Spalte '{table.objective_column}' ({total:.6g}) "
|
||||||
|
f"weicht vom Zielwert ({objective_value:.6g}) ab – Tabelle bitte nicht verwenden.")
|
||||||
|
warnings.append(abweichung)
|
||||||
|
if len(values) > 1 and max(abs(v) for v in values) > 0:
|
||||||
|
largest = max(abs(v) for v in values)
|
||||||
|
tiny = [row[0] for row, v in cells if _is_number(v) and v in values and 0 < abs(v) < largest * 1e-4]
|
||||||
|
if tiny:
|
||||||
|
warnings.append(
|
||||||
|
f"Tabelle '{table.title}': {', '.join(str(t) for t in tiny)} ist um Größenordnungen kleiner "
|
||||||
|
f"als die übrigen Bestandteile – vermutlich uneinheitliche Einheiten (z. B. relativ statt absolut)."
|
||||||
|
)
|
||||||
|
# nur die Schlusszeile: eine Zwischensumme weicht von der Gesamtsumme naturgemäß ab
|
||||||
|
if cells and _is_number(cells[-1][1]) and summenzeile(letzte, *cells[-1]) and not _close(cells[-1][1], total):
|
||||||
|
warnings.append(f"Tabelle '{table.title}': Summenzeile ({cells[-1][1]:.6g}) passt nicht zu den Zeilen "
|
||||||
|
f"({total:.6g}).")
|
||||||
|
return belegt, abweichung, warnings
|
||||||
|
|
||||||
|
|
||||||
def parse_result_tables(raw_tables: list, objective_value) -> tuple:
|
def parse_result_tables(raw_tables: list, objective_value) -> tuple:
|
||||||
"""Ergebnistabellen validieren und deterministisch gegen den Zielwert prüfen.
|
"""Ergebnistabellen validieren und deterministisch gegen den Zielwert prüfen.
|
||||||
|
|
||||||
|
|
@ -299,47 +365,37 @@ def parse_result_tables(raw_tables: list, objective_value) -> tuple:
|
||||||
warnings.append(f"Ergebnistabelle ungültig und verworfen: {str(exc)[:200]}")
|
warnings.append(f"Ergebnistabelle ungültig und verworfen: {str(exc)[:200]}")
|
||||||
continue
|
continue
|
||||||
tables.append(table)
|
tables.append(table)
|
||||||
if not table.objective_column:
|
warnings += check_table(table, objective_value)[2]
|
||||||
continue
|
|
||||||
if table.objective_column not in table.columns:
|
|
||||||
warnings.append(f"Tabelle '{table.title}': Spalte '{table.objective_column}' existiert nicht.")
|
|
||||||
continue
|
|
||||||
col = table.columns.index(table.objective_column)
|
|
||||||
cells = [(row, row[col] if col < len(row) else None) for row in table.rows]
|
|
||||||
numeric = [v for _, v in cells if isinstance(v, (int, float)) and not isinstance(v, bool)]
|
|
||||||
# letzte Zeile kann auch ohne Schlüsselwort die Summe sein (z. B. Beschriftung "Zielwert")
|
|
||||||
values = [v for n, (row, v) in enumerate(cells)
|
|
||||||
if not _is_sum_row(row, v if n == len(cells) - 1 else None, numeric[:-1] if n == len(cells) - 1 else None)]
|
|
||||||
if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
|
|
||||||
warnings.append(f"Tabelle '{table.title}': Spalte '{table.objective_column}' enthält nicht nur Zahlen – Summe nicht prüfbar.")
|
|
||||||
continue
|
|
||||||
total = sum(values)
|
|
||||||
if _is_comparison(values, len(values) == len(cells), total, objective_value):
|
|
||||||
continue
|
|
||||||
if objective_value is not None and not _close(total, objective_value):
|
|
||||||
warnings.append(
|
|
||||||
f"Tabelle '{table.title}': Summe der Spalte '{table.objective_column}' ({total:.6g}) "
|
|
||||||
f"weicht vom Zielwert ({objective_value:.6g}) ab – Tabelle bitte nicht verwenden."
|
|
||||||
)
|
|
||||||
if len(values) > 1 and max(abs(v) for v in values) > 0:
|
|
||||||
largest = max(abs(v) for v in values)
|
|
||||||
tiny = [row[0] for row, v in cells
|
|
||||||
if isinstance(v, (int, float)) and not isinstance(v, bool) and v in values
|
|
||||||
and 0 < abs(v) < largest * 1e-4]
|
|
||||||
if tiny:
|
|
||||||
warnings.append(
|
|
||||||
f"Tabelle '{table.title}': {', '.join(str(t) for t in tiny)} ist um Größenordnungen kleiner "
|
|
||||||
f"als die übrigen Bestandteile – vermutlich uneinheitliche Einheiten (z. B. relativ statt absolut)."
|
|
||||||
)
|
|
||||||
for n, (row, value) in enumerate(cells):
|
|
||||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
||||||
continue
|
|
||||||
if _is_sum_row(row, value if n == len(cells) - 1 else None, numeric[:-1] if n == len(cells) - 1 else None) \
|
|
||||||
and not _close(value, total):
|
|
||||||
warnings.append(f"Tabelle '{table.title}': Summenzeile ({value:.6g}) passt nicht zu den Zeilen ({total:.6g}).")
|
|
||||||
return tables, warnings
|
return tables, warnings
|
||||||
|
|
||||||
|
|
||||||
|
# Lineare Zielfunktionen sind immer eine Summe von Bestandteilen – dort ist die Zerlegung Pflicht. Bei
|
||||||
|
# QP/SOCP/NLP nicht (Volatilität = Wurzel ist nicht additiv); eine vorhandene Beitragsspalte muss aber stimmen.
|
||||||
|
DECOMPOSITION_REQUIRED = {ProblemClass.LP, ProblemClass.MILP, ProblemClass.CP}
|
||||||
|
DECOMPOSITION_MISSING = "Zielwert nicht aus Bestandteilen belegt"
|
||||||
|
|
||||||
|
|
||||||
|
def check_objective_decomposition(run, spec: OptimizationModelSpecification) -> list:
|
||||||
|
"""Ist der gemeldete Zielwert aus Bestandteilen nachgerechnet – oder nur behauptet?
|
||||||
|
|
||||||
|
`check_feasibility` prüft die Restriktionen, nicht den Zielwert. Den belegt erst eine Beitragsspalte,
|
||||||
|
deren Summe ihn ergibt. Gemessen an 90 Läufen linearer Klassen seit dem 17.09.2026: jede hatte eine –
|
||||||
|
die Pflicht kostet also kaum Reparaturen, fängt aber Rückfälle. Bleibt der Befund nach den Reparaturen,
|
||||||
|
ist er eine Warnung: die Lösung selbst ist geprüft zulässig.
|
||||||
|
"""
|
||||||
|
if run.status not in (SolverStatus.OPTIMAL, SolverStatus.FEASIBLE) or run.objective_value is None:
|
||||||
|
return []
|
||||||
|
geprueft = [check_table(t, run.objective_value) for t in run.tables]
|
||||||
|
befunde = [abweichung for _, abweichung, _ in geprueft if abweichung]
|
||||||
|
if spec.problem_class in DECOMPOSITION_REQUIRED and not any(belegt for belegt, _, _ in geprueft):
|
||||||
|
befunde.append(
|
||||||
|
f"{DECOMPOSITION_MISSING}: keine Ergebnistabelle hat eine objective_column, deren Summe den Zielwert "
|
||||||
|
f"{run.objective_value:.6g} ergibt. Die Zielfunktion ist linear, also eine Summe – melde die Tabelle "
|
||||||
|
f"„Zielwert-Zerlegung“ mit einer Zeile je Bestandteil, objective_column \"Wert\" und der Summenzeile "
|
||||||
|
f"„Zielwert“.")
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
def _parse_list(raw, model_type) -> list:
|
def _parse_list(raw, model_type) -> list:
|
||||||
"""Fehlerhafte Einträge verwerfen statt den Lauf scheitern zu lassen (die Lösung ist davon unberührt)."""
|
"""Fehlerhafte Einträge verwerfen statt den Lauf scheitern zu lassen (die Lösung ist davon unberührt)."""
|
||||||
parsed = []
|
parsed = []
|
||||||
|
|
@ -512,7 +568,7 @@ def solver_env(tmp: str) -> dict:
|
||||||
return {**env, "HOME": tmp, "TMPDIR": tmp, "PYTHONDONTWRITEBYTECODE": "1"}
|
return {**env, "HOME": tmp, "TMPDIR": tmp, "PYTHONDONTWRITEBYTECODE": "1"}
|
||||||
|
|
||||||
|
|
||||||
def run_solver_code(code: str, timeout: int = 60, data: dict | None = None,
|
def run_solver_code(code: str, timeout: int = SOLVER_TIMEOUT, data: dict | None = None,
|
||||||
data_files=()) -> SolverRun:
|
data_files=()) -> SolverRun:
|
||||||
try:
|
try:
|
||||||
validate_imports(code)
|
validate_imports(code)
|
||||||
|
|
@ -576,6 +632,11 @@ def run_solver_code(code: str, timeout: int = 60, data: dict | None = None,
|
||||||
return SolverRun(status=SolverStatus.ERROR, error=f"Ungültiges Ergebnis von report(): {exc}: {result_lines[0][:500]}")
|
return SolverRun(status=SolverStatus.ERROR, error=f"Ungültiges Ergebnis von report(): {exc}: {result_lines[0][:500]}")
|
||||||
|
|
||||||
|
|
||||||
|
def solver_prompt() -> str:
|
||||||
|
"""System-Prompt der Solver-Stufe mit dem Zeitbudget dieses Laufs (SOLVER_TIMEOUT)."""
|
||||||
|
return SOLVER_PROMPT_FILE.read_text(encoding="utf-8").replace("{ZEITLIMIT}", str(SOLVER_TIMEOUT))
|
||||||
|
|
||||||
|
|
||||||
def generate_solver_code(
|
def generate_solver_code(
|
||||||
session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
||||||
previous: Optional[SolverCode] = None, previous_error: Optional[str] = None, understanding=None,
|
previous: Optional[SolverCode] = None, previous_error: Optional[str] = None, understanding=None,
|
||||||
|
|
@ -597,7 +658,7 @@ def generate_solver_code(
|
||||||
return session.create(
|
return session.create(
|
||||||
SolverCode,
|
SolverCode,
|
||||||
[
|
[
|
||||||
{"role": "system", "content": SOLVER_PROMPT_FILE.read_text(encoding="utf-8")},
|
{"role": "system", "content": solver_prompt()},
|
||||||
{"role": "user", "content": content},
|
{"role": "user", "content": content},
|
||||||
],
|
],
|
||||||
step="Solver-Reparatur" if previous is not None else "Solver-Code",
|
step="Solver-Reparatur" if previous is not None else "Solver-Code",
|
||||||
|
|
@ -746,7 +807,7 @@ def check_text_quality(run, summary: str = "") -> list:
|
||||||
MAX_SCENARIOS = 5 # jedes Szenario kostet einen weiteren Programmlauf (kein LLM-Aufruf)
|
MAX_SCENARIOS = 5 # jedes Szenario kostet einen weiteren Programmlauf (kein LLM-Aufruf)
|
||||||
|
|
||||||
|
|
||||||
def verify_scenarios(run, code: str, timeout: int = 60, data_files=()) -> list:
|
def verify_scenarios(run, code: str, timeout: int = SOLVER_TIMEOUT, data_files=()) -> list:
|
||||||
"""Jedes Szenario mit übersteuerten Daten NEU rechnen und den gemeldeten Zielwert bestätigen.
|
"""Jedes Szenario mit übersteuerten Daten NEU rechnen und den gemeldeten Zielwert bestätigen.
|
||||||
|
|
||||||
Ein Szenario ist eine Datenvariante desselben Programms – dieselbe Maschinerie wie `--resolve`.
|
Ein Szenario ist eine Datenvariante desselben Programms – dieselbe Maschinerie wie `--resolve`.
|
||||||
|
|
@ -797,7 +858,7 @@ def check_scenarios_solved(run) -> list:
|
||||||
f"{szenario.verified_status or 'nicht ausgeführt'}) – vermutlich ein Zeitlimit ohne Lösung. "
|
f"{szenario.verified_status or 'nicht ausgeführt'}) – vermutlich ein Zeitlimit ohne Lösung. "
|
||||||
f"„Keine Lösung\" nur bei bewiesener Unlösbarkeit. Haben die Szenarien dieselben Restriktionen, die "
|
f"„Keine Lösung\" nur bei bewiesener Unlösbarkeit. Haben die Szenarien dieselben Restriktionen, die "
|
||||||
f"Lösung eines anderen Szenarios als Startlösung übergeben; Zeitlimits so wählen, dass alle Läufe "
|
f"Lösung eines anderen Szenarios als Startlösung übergeben; Zeitlimits so wählen, dass alle Läufe "
|
||||||
f"zusammen unter 60 s bleiben.")
|
f"zusammen unter {SOLVER_TIMEOUT} s bleiben.")
|
||||||
return befunde
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1033,7 +1094,7 @@ def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification
|
||||||
|
|
||||||
|
|
||||||
def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
||||||
retries: int = 2, timeout: int = 60, log=lambda msg: None, understanding=None,
|
retries: int = 2, timeout: int = SOLVER_TIMEOUT, log=lambda msg: None, understanding=None,
|
||||||
data_files=(), data_prompt: str = "") -> tuple:
|
data_files=(), data_prompt: str = "") -> tuple:
|
||||||
"""Code erzeugen und ausführen; bei Fehlern bis zu `retries` Reparaturversuche.
|
"""Code erzeugen und ausführen; bei Fehlern bis zu `retries` Reparaturversuche.
|
||||||
|
|
||||||
|
|
@ -1047,16 +1108,18 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
||||||
run = _check_and_run(solver_code.code, timeout, spec, understanding, task, data_files, data_prompt)
|
run = _check_and_run(solver_code.code, timeout, spec, understanding, task, data_files, data_prompt)
|
||||||
run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
|
run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
|
||||||
einheiten = check_answer_units(run, task, data_prompt)
|
einheiten = check_answer_units(run, task, data_prompt)
|
||||||
|
zerlegung = check_objective_decomposition(run, spec)
|
||||||
attempt = 1
|
attempt = 1
|
||||||
while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios
|
while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data or run.unsolved_scenarios
|
||||||
or einheiten) and attempt <= retries:
|
or einheiten or zerlegung) and attempt <= retries:
|
||||||
# Alle Befundarten zugleich melden – sonst kostet jede weitere Art einen eigenen Reparaturversuch
|
# Alle Befundarten zugleich melden – sonst kostet jede weitere Art einen eigenen Reparaturversuch
|
||||||
fehler = run.error or "\n\n".join(
|
fehler = run.error or "\n\n".join(
|
||||||
titel + ":\n- " + "\n- ".join(befunde)
|
titel + ":\n- " + "\n- ".join(befunde)
|
||||||
for titel, befunde in (("Die Lösung hält das Modell nicht ein", run.feasibility),
|
for titel, befunde in (("Die Lösung hält das Modell nicht ein", run.feasibility),
|
||||||
("Eingabedaten nicht ausgewiesen", run.undeclared_data),
|
("Eingabedaten nicht ausgewiesen", run.undeclared_data),
|
||||||
("Szenario ohne Ergebnis", run.unsolved_scenarios),
|
("Szenario ohne Ergebnis", run.unsolved_scenarios),
|
||||||
("Einheit in der Antwort erfunden", einheiten))
|
("Einheit in der Antwort erfunden", einheiten),
|
||||||
|
("Zielwert nicht nachvollziehbar", zerlegung))
|
||||||
if befunde)
|
if befunde)
|
||||||
log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}")
|
log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}")
|
||||||
solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler,
|
solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler,
|
||||||
|
|
@ -1065,8 +1128,10 @@ def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
||||||
run = _check_and_run(solver_code.code, timeout, spec, understanding, task, data_files, data_prompt)
|
run = _check_and_run(solver_code.code, timeout, spec, understanding, task, data_files, data_prompt)
|
||||||
run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
|
run.undeclared_data = check_generated_data(solver_code.code, solver_code, spec)
|
||||||
einheiten = check_answer_units(run, task, data_prompt)
|
einheiten = check_answer_units(run, task, data_prompt)
|
||||||
|
zerlegung = check_objective_decomposition(run, spec)
|
||||||
attempt += 1
|
attempt += 1
|
||||||
# Bleibt die erfundene Einheit, ist das Ergebnis trotzdem richtig – Warnung, nicht unzuverlässig
|
# Bleiben erfundene Einheit oder fehlende Zerlegung, ist die Lösung trotzdem geprüft zulässig – Warnung, nicht
|
||||||
run.warnings += einheiten
|
# unzuverlässig. Eine abweichende Summe steht schon in den Warnungen (parse_result_tables).
|
||||||
|
run.warnings += einheiten + [z for z in zerlegung if z.startswith(DECOMPOSITION_MISSING)]
|
||||||
run.attempts = attempt
|
run.attempts = attempt
|
||||||
return solver_code, run
|
return solver_code, run
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,8 @@ def main():
|
||||||
"""Loest und beantwortet."""
|
"""Loest und beantwortet."""
|
||||||
report("optimal", 9.0, {"x": 1.0},
|
report("optimal", 9.0, {"x": 1.0},
|
||||||
headline="Die Kosten betragen 9 Geldeinheiten.",
|
headline="Die Kosten betragen 9 Geldeinheiten.",
|
||||||
|
tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"],
|
||||||
|
"objective_column": "Wert", "rows": [["Kosten", 9.0]]}],
|
||||||
constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}],
|
constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}],
|
||||||
variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}],
|
variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}],
|
||||||
answers=[{"question_id": "a", "text": "Das Modell ist ein lineares Programm."},
|
answers=[{"question_id": "a", "text": "Das Modell ist ein lineares Programm."},
|
||||||
|
|
@ -467,6 +469,8 @@ DATA = {"faktor": 1.0}
|
||||||
def main():
|
def main():
|
||||||
"""Meldet zwei Datenvarianten."""
|
"""Meldet zwei Datenvarianten."""
|
||||||
report("optimal", 7.0, {"x": 1.0},
|
report("optimal", 7.0, {"x": 1.0},
|
||||||
|
tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"],
|
||||||
|
"objective_column": "Wert", "rows": [["Kosten", 7.0]]}],
|
||||||
constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}],
|
constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}],
|
||||||
variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}],
|
variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}],
|
||||||
scenarios=[{"name": "Doppelt", "description": "Faktor verdoppelt",
|
scenarios=[{"name": "Doppelt", "description": "Faktor verdoppelt",
|
||||||
|
|
@ -689,3 +693,46 @@ if __name__ == "__main__":
|
||||||
assert session.steps == ["Solver-Code", "Solver-Reparatur"]
|
assert session.steps == ["Solver-Code", "Solver-Reparatur"]
|
||||||
assert "hält das Modell nicht ein" in session.letzter_prompt
|
assert "hält das Modell nicht ein" in session.letzter_prompt
|
||||||
assert "Eingabedaten nicht ausgewiesen" in session.letzter_prompt
|
assert "Eingabedaten nicht ausgewiesen" in session.letzter_prompt
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zielwert-Zerlegung in der Solver-Stufe ---
|
||||||
|
|
||||||
|
OHNE_ZERLEGUNG = STRUCTURED_CODE.split(" tables=[")[0] + " )\n\n\nif __name__ == \"__main__\":\n main()\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_decomposition_is_repaired():
|
||||||
|
assert "tables" not in OHNE_ZERLEGUNG
|
||||||
|
session = FakeSession([GOOD], [OK], [SolverCode(code=OHNE_ZERLEGUNG), SolverCode(code=STRUCTURED_CODE)])
|
||||||
|
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
|
||||||
|
assert session.steps.count("Solver-Reparatur") == 1
|
||||||
|
assert "Zielwert-Zerlegung" in session.letzter_prompt
|
||||||
|
assert result.solver_run.attempts == 2 and result.reliable
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistently_missing_decomposition_is_only_a_warning():
|
||||||
|
# Die Lösung ist geprüft zulässig – nur ihr Zielwert ist nicht aus Bestandteilen belegt
|
||||||
|
from solving.solver_stage import DECOMPOSITION_MISSING
|
||||||
|
session = FakeSession([GOOD], [OK], [SolverCode(code=OHNE_ZERLEGUNG)] * 3)
|
||||||
|
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
|
||||||
|
assert result.reliable and result.solver_run.objective_value == 9
|
||||||
|
assert any(w.startswith(DECOMPOSITION_MISSING) for w in result.solver_run.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_untracked_files_do_not_mark_the_measurement_dirty(monkeypatch):
|
||||||
|
# Eine abgelegte Aufgabe in tasks/ ließ bisher jede Messung als „nicht eingecheckte Änderungen“ erscheinen
|
||||||
|
import subprocess
|
||||||
|
from benchmark import benchmark
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
def git(befehl, **_):
|
||||||
|
aufrufe.append(befehl)
|
||||||
|
return subprocess.CompletedProcess(befehl, 0, stdout="abc1234\n" if "rev-parse" in befehl else "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(benchmark.subprocess, "run", git)
|
||||||
|
benchmark.prompt_state.cache_clear()
|
||||||
|
try:
|
||||||
|
stand = benchmark.prompt_state()
|
||||||
|
finally:
|
||||||
|
benchmark.prompt_state.cache_clear()
|
||||||
|
assert stand["dirty"] is False
|
||||||
|
assert any("--untracked-files=no" in befehl for befehl in aufrufe)
|
||||||
|
|
|
||||||
|
|
@ -345,3 +345,54 @@ def test_english_unit_words_cover_german_answers():
|
||||||
aufgabe = "One ton of raw material X costs 3 thousand euros; at least 4 tons are required."
|
aufgabe = "One ton of raw material X costs 3 thousand euros; at least 4 tons are required."
|
||||||
assert check_answer_units(_run_with_answer("Es sollten 1 Tonne X und 3 Tonnen Y gekauft werden, Kosten 9 Tausend Euro."), aufgabe) == []
|
assert check_answer_units(_run_with_answer("Es sollten 1 Tonne X und 3 Tonnen Y gekauft werden, Kosten 9 Tausend Euro."), aufgabe) == []
|
||||||
assert check_answer_units(_run_with_answer("It takes 12 hours."), "Minimize the cost.") != [] # erfunden bleibt erfunden
|
assert check_answer_units(_run_with_answer("It takes 12 hours."), "Minimize the cost.") != [] # erfunden bleibt erfunden
|
||||||
|
|
||||||
|
|
||||||
|
# --- Summenzeilen und Zielwert-Zerlegung (check_table, check_objective_decomposition) ---
|
||||||
|
|
||||||
|
def _tabelle(rows):
|
||||||
|
from core.model_schema import ResultTable
|
||||||
|
return ResultTable(title="Zielwert-Zerlegung", columns=["Bestandteil", "Wert"], rows=rows, objective_column="Wert")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rows", [
|
||||||
|
[["Nutzen der eingepackten Gegenstände", 51.0], ["Zielwert", 51.0]], # beobachtet: 15 Rucksack-Läufe
|
||||||
|
[["Gesamtnutzen der eingepackten Gegenstände", 51]], # einzige Zeile ist keine Summe
|
||||||
|
[["Nutzen Kocher", 13.0], ["Nutzen Schlafsack", 23.0], ["Nutzen Proviant", 15.0], ["Zielwert", 51.0]],
|
||||||
|
[["Kocher", 13.0], ["Schlafsack", 23.0], ["Zwischensumme", 36.0], ["Proviant", 15.0], ["Summe", 51.0]],
|
||||||
|
])
|
||||||
|
def test_components_named_like_a_sum_are_not_dropped(rows):
|
||||||
|
from solving.solver_stage import check_table
|
||||||
|
belegt, abweichung, warnungen = check_table(_tabelle(rows), 51.0)
|
||||||
|
assert belegt and abweichung is None and warnungen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_decomposition_is_still_detected():
|
||||||
|
from solving.solver_stage import check_table
|
||||||
|
belegt, abweichung, _ = check_table(_tabelle([["Kocher", 13.0], ["Schlafsack", 23.0], ["Summe", 36.0]]), 51.0)
|
||||||
|
assert not belegt and "weicht vom Zielwert" in abweichung
|
||||||
|
|
||||||
|
|
||||||
|
def _lauf(tables, status="optimal", objective=51.0):
|
||||||
|
from core.model_schema import SolverRun, SolverStatus
|
||||||
|
return SolverRun(status=SolverStatus(status), objective_value=objective, tables=tables)
|
||||||
|
|
||||||
|
|
||||||
|
def test_linear_model_without_decomposition_is_a_finding():
|
||||||
|
from solving.solver_stage import DECOMPOSITION_MISSING, check_objective_decomposition
|
||||||
|
befunde = check_objective_decomposition(_lauf([]), make_spec())
|
||||||
|
assert len(befunde) == 1 and befunde[0].startswith(DECOMPOSITION_MISSING)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decomposition_is_optional_for_non_additive_objectives():
|
||||||
|
# Volatilität = Wurzel: keine Summe, also keine Pflicht – eine vorhandene Beitragsspalte muss aber stimmen
|
||||||
|
from solving.solver_stage import check_objective_decomposition
|
||||||
|
spec = make_spec(problem_class="Second-Order Cone Programming (SOCP)", target_stack="CVXPY")
|
||||||
|
assert check_objective_decomposition(_lauf([]), spec) == []
|
||||||
|
falsch = _tabelle([["Rendite", 0.1], ["Risiko", -0.02]])
|
||||||
|
assert "weicht vom Zielwert" in check_objective_decomposition(_lauf([falsch], objective=0.5), spec)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_decomposition_needed_without_a_solution():
|
||||||
|
from solving.solver_stage import check_objective_decomposition
|
||||||
|
assert check_objective_decomposition(_lauf([], status="infeasible", objective=None), make_spec()) == []
|
||||||
|
assert check_objective_decomposition(_lauf([_tabelle([["Kosten", 51.0]])]), make_spec()) == []
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ def main():
|
||||||
constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}],
|
constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}],
|
||||||
variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3},
|
variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3},
|
||||||
{"name": "y", "value": y, "var_type": "continuous", "lb": 0, "ub": 3}],
|
{"name": "y", "value": y, "var_type": "continuous", "lb": 0, "ub": 3}],
|
||||||
|
tables=[{"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"], "objective_column": "Wert",
|
||||||
|
"rows": [["Kosten Rohstoff X", COSTS[0] * x], ["Kosten Rohstoff Y", COSTS[1] * y],
|
||||||
|
["Zielwert", float(res.fun)]]}],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -870,3 +870,17 @@ def test_sandbox_hides_the_home_directory():
|
||||||
assert run.status == SolverStatus.OPTIMAL, run.error
|
assert run.status == SolverStatus.OPTIMAL, run.error
|
||||||
# Sichtbar bleiben höchstens die Einhängepunkte der Python-Installationen (.venv, Basis-Python)
|
# Sichtbar bleiben höchstens die Einhängepunkte der Python-Installationen (.venv, Basis-Python)
|
||||||
assert run.solution["home_eintraege"] <= 2
|
assert run.solution["home_eintraege"] <= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_budget_in_the_prompt_follows_solver_timeout(monkeypatch):
|
||||||
|
# Ein höheres Zeitlimit nützt nur, wenn auch das Programm seine internen Solver-Zeitlimits danach wählt
|
||||||
|
from solving import solver_stage
|
||||||
|
assert "{ZEITLIMIT}" not in solver_stage.solver_prompt() and "unter 60 s" in solver_stage.solver_prompt()
|
||||||
|
monkeypatch.setattr(solver_stage, "SOLVER_TIMEOUT", 180)
|
||||||
|
assert "unter 180 s" in solver_stage.solver_prompt() and "unter 60 s" not in solver_stage.solver_prompt()
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_ends_a_slow_program():
|
||||||
|
code = "def main():\n while True:\n pass\n\n\nif __name__ == '__main__':\n main()\n"
|
||||||
|
run = run_solver_code(code, timeout=1)
|
||||||
|
assert run.status == SolverStatus.ERROR and "Timeout nach 1 s" in run.error
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue