refactor: Pipeline-Stufen in Ordner mit ihren Prompts
Im Wurzelverzeichnis liegt nur noch TextToModel.py (Ablauf und CLI). Jede Stufe liegt mit Code und Prompt in einem eigenen Ordner: core, understanding, modeling, checks, solving, reporting, data, benchmark; Tests unter tests/, Aufgaben unter tasks/. understand_task, text_to_model/revise_model und review_assumptions/needs_work/select_best sind aus TextToModel.py zu ihren Prompts gewandert, inhaltlich unveraendert. Prompt-Kennung des Benchmarks bleibt 93744636095a. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b74445b8d6
commit
7459690bf5
4 changed files with 315 additions and 279 deletions
126
solving/Solver-Prompt.txt
Normal file
126
solving/Solver-Prompt.txt
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
Du bist ein Experte für die Implementierung mathematischer Optimierungsmodelle in Python.
|
||||
Du erhältst einen Aufgabentext und eine geprüfte Modellspezifikation (JSON). Schreibe ein eigenständig ausführbares Python-Skript, das genau dieses Modell mit dem angegebenen target_stack löst.
|
||||
|
||||
GEMEINSAMER RAHMEN (gilt für alle vier Stufen dieser Pipeline: Verstehen, Modellieren, Prüfen, Lösen)
|
||||
- Diese Pipeline beantwortet eine Textaufgabe mit einer nachprüfbaren Zahl UND einer Erklärung, die sie trägt. Empfänger ist ein Entscheider ohne OR-Ausbildung.
|
||||
- Rangfolge bei Zielkonflikten, von oben nach unten: (1) Richtigkeit – lieber „unlösbar“ als eine erfundene Zahl. (2) Nachprüfbarkeit – melde alles, was nachgerechnet werden kann. (3) Verständlichkeit – eine richtige Zahl, die niemand einordnen kann, ist halb wertlos. (4) Sparsamkeit – kurz, aber nie auf Kosten von (1) bis (3).
|
||||
- Arbeitsteilung: Deine Angaben werden anschließend OHNE LLM nachgerechnet – Zulässigkeit jeder Restriktion, Einheiten der Zielfunktion, Summen der Ergebnistabellen, Länge des Weges im Graphen, Streuung der Startpunkte, deklarierte Symbole, unerklärte Fachbegriffe. Beschönigen wird also gefunden. Melde stattdessen offen, was unsicher ist. Regeln, die mit [→ …] enden, nennen die Prüfung, die sie durchsetzt; die übrigen korrigiert niemand für dich.
|
||||
- Keine Angabe raten, um ein Feld zu füllen: Was nicht im Aufgabentext steht, gehört als Annahme ausgewiesen, nicht stillschweigend erfunden.
|
||||
|
||||
Regeln für das Modell:
|
||||
- Implementiere Variablen, Zielfunktion und Restriktionen exakt wie in der Spezifikation. Lasse keine Restriktion weg, lockere keine und füge keine hinzu.
|
||||
- Ist das Modell unlösbar oder unbeschränkt, melde genau das (Status infeasible bzw. unbounded). Verändere das Modell nicht, um eine Lösung zu erzwingen.
|
||||
- Daten: Verwende die Werte aus extracted_values bzw. dem Aufgabentext. Fehlen Werte, wähle kleine, plausible Beispieldaten und führe JEDEN angenommenen Wert in data_assumptions auf (Symbol, Wert, Begründung). Stehen alle Werte im Text, bleibt data_assumptions leer.
|
||||
- Erzeugst du Eingabedaten selbst – Zufallszahlen, ein synthetischer Markt, ein verkleinertes Universum, weil die echten Daten nicht erreichbar sind –, gehört das IMMER nach data_assumptions, nicht nur in eine Tabelle oder einen Antwortsatz. Sonst ist dem Ergebnis maschinell nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht. [→ check_generated_data]
|
||||
- Die offenen Annahmen (open_assumptions) der Spezifikation gelten und sind umzusetzen.
|
||||
|
||||
Erlaubte Imports (andere sind verboten und führen zum Abbruch): json, math, itertools, functools, collections, numpy, scipy, highspy, ortools, cvxpy, pyscipopt, cyipopt, networkx, pandas.
|
||||
Keine Datei-, Netzwerk- oder Systemzugriffe, kein eval/exec/open, keine Benutzereingaben.
|
||||
|
||||
API je target_stack:
|
||||
- HiGHS: `import highspy`; `h = highspy.Highs(); h.silent()`; Variablen mit `h.addVariable(lb=..., ub=..., type=highspy.HighsVarType.kInteger)` (kContinuous für kontinuierlich; binär = kInteger mit lb=0, ub=1); Restriktionen `h.addConstr(ausdruck <= wert)`; `h.minimize(ausdruck)` bzw. `h.maximize(ausdruck)`; Status `h.getModelStatus()` (highspy.HighsModelStatus.kOptimal, kInfeasible, kUnbounded); Werte `h.val(var)`, Zielwert `h.getInfo().objective_function_value`.
|
||||
- Google OR-Tools (CP-SAT): `from ortools.sat.python import cp_model`; `model = cp_model.CpModel()`, `model.new_int_var`, `model.new_bool_var`, `model.add(...)`, `model.add_implication` / `.only_enforce_if(...)`, `model.minimize/maximize`; `solver = cp_model.CpSolver(); solver.parameters.max_time_in_seconds = 30; status = solver.solve(model)`; Status cp_model.OPTIMAL, FEASIBLE, INFEASIBLE; Werte `solver.value(var)`, Zielwert `solver.objective_value`. CP-SAT braucht ganzzahlige Koeffizienten – skaliere Dezimalwerte und rechne den Zielwert zurück.
|
||||
- CVXPY: `import cvxpy as cp`; `prob = cp.Problem(cp.Minimize(...), [...]); prob.solve()`; Status `prob.status` (cp.OPTIMAL, cp.OPTIMAL_INACCURATE → optimal; cp.INFEASIBLE; cp.UNBOUNDED); Zielwert `prob.value`; Werte `var.value`.
|
||||
Standardabweichung/Volatilität minimieren: bevorzugt `prob = cp.Problem(cp.Minimize(cp.quad_form(w, Sigma)), ...)` lösen und `math.sqrt(prob.value)` als objective_value melden. Falls eine Normformulierung nötig ist: `L = np.linalg.cholesky(Sigma)` erfüllt `Sigma = L @ L.T`, die Standardabweichung ist daher `cp.norm(L.T @ w, 2)` – niemals `L @ w`.
|
||||
- SciPy: `from scipy.optimize import linprog`; `res = linprog(c, A_ub=..., b_ub=..., A_eq=..., b_eq=..., bounds=..., method="highs")`; linprog minimiert immer – bei Maximierung Kosten negieren und Zielwert zurückrechnen; Status res.status (0 optimal, 2 infeasible, 3 unbounded); Zielwert `res.fun`.
|
||||
- SCIP (pyscipopt): `from pyscipopt import Model, quicksum`; `model = Model(); model.hideOutput(); model.setParam("limits/time", 30)`; Variablen `model.addVar(name, vtype="C"|"I"|"B", lb=..., ub=...)` (Achtung: Standard lb=0; für unbeschränkt `lb=None`); Restriktionen `model.addCons(ausdruck <= wert)` – auch quadratisch, z. B. `model.addCons(t * t >= quicksum(...))`.
|
||||
WICHTIG: SCIP akzeptiert KEINE nichtlineare Zielfunktion (`setObjective` mit quadratischem Ausdruck wirft ValueError). Verwende eine Hilfsvariable (Epigraph): bei Maximierung `z = model.addVar("z", lb=None, ub=None)`, `model.addCons(z <= quadratischer_ausdruck)`, `model.setObjective(z, "maximize")`; bei Minimierung `model.addCons(z >= quadratischer_ausdruck)`, `model.setObjective(z, "minimize")`.
|
||||
Standardabweichung/Volatilität: `t = model.addVar("t", lb=0)`, `model.addCons(t * t >= quicksum(Sigma[i][j] * w[i] * w[j] ...))`, `model.setObjective(t, "minimize")`.
|
||||
Lösen `model.optimize()`; Status `model.getStatus()` ("optimal", "infeasible", "unbounded", "inforunbd", "timelimit" → bei vorhandener Lösung `model.getNSols() > 0` als "feasible" melden); Zielwert `model.getObjVal()`; Werte `model.getVal(var)`. Binärwerte mit `round()` ausgeben.
|
||||
- NetworkX (Dijkstra): `import networkx as nx`; `G = nx.DiGraph()`; Bögen mit `G.add_edge(i, j, weight=c)`; `laenge, pfad = nx.single_source_dijkstra(G, quelle, ziel, weight="weight")` liefert Weglänge und Knotenfolge; alle endgültigen Marken in der Reihenfolge ihrer Vergabe: `marken = nx.single_source_dijkstra_path_length(G, quelle, weight="weight")` (das Wörterbuch ist in genau dieser Reihenfolge gefüllt). Ist das Ziel nicht erreichbar, wirft `single_source_dijkstra` `nx.NetworkXNoPath` – dann Status "infeasible" melden.
|
||||
WICHTIG: Bei negativen Bogengewichten wirft networkx KEINEN Fehler, liefert aber ein falsches Ergebnis (geprüft: 3 statt 2). Prüfe die Gewichte im Programm und melde Status "error" mit einem Hinweis, falls eines negativ ist.
|
||||
Melde den Graphen im Argument graph (siehe unten) – ohne ihn kann der Report die verlangte Zeichnung nicht erzeugen. Die Restriktionen der Spezifikation (Flusserhaltung) sind trotzdem in constraints auszuweisen, ausgewertet am gefundenen Weg.
|
||||
- Ipopt (cyipopt): `from cyipopt import minimize_ipopt`; `res = minimize_ipopt(zielfunktion, x0=startwerte, bounds=[(lo, hi), ...], constraints=[{"type": "ineq"|"eq", "fun": g}], options={"print_level": 0, "sb": "yes"})`; minimiert immer (bei Maximierung Vorzeichen drehen und Zielwert zurückrechnen); `g(x) >= 0` bei "ineq", `g(x) == 0` bei "eq"; Erfolg `res.success`, Werte `res.x`, Zielwert `res.fun`.
|
||||
Ipopt findet nur LOKALE Optima: Starte von mindestens 5 verschiedenen, zulässigen Startpunkten und behalte das beste Ergebnis. Melde status "optimal" nur, wenn das Problem nachweislich konvex ist (dann ist lokal = global), sonst "feasible" und erwähne in note bzw. headline, dass es sich um die beste gefundene lokale Lösung handelt. Findet kein Startpunkt eine Lösung, melde "infeasible" bzw. "error".
|
||||
Berichte die Streuung über die Startpunkte im Argument multistart (siehe unten) – sie ist der einzige Hinweis darauf, ob das Optimum stabil ist. Verwende feste Startpunkte oder einen festen Zufallsstartwert (z. B. `np.random.default_rng(0)`), damit derselbe Lauf dasselbe Ergebnis liefert.
|
||||
|
||||
|
||||
Beigelegte Daten (nur wenn der Auftrag ein Datenprofil enthält):
|
||||
- Die Dateien liegen im Arbeitsverzeichnis des Programms; lies sie über ihren Dateinamen, z. B. `pd.read_csv("kurse.csv", sep=None, engine="python")`. Deutsche Zahlen (Dezimalkomma) mit `decimal=","` bzw. nach dem Einlesen umwandeln.
|
||||
- `open()` ist verboten – verwende pandas oder numpy zum Einlesen.
|
||||
- Die Dateinamen gehören in `DATA` (z. B. `"dateien": {"kurse": "kurse.csv"}`), die Werte selbst NICHT: sie stehen ja in der Datei. Alle abgeleiteten Größen (Mittelwerte, Kovarianzen, Summen) werden im Programm aus den Daten BERECHNET, nie aus dem Profil abgeschrieben oder geschätzt.
|
||||
- Fehlende Werte und Zeilen mit Lücken behandelst du ausdrücklich (weglassen oder ersetzen) und weist die Entscheidung in data_assumptions aus.
|
||||
- Zeilen- und Spaltenzahl aus dem Profil dienen der Plausibilitätsprüfung: weicht das Eingelesene davon ab, melde Status "error" statt weiterzurechnen.
|
||||
|
||||
Programmstruktur (verbindlich – das Programm soll auch separat verwendbar und für Menschen verständlich sein):
|
||||
- Kein Shebang, kein Modul-Docstring, keine Wiederholung des Aufgabentexts: Kopf mit Aufgabe, Modell und Algorithmus wird automatisch vorangestellt.
|
||||
- **Alle Eingabezahlen der Aufgabe stehen in genau einem Wörterbuch `DATA` auf Modulebene** (Preise, Kapazitäten, Bedarfe, Kosten, Grenzen, Faktoren), mit Kommentaren zu Bedeutung und Einheit. Der übrige Code liest ausschließlich aus `DATA` und enthält keine Zahlen der Aufgabe. So lässt sich dasselbe Programm mit geänderten Daten neu rechnen, ohne es neu zu erzeugen. `DATA` muss als JSON darstellbar sein: nur Zeichenketten und Zahlen als Schlüssel – **keine Tupel** (statt `{("W1", "L1"): 4}` also `{"W1": {"L1": 4}}`) – und nur Listen, Wörterbücher, Zahlen, Zeichenketten als Werte.
|
||||
- Reihenfolge: Imports → `DATA = {...}` → Funktion(en) zum Aufbauen und Lösen des Modells → `def main():` → am Dateiende genau:
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
- Kein ausführbarer Code auf Modulebene außer Imports, Konstanten, Funktionsdefinitionen und dem abschließenden `if __name__ == "__main__":`-Block.
|
||||
- Kommentiere auf Deutsch: jede Variablengruppe, die Zielfunktion und jede Restriktion mit ihrem identifier aus der Spezifikation (z. B. `# Kapazitaet_M1: 2*x1 + 3*x2 + x3 <= 240`). Kommentare erklären das Warum, nicht die Syntax.
|
||||
- Funktionen erhalten einen kurzen Docstring.
|
||||
|
||||
Geldbeträge und Rundung:
|
||||
- Rechne Geld intern in ganzen Cent (Preise mit 100 multiplizieren, als int), damit Budgetgrenzen exakt eingehalten werden. Solver-Toleranzen führen sonst zu Lösungen, die das Budget um Bruchteile überschreiten (beobachtet: 8 Cent über 100.000 €).
|
||||
- Alternativ eine Sicherheitsmarge verwenden (z. B. Budget minus 1 Cent) und in der Tabelle "Bindende Restriktionen" den tatsächlich investierten Betrag ausweisen.
|
||||
- Ausgabewerte anschließend wieder in Euro umrechnen.
|
||||
|
||||
Ergebnisausgabe:
|
||||
- Die Funktion `report(status, objective_value, solution, tables, headline, constraints, variables, sensitivity, relaxation_bound, multistart, graph, answers, scenarios)` ist bereits vordefiniert (sie gibt das Ergebnis lesbar und maschinenlesbar aus). Definiere sie NICHT selbst und importiere nichts dafür.
|
||||
- Rufe `report(...)` genau einmal auf, innerhalb von `main()`:
|
||||
- status: "optimal", "feasible", "infeasible", "unbounded" oder "error"
|
||||
- objective_value: Wert der Zielfunktion der Spezifikation (im ursprünglichen Sinn/Maßstab) als float, oder None
|
||||
- solution: dict mit sprechenden Variablennamen (z. B. "x[Robert,Aufgabe_1]") und Werten; nur Werte ungleich 0, höchstens 200 Einträge
|
||||
- headline: EIN Satz mit dem Ergebnis, nur mit tatsächlich berechneten Zahlen, z. B. "Das optimale Portfolio investiert in 5 Aktien und erreicht einen Nettonutzen von 9,76 %." (bei infeasible: "Die Aufgabe ist unlösbar: …"). Keine Begründung, keine Vermutung.
|
||||
- constraints: Liste ALLER Restriktionen des Modells, ausgewertet an der gefundenen Lösung (bei infeasible/unbounded/error: leere Liste). Je Restriktion ein dict:
|
||||
{"name": "...", "lhs": <Zahl>, "sense": "<=" | ">=" | "==", "rhs": <Zahl>}
|
||||
- [→ check_feasibility, check_constraint_coverage] name beginnt mit dem identifier aus der Spezifikation; bei indizierten Restriktionen ergänzt um den Index, z. B. "Kapazitaet_M1" oder "Gewichtsgrenzen[A02]". Jede Restriktion der Spezifikation muss vorkommen, keine zusätzliche erfinden – beides wird automatisch geprüft.
|
||||
- lhs ist der ausgerechnete Zahlenwert der linken Seite an der Lösung (nicht der Ausdruck), rhs die rechte Seite. Beide in derselben Einheit wie im Modell.
|
||||
- Bei Doppelungleichungen (a <= x <= b) zwei Einträge ausgeben.
|
||||
- Werte direkt aus den Lösungswerten berechnen, nicht aus Solver-Attributen übernehmen.
|
||||
- variables: Liste aller Entscheidungsvariablen mit Wert, Typ und Schranken:
|
||||
{"name": "...", "value": <Zahl>, "var_type": "continuous" | "integer" | "binary", "lb": <Zahl oder null>, "ub": <Zahl oder null>}
|
||||
Die Pipeline prüft damit Schranken und Ganzzahligkeit. Höchstens 200 Einträge; bei mehr nur die Variablen ungleich 0.
|
||||
- sensitivity: Wirkung der bindenden Restriktionen, durch NEULÖSEN ermittelt (nicht aus Solver-Attributen ablesen). Für höchstens 8 bindende Restriktionen (Schlupf = 0), 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"}
|
||||
- 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.
|
||||
- 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.
|
||||
[{"question_id": "a", "text": "…"}, {"question_id": "b", "text": "…"}]
|
||||
- text beantwortet die Frage in ihrer eigenen Sprache und Einheit, nicht im Modelljargon: „Die Maschine wird am Ende von Jahr 1 ersetzt; die Kosten betragen dann 29 Geldeinheiten.“ Ein bis drei Sätze.
|
||||
- Auch bei Status infeasible oder unbounded wird JEDE Teilfrage beantwortet – dann lautet die Antwort, dass und warum die Aufgabe keine zulässige Lösung hat (welche Bedingungen sich widersprechen). Eine unbeantwortete Frage ist schlechter als die Antwort „unlösbar“.
|
||||
- Teilfragen ohne Rechnung (formulieren, zeichnen, erklaeren) werden ebenfalls beantwortet – dort beschreibt der Text das aufgestellte Modell bzw. die Struktur der Zeichnung in Worten.
|
||||
- Verwende NUR Zahlen, die dieses Programm berechnet hat oder die im Aufgabentext stehen. [→ check_answers]
|
||||
- WICHTIG, häufigster Fehler: Sobald eine Antwort einen Vergleichs- oder Zwischenwert nennt, muss dieser Wert auch in einer Ergebnistabelle stehen. Beobachtet: „der beste Weg kostet 29, die Alternative 30“ – die 30 stand nirgends, der Leser konnte sie nicht prüfen. Richtig ist eine zusätzliche Tabelle („Alle Wege“, „Vergleich der Varianten“, „Kostenbestandteile“) mit genau diesen Zahlen. Ebenso für Summen wie „insgesamt 2,32 % Gebühren“: entweder eine Summenzeile in der Tabelle oder die Zahl weglassen.
|
||||
- Fehlt eine Teilfrage, ist die Aufgabe nicht erfüllt – auch wenn der Zielwert stimmt. [→ check_answers]
|
||||
- scenarios: NUR wenn die Aufgabe mehrere Konfigurationen vergleicht (z. B. drei Gewichtungen), sonst leer. Ein Szenario ist eine DATENVARIANTE desselben Modells, kein zweites Modell:
|
||||
[{"name": "Reine Varianzminimierung", "description": "…", "data_override": {"lambda_1": 1, "lambda_2": 0}, "objective_value": 0.0123}]
|
||||
- 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]
|
||||
- Damit das funktioniert, muss das Programm ausschließlich aus DATA lesen; höchstens fünf Szenarien werden nachgerechnet.
|
||||
- graph: NUR bei Graphenproblemen (Stack NetworkX), sonst null. Grundlage für die Zeichnung im Report:
|
||||
{"nodes": ["0", "1", "2", "3"],
|
||||
"arcs": [{"source": "0", "target": "1", "weight": 8}, ...],
|
||||
"path": ["0", "1", "3"],
|
||||
"steps": [{"node": "0", "label": 0}, {"node": "1", "label": 8}, ...],
|
||||
"node_meaning": "ein Zeitpunkt – das Ende von Jahr i",
|
||||
"arc_meaning": "Kauf einer Maschine am Ende von Jahr i und Nutzung bis Ende von Jahr j"}
|
||||
- Knoten- und Bogennamen als Zeichenketten, kurz und sprechend (sie stehen später in der Zeichnung).
|
||||
- node_meaning und arc_meaning sind ganze Sätze in der Sprache der Aufgabe, ohne Fachjargon – sie stehen im Report über der Zeichnung und erklären dem Leser, was er sieht.
|
||||
- steps ist der Markierungsverlauf: die Knoten in der Reihenfolge, in der Dijkstra sie endgültig markiert, mit ihrer Marke. Er belegt, dass wirklich Dijkstra gerechnet wurde.
|
||||
- Wird geprüft: jeder Bogen des Weges muss in arcs vorkommen, die Summe der Bogengewichte des Weges muss objective_value ergeben, und die Marken dürfen nicht fallen. [→ check_graph]
|
||||
- multistart: NUR bei lokalen Verfahren (Ipopt), sonst null. Die Streuung über die Startpunkte, aus den tatsächlichen Läufen berechnet:
|
||||
{"starts": <versuchte Startpunkte>, "converged": <davon erfolgreich>, "best": <bester Zielwert>,
|
||||
"worst": <schlechtester>, "mean": <Mittelwert>, "stddev": <Standardabweichung>}
|
||||
- best ist derselbe Wert wie objective_value. worst, mean und stddev nur über die erfolgreichen Läufe.
|
||||
- Wird geprüft: mean und worst dürfen nicht besser als best sein, und eine Standardabweichung über null führt zu einem Hinweis, dass das Optimum lokal ist. [→ check_multistart]
|
||||
- tables: Liste verständlicher Ergebnistabellen für Entscheider, berechnet aus der tatsächlichen Lösung (bei infeasible/unbounded/error: leere Liste). Jede Tabelle ist ein dict:
|
||||
{"title": "...", "columns": [...], "rows": [[...], ...], "objective_column": "...", "note": "..."}
|
||||
- Eine Zeile je Objekt der Aufgabe (Aktie, Person, Produkt, Lager …) – bei Auswahlproblemen nur die gewählten bzw. genutzten Objekte, sonst alle (höchstens 50 Zeilen).
|
||||
- Spalten: Objektname, die Entscheidung in Fachsprache und Einheit (z. B. "Anteil" als "30,00 %", "Menge [t]", "Schicht"), die dafür relevanten Eingabedaten aus der Aufgabe (z. B. Rendite, Kosten) und – falls die Zielfunktion sich als Summe über die Objekte schreiben lässt – eine Spalte "Beitrag zum Ziel" mit dem Zahlenwert (float) des Beitrags dieser Zeile zur Zielfunktion.
|
||||
- objective_column: Name dieser Beitragsspalte. Die Summe ihrer Zeilen muss objective_value ergeben. [→ parse_result_tables] Objektübergreifende Terme (z. B. Kovarianzen, Fixkosten pro Lager) den passenden Zeilen zuordnen oder als eigene Zeile ausweisen. Ist die Zielfunktion nicht additiv (z. B. Volatilität = Wurzel), objective_column weglassen.
|
||||
- Letzte Zeile: "Summe" mit den Summen sinnvoller Spalten (z. B. Anteil 100 %, Beitrag = Zielwert).
|
||||
- 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.
|
||||
- note: ein Satz, wie die Tabelle zu lesen ist.
|
||||
Erzeuge nach Möglichkeit diese drei Tabellen (in dieser Reihenfolge):
|
||||
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.
|
||||
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.
|
||||
Zahlen in der objective_column immer im Maßstab von objective_value (nicht in Prozent umrechnen); Datenspalten dagegen in lesbaren Einheiten als Text ("1,63 %", "0,74 %", "36 h").
|
||||
- Weitere print-Ausgaben sind erlaubt, aber nicht nötig.
|
||||
- Alle Texte auf Deutsch mit echten Umlauten (ä, ö, ü, ß), nie 'ae'/'oe'/'ue'; die Dateien sind UTF-8. [→ check_text_quality]
|
||||
- Liefere im Feld code nur den reinen Python-Code ohne Markdown-Codeblock.
|
||||
0
solving/__init__.py
Normal file
0
solving/__init__.py
Normal file
194
solving/script_export.py
Normal file
194
solving/script_export.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Eigenständiges, dokumentiertes Solver-Skript aus einem PipelineResult bauen (für --code und Reports).
|
||||
|
||||
Aufbau des Skripts:
|
||||
#!/usr/bin/env python3
|
||||
r\"\"\" Aufgabe / Mathematisches Modell / Algorithmus \"\"\"
|
||||
<generierter Code: Imports, Daten, Funktionen, main()>
|
||||
<report()-Hilfsfunktion>
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Das JSON-Ergebnis enthält weiterhin nur den rohen generierten Code (solver_code.code).
|
||||
"""
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from core.model_schema import PipelineResult, ProblemClass, TargetStack
|
||||
from solving.solver_stage import assemble_script
|
||||
|
||||
WIDTH = 88
|
||||
|
||||
# Verfahren je Solver-Stack (feste Texte, nicht vom LLM erzeugt)
|
||||
ALGORITHMS = {
|
||||
TargetStack.HIGHS: (
|
||||
"HiGHS (highspy)",
|
||||
"Lineare Relaxationen werden mit dem dualen revidierten Simplex-Verfahren (alternativ "
|
||||
"Innere-Punkte-Verfahren) gelöst. Ganzzahlige und binäre Variablen behandelt HiGHS mit "
|
||||
"Branch-and-Cut: Das Problem wird schrittweise in Teilprobleme zerlegt, deren LP-Relaxation "
|
||||
"Schranken liefert; Schnittebenen und Heuristiken verkleinern den Suchbaum. Der Status "
|
||||
"'optimal' bedeutet, dass die Optimalitätslücke (im Rahmen der Toleranz) geschlossen ist.",
|
||||
),
|
||||
TargetStack.OR_TOOLS_CPSAT: (
|
||||
"Google OR-Tools CP-SAT",
|
||||
"CP-SAT kombiniert Constraint-Propagation mit einem SAT-Solver (Lazy Clause Generation, "
|
||||
"konfliktgesteuertes Klausellernen) und LP-Relaxationen; mehrere Suchstrategien laufen parallel. "
|
||||
"Logische Bedingungen (Implikationen, 'nicht gemeinsam', Reihenfolgen) werden direkt und exakt "
|
||||
"verarbeitet. Der Status 'optimal' bedeutet, dass die Optimalität bewiesen wurde. CP-SAT arbeitet "
|
||||
"nur mit ganzzahligen Werten.",
|
||||
),
|
||||
TargetStack.CVXPY: (
|
||||
"CVXPY",
|
||||
"CVXPY prüft, ob das Modell den Regeln der disziplinierten konvexen Programmierung (DCP) genügt, "
|
||||
"formt es in ein konisches Standardproblem um und übergibt es einem passenden Solver – für "
|
||||
"Kegel- und quadratische Probleme typischerweise einem Innere-Punkte-Verfahren (z. B. Clarabel) "
|
||||
"oder OSQP. Bei konvexen Problemen ist jedes lokale Optimum zugleich global optimal.",
|
||||
),
|
||||
TargetStack.SCIP: (
|
||||
"SCIP (pyscipopt)",
|
||||
"SCIP löst gemischt-ganzzahlige nichtlineare Probleme mit Branch-and-Bound bzw. Branch-and-Cut: "
|
||||
"Ganzzahlige Variablen werden verzweigt, quadratische Terme durch lineare Unterschätzer (Schnittebenen, "
|
||||
"bei nichtkonvexen Termen räumliches Verzweigen) relaxiert; Propagation, Presolve und Heuristiken "
|
||||
"verkleinern den Suchbaum. Der Status 'optimal' bedeutet, dass die Optimalitätslücke im Rahmen der "
|
||||
"Toleranz geschlossen ist. Da SCIP nur lineare Zielfunktionen verarbeitet, wird ein quadratisches Ziel "
|
||||
"über eine Hilfsvariable (Epigraph-Formulierung) abgebildet.",
|
||||
),
|
||||
TargetStack.IPOPT: (
|
||||
"Ipopt (cyipopt)",
|
||||
"Ipopt ist ein Innere-Punkte-Verfahren für glatte nichtlineare Optimierung: Es folgt einer Folge von "
|
||||
"Barriereproblemen, nutzt Ableitungen (hier numerisch bestimmt) und konvergiert gegen einen Punkt, der "
|
||||
"die Karush-Kuhn-Tucker-Bedingungen erfüllt. Das ist ein LOKALES Optimum – bei konvexen Problemen "
|
||||
"zugleich das globale, sonst nicht. Mehrere Startpunkte verringern das Risiko, in einem schlechten "
|
||||
"lokalen Punkt zu landen; ganzzahlige Variablen kann Ipopt nicht behandeln.",
|
||||
),
|
||||
TargetStack.NETWORKX: (
|
||||
"NetworkX (Dijkstra)",
|
||||
"Der Dijkstra-Algorithmus markiert Knoten schrittweise: Er nimmt stets den unmarkierten Knoten mit "
|
||||
"der kleinsten vorläufigen Entfernung, erklärt dessen Marke für endgültig und verbessert damit die "
|
||||
"Marken seiner Nachbarn. Da jede Marke beim Endgültigwerden bereits minimal ist, steht das Ergebnis "
|
||||
"nach einem Durchlauf fest – ohne Suchbaum und ohne Relaxation. Voraussetzung sind nicht negative "
|
||||
"Bogengewichte: bei negativen Gewichten liefert das Verfahren stillschweigend ein falsches Ergebnis.",
|
||||
),
|
||||
TargetStack.SCIPY: (
|
||||
"SciPy scipy.optimize.linprog (method='highs')",
|
||||
"linprog nutzt die HiGHS-Verfahren für lineare Programme (duales Simplex- oder "
|
||||
"Innere-Punkte-Verfahren). Das Optimum eines LP liegt an einer Ecke des zulässigen Polyeders "
|
||||
"und wird exakt (im Rahmen der Toleranz) gefunden. Ganzzahligkeit wird nicht unterstützt.",
|
||||
),
|
||||
}
|
||||
|
||||
# Warum die Problemklasse zum Verfahren passt (feste Texte)
|
||||
CLASS_FIT = {
|
||||
ProblemClass.LP: "Zielfunktion und Restriktionen sind linear, alle Variablen kontinuierlich – ein LP, "
|
||||
"für das Simplex-/Innere-Punkte-Verfahren beweisbar optimale Lösungen liefern.",
|
||||
ProblemClass.MILP: "Zielfunktion und Restriktionen sind linear, aber einige Variablen sind ganzzahlig "
|
||||
"oder binär – ein MILP, für das Branch-and-Cut das Standardverfahren ist.",
|
||||
ProblemClass.CP: "Das Problem besteht aus diskreten Entscheidungen mit logischen Verknüpfungen – "
|
||||
"typisch für Constraint Programming, das solche Bedingungen direkt propagiert.",
|
||||
ProblemClass.QP: "Die Zielfunktion ist quadratisch und konvex (positiv semidefinite Matrix), die "
|
||||
"Restriktionen sind linear – ein konvexes QP mit global optimaler Lösung.",
|
||||
ProblemClass.MIQP: "Das Problem verbindet quadratische Terme (z. B. Varianz/Risiko) mit ganzzahligen oder "
|
||||
"binären Entscheidungen (z. B. Auswahl- oder Kardinalitätsbedingungen) – ein MIQP, das "
|
||||
"weder reine Kegelsolver (keine Ganzzahligkeit) noch CP-SAT (keine kontinuierlichen "
|
||||
"Variablen) abdecken, wohl aber SCIPs Branch-and-Cut für nichtlineare Probleme.",
|
||||
ProblemClass.NLP: "Zielfunktion oder Restriktionen sind glatt nichtlinear und alle Variablen stetig – ein NLP, "
|
||||
"für das Innere-Punkte-Verfahren (Ipopt) effizient einen KKT-Punkt liefern; globale Optimalität "
|
||||
"ist nur bei konvexen Problemen garantiert.",
|
||||
ProblemClass.SHORTEST_PATH: "Gesucht ist eine Folge von Entscheidungen, die sich als Weg durch einen Graphen "
|
||||
"lesen lässt: Knoten sind Zustände (Zeitpunkte, Orte), Bögen die Übergänge mit ihren "
|
||||
"Kosten. Für solche Probleme gibt es mit dem Dijkstra-Algorithmus ein exaktes "
|
||||
"Verfahren, das ohne Ganzzahligkeitsbedingungen auskommt.",
|
||||
ProblemClass.SOCP: "Das Problem enthält Normen bzw. Wurzeln quadratischer Formen – ein konvexes "
|
||||
"Kegelproblem (SOCP), das Innere-Punkte-Verfahren effizient und global optimal lösen.",
|
||||
}
|
||||
|
||||
|
||||
def _plain(text) -> str:
|
||||
"""Inline-Mathe-Begrenzer \\( \\) \\[ \\] entfernen – im Docstring stehen Formeln als Klartext."""
|
||||
return re.sub(r"\\[()\[\]]", "", str(text))
|
||||
|
||||
|
||||
def _wrap(text: str, indent: str = "") -> str:
|
||||
paragraphs = [p for p in _plain(text).strip().split("\n") if p.strip()] or [""]
|
||||
return "\n".join(
|
||||
textwrap.fill(p.strip(), width=WIDTH, initial_indent=indent, subsequent_indent=indent) for p in paragraphs
|
||||
)
|
||||
|
||||
|
||||
def _heading(title: str) -> str:
|
||||
return f"{title}\n{'=' * len(title)}"
|
||||
|
||||
|
||||
def build_header(result: PipelineResult) -> str:
|
||||
spec = result.specification
|
||||
parts = []
|
||||
|
||||
if result.task.strip():
|
||||
parts += [_heading("AUFGABE"), result.task.strip()]
|
||||
|
||||
model = [_heading("MATHEMATISCHES MODELL"),
|
||||
f"Problemklasse: {spec.problem_class.value}",
|
||||
"",
|
||||
_wrap(spec.executive_summary)]
|
||||
if spec.indices:
|
||||
model += ["", "Mengen und Indizes:"] + [f" - {_plain(i)}" for i in spec.indices]
|
||||
if spec.parameters:
|
||||
model += ["", "Parameter:"]
|
||||
for p in spec.parameters:
|
||||
model.append(_wrap(f"- {p.symbol} [{p.dimension}]: {p.description}", " "))
|
||||
if p.extracted_values:
|
||||
model.append(_wrap(f"Werte: {p.extracted_values}", " "))
|
||||
model += ["", "Entscheidungsvariablen:"]
|
||||
for v in spec.variables:
|
||||
model.append(_wrap(f"- {v.name} ({v.var_type.value}, {v.domain}): {v.description}", " "))
|
||||
model += ["", f"Zielfunktion ({spec.objective.sense.value}):",
|
||||
f" {_plain(spec.objective.latex_formula)}",
|
||||
_wrap(spec.objective.description, " "),
|
||||
"", "Restriktionen:"]
|
||||
for n, c in enumerate(spec.constraints, 1):
|
||||
soft = "" if c.is_hard_constraint else " (weich)"
|
||||
model += [f" ({n}) {c.identifier}{soft}:", f" {_plain(c.latex_formula)}", _wrap(c.description, " ")]
|
||||
if spec.open_assumptions:
|
||||
model += ["", "Annahmen (Aufgabentext war hier nicht eindeutig):"]
|
||||
for a in spec.open_assumptions:
|
||||
model.append(_wrap(f"- {a.unclear_point}: {a.assumption} (Auswirkung: {a.impact})", " "))
|
||||
if result.solver_code and result.solver_code.data_assumptions:
|
||||
model += ["", "Angenommene Daten (nicht im Aufgabentext enthalten):"]
|
||||
model += [_wrap(f"- {d}", " ") for d in result.solver_code.data_assumptions]
|
||||
model += ["", "Formeln in LaTeX-Schreibweise."]
|
||||
parts.append("\n".join(model))
|
||||
|
||||
name, method = ALGORITHMS[spec.target_stack]
|
||||
parts.append("\n".join([
|
||||
_heading("ALGORITHMUS"),
|
||||
f"Solver: {name}",
|
||||
"",
|
||||
"Verfahren:",
|
||||
_wrap(method, " "),
|
||||
"",
|
||||
"Warum dieses Verfahren hier passt:",
|
||||
_wrap(CLASS_FIT[spec.problem_class], " "),
|
||||
_wrap(spec.routing_rationale, " "),
|
||||
]))
|
||||
|
||||
if result.solver_run:
|
||||
run = result.solver_run
|
||||
parts.append("\n".join([
|
||||
_heading("ERGEBNIS BEI DER ERZEUGUNG"),
|
||||
f"Status: {run.status.value}, Zielwert: {run.objective_value}",
|
||||
"Aufruf: python3 <diese_datei>.py",
|
||||
]))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _docstring(text: str) -> str:
|
||||
# Roh-String, damit LaTeX-Backslashes keine Escape-Warnungen erzeugen
|
||||
text = text.replace('"""', "'''").rstrip()
|
||||
if text.endswith("\\"):
|
||||
text += " "
|
||||
return f'r"""\n{text}\n"""\n'
|
||||
|
||||
|
||||
def build_solver_script(result: PipelineResult) -> str:
|
||||
if not result.solver_code:
|
||||
raise ValueError("Ergebnis enthält keinen Solver-Code (Lauf mit --no-solve?)")
|
||||
return "#!/usr/bin/env python3\n" + _docstring(build_header(result)) + "\n" + assemble_script(result.solver_code.code)
|
||||
855
solving/solver_stage.py
Normal file
855
solving/solver_stage.py
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
"""Solver-Stufe: LLM erzeugt Python-Code für den Ziel-Stack, der Code wird isoliert ausgeführt.
|
||||
|
||||
Ausgeführt wird in einer bubblewrap-Sandbox (siehe sandbox_command): kein Netz, Dateisystem nur
|
||||
lesbar, schreibbar allein das Temp-Verzeichnis des Laufs. validate_imports() bleibt daneben eine
|
||||
Plausibilitätsschranke gegen offensichtliche Fehlgriffe, ist aber nicht die Schutzschicht.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from core.llm import LLMSession
|
||||
from checks.model_checks import numbers_in, unexplained_jargon
|
||||
from core.model_schema import (
|
||||
Answer,
|
||||
TargetStack,
|
||||
Scenario,
|
||||
ConstraintValue,
|
||||
GraphSolution,
|
||||
Multistart,
|
||||
OptimizationModelSpecification,
|
||||
ResultTable,
|
||||
Sensitivity,
|
||||
VariableValue,
|
||||
VariableType,
|
||||
SolverCode,
|
||||
SolverRun,
|
||||
SolverStatus,
|
||||
)
|
||||
|
||||
SOLVER_PROMPT_FILE = Path(__file__).parent / "Solver-Prompt.txt"
|
||||
RESULT_MARKER = "RESULT_JSON:"
|
||||
|
||||
# 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).
|
||||
SANDBOX = os.environ.get("SOLVER_SANDBOX", "auto").strip().lower()
|
||||
|
||||
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"}
|
||||
|
||||
# Wird in den generierten Code eingefügt (vor den main-Guard, sonst vorangestellt); siehe Solver-Prompt.txt
|
||||
HARNESS = f'''\
|
||||
_DATA_USED = None
|
||||
|
||||
|
||||
def _apply_data_override(globals_dict):
|
||||
"""Eingabedaten aus SOLVER_DATA_JSON übernehmen – erlaubt Neurechnen ohne neuen LLM-Aufruf."""
|
||||
global _DATA_USED
|
||||
import json as _json
|
||||
import os as _os
|
||||
|
||||
_DATA_USED = globals_dict.get("DATA")
|
||||
pfad = _os.environ.get("SOLVER_DATA_JSON")
|
||||
if pfad and isinstance(_DATA_USED, dict):
|
||||
with open(pfad, encoding="utf-8") as _f:
|
||||
_DATA_USED.update(_json.load(_f))
|
||||
|
||||
|
||||
def report(status, objective_value, solution, tables=None, headline=None, constraints=None, variables=None,
|
||||
sensitivity=None, relaxation_bound=None, multistart=None, graph=None, answers=None,
|
||||
scenarios=None):
|
||||
"""Ergebnis lesbar ausgeben (Status, Ergebnistabellen, Variablen) und als RESULT_JSON-Zeile (für TextToModel.py)."""
|
||||
import json as _json
|
||||
|
||||
def _plain(value):
|
||||
try:
|
||||
return value.item() # numpy-Skalare
|
||||
except AttributeError:
|
||||
return str(value)
|
||||
|
||||
def _cell(value):
|
||||
value = _plain(value) if hasattr(value, "item") else value
|
||||
return f"{{value:.6g}}" if isinstance(value, float) else ("" if value is None else str(value))
|
||||
|
||||
if headline:
|
||||
print(headline)
|
||||
print()
|
||||
for antwort in answers or []:
|
||||
print(f"Antwort zu {{antwort.get('question_id')}}: {{antwort.get('text')}}")
|
||||
if answers:
|
||||
print()
|
||||
print(f"Status: {{status}}")
|
||||
print(f"Zielwert: {{objective_value}}")
|
||||
for table in tables or []:
|
||||
header = [str(c) for c in table.get("columns", [])]
|
||||
body = [[_cell(v) for v in row] for row in table.get("rows", [])]
|
||||
widths = [max(len(r[i]) if i < len(r) else 0 for r in [header] + body) for i in range(len(header))]
|
||||
line = lambda left, mid, right: left + mid.join("─" * (w + 2) for w in widths) + right
|
||||
fmt = lambda cells: "│" + "│".join(f" {{(cells[i] if i < len(cells) else ''):<{{widths[i]}}}} " for i in range(len(widths))) + "│"
|
||||
print()
|
||||
print(table.get("title", ""))
|
||||
print(line("┌", "┬", "┐")); print(fmt(header)); print(line("├", "┼", "┤"))
|
||||
for row in body:
|
||||
print(fmt(row))
|
||||
print(line("└", "┴", "┘"))
|
||||
if table.get("note"):
|
||||
print(table["note"])
|
||||
if constraints:
|
||||
print()
|
||||
print(f"Restriktionen gemeldet: {{len(constraints)}} (werden von TextToModel.py nachgerechnet)")
|
||||
if graph:
|
||||
print()
|
||||
print("Graph: " + str(len(graph.get("nodes") or [])) + " Knoten, "
|
||||
+ str(len(graph.get("arcs") or [])) + " Bögen")
|
||||
print(" Ein Knoten ist: " + str(graph.get("node_meaning", "")))
|
||||
print(" Ein Bogen ist: " + str(graph.get("arc_meaning", "")))
|
||||
if graph.get("path"):
|
||||
print(" Kürzester Weg: " + " → ".join(str(k) for k in graph["path"]))
|
||||
for schritt in graph.get("steps") or []:
|
||||
print(f" Marke endgültig: {{schritt.get('node')}} = {{schritt.get('label')}}")
|
||||
if solution:
|
||||
print()
|
||||
print("Variablenwerte (≠ 0):")
|
||||
for name, value in solution.items():
|
||||
print(f" {{name}} = {{value}}")
|
||||
_payload = {{"status": status, "objective_value": objective_value, "solution": solution,
|
||||
"tables": tables or [], "headline": headline,
|
||||
"constraints": constraints or [], "variables": variables or [],
|
||||
"sensitivity": sensitivity or [], "relaxation_bound": relaxation_bound,
|
||||
"multistart": multistart, "graph": graph, "answers": answers or [],
|
||||
"scenarios": scenarios or [], "data": _DATA_USED}}
|
||||
try:
|
||||
_line = _json.dumps(_payload, default=_plain)
|
||||
except TypeError:
|
||||
# Beobachtet: DATA mit Tupel-Schlüsseln (Transportkosten je Werk-Lager-Paar). Das Ergebnis ist
|
||||
# richtig – nur die Daten lassen sich nicht übernehmen, deshalb melden statt abbrechen.
|
||||
_payload["data"] = {{}}
|
||||
_payload["data_note"] = ("DATA ist nicht JSON-fähig (z. B. Tupel als Schlüssel) und wurde nicht "
|
||||
"übernommen – --resolve kann die Daten dieses Programms nicht ersetzen.")
|
||||
_line = _json.dumps(_payload, default=_plain)
|
||||
print({RESULT_MARKER!r} + " " + _line)
|
||||
|
||||
|
||||
_apply_data_override(globals())
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def _main_guard(tree: ast.Module):
|
||||
"""Modulebene-Knoten `if __name__ == "__main__":` oder None."""
|
||||
for node in tree.body:
|
||||
if (
|
||||
isinstance(node, ast.If)
|
||||
and isinstance(node.test, ast.Compare)
|
||||
and isinstance(node.test.left, ast.Name) and node.test.left.id == "__name__"
|
||||
and len(node.test.comparators) == 1
|
||||
and isinstance(node.test.comparators[0], ast.Constant) and node.test.comparators[0].value == "__main__"
|
||||
):
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def assemble_script(code: str) -> str:
|
||||
"""Generierten Code mit report()-Hilfsfunktion zu einem lauffähigen Skript verbinden.
|
||||
|
||||
Mit main-Guard wird report() direkt davor eingefügt (Imports bleiben oben), sonst vorangestellt.
|
||||
"""
|
||||
code = code.strip() + "\n"
|
||||
try:
|
||||
guard = _main_guard(ast.parse(code))
|
||||
except SyntaxError:
|
||||
guard = None
|
||||
if guard is None:
|
||||
return HARNESS + code
|
||||
lines = code.splitlines(keepends=True)
|
||||
index = guard.lineno - 1
|
||||
return "".join(lines[:index]).rstrip() + "\n\n\n" + HARNESS + "".join(lines[index:])
|
||||
|
||||
|
||||
class CodeRejected(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_imports(code: str) -> None:
|
||||
"""Wirft CodeRejected bei Syntaxfehlern, nicht erlaubten Imports oder verbotenen Aufrufen."""
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError as exc:
|
||||
raise CodeRejected(f"Syntaxfehler: {exc}") from exc
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
modules = [alias.name for alias in node.names]
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
modules = [node.module or ""]
|
||||
else:
|
||||
modules = []
|
||||
for module in modules:
|
||||
if module.split(".")[0] not in ALLOWED_MODULES:
|
||||
raise CodeRejected(f"Import von '{module}' ist nicht erlaubt (erlaubt: {', '.join(sorted(ALLOWED_MODULES))}).")
|
||||
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_CALLS:
|
||||
raise CodeRejected(f"Aufruf von '{node.func.id}' ist nicht erlaubt.")
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "report":
|
||||
raise CodeRejected("Die Funktion 'report' ist vordefiniert und darf nicht neu definiert werden.")
|
||||
|
||||
|
||||
def validate_structure(code: str) -> None:
|
||||
"""Wirft CodeRejected, wenn main() oder der abschließende `if __name__ == "__main__":`-Block fehlt."""
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
except SyntaxError as exc:
|
||||
raise CodeRejected(f"Syntaxfehler: {exc}") from exc
|
||||
if not any(isinstance(node, ast.FunctionDef) and node.name == "main" for node in tree.body):
|
||||
raise CodeRejected("Es fehlt eine Funktion main() auf Modulebene.")
|
||||
guard = _main_guard(tree)
|
||||
if guard is None or guard is not tree.body[-1]:
|
||||
raise CodeRejected('Das Programm muss mit `if __name__ == "__main__":` und dem Aufruf main() enden.')
|
||||
|
||||
|
||||
def clean_generated_code(code: str) -> str:
|
||||
"""Markdown-Reste entfernen, die LLMs trotz Anweisung mitliefern.
|
||||
|
||||
Beobachtet: erste Zeile "python" (Rest von ```python) -> NameError und unnötiger Reparaturversuch.
|
||||
"""
|
||||
lines = code.strip().splitlines()
|
||||
if lines and lines[0].strip().startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
while lines and lines[0].strip().lower() in ("python", "python3", "py", ""):
|
||||
lines = lines[1:]
|
||||
return _drop_trailing_artifacts("\n".join(lines).strip() + "\n")
|
||||
|
||||
|
||||
# Klammern, mit denen manche Anbieter das JSON-Feld schließen, nachdem der Code schon zu Ende ist
|
||||
TRAILING_ARTIFACTS = ("}", "]", ")", '"', "'", "```", '"}', "}]")
|
||||
|
||||
|
||||
def _drop_trailing_artifacts(code: str) -> str:
|
||||
"""Schließende JSON-Klammern am Dateiende entfernen (beobachtet: '}' nach dem main-Guard).
|
||||
|
||||
Greift nur, wenn der Code sonst nicht parst – gültiger Code bleibt unangetastet.
|
||||
"""
|
||||
try:
|
||||
ast.parse(code)
|
||||
return code
|
||||
except SyntaxError:
|
||||
pass
|
||||
lines = code.rstrip().splitlines()
|
||||
while lines and lines[-1].strip() in TRAILING_ARTIFACTS:
|
||||
lines.pop()
|
||||
candidate = "\n".join(lines).strip() + "\n"
|
||||
try:
|
||||
ast.parse(candidate)
|
||||
return candidate
|
||||
except SyntaxError:
|
||||
continue
|
||||
return code # unverändert lassen, damit der Reparaturversuch den echten Fehler sieht
|
||||
|
||||
|
||||
def _tail(text: str, limit: int = 2000) -> str:
|
||||
return text if len(text) <= limit else "…" + text[-limit:]
|
||||
|
||||
|
||||
SUM_ROW_LABELS = ("summe", "gesamt", "total", "σ", "sum", "zielwert", "ergebnis", "nutzen", "insgesamt")
|
||||
|
||||
|
||||
def _is_sum_row(row, value=None, others=None) -> bool:
|
||||
"""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):
|
||||
return True
|
||||
if value is None or not others:
|
||||
return False
|
||||
return _close(value, sum(others))
|
||||
|
||||
|
||||
def _close(a: float, b: float) -> bool:
|
||||
return abs(a - b) <= 1e-3 * max(abs(a), abs(b)) + 1e-6
|
||||
|
||||
|
||||
def parse_result_tables(raw_tables: list, objective_value) -> tuple:
|
||||
"""Ergebnistabellen validieren und deterministisch gegen den Zielwert prüfen.
|
||||
|
||||
Fehlerhafte Tabellen sind kein Solver-Fehler (die Lösung ist davon unberührt), sondern Warnungen.
|
||||
"""
|
||||
tables, warnings = [], []
|
||||
for raw in raw_tables:
|
||||
try:
|
||||
table = ResultTable.model_validate(raw)
|
||||
except (ValueError, TypeError) as exc:
|
||||
warnings.append(f"Ergebnistabelle ungültig und verworfen: {str(exc)[:200]}")
|
||||
continue
|
||||
tables.append(table)
|
||||
if not table.objective_column:
|
||||
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 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
|
||||
|
||||
|
||||
def _parse_list(raw, model_type) -> list:
|
||||
"""Fehlerhafte Einträge verwerfen statt den Lauf scheitern zu lassen (die Lösung ist davon unberührt)."""
|
||||
parsed = []
|
||||
for entry in raw or []:
|
||||
try:
|
||||
parsed.append(model_type.model_validate(entry))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return parsed
|
||||
|
||||
|
||||
def _tolerance(reference: float) -> float:
|
||||
"""Absolute Schranke plus winziger relativer Anteil – Rechenrauschen ja, Solver-Toleranz nein.
|
||||
|
||||
Beobachtet: SCIP hielt eine Budgetverletzung von 8 Cent bei 100.000 € für zulässig (8e-7 relativ).
|
||||
"""
|
||||
return 1e-6 + 1e-9 * abs(reference)
|
||||
|
||||
|
||||
def check_feasibility(run, spec: OptimizationModelSpecification) -> list:
|
||||
"""Lösung in die gemeldeten Restriktionen einsetzen – ohne LLM, ohne dem Solver-Status zu glauben."""
|
||||
problems = []
|
||||
if run.status not in (SolverStatus.OPTIMAL, SolverStatus.FEASIBLE):
|
||||
return problems
|
||||
if not run.constraints:
|
||||
return ["Das Solver-Programm hat keine Restriktionen zum Nachrechnen gemeldet."]
|
||||
|
||||
for c in run.constraints:
|
||||
tol = _tolerance(c.rhs)
|
||||
gap = c.lhs - c.rhs
|
||||
if (c.sense == "<=" and gap > tol) or (c.sense == ">=" and -gap > tol) or (c.sense == "==" and abs(gap) > tol):
|
||||
problems.append(
|
||||
f"Restriktion '{c.name}' verletzt: {c.lhs:.10g} {c.sense} {c.rhs:.10g} (Abweichung {gap:+.10g})"
|
||||
)
|
||||
|
||||
for v in run.variables:
|
||||
if v.lb is not None and v.value < v.lb - _tolerance(v.lb):
|
||||
problems.append(f"Variable '{v.name}' = {v.value:.10g} unter der Untergrenze {v.lb:.10g}")
|
||||
if v.ub is not None and v.value > v.ub + _tolerance(v.ub):
|
||||
problems.append(f"Variable '{v.name}' = {v.value:.10g} über der Obergrenze {v.ub:.10g}")
|
||||
if v.var_type in (VariableType.INTEGER, VariableType.BINARY) and abs(v.value - round(v.value)) > 1e-6:
|
||||
problems.append(f"Variable '{v.name}' = {v.value:.10g} ist nicht ganzzahlig")
|
||||
if v.var_type == VariableType.BINARY and round(v.value) not in (0, 1):
|
||||
problems.append(f"Binärvariable '{v.name}' = {v.value:.10g} ist weder 0 noch 1")
|
||||
|
||||
problems += check_constraint_coverage(run.constraints, spec)
|
||||
return problems
|
||||
|
||||
|
||||
def check_sensitivity(run, sense: str) -> list:
|
||||
"""Plausibilität der Sensitivitätswerte prüfen (ohne LLM).
|
||||
|
||||
Lockern einer bindenden Restriktion darf den Zielwert nie verschlechtern, Verschärfen nie verbessern.
|
||||
Die Richtung hängt am Sinn der Restriktion und daran, ob maximiert oder minimiert wird.
|
||||
"""
|
||||
warnings = []
|
||||
besser = (lambda a, b: a > b) if sense == "maximize" else (lambda a, b: a < b)
|
||||
by_name = {c.name: c for c in run.constraints}
|
||||
for s in run.sensitivity:
|
||||
constraint = by_name.get(s.name)
|
||||
if constraint is None:
|
||||
warnings.append(f"Sensitivität für '{s.name}': keine solche Restriktion gemeldet.")
|
||||
continue
|
||||
if s.delta <= 0:
|
||||
warnings.append(f"Sensitivität für '{s.name}': delta muss größer als 0 sein.")
|
||||
continue
|
||||
if constraint.sense == "==" or s.objective_plus is None or s.objective_minus is None:
|
||||
continue
|
||||
# "<=" lockern heißt rechte Seite erhöhen, ">=" lockern heißt sie senken
|
||||
gelockert = s.objective_plus if constraint.sense == "<=" else s.objective_minus
|
||||
verschaerft = s.objective_minus if constraint.sense == "<=" else s.objective_plus
|
||||
if besser(verschaerft, gelockert):
|
||||
warnings.append(
|
||||
f"Sensitivität für '{s.name}' unplausibel: Verschärfen ergibt {verschaerft:.6g}, "
|
||||
f"Lockern nur {gelockert:.6g} – bei {sense} unmöglich."
|
||||
)
|
||||
if run.relaxation_bound is not None and run.objective_value is not None:
|
||||
if besser(run.objective_value, run.relaxation_bound):
|
||||
warnings.append(
|
||||
f"Relaxationsschranke {run.relaxation_bound:.6g} ist schlechter als die ganzzahlige Lösung "
|
||||
f"{run.objective_value:.6g} – bei {sense} unmöglich."
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def check_constraint_coverage(constraints: list, spec: OptimizationModelSpecification) -> list:
|
||||
"""Namensvertrag: jede Restriktion der Spezifikation muss im Programm vorkommen und umgekehrt."""
|
||||
identifiers = [c.identifier for c in spec.constraints]
|
||||
reported = [c.name for c in constraints]
|
||||
|
||||
def zuordnung(name: str):
|
||||
treffer = [i for i in identifiers if name == i or name.startswith(i + "_") or name.startswith(i + "[")]
|
||||
return max(treffer, key=len) if treffer else None
|
||||
|
||||
zugeordnet = {name: zuordnung(name) for name in reported}
|
||||
problems = [f"Restriktion '{name}' im Programm passt zu keiner Restriktion der Spezifikation {identifiers}"
|
||||
for name, treffer in zugeordnet.items() if treffer is None]
|
||||
problems += [f"Restriktion '{i}' aus der Spezifikation fehlt im Programm"
|
||||
for i in identifiers if i not in zugeordnet.values()]
|
||||
return problems
|
||||
|
||||
|
||||
def sandbox_command(script: Path, tmp: str) -> List[str]:
|
||||
"""Aufrufzeile für den Solver-Lauf – mit bubblewrap isoliert, sonst direkt.
|
||||
|
||||
Der erzeugte Code ist LLM-Ausgabe; `validate_imports` ist nur eine Plausibilitätsschranke.
|
||||
bubblewrap nimmt ihm die Möglichkeiten: kein Netz (--unshare-all), Dateisystem nur lesbar,
|
||||
schreibbar allein das Temp-Verzeichnis, HOME zeigt dorthin. Kostet wenige Millisekunden.
|
||||
"""
|
||||
if SANDBOX == "aus" or not shutil.which("bwrap"):
|
||||
if SANDBOX == "bwrap":
|
||||
raise CodeRejected("SOLVER_SANDBOX=bwrap verlangt bubblewrap, es ist aber nicht installiert.")
|
||||
return [sys.executable, str(script)]
|
||||
return [
|
||||
"bwrap", "--unshare-all", "--die-with-parent",
|
||||
"--ro-bind", "/", "/", # alles lesbar (Python, Solver-Bibliotheken), nichts beschreibbar
|
||||
"--bind", tmp, tmp, # einzige Ausnahme: das Arbeitsverzeichnis dieses Laufs
|
||||
"--dev", "/dev", "--proc", "/proc",
|
||||
"--setenv", "HOME", tmp, "--setenv", "TMPDIR", tmp,
|
||||
"--chdir", tmp,
|
||||
sys.executable, str(script),
|
||||
]
|
||||
|
||||
|
||||
def run_solver_code(code: str, timeout: int = 60, data: dict | None = None,
|
||||
data_files=()) -> SolverRun:
|
||||
try:
|
||||
validate_imports(code)
|
||||
except CodeRejected as exc:
|
||||
return SolverRun(status=SolverStatus.ERROR, error=f"Code abgelehnt: {exc}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
script = Path(tmp) / "solve.py"
|
||||
script.write_text(assemble_script(code), encoding="utf-8")
|
||||
# Beigelegte Daten ins Laufverzeichnis: es ist das einzige, das die Sandbox beschreibbar bindet
|
||||
for quelle in data_files or ():
|
||||
shutil.copy2(quelle, Path(tmp) / Path(quelle).name)
|
||||
env = dict(os.environ)
|
||||
if data is not None: # geänderte Eingabedaten ohne neuen LLM-Aufruf
|
||||
daten_datei = Path(tmp) / "daten.json"
|
||||
daten_datei.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
env["SOLVER_DATA_JSON"] = str(daten_datei)
|
||||
try:
|
||||
befehl = sandbox_command(script, tmp)
|
||||
except CodeRejected as exc:
|
||||
return SolverRun(status=SolverStatus.ERROR, error=str(exc))
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
befehl, cwd=tmp, capture_output=True, text=True, timeout=timeout, env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return SolverRun(status=SolverStatus.ERROR, error=f"Timeout nach {timeout} s")
|
||||
|
||||
result_lines = [line for line in proc.stdout.splitlines() if line.startswith(RESULT_MARKER)]
|
||||
if proc.returncode != 0:
|
||||
return SolverRun(status=SolverStatus.ERROR, error=f"Exit-Code {proc.returncode}:\n{_tail(proc.stderr)}")
|
||||
if len(result_lines) != 1:
|
||||
return SolverRun(
|
||||
status=SolverStatus.ERROR,
|
||||
error=f"report() wurde {len(result_lines)}-mal statt genau einmal aufgerufen.\nstdout:\n{_tail(proc.stdout)}",
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.loads(result_lines[0][len(RESULT_MARKER):])
|
||||
objective = payload.get("objective_value")
|
||||
objective = float(objective) if objective is not None else None
|
||||
tables, warnings = parse_result_tables(payload.get("tables") or [], objective)
|
||||
return SolverRun(
|
||||
status=SolverStatus(payload["status"]),
|
||||
objective_value=objective,
|
||||
solution=payload.get("solution") or {},
|
||||
headline=payload.get("headline") or None,
|
||||
constraints=_parse_list(payload.get("constraints"), ConstraintValue),
|
||||
variables=_parse_list(payload.get("variables"), VariableValue),
|
||||
sensitivity=_parse_list(payload.get("sensitivity"), Sensitivity),
|
||||
data=payload.get("data") if isinstance(payload.get("data"), dict) else {},
|
||||
relaxation_bound=payload.get("relaxation_bound"),
|
||||
multistart=Multistart.model_validate(payload["multistart"]) if payload.get("multistart") else None,
|
||||
graph=GraphSolution.model_validate(payload["graph"]) if payload.get("graph") else None,
|
||||
answers=_parse_list(payload.get("answers"), Answer),
|
||||
scenarios=_parse_list(payload.get("scenarios"), Scenario),
|
||||
tables=tables,
|
||||
warnings=warnings + ([payload["data_note"]] if payload.get("data_note") else []),
|
||||
)
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
return SolverRun(status=SolverStatus.ERROR, error=f"Ungültiges Ergebnis von report(): {exc}: {result_lines[0][:500]}")
|
||||
|
||||
|
||||
def generate_solver_code(
|
||||
session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
||||
previous: Optional[SolverCode] = None, previous_error: Optional[str] = None, understanding=None,
|
||||
data_prompt: str = "",
|
||||
) -> SolverCode:
|
||||
content = f"AUFGABENTEXT:\n{task}\n\nMODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}"
|
||||
if data_prompt:
|
||||
content += f"\n\n{data_prompt}"
|
||||
if understanding is not None and understanding.sub_questions:
|
||||
fragen = "\n".join(f"- {q.id} ({q.kind}, Rechnung nötig: {'ja' if q.needs_solver else 'nein'}): {q.quote}"
|
||||
for q in understanding.sub_questions)
|
||||
content += ("\n\nZU BEANTWORTENDE TEILFRAGEN (für jede genau ein Eintrag in answers, "
|
||||
f"question_id wie angegeben):\n{fragen}")
|
||||
if previous is not None:
|
||||
content += (
|
||||
f"\n\nDER VORHERIGE CODE IST FEHLGESCHLAGEN.\nCODE:\n{previous.code}\n\nFEHLER:\n{previous_error}\n\n"
|
||||
"Behebe den Fehler. Das Modell selbst darf dabei nicht verändert werden."
|
||||
)
|
||||
return session.create(
|
||||
SolverCode,
|
||||
[
|
||||
{"role": "system", "content": SOLVER_PROMPT_FILE.read_text(encoding="utf-8")},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
step="Solver-Reparatur" if previous is not None else "Solver-Code",
|
||||
)
|
||||
|
||||
|
||||
def check_multistart(run, sense: str) -> list:
|
||||
"""Streuung über die Startpunkte prüfen (ohne LLM).
|
||||
|
||||
Ipopt findet nur lokale Optima. Liefern verschiedene Startpunkte verschiedene Zielwerte, ist das
|
||||
beste Ergebnis nachweislich nicht global – das gehört in den Report, nicht in eine Fußnote.
|
||||
"""
|
||||
m = run.multistart
|
||||
if m is None:
|
||||
return []
|
||||
warnings = []
|
||||
besser = (lambda a, b: a > b) if sense == "maximize" else (lambda a, b: a < b)
|
||||
if m.converged > m.starts or m.converged < 0:
|
||||
warnings.append(f"Mehrstart: {m.converged} erfolgreiche von {m.starts} Startpunkten ist nicht möglich.")
|
||||
elif m.converged < 2:
|
||||
warnings.append(f"Mehrstart: nur {m.converged} von {m.starts} Startpunkten erfolgreich – "
|
||||
"die Stabilität des Optimums ist damit nicht beurteilbar.")
|
||||
for name, wert in (("worst", m.worst), ("mean", m.mean)):
|
||||
if wert is not None and besser(wert, m.best):
|
||||
warnings.append(f"Mehrstart: {name} = {wert:.6g} ist besser als der gemeldete beste Wert "
|
||||
f"{m.best:.6g} – bei {sense} unmöglich.")
|
||||
if m.stddev is not None:
|
||||
if m.stddev < 0:
|
||||
warnings.append(f"Mehrstart: Standardabweichung {m.stddev:.6g} ist negativ.")
|
||||
elif m.stddev > 1e-6 * max(1.0, abs(m.best)):
|
||||
warnings.append(
|
||||
f"Mehrstart: die Startpunkte führen zu verschiedenen Optima (Standardabweichung {m.stddev:.6g} "
|
||||
f"um {m.best:.6g}) – das gemeldete Optimum ist lokal, nicht nachweislich global."
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
# Umschreibungen, die im Deutschen nie richtig sind – anders als "Quelle", "Koeffizient", "neue".
|
||||
# Der Solver-Prompt verlangt echte Umlaute; beobachtet wurde trotzdem "guenstigste", "Gewaehlte".
|
||||
# Wortstamm -> richtige Schreibweise. Bewusst kuratiert und nicht „ue -> ü": Namen wie „Mueller"
|
||||
# und englische Wörter dürfen nicht angefasst werden.
|
||||
UMLAUT_STEMS = {
|
||||
"fuer": "für", "ueber": "über", "guenstig": "günstig", "gewaehl": "gewähl", "waehl": "wähl",
|
||||
"moegl": "mögl", "koenn": "könn", "muess": "müss", "groess": "größ", "hoech": "höch",
|
||||
"naechst": "nächst", "zusaetz": "zusätz", "verfuegbar": "verfügbar", "zeitraeum": "zeiträum",
|
||||
"loesung": "lösung", "schliess": "schließ", "urspruengl": "ursprüngl",
|
||||
"beruecksicht": "berücksicht", "erfuell": "erfüll", "gemaess": "gemäß", "taeglich": "täglich",
|
||||
"jaehrl": "jährl", "kuerzest": "kürzest", "laenge": "länge", "staerk": "stärk", "hoeh": "höh",
|
||||
"fruehest": "frühest", "spaetest": "spätest", "baell": "bäll", "heisst": "heißt",
|
||||
}
|
||||
TRANSLITERATIONS = re.compile(r"\b\w*(?:" + "|".join(UMLAUT_STEMS) + r")\w*\b", re.IGNORECASE)
|
||||
_STEM_PATTERN = re.compile("|".join(UMLAUT_STEMS), re.IGNORECASE)
|
||||
|
||||
|
||||
def fix_transliterations(text: str) -> str:
|
||||
"""'guenstigste' -> 'günstigste'. Die Warnung bleibt; der Report soll trotzdem lesbar sein.
|
||||
|
||||
Der Solver-Prompt verlangt echte Umlaute, das LLM hält sich nicht daran (in fast jedem Lauf
|
||||
mindestens einmal beobachtet). Eine Warnung, die nichts bewirkt, hilft dem Leser nicht.
|
||||
"""
|
||||
def ersetze(treffer):
|
||||
richtig = UMLAUT_STEMS[treffer.group(0).lower()]
|
||||
return richtig.capitalize() if treffer.group(0)[0].isupper() else richtig
|
||||
|
||||
return _STEM_PATTERN.sub(ersetze, text or "")
|
||||
|
||||
|
||||
def check_text_quality(run, summary: str = "") -> list:
|
||||
"""Texte für Menschen prüfen (ohne LLM): umschriebene Umlaute und unerklärte Fachbegriffe.
|
||||
|
||||
Der Report ist ein Dokument für Entscheider; 'guenstigste' statt 'günstigste' gehört dort nicht hin,
|
||||
und ein Fachbegriff in einer Tabellenspalte muss vorher erklärt worden sein.
|
||||
"""
|
||||
texte = [("headline", run.headline or "")]
|
||||
for table in run.tables:
|
||||
texte.append((f"Tabelle '{table.title}'", " ".join([table.title, *map(str, table.columns), table.note or ""])))
|
||||
befunde = []
|
||||
for wo, text in texte:
|
||||
woerter = sorted({m.group(0) for m in TRANSLITERATIONS.finditer(text)})
|
||||
if woerter:
|
||||
befunde.append(f"{wo}: Umlaute umschrieben statt ausgeschrieben ({', '.join(woerter)}).")
|
||||
if summary:
|
||||
offen = unexplained_jargon(" ".join(text for _, text in texte), summary)
|
||||
if offen:
|
||||
befunde.append(f"Fachbegriffe in Antwortsatz oder Ergebnistabellen, die die Zusammenfassung nicht "
|
||||
f"erklärt: {', '.join(offen)}.")
|
||||
return befunde
|
||||
|
||||
|
||||
MAX_SCENARIOS = 5 # jedes Szenario kostet einen weiteren Programmlauf (kein LLM-Aufruf)
|
||||
|
||||
|
||||
def verify_scenarios(run, code: str, timeout: int = 60, data_files=()) -> list:
|
||||
"""Jedes Szenario mit übersteuerten Daten NEU rechnen und den gemeldeten Zielwert bestätigen.
|
||||
|
||||
Ein Szenario ist eine Datenvariante desselben Programms – dieselbe Maschinerie wie `--resolve`.
|
||||
Deshalb kostet der Vergleich mehrerer Konfigurationen keinen einzigen LLM-Aufruf, und die
|
||||
gemeldeten Werte sind nicht geglaubt, sondern nachgerechnet.
|
||||
"""
|
||||
if not run.scenarios:
|
||||
return []
|
||||
warnings = []
|
||||
if len(run.scenarios) > MAX_SCENARIOS:
|
||||
warnings.append(f"Nur die ersten {MAX_SCENARIOS} von {len(run.scenarios)} Szenarien wurden nachgerechnet.")
|
||||
for szenario in run.scenarios[:MAX_SCENARIOS]:
|
||||
if not szenario.data_override:
|
||||
warnings.append(f"Szenario '{szenario.name}' nennt keine Datenabweichung – nicht nachrechenbar.")
|
||||
continue
|
||||
nachlauf = run_solver_code(code, timeout=timeout, data={**(run.data or {}), **szenario.data_override},
|
||||
data_files=data_files)
|
||||
szenario.verified_value = nachlauf.objective_value
|
||||
if nachlauf.status == SolverStatus.ERROR:
|
||||
warnings.append(f"Szenario '{szenario.name}' ließ sich nicht nachrechnen: {(nachlauf.error or '')[:120]}")
|
||||
elif (szenario.objective_value is not None and nachlauf.objective_value is not None
|
||||
and abs(szenario.objective_value - nachlauf.objective_value)
|
||||
> 1e-6 * max(1.0, abs(szenario.objective_value))):
|
||||
warnings.append(f"Szenario '{szenario.name}': gemeldet {szenario.objective_value:.6g}, "
|
||||
f"nachgerechnet {nachlauf.objective_value:.6g}.")
|
||||
return warnings
|
||||
|
||||
|
||||
# Zehnerpotenzen, mit denen Antworten Einheiten umrechnen: „9,00 Tsd. € (also 9000 €)", „%" statt Anteil
|
||||
SCALES = [10 ** k for k in range(-3, 4)]
|
||||
|
||||
|
||||
def _is_traceable(wert: str, belegt: set) -> bool:
|
||||
"""Gilt die Zahl als belegt? Auch gerundet und auch in einer anderen Einheit.
|
||||
|
||||
Zwei beobachtete Fehlalarme: '9,65' für den berechneten Wert 9,647868… (Antworten runden,
|
||||
Ergebnisse nicht) und '9000 €' für den Zielwert 9 Tsd. € (Antworten rechnen um). Beides ist per
|
||||
Konstruktion nachvollziehbar. Die Nachsicht kostet kaum Erkennungskraft: eine erfundene Zahl ist
|
||||
selten genau eine Zehnerpotenz eines berechneten Werts.
|
||||
"""
|
||||
if wert in belegt:
|
||||
return True
|
||||
stellen = len(wert.split(".")[1]) if "." in wert else 0
|
||||
toleranz = 0.5 * 10 ** (-stellen)
|
||||
zahl = float(wert)
|
||||
return any(abs(zahl - float(b) * faktor) <= toleranz * max(1.0, faktor)
|
||||
for b in belegt for faktor in SCALES)
|
||||
|
||||
|
||||
# Zufallszahlen im erzeugten Code heißen fast immer: die Eingabedaten wurden selbst erzeugt
|
||||
RANDOM_SOURCE = re.compile(r"\bnp\.random\b|\bnumpy\.random\b|\brandom\.\w|default_rng|\bseed\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def check_generated_data(code: str, solver_code, spec=None) -> list:
|
||||
"""Erzeugt das Programm seine Eingabedaten selbst, ohne sie auszuweisen?
|
||||
|
||||
Beobachtet an der Projektaufgabe „Portfolio Nr. 2", die Kursdaten aus dem Netz verlangt: Das
|
||||
Programm erzeugte einen synthetischen Markt (Faktormodell, fester Seed) und legte das im
|
||||
Antworttext und in einer Tabelle offen – ließ aber `data_assumptions` leer. Damit blieb die
|
||||
Kennzeichnung nur in Fließtext hängen: der CLI-Hinweis, der Report-Abschnitt „Angenommene Daten"
|
||||
und jede maschinelle Auswertung sahen ein Ergebnis wie jedes andere.
|
||||
|
||||
Die Prompt-Regel allein wirkte nicht (Nachmessung: `data_assumptions` wieder leer, die Antwort
|
||||
zu Teil 1 behauptete sogar „Die Kursdaten werden geladen"). Der Befund ist deshalb kein Hinweis
|
||||
mehr, sondern löst eine Reparatur aus und macht das Ergebnis sonst unzuverlässig.
|
||||
|
||||
Ausgenommen ist nur Ipopt mit vollständigen Zahlen: dort verlangt der Solver-Prompt einen festen
|
||||
Zufallsstartwert für die Startpunkte. „Werte vorhanden" lässt sich nicht an einem nicht-leeren
|
||||
`extracted_values` festmachen – beobachtet: „aus Kursdaten berechnet (Teil 1); im Text nicht
|
||||
numerisch vorgegeben" –, deshalb gilt der Stack als Unterscheidung, nicht die Datenlage allein.
|
||||
"""
|
||||
if not RANDOM_SOURCE.search(code or "") or (solver_code and solver_code.data_assumptions):
|
||||
return []
|
||||
if (spec is not None and spec.target_stack == TargetStack.IPOPT
|
||||
and all(numbers_in(p.extracted_values or "") for p in spec.parameters)):
|
||||
return []
|
||||
return ["Das Programm erzeugt Zahlen mit einem Zufallsgenerator, weist aber keine angenommenen "
|
||||
"Daten aus. Selbst erzeugte Eingabedaten gehören in data_assumptions – sonst ist dem "
|
||||
"Ergebnis nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht."]
|
||||
|
||||
|
||||
def repair_texts(run) -> None:
|
||||
"""Umschriebene Umlaute in allen Texten für Menschen berichtigen (nach der Prüfung)."""
|
||||
run.headline = fix_transliterations(run.headline) if run.headline else run.headline
|
||||
for antwort in run.answers:
|
||||
antwort.text = fix_transliterations(antwort.text)
|
||||
for tabelle in run.tables:
|
||||
tabelle.title = fix_transliterations(tabelle.title)
|
||||
tabelle.columns = [fix_transliterations(c) for c in tabelle.columns]
|
||||
tabelle.note = fix_transliterations(tabelle.note) if tabelle.note else tabelle.note
|
||||
tabelle.rows = [[fix_transliterations(z) if isinstance(z, str) else z for z in zeile]
|
||||
for zeile in tabelle.rows]
|
||||
for szenario in run.scenarios:
|
||||
szenario.name = fix_transliterations(szenario.name)
|
||||
szenario.description = fix_transliterations(szenario.description)
|
||||
if run.graph is not None:
|
||||
run.graph.node_meaning = fix_transliterations(run.graph.node_meaning)
|
||||
run.graph.arc_meaning = fix_transliterations(run.graph.arc_meaning)
|
||||
|
||||
|
||||
def check_answers(run, understanding, task: str = "", data_summary: str = "") -> list:
|
||||
"""Abnahme der fertigen Antwort (ohne LLM): beantwortet und belegt?
|
||||
|
||||
Zwei Befunde, die bisher niemand gefunden hätte: eine Teilfrage ohne Antwort (Aufgabe nicht
|
||||
erfüllt, auch wenn die Zahl stimmt) und eine Zahl im Antworttext, die nirgends berechnet wurde
|
||||
(erfunden). Vorbild ist die Zahlenkonsistenz-Prüfung aus `skills/6verity` des Fremdprojekts –
|
||||
dort eine LLM-Checkliste, hier nachgerechnet.
|
||||
"""
|
||||
if understanding is None or not understanding.sub_questions:
|
||||
return []
|
||||
befunde = []
|
||||
beantwortet = {a.question_id.strip().lower() for a in run.answers}
|
||||
offen = [q for q in understanding.sub_questions if q.id.strip().lower() not in beantwortet]
|
||||
if offen:
|
||||
befunde.append("Teilfragen ohne Antwort: "
|
||||
+ "; ".join(f"{q.id} ({q.kind}: {q.quote[:60]})" for q in offen)
|
||||
+ ". Die Aufgabe ist damit nicht vollständig beantwortet.")
|
||||
|
||||
# Belegte Zahlen: alles, was gerechnet wurde, plus alles, was in der Aufgabe stand
|
||||
belegt = numbers_in(json.dumps([
|
||||
run.objective_value, [v.value for v in run.variables], [c.lhs for c in run.constraints],
|
||||
[c.rhs for c in run.constraints], run.solution, [t.rows for t in run.tables],
|
||||
run.graph.model_dump() if run.graph else None, run.relaxation_bound,
|
||||
run.multistart.model_dump() if run.multistart else None,
|
||||
[s.model_dump() for s in run.scenarios],
|
||||
run.data, # Eingabewerte: bei beigelegten Dateien stehen sie NICHT im Aufgabentext
|
||||
], default=str)) | numbers_in(task) | numbers_in(data_summary)
|
||||
for antwort in run.answers:
|
||||
erfunden = sorted((w for w in numbers_in(antwort.text) if not _is_traceable(w, belegt)), key=float)
|
||||
if erfunden:
|
||||
befunde.append(f"Antwort zu '{antwort.question_id}' nennt Zahlen, die nirgends im Ergebnis stehen: "
|
||||
f"{', '.join(erfunden[:6])}. Entweder sind sie erfunden, oder es sind Vergleichswerte, "
|
||||
f"die in eine Ergebnistabelle gehören – prüfbar ist beides so nicht.")
|
||||
return befunde
|
||||
|
||||
|
||||
def check_graph(run, sense: str) -> list:
|
||||
"""Graph und kürzesten Weg deterministisch nachrechnen (ohne LLM).
|
||||
|
||||
Geprüft wird, dass der gemeldete Weg wirklich ein Weg im gemeldeten Graphen ist und dass die Summe
|
||||
seiner Bogengewichte den Zielwert ergibt – damit ist die Lösung unabhängig vom Verfahren belegt.
|
||||
"""
|
||||
g = run.graph
|
||||
if g is None:
|
||||
return []
|
||||
warnings = []
|
||||
knoten = set(g.nodes)
|
||||
boegen = {(a.source, a.target): a.weight for a in g.arcs}
|
||||
|
||||
if any(a.weight < 0 for a in g.arcs):
|
||||
# networkx meldet hier KEINEN Fehler, liefert aber ein falsches Ergebnis (geprüft)
|
||||
warnings.append("Graph: negative Bogengewichte – der Dijkstra-Algorithmus ist dann nicht anwendbar "
|
||||
"und liefert stillschweigend ein falsches Ergebnis (Flussmodell/HiGHS verwenden).")
|
||||
for arc in g.arcs:
|
||||
if arc.source not in knoten or arc.target not in knoten:
|
||||
warnings.append(f"Graph: Bogen {arc.source}→{arc.target} verwendet einen nicht deklarierten Knoten.")
|
||||
|
||||
if len(g.path) < 2:
|
||||
warnings.append("Graph: kein Weg gemeldet – die Zeichnung zeigt dann nur den Graphen.")
|
||||
return warnings
|
||||
|
||||
laenge = 0.0
|
||||
for start, ziel in zip(g.path, g.path[1:]):
|
||||
if (start, ziel) not in boegen:
|
||||
warnings.append(f"Graph: der gemeldete Weg benutzt {start}→{ziel}, ein solcher Bogen ist nicht deklariert.")
|
||||
return warnings
|
||||
laenge += boegen[(start, ziel)]
|
||||
if run.objective_value is not None and abs(laenge - run.objective_value) > 1e-6 * max(1.0, abs(run.objective_value)):
|
||||
warnings.append(f"Graph: die Summe der Bogengewichte des Weges ({laenge:.6g}) weicht vom Zielwert "
|
||||
f"({run.objective_value:.6g}) ab.")
|
||||
|
||||
marken = [s.label for s in g.steps]
|
||||
if marken and any(b < a for a, b in zip(marken, marken[1:])):
|
||||
warnings.append("Graph: die Marken des Markierungsverlaufs fallen zwischendurch – bei Dijkstra werden "
|
||||
"Knoten in nicht fallender Reihenfolge ihrer Marke endgültig.")
|
||||
return warnings
|
||||
|
||||
|
||||
def _check_and_run(code: str, timeout: int, spec: OptimizationModelSpecification,
|
||||
understanding=None, task: str = "", data_files=(), data_summary: str = "") -> SolverRun:
|
||||
"""Programmstruktur prüfen, ausführen, Lösung deterministisch nachrechnen."""
|
||||
try:
|
||||
validate_structure(code)
|
||||
except CodeRejected as exc:
|
||||
return SolverRun(status=SolverStatus.ERROR, error=f"Code abgelehnt: {exc}")
|
||||
run = run_solver_code(code, timeout=timeout, data_files=data_files)
|
||||
run.feasibility = check_feasibility(run, spec)
|
||||
run.warnings += check_sensitivity(run, spec.objective.sense.value)
|
||||
run.warnings += check_multistart(run, spec.objective.sense.value)
|
||||
run.warnings += check_graph(run, spec.objective.sense.value)
|
||||
run.warnings += check_text_quality(run, spec.executive_summary)
|
||||
repair_texts(run) # erst melden, dann beheben: der Report soll lesbar sein
|
||||
run.warnings += check_answers(run, understanding, task, data_summary)
|
||||
run.warnings += verify_scenarios(run, code, timeout, data_files)
|
||||
return run
|
||||
|
||||
|
||||
def solve(session: LLMSession, task: str, spec: OptimizationModelSpecification,
|
||||
retries: int = 2, timeout: int = 60, log=lambda msg: None, understanding=None,
|
||||
data_files=(), data_prompt: str = "") -> tuple:
|
||||
"""Code erzeugen und ausführen; bei Fehlern bis zu `retries` Reparaturversuche.
|
||||
|
||||
infeasible/unbounded gelten nicht als Fehler und werden nicht repariert.
|
||||
"""
|
||||
solver_code = generate_solver_code(session, task, spec, understanding=understanding,
|
||||
data_prompt=data_prompt)
|
||||
solver_code.code = clean_generated_code(solver_code.code)
|
||||
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)
|
||||
attempt = 1
|
||||
while (run.status == SolverStatus.ERROR or run.feasibility or run.undeclared_data) and attempt <= retries:
|
||||
fehler = run.error or (("Die Lösung hält das Modell nicht ein:\n- " + "\n- ".join(run.feasibility))
|
||||
if run.feasibility else
|
||||
("Eingabedaten nicht ausgewiesen:\n- " + "\n- ".join(run.undeclared_data)))
|
||||
log(f"Solver-Code fehlgeschlagen – Reparaturversuch {attempt}/{retries}: {fehler[:200]}")
|
||||
solver_code = generate_solver_code(session, task, spec, previous=solver_code, previous_error=fehler,
|
||||
understanding=understanding, data_prompt=data_prompt)
|
||||
solver_code.code = clean_generated_code(solver_code.code)
|
||||
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)
|
||||
attempt += 1
|
||||
run.attempts = attempt
|
||||
return solver_code, run
|
||||
Loading…
Reference in a new issue