commit b74445b8d6701480733a52507b7ade110a68c5f5 Author: dschlueter Date: Thu Sep 17 11:42:58 2026 +0200 Erste öffentliche Fassung: Math Model Generator & Solver Übersetzt natürlichsprachige Optimierungsaufgaben (Deutsch) per LLM in eine geprüfte Modellspezifikation, erzeugt Solver-Code und führt ihn in einer bubblewrap-Sandbox aus. Deterministische Prüfungen ohne LLM, Report als Markdown/HTML, Benchmark mit 19 verifizierten Fällen. Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..530714e --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ + +# Virtuelle Umgebung +.venv/ +venv/ + +# Test- und Werkzeug-Caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Geheimnisse (API-Keys gehören in Umgebungsvariablen) +.env +.env.* + +# Laufzeitausgaben +benchmark/results/ +ausgabe/ +*.log + +# Editor / IDE / Betriebssystem +.vscode/ +.idea/ +*.swp +.DS_Store + +# Lokale Claude-Code-Dateien +.claude/ + +# Interne Arbeitsnotizen (lokal, nicht veröffentlicht) +CLAUDE.md +STAND.md +So_machen_es_die_anderen.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c1ff7f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dieter Schlüter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Pruef-Prompt.txt b/Pruef-Prompt.txt new file mode 100644 index 0000000..ee28f4c --- /dev/null +++ b/Pruef-Prompt.txt @@ -0,0 +1,49 @@ +Du bist ein kritischer Gutachter für mathematische Optimierungsmodelle. +Du erhältst einen Aufgabentext und eine daraus erzeugte Modellspezifikation (JSON). Deine Aufgabe ist es, die Zielfunktion und die offenen Annahmen (open_assumptions) streng gegen den Aufgabentext abzugleichen. Du erstellst selbst kein neues Modell. + +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. + +Schritt 1 – Zielgröße (objective_check), immer zuerst: +1. Zitiere die Stelle im Aufgabentext, die sagt, was optimiert bzw. angegeben werden soll (task_evidence). +2. Benenne die gefragte Größe genau, mit Einheit und Maßstab (asked_quantity). +3. Benenne, was objective.latex_formula tatsächlich berechnet (modeled_quantity). +4. matches ist nur True, wenn beide Größen identisch sind: gleiche Größe, gleiche Richtung (min/max), gleicher Maßstab. + Eine andere, nur monoton verwandte Größe ist False – auch wenn sie dieselbe optimale Lösung hätte, denn der ausgegebene Zielwert wäre falsch. Beispiele: + - gefragt Standardabweichung/Volatilität, modelliert Varianz w^T Σ w → False (Korrektur: \min \sqrt{w^T Σ w} bzw. Norm-Formulierung) + - gefragt Varianz, modelliert Varianz → True (nicht in Standardabweichung „korrigieren“) + - gefragt Gewinn nach Abzug der Fixkosten, modelliert nur Deckungsbeitrag → False + - gefragt Kosten in Tsd. €, modelliert Kosten in € → False +5. Prüfe die Einheiten: Benenne in unit_explanation die Einheit jedes Terms der Zielfunktion. units_consistent ist nur True, wenn alle Terme dieselbe Einheit haben. Beispiele für False: + - Rendite und Kosten in Euro, Risiko als \(\sum w_i^2 \sigma_i^2\) über Anteile (dimensionslos) – das Risiko wäre um den Faktor Kapital zu klein und praktisch wirkungslos. + - Kosten in Tsd. €, Erlöse in €. + Bei False gehört in correction die korrigierte Zielfunktion mit einheitlicher Einheit (z. B. Risiko mit dem Kapital multipliziert). +6. Bei matches False gib in correction die korrigierte Zielfunktion in LaTeX an (ohne Begrenzer). + +Schreibweise: In allen Textfeldern (task_evidence ausgenommen, das ist ein wörtliches Zitat) steht jede Formel und jedes Symbol in \( … \), z. B. „modelliert wird \(w^\top \Sigma w\)“. Keine $-Zeichen als Formelbegrenzer. + +Schritt 2 – Vorgehen für jede Annahme: +1. Suche die zugehörige Stelle im Aufgabentext und zitiere sie wörtlich (task_evidence). +2. Prüfe, ob der Text an dieser Stelle wirklich unklar ist oder eine eindeutige Aussage trifft. +3. Prüfe die Annahme und die daraus abgeleiteten Restriktionen logisch gegen den Text: + - Richtung von Bedingungen: „Wenn A, dann B“ ist eine Implikation (A ⇒ B), keine Äquivalenz (A ⇔ B). + - „mindestens“ / „höchstens“ / „genau“ müssen als ≥ / ≤ / = abgebildet sein. + - Das Modell darf nichts verbieten, was der Text erlaubt (stricter_than_task), und nichts erlauben, was der Text verbietet (weaker_than_task). + - Eine Annahme darf dem Text nicht widersprechen (contradicts_task). +4. Prüfe die zweite Lesart im Feld alternative: Stützt der Aufgabentext sie besser als die gewählte Annahme, ist die Annahme falsch – vergib dann contradicts_task, stricter_than_task oder weaker_than_task und gib in correction die Lesart an, der das Modell folgen muss. Steht dort „keine zweite Lesart“, obwohl der Text die Stelle offen lässt, gehört das in die Begründung. +5. Vergib genau ein Urteil (verdict): + - justified: Text ist unklar, Annahme ist plausibel und textkonform. + - unnecessary: Text ist eindeutig, die Annahme ist überflüssig, aber nicht falsch. + - contradicts_task, stricter_than_task, weaker_than_task: wie oben beschrieben; gib dann in correction die korrigierte Annahme bzw. Formel (LaTeX) an. + +Was du NICHT prüfst (das rechnet die Pipeline ohne LLM nach – verschwende dafür keine Zeichen): +- Einheit je Parameter, Summen der Ergebnistabellen, Zulässigkeit der Lösung, Länge des Weges im Graphen, Streuung der Startpunkte, deklarierte Symbole, unerklärte Fachbegriffe, ob ein im Text verlangtes Verfahren (z. B. Dijkstra) verwendet wurde. +- Ob Problemklasse und Stack zueinander passen: dafür gibt es eine feste Tabelle. Deine Aufgabe ist die Zielgröße und die Annahmen – dort sitzen die Fehler, die keine Tabelle findet. + +Zusätzlich: +- Prüfe, ob das Modell unklare Stellen des Textes stillschweigend entschieden hat, ohne sie als Annahme auszuweisen. Führe diese in missing_assumptions auf – mit denselben Feldern, also auch mit der zweiten Lesart (alternative), die an dieser Stelle möglich wäre. +- Beurteile ausschließlich auf Basis des Aufgabentextes, nicht auf Basis von Allgemeinwissen über typische Problemstellungen. +- Sei präzise und knapp. Wenn alles korrekt ist, vergib justified bzw. unnecessary und lasse missing_assumptions leer. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c26e5d7 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# Math Model Generator & Solver + +Übersetzt eine **natürlichsprachige Optimierungsaufgabe** (Deutsch) mit einem LLM in ein formales mathematisches Modell, prüft es, korrigiert Fehler und **löst es tatsächlich** mit einem passenden Python-Solver. Ergebnis ist ein nachvollziehbarer Report mit Formeln, Prüfbericht, Lösung und dem erzeugten Python-Code. + +```text +Aufgabentext (+ optional beigelegte Daten: --daten kurse.csv) + │ ohne LLM: Profil je Datei – Zeilen, Spalten, Spannweiten, Lücken + ▼ +1. Verstehen ───────── LLM: Teilfragen a)/b)/…, alle Zahlen des Textes mit Fundstelle, + │ übergangene Zahlen mit Grund; ist es überhaupt eine Aufgabe? + │ ohne LLM geprüft: keine Zahl, keine Teilfrage übersehen + ▼ +2. Extraktion ──────── LLM: Problemklasse, Solver-Wahl, Indizes, Parameter, + │ Variablen, Zielfunktion, Restriktionen (LaTeX), Annahmen + ▼ +3. Konsistenz-Checks ─ ohne LLM: Solver passt zur Klasse? Einheiten? undeklarierte + │ Symbole? kamen die erfassten Zahlen im Modell an? + ▼ +4. LLM-Prüfung ─────── Zielfunktion = gefragte Größe? Annahmen textkonform? + │ ▲ + ▼ │ Korrekturschleife (max. 2 Runden, beste Runde gewinnt) +5. Korrektur ┘ + │ + ▼ +6. Solver ──────────── LLM erzeugt Python-Code, Ausführung in der Sandbox, Reparatur + │ bei Fehlern; Szenarien = Datenvarianten, ohne LLM nachgerechnet + ▼ +7. Abnahme ─────────── ohne LLM: jede Teilfrage beantwortet? jede Zahl der Antwort + │ belegt? Lösung zulässig? Tabellensummen richtig? + ▼ +Report (Markdown/HTML) + JSON + eigenständiges Solver-Skript +``` + +## Unterstützte Problemklassen + +| Klasse | Solver | Typische Aufgaben | +|---|---|---| +| Lineare Programmierung (LP) | SciPy `linprog` | Mischungs-, einfache Produktionsprobleme | +| Gemischt-ganzzahlig (MILP) | HiGHS (`highspy`) | Zuordnung, Rucksack, Standortwahl, Rüst-/Fixkosten | +| Constraint Programming (CP) | Google OR-Tools CP-SAT | Schicht- und Personalplanung, logische Bedingungen | +| Quadratisch / Kegel (QP, SOCP) | CVXPY | Portfoliooptimierung (Varianz, Volatilität) mit kontinuierlichen Anteilen | +| Gemischt-ganzzahlig quadratisch (MIQP) | SCIP (`pyscipopt`) | Portfolio mit Kardinalität (mind./höchstens k Titel), Mindestgewicht nur für gewählte Titel, fixe Transaktionskosten | +| Nichtlinear, stetig (NLP) | Ipopt (`cyipopt`) | nichtlineare Ausbeute- oder Kostenfunktionen, Kurvenanpassung; mehrere Startpunkte, Streuung wird berichtet | +| Kürzeste Wege (Graph) | NetworkX (Dijkstra) | Wegewahl im Netz, Ersatz- und Erneuerungsprobleme; der Report zeichnet den Graphen und zeigt den Markierungsverlauf | + +## Installation + +Voraussetzungen: Python ≥ 3.10, Linux. Systempakete: + +- `bubblewrap` für die Sandbox des erzeugten Codes (Debian/Ubuntu: `sudo apt install bubblewrap`, siehe Sicherheitshinweis) +- die Ipopt-Bibliothek für `cyipopt` (Debian/Ubuntu: `sudo apt install coinor-libipopt-dev pkg-config`), sofern pip kein fertiges Paket findet + +```bash +python -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +API-Key als Umgebungsvariable setzen (nicht in Dateien speichern): + +| Anbieter | Variable | Modellnamen | +|---|---|---| +| OpenRouter (Standard) | `OPENROUTER_API_KEY` | mit `/`, z. B. `deepseek/deepseek-v4.1-flash` | +| Google Gemini | `GEMINI_API_KEY` | ohne `/`, z. B. `gemini-2.5-flash` | + +Der Anbieter wird am Modellnamen erkannt. Das kostenlose Gemini-Kontingent reicht nur für wenige Läufe pro Tag. + +## Benutzung + +```bash +# Aufgabe aus Datei lösen, Report und Solver-Code speichern +.venv/bin/python TextToModel.py aufgabe.txt --report ausgabe/modell.html --code ausgabe/solve.py > ausgabe/ergebnis.json + +# Aufgabe über stdin +echo "Ein Betrieb ..." | .venv/bin/python TextToModel.py --report ausgabe/modell.md + +# nur modellieren und prüfen, nicht lösen; anderes Modell +.venv/bin/python TextToModel.py aufgabe.txt --no-solve --model deepseek/deepseek-v4-pro-0813 + +# Report nachträglich aus gespeichertem Ergebnis erzeugen (keine LLM-Kosten) +.venv/bin/python report.py ausgabe/ergebnis.json ausgabe/modell.md + +# exportiertes Solver-Programm erneut ausführen +.venv/bin/python ausgabe/solve.py + +# Variante rechnen: geänderte Eingabedaten, gleiches Programm, KEIN LLM-Aufruf +.venv/bin/python TextToModel.py --resolve ausgabe/ergebnis.json --data neue_daten.json > ausgabe/variante.json + +# Aufgabe mit beigelegten Daten (CSV/Excel); das Profil geht in die Prompts, die Datei ins Programm +.venv/bin/python TextToModel.py aufgabe.md --daten kurse.csv --report ausgabe/modell.html + +# nur die Solver-Stufe wiederholen: Modell und Prüfbericht bleiben, spart die Aufrufe davor +.venv/bin/python TextToModel.py --von solver ausgabe/ergebnis.json > ausgabe/neu.json +``` + +Der Ordner `ausgabe/` ist in `.gitignore` eingetragen. Ohne Eingabe wird eine eingebaute Beispielaufgabe verwendet. Fortschritt und Kosten erscheinen auf stderr, das Ergebnis-JSON auf stdout. + +| Option / Variable | Bedeutung | Standard | +|---|---|---| +| `--report DATEI` | Report als `.md` (z. B. VS Code-Vorschau: Strg+Umschalt+V) oder `.html` (Browser, Formeln via MathJax) | – | +| `--code DATEI` | Solver-Programm als eigenständiges, dokumentiertes Python-Skript speichern | – | +| `--model` / `LLM_MODEL` | LLM-Modell | `deepseek/deepseek-v4.1-flash` | +| `--daten DATEI …` | beigelegte CSV-/Excel-Dateien; ihr Profil (Zeilen, Spalten, Spannweiten, Lücken) geht in die Prompts, die Dateien ins Arbeitsverzeichnis des Programms | – | +| `--von STUFE ERGEBNIS.JSON` | gespeichertes Ergebnis fortsetzen statt alles zu wiederholen: `solver` (nur Solver-Code neu) oder `pruefung` (ab Prüfung/Korrektur) | – | +| `--max-revisions` / `MAX_REVISIONS` | Korrekturrunden | 2 | +| `--no-solve` | keinen Solver-Code erzeugen | aus | +| `SOLVER_RETRIES` | Reparaturversuche für fehlerhaften Solver-Code | 2 | +| `INFEASIBLE_RECHECK` | Gegenprobe mit unabhängiger Modellierung bei „unlösbar“ (0 schaltet ab) | 1 | +| `LLM_MAX_TOKENS` | Obergrenze je Antwort (OpenRouter) | 32000 | +| `--max-restarts` / `MAX_RESTARTS` | komplette Neustarts, wenn das Modell nach allen Korrekturrunden fehlerhaft ist | 1 | +| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des OpenRouter-Anbieters (Tokens/s); Präferenz, kein harter Filter | 75 | +| `LLM_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 | +| `SOLVER_SANDBOX` | Isolierung des erzeugten Codes: `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` | `auto` | +| `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` | + +### Solver-Programme + +Das erzeugte Programm (`--code` sowie im HTML-/Markdown-Report) ist **separat verwendbar** und aufgebaut als: + +1. `#!/usr/bin/env python3` +2. Docstring mit **Aufgabentext**, **mathematischem Modell** (Indizes, Parameter, Variablen, Zielfunktion, Restriktionen – mit Erläuterungen und Annahmen) und dem **Algorithmus** samt Begründung, warum er hier passt +3. kommentierter Python-Code: Imports, Eingabedaten als Konstanten, Modellfunktion(en), `main()` +4. `if __name__ == "__main__": main()` + +Die Ausgabe ist lesbar – Status, Zielwert, **verständliche Ergebnistabellen** (z. B. je Aktie: Anteil, Rendite, Kosten, Beitrag zum Ziel, Summenzeile) und die Variablenwerte – und endet mit einer maschinenlesbaren `RESULT_JSON`-Zeile. Die Tabellen berechnet das Programm aus der tatsächlichen Lösung; die Pipeline prüft automatisch, dass die Beiträge sich zum Zielwert summieren, und warnt sonst. Der Report beginnt mit dem **Ergebnis**: ein Antwortsatz, die Kennzahlen, die Ergebnistabellen samt **Diagramm** (Balken je Objekt, Wasserfall für die Zielwert-Zerlegung aus Ertrag, Risiko und Kosten) und eine Tabelle der **bindenden Restriktionen**. Darunter folgen die Rohwerte nach Variablen gruppiert, danach Aufgabe, Modell, Prüfbericht und Programm. Die Diagramme sind reines SVG ohne externe Bibliothek und funktionieren offline, hell wie dunkel. Im HTML-Report lässt sich das Programm mit einem Klick auf das Kopier-Symbol in die Zwischenablage übernehmen. Das JSON-Ergebnis enthält weiterhin nur den rohen generierten Code. + +**Mit Datenanhängen (`--daten`):** Das erzeugte Programm liest die Dateien über ihren Dateinamen aus dem Arbeitsverzeichnis. Ein mit `--code` exportiertes Programm braucht die Datendateien deshalb **im selben Verzeichnis** – sonst endet es mit `FileNotFoundError`. Kopiere sie mit: + +```bash +.venv/bin/python TextToModel.py aufgabe.md --daten kurse.csv --code ausgabe/solve.py +cp kurse.csv ausgabe/ && (cd ausgabe && ../.venv/bin/python solve.py) +``` + + +## Wie verlässlich ist das? + +Das LLM kann Fehler machen – deshalb gibt es mehrere Sicherungen: + +- **Zielgrößen-Prüfung:** erkennt z. B., wenn die Varianz statt der gefragten Volatilität minimiert wird. +- **Annahmen-Prüfung:** Unklarheiten im Text müssen als Annahme ausgewiesen werden und werden gegen den Text geprüft (z. B. „wenn A, dann B“ ist keine Äquivalenz). +- **Zulässigkeitsprüfung ohne LLM:** Die gefundene Lösung wird in alle Restriktionen eingesetzt, Schranken und Ganzzahligkeit geprüft und abgeglichen, ob jede Restriktion des Modells im Programm vorkommt. Verletzungen lösen eine Reparatur aus – Solver-Toleranzen (beobachtet: 8 Cent über dem Budget) fallen damit auf. +- **Unlösbare Aufgaben** werden als `infeasible` gemeldet, nicht durch Weglassen von Bedingungen „passend gemacht“ – aber auch nicht ungeprüft geglaubt: Eine zweite, unabhängige Modellierung bestätigt oder widerlegt den Status. +- **Beste Korrekturrunde:** Verschlechtert eine Korrektur das Modell, wird die frühere Fassung verwendet. +- **Sensitivität:** Für bindende Restriktionen rechnet das Programm mit verschobener rechter Seite neu; der Report zeigt, was eine Lockerung je Einheit bringt, und bei ganzzahligen Modellen den Preis der Ganzzahligkeit. Unplausible Richtungen werden gemeldet. +- **Neustart bei Fremdproblem:** Gelegentlich modelliert das LLM ein anderes als das gestellte Problem. Passt die Zielgröße auch nach allen Korrekturrunden nicht, wird die Modellierung komplett neu gestartet. +- **Jede Teilfrage beantwortet:** Der Aufgabentext wird vor dem Modellieren in seine Teilfragen zerlegt (a, b, …); das Programm liefert je Teilfrage einen Antwortsatz. Fehlt eine Antwort, gilt die Aufgabe als nicht erfüllt – auch wenn der Zielwert stimmt. +- **Jede Zahl der Antwort belegt:** Zahlen im Antworttext müssen aus dem Ergebnis oder der Aufgabe stammen (Rundungen und Einheitenumrechnungen werden akzeptiert). Ein Vergleichswert, der in keiner Tabelle steht, fällt auf. +- **Jede Zahl der Aufgabe erfasst:** Zahlen des Aufgabentexts müssen als Fakt mit Fundstelle oder mit Begründung als irrelevant ausgewiesen sein – sonst betreten sie die Pipeline erst im Solver-Code, hinter allen Prüfungen. +- **Selbst erzeugte Daten sind gekennzeichnet:** Erzeugt das Programm Eingabedaten (Zufallszahlen, synthetischer Markt, verkleinertes Universum), muss das in `data_assumptions` stehen, nicht nur im Fließtext – sonst folgt eine Reparatur und notfalls die Markierung „unzuverlässig“. +- **Szenarien nachgerechnet:** Vergleicht die Aufgabe mehrere Konfigurationen, wird jede durch erneutes Ausführen des Programms bestätigt – ohne LLM. +- **Sandbox:** Der erzeugte Code läuft ohne Netzzugang, mit nur lesbarem Dateisystem und beschreibbar allein dem Arbeitsverzeichnis des Laufs. +- **Kein falsches „optimal“:** Bleibt das Modell fehlerhaft, wird das Ergebnis als **unzuverlässig** markiert – bei falscher Zielgröße ohne Solver-Lauf, mit Warnkasten im Report und Exit-Code 2. + +Trotzdem gilt: **Ergebnisse vor Verwendung prüfen** – der Report zeigt dafür Modell, Annahmen, Prüfbericht und Code. + +### Benchmark + +`benchmark/cases/` enthält 19 Aufgaben mit **unabhängig verifiziertem Optimum** (Brute Force bzw. zweiter Solver) – von kleinen LPs bis zu Aufgaben mit eingebauten Fallen (Rüstkosten/Mindestlos, Wenn-dann-Regel als Implikation, Volatilität statt Varianz, nichtkonvexes NLP mit globalem Optimum, beweisbar unlösbare Mischung, Losgrößen als kürzester Weg). Gemessen wird der **Zielwert** und, strenger, ob die **Aufgabe erfüllt** ist: jede Teilfrage beantwortet, jede Zahl der Antwort belegt. Dateien im Fallverzeichnis außer `task.txt`/`expected.json` werden als Datenanhang übergeben. + +```bash +.venv/bin/python benchmark.py --models deepseek/deepseek-v4.1-flash deepseek/deepseek-v4-flash-0731 --runs 2 --jobs 3 +.venv/bin/python benchmark.py --cases hard_schichtplan_woche --runs 3 +``` + +Letzter Stand (September 2026, DeepSeek V4.1-Flash über OpenRouter, je 2 Läufe): **38 von 38 Läufen** über alle 19 Fälle mit richtigem Zielwert und erfüllter Aufgabe, etwa 0,05–0,15 $ und 3–10 Minuten je Lauf. Die Fallmenge ist damit gesättigt; die Grenzen der Pipeline zeigen sich eher an schlecht gestellten Aufgaben (fehlende Daten, mehrdeutige Angaben) – Beispiele dafür liegen in `aufgaben/`. Hinweis: Drei Fälle wurden nach dieser Messung sprachlich neu gefasst (gleiche Zahlen und Optima). + +## Tests + +```bash +.venv/bin/python -m pytest -q # offline, ohne API-Kosten +.venv/bin/python -m pytest -q test_solver_stage.py -k cvxpy # einzelner Test +``` + +## Projektstruktur + +| Datei | Inhalt | +|---|---| +| `TextToModel.py` | CLI und Pipeline (`run_pipeline`) | +| `model_schema.py` | Pydantic-Schemas aller Zwischenergebnisse | +| `model_checks.py` | deterministische Konsistenz-Checks | +| `solver_stage.py` | Solver-Code erzeugen, prüfen, ausführen | +| `llm.py` | LLM-Zugriff (OpenRouter/Gemini), Wiederholungen, Kostenerfassung | +| `report.py` | Report als Markdown/HTML (mit Kopier-Symbol), auch als eigenständiges CLI | +| `script_export.py` | dokumentiertes, eigenständiges Solver-Programm (Kopf mit Aufgabe, Modell, Algorithmus) | +| `data_profile.py` | Profil beigelegter CSV-/Excel-Dateien, ohne LLM | +| `charts.py` | Diagramme und Graphenzeichnung als Inline-SVG | +| `benchmark.py`, `benchmark/cases/` | Benchmark und Referenzaufgaben | +| `aufgaben/` | weitere Aufgaben zum Ausprobieren (u. a. mit fehlenden Daten) | +| `*-Prompt.txt` | System-Prompts für Verstehen, Extraktion, Prüfung und Solver-Code | +| `test_*.py` | Offline-Tests | + +## Sicherheitshinweis + +Der Solver-Code wird vom LLM erzeugt. Er läuft in einer **bubblewrap-Sandbox**: kein Netzzugang, das Dateisystem nur lesbar, beschreibbar allein das Temp-Verzeichnis des jeweiligen Laufs, `HOME` zeigt ebenfalls dorthin. Davor greifen weiterhin eine Import-Whitelist (nur Mathe- und Solver-Bibliotheken), das Verbot von `open`/`eval`/`exec` und ein Timeout. + +Voraussetzung ist ein installiertes `bubblewrap` (Debian/Ubuntu: `sudo apt install bubblewrap`). Fehlt es, läuft der Code **ungeschützt mit deinen Benutzerrechten** – dann nur eigene, vertrauenswürdige Aufgabentexte verwenden. Mit `SOLVER_SANDBOX=bwrap` wird die Sandbox zur Pflicht: ohne bubblewrap wird der Lauf abgelehnt statt ungeschützt ausgeführt. + +```bash +.venv/bin/python -m pytest -q -k sandbox # prüft, dass Schreiben außerhalb des Laufs scheitert +``` + +## Lizenz + +MIT – siehe [`LICENSE`](LICENSE). diff --git a/Routing-und-Extraktions-Prompt.txt b/Routing-und-Extraktions-Prompt.txt new file mode 100644 index 0000000..8e3a667 --- /dev/null +++ b/Routing-und-Extraktions-Prompt.txt @@ -0,0 +1,69 @@ +Du bist ein Experte für mathematische Optimierung und OR-Architektur. +Deine Aufgabe ist es, eine natürlichsprachige Optimierungsaufgabe systematisch zu dekonstruieren, in ein formales mathematisches Modell zu überführen und der optimalen Python-Bibliothek zuzuweisen. + +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. + +Entscheide streng nach folgenden Routing-Regeln (die Zuordnung Klasse → Stack wird nachgerechnet) [→ STACK_MISMATCH]: + +1. ZIEL: Google OR-Tools (CP-SAT) + - Typ: Constraint Programming (CP) + - Indikatoren: Schicht- und Personalplanung, Disjunktive Zeitfenster (Interval-Variablen), Reihenfolgeentscheidungen (No-Overlap), logische Verknüpfungen (IF-THEN, XOR), diskrete Packungs- oder Zuweisungsprobleme ohne Konvexität. + +2. ZIEL: HiGHS (highspy) / SciPy + - Typ: Lineare Programmierung (LP) oder Mixed-Integer LP (MILP) + - Indikatoren: Transport- und Netzwerkflüsse, klassische Produktionsplanung, Mischungsprobleme, Rucksackprobleme mit linearen Kosten. + - Wähle HiGHS bei Ganzzahligkeit (MILP), SciPy bei reinen kontinuierlichen LPs. + +3. ZIEL: CVXPY + - Typ: Quadratische (QP) oder konvexe Optimierung (SOCP) – nur mit kontinuierlichen Variablen + - Indikatoren: Portfoliooptimierung (Varianz-/Kovarianzmatrizen, Markowitz), Risiko-Minimierung, quadratische Abweichungen, Entropie- oder geometrische Programmierung. + +4. ZIEL: SCIP (pyscipopt) + - Typ: Mixed-Integer Quadratic Programming (MIQP) – quadratische bzw. nichtlineare Zielfunktion oder Restriktionen UND ganzzahlige/binäre Variablen + - Indikatoren: Portfolio mit Kardinalitätsbedingung (mindestens/höchstens k Titel), Mindest-/Höchstgewicht nur für ausgewählte Titel, fixe Transaktions- oder Auswahlkosten zusammen mit Varianz/Risiko, Standortwahl mit quadratischen Kosten, Losgrößen mit quadratischen Kosten. + - Sobald ein quadratischer Term und eine Ganzzahligkeit im selben Modell vorkommen, ist es MIQP/SCIP – nicht CVXPY (keine Ganzzahligkeit) und nicht CP-SAT (keine kontinuierlichen Variablen). [→ CVXPY_WITH_DISCRETE_VARS, CPSAT_WITH_CONTINUOUS_VARS] + +5. ZIEL: Ipopt (cyipopt) + - Typ: Nonlinear Programming (NLP) – glatte nichtlineare Zielfunktion oder Restriktionen, ausschließlich stetige Variablen + - Indikatoren: Produktions- oder Mischungsprobleme mit nichtlinearen Ausbeute-, Verlust- oder Kostenfunktionen, Kurvenanpassung/Parameterschätzung (kleinste Quadrate mit nichtlinearem Modell), physikalische oder verfahrenstechnische Auslegung, Gleichgewichtsbedingungen. + - Ipopt liefert nur ein LOKALES Optimum. Verlangt die Aufgabe nachweislich das globale Optimum und ist das Problem nicht konvex, wähle stattdessen SCIP (globale Optimierung); mit ganzzahligen Variablen ohnehin SCIP. [→ IPOPT_WITH_DISCRETE_VARS] + +6. ZIEL: NetworkX (Dijkstra) + - Typ: Kürzeste Wege im Graphen (Shortest Path) + - Indikatoren: kürzeste/billigste/schnellste Verbindung in einem Netz; Ersatz- und Erneuerungsprobleme („wann Maschine ersetzen?“ – Knoten sind Zeitpunkte, ein Bogen i→j heißt „Kauf am Ende von i, Nutzung bis Ende j“); Wegewahl in Verkehrs- oder Leitungsnetzen; Aufgabenstellungen, die ausdrücklich Dijkstra, Bellman-Ford oder „Kürzeste-Wege-Problem“ nennen. + - Verlangt die Aufgabe ausdrücklich ein bestimmtes Graphverfahren (typisch: „Lösen Sie mit dem Dijkstra-Algorithmus“), ist dieser Stack zu wählen – ein gleichwertiges Flussmodell erfüllt die Aufgabenstellung nicht. [→ REQUESTED_METHOD_IGNORED] + - VORAUSSETZUNG: alle Bogengewichte sind nicht negativ. Gibt es negative Gewichte (Erlöse, Gutschriften), ist Dijkstra NICHT anwendbar – dann Flussmodell als MILP über HiGHS wählen und das in routing_rationale begründen. [→ check_graph] + - Die Entscheidungsvariablen sind die Bögen des Weges; deklariere sie als binäre \(x_{ij}\), auch wenn der Algorithmus selbst ohne sie auskommt. + +GRÖSSENORDNUNG: Schätze aus dem Produkt der Indexmengen, wie viele Variablen das Modell bekommt, und nenne die Zahl in routing_rationale. Praktische Grenzen: LP ~10.000 Variablen, MILP/CP ~100–1.000 diskrete (NP-schwer), QP/SOCP ~1.000 stetige, MIQP/NLP ~100; kürzeste Wege sind auch bei \(10^5\) Knoten unkritisch. Über der Grenze bleibt der fachlich richtige Stack – die Folge („Optimalität im Zeitlimit voraussichtlich nicht beweisbar“) gehört als Annahme ausgewiesen, nicht das Modell vereinfacht. + +Anforderungen an die Ausgabe: +- Formelfelder (latex_formula, symbol, name, domain) enthalten reines LaTeX ohne Begrenzer und ohne Markdown. In name steht nur das Symbol, das auch in den Formeln vorkommt („a“, „x_{i,j}“), nie eine Bezeichnung wie „Asymptotischer Verschleiß“ – die gehört in description. Jedes Symbol aus Zielfunktion und Restriktionen muss als Variable, Parameter oder Index auffindbar sein. [→ UNDECLARED_SYMBOL] +- In Textfeldern steht jede Formel in \( … \), z. B. „Anteil \(w_i\) je Fonds \(i \in I\)“; keine $-Zeichen, % in Formeln als \%. +- Liegt ein bereits erfasstes Verständnis bei, übernimm dessen Zahlen: JEDER Wert aus facts gehört in das extracted_values des zugehörigen Parameters (bei Matrizen und Vektoren die vollständige Werteliste, nicht nur ein Beispiel). Ein Modell ohne Zahlen lässt sich nicht nachrechnen. [→ UNUSED_FACTS] +- Jeder Parameter nennt im Feld unit seine Einheit („€“, „€/t“, „h“, „Stück“, „%“, „dimensionslos“), und alle Terme der Zielfunktion müssen dieselbe haben. Mischt die Aufgabe relative mit absoluten Größen, rechne um – Risiko \(\sigma^2 w^2\) mit dem Kapital \(B\) multiplizieren, wenn Kosten in Euro stehen – und weise die gewählte Einheit als Annahme aus. [→ MIXED_OBJECTIVE_UNITS] +- Die Zielfunktion muss genau die im Text gefragte Größe in deren Maßstab liefern, nicht nur eine Größe mit derselben optimalen Lösung (z. B. Standardabweichung/Volatilität ≠ Varianz, Gewinn nach Fixkosten ≠ Deckungsbeitrag). + +MODELLGRENZEN (eine Lösung muss in der Wirklichkeit möglich sein): +- Jede Variable bekommt einen Definitionsbereich, den die Wirklichkeit zulässt: Mengen, Zeiten, Stückzahlen und Kapital nicht negativ, Anteile in \([0, 1]\), Zeiten durch die verfügbare Zeit begrenzt. +- Prüfe vor der Ausgabe jede Variable, die die Zielfunktion verbessert: Was begrenzt sie nach oben? Fehlt die Grenze im Text, leite sie aus den Daten ab (Budget/Preis, Kapazität, Bedarf, Horizont) und weise sie als Annahme aus – eine Lösung darf nicht nur deshalb beliebig groß werden, weil eine Selbstverständlichkeit fehlt. + +ERKLÄREN (ebenso wichtig wie die Richtigkeit des Modells – der Report soll jemanden überzeugen, der die Aufgabe nicht kennt): +- Adressat ist ein Entscheider ohne OR-Ausbildung. Schreibe wie in einem Praxishandbuch: kurze Sätze, Fachbegriff erst erklären, dann verwenden. +- JEDER Fachbegriff, der später in Restriktionsnamen, Variablennamen oder Ergebnistabellen auftaucht (Bogen, Knoten, Fluss, Relaxation, Schlupf, Kardinalität, Epigraph …), wird in executive_summary in der Sprache der Aufgabe eingeführt – z. B. „ein Bogen \(i \to j\) bedeutet: Kauf am Ende von Jahr \(i\) und Nutzung bis Ende Jahr \(j\)“. Ein Begriff, der nirgends erklärt wird, gilt als Fehler. [→ UNEXPLAINED_JARGON] +- executive_summary beantwortet in dieser Reihenfolge: (1) Welche Entscheidung ist zu treffen? (2) Wie bildet das Modell sie ab, in Alltagssprache? (3) Woran erkennt man eine gute Lösung? Keine Wiederholung des Aufgabentexts, keine Formelwüste. +- Verlangt die Aufgabe eine Zeichnung, eine Skizze oder einen Graphen, beschreibe die Struktur so, dass sie sich zeichnen lässt (Knoten, Bögen, Richtung, Beschriftung) – der Report zeichnet sie daraus. +- description bei Variablen, Parametern und Restriktionen sagt, WAS die Größe fachlich bedeutet, nicht was die Formel syntaktisch tut. Schlecht: „Summe über alle j“. Gut: „Die Maschine wird in genau einem Jahr ersetzt“. +- Nennt der Text keine Bedeutung, erfinde keine – erkläre mathematisch (Mindest-/Höchstbedingungen) und sage, dass die Bedeutung fehlt. + +Umgang mit Unklarheiten: +- Erfinde keine Informationen stillschweigend. Ist die Aufgabe unvollständig oder mehrdeutig (z. B. fehlende Kostenwerte, unklarer Planungshorizont, mehrdeutige Begriffe wie „aufeinanderfolgend“), triff eine plausible Annahme und weise sie im Feld open_assumptions aus. +- Jede Annahme nennt: die unklare Textstelle, die getroffene Annahme, die verworfene zweite Lesart (alternative) und ihre Auswirkung auf das Modell. +- ZWEI LESARTEN: Formuliere bei jeder Unklarheit erst beide Deutungen aus, entscheide dich dann. In alternative steht die Deutung, der du NICHT folgst, als vollständiger Satz mit dem Grund der Ablehnung – z. B. „‚aufeinanderfolgend‘ könnte auch nur Kalendertage ohne Wochenenden meinen; verworfen, weil der Text von durchgehendem Betrieb spricht“. Ist eine Stelle wirklich eindeutig, gehört sie nicht in open_assumptions; nur wenn du sie trotzdem ausweist, schreibe „keine zweite Lesart“. +- Suche Mehrdeutigkeiten aktiv, statt die erste Deutung zu übernehmen: Bezugsgröße (pro Stück, pro Charge, gesamt), Zeitbezug (je Tag, je Periode, kumuliert), Einschluss der Grenze (mindestens/höchstens vs. mehr/weniger als), Brutto oder Netto, ganze Einheiten oder teilbare Mengen. +- Modelliere jede Restriktion der Aufgabe auf Basis der getroffenen Annahme vollständig. Lasse keine Restriktion weg und verweise nicht nur in einer Beschreibung darauf, dass sie „nicht modelliert“ sei. +- Ist die Aufgabe eindeutig, bleibt open_assumptions leer. \ No newline at end of file diff --git a/Solver-Prompt.txt b/Solver-Prompt.txt new file mode 100644 index 0000000..abd22c5 --- /dev/null +++ b/Solver-Prompt.txt @@ -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": , "sense": "<=" | ">=" | "==", "rhs": } + - [→ 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": , "var_type": "continuous" | "integer" | "binary", "lb": , "ub": } + 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": "", "delta": 0>, "objective_plus": , "objective_minus": , "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": , "converged": , "best": , + "worst": , "mean": , "stddev": } + - 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. diff --git a/TextToModel.py b/TextToModel.py new file mode 100644 index 0000000..c8409fb --- /dev/null +++ b/TextToModel.py @@ -0,0 +1,522 @@ +import argparse +import json +import os +import sys +from pathlib import Path +from typing import List + +from llm import DEFAULT_MODEL, LLMSession +import data_profile +from model_checks import check_spec, check_understanding, has_errors +from model_schema import ( + AssumptionReview, + SolverStatus, + CheckIssue, + CheckSeverity, + OptimizationModelSpecification, + PipelineResult, + TaskUnderstanding, +) +from report import write_report +from script_export import build_solver_script +from solver_stage import check_feasibility, check_multistart, check_sensitivity, run_solver_code, solve + +PROMPT_DIR = Path(__file__).parent +ROUTING_PROMPT_FILE = PROMPT_DIR / "Routing-und-Extraktions-Prompt.txt" +REVIEW_PROMPT_FILE = PROMPT_DIR / "Pruef-Prompt.txt" +UNDERSTAND_PROMPT_FILE = PROMPT_DIR / "Verstehen-Prompt.txt" +MAX_REVISIONS = int(os.environ.get("MAX_REVISIONS", "2")) +SOLVER_RETRIES = int(os.environ.get("SOLVER_RETRIES", "2")) +MAX_RESTARTS = int(os.environ.get("MAX_RESTARTS", "1")) +# "unlösbar" kann ein Modellierungsfehler sein (beobachtet bei qwen3-235b: Aufgabe war lösbar). +# 1 = einmal unabhängig neu modellieren und erneut lösen, bevor "unlösbar" berichtet wird. +INFEASIBLE_RECHECK = int(os.environ.get("INFEASIBLE_RECHECK", "1")) +# Ein Nachfassen, wenn die Verstehensstufe Zahlen oder Teilfragen übersehen hat (0 schaltet die Stufe ab) +UNDERSTAND_RETRIES = int(os.environ.get("UNDERSTAND_RETRIES", "1")) + +task_description = """ +Ein Logistikzentrum muss 12 Mitarbeiter auf 3 Acht-Stunden-Schichten verteilen. +In Schicht 1 werden mindestens 3 Mitarbeiter benötigt, in Schicht 2 mindestens 5 +und in Schicht 3 genau 2. Kein Mitarbeiter darf zwei aufeinanderfolgende +Schichten arbeiten. Zudem gilt: Wenn Mitarbeiter A arbeitet, muss Mitarbeiter B +in derselben Schicht sein. Die Gesamtkosten für Schichtzulagen sollen minimiert werden. +""" + + +def log(msg: str) -> None: + # stderr, damit stdout reines JSON bleibt + print(msg, file=sys.stderr) + + +def understand_task(session: LLMSession, task: str, issues: List[CheckIssue] = (), + data_prompt: str = "") -> TaskUnderstanding: + """Stufe 0: erfassen, was im Text steht – Teilfragen, Zahlen, Machbarkeit. Noch kein Modell.""" + nachtrag = "" + if issues: + nachtrag = ("\n\nDEIN VORIGER VERSUCH WAR UNVOLLSTÄNDIG:\n" + + "\n".join(f"- [{i.code}] {i.message}" for i in issues) + + "\nErfasse die fehlenden Punkte, behalte den Rest bei.") + return session.create( + TaskUnderstanding, + [ + {"role": "system", "content": UNDERSTAND_PROMPT_FILE.read_text(encoding="utf-8")}, + {"role": "user", "content": f"AUFGABENTEXT:\n{task}\n\n{data_prompt}{nachtrag}".strip()}, + ], + step="Verstehen", + ) + + +def text_to_model(session: LLMSession, task: str, + understanding: TaskUnderstanding | None = None, + data_prompt: str = "") -> OptimizationModelSpecification: + inhalt = task if not data_prompt else f"{task}\n\n{data_prompt}" + if understanding is not None: + # Die Verstehensstufe hat Teilfragen und Zahlen schon erfasst – die Extraktion muss den Text + # nicht noch einmal danach absuchen, sondern baut darauf auf. + inhalt += ("\n\nBEREITS ERFASSTES VERSTÄNDNIS (Teilfragen und Zahlen; übernimm die Werte aus facts " + "in extracted_values, statt sie neu zu suchen):\n" + understanding.model_dump_json(indent=2)) + return session.create( + OptimizationModelSpecification, + [ + {"role": "system", "content": ROUTING_PROMPT_FILE.read_text(encoding="utf-8")}, + {"role": "user", "content": inhalt}, + ], + step="Extraktion", + ) + + +def review_assumptions(session: LLMSession, task: str, spec: OptimizationModelSpecification) -> AssumptionReview: + return session.create( + AssumptionReview, + [ + {"role": "system", "content": REVIEW_PROMPT_FILE.read_text(encoding="utf-8")}, + { + "role": "user", + "content": f"AUFGABENTEXT:\n{task}\n\nMODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}", + }, + ], + step="Prüfung", + ) + + +def revise_model( + session: LLMSession, task: str, spec: OptimizationModelSpecification, review: AssumptionReview, + issues: List[CheckIssue] = (), +) -> OptimizationModelSpecification: + errors = [i for i in issues if i.severity == CheckSeverity.ERROR] + check_text = "\n".join(f"- [{i.code}] {i.message}" for i in errors) or "keine" + # Warnungen lösen für sich keine Korrekturrunde aus; findet aber ohnehin eine statt, + # sollen sie mit behoben werden – sonst erreicht z. B. unerklärter Fachjargon das LLM nie. + hinweise = [i for i in issues if i.severity == CheckSeverity.WARNING] + hinweis_text = "\n".join(f"- [{i.code}] {i.message}" for i in hinweise) or "keine" + return session.create( + OptimizationModelSpecification, + [ + {"role": "system", "content": ROUTING_PROMPT_FILE.read_text(encoding="utf-8")}, + { + "role": "user", + "content": ( + f"AUFGABENTEXT:\n{task}\n\n" + f"BISHERIGE MODELLSPEZIFIKATION:\n{spec.model_dump_json(indent=2)}\n\n" + f"PRÜFBERICHT:\n{review.model_dump_json(indent=2)}\n\n" + f"FEHLER DER AUTOMATISCHEN KONSISTENZPRÜFUNG:\n{check_text}\n\n" + f"HINWEISE DER AUTOMATISCHEN PRÜFUNG (kein Fehler, aber zu verbessern):\n{hinweis_text}\n\n" + "Überarbeite die Modellspezifikation: Setze alle Korrekturen aus dem Prüfbericht um, " + "behebe alle Fehler der Konsistenzprüfung, passe die betroffenen Restriktionen, Variablen " + "und Parameter an und weise fehlende Annahmen in open_assumptions aus. " + "Arbeite auch die Hinweise ab, soweit das Modell dadurch nicht schlechter wird – " + "unerklärte Fachbegriffe gehören in executive_summary, nicht weggelassen. " + "Behalte korrekte Teile unverändert bei." + ), + }, + ], + step="Korrektur", + ) + + +# Warnungen, die eine Korrekturrunde wert sind: keine Fehler (ein richtiges Ergebnis bleibt zuverlässig), +# aber in einer Runde billig zu beheben. Unerklärte Fachbegriffe kosten den Leser sonst das Verständnis. +REVISION_WORTHY_WARNINGS = {"UNEXPLAINED_JARGON", "UNUSED_FACTS"} + + +def needs_work(review: AssumptionReview, issues: List[CheckIssue]) -> bool: + """Rechtfertigt der Stand eine Korrekturrunde?""" + return (review.needs_revision or has_errors(issues) + or any(i.code in REVISION_WORTHY_WARNINGS for i in issues)) + + +def select_best(candidates: List[tuple]) -> int: + """Index der besten (spec, issues, review)-Runde. + + Rangfolge: passende Zielgröße vor unpassender (sonst womöglich Fremdproblem), keine Check-Errors vor Errors, + Prüfung ohne Beanstandung vor Beanstandung, weniger Errors; + bei Gleichstand gewinnt die spätere Runde. Verhindert, dass eine Korrekturrunde, die das Modell + verschlechtert (z. B. halluziniertes Fremdproblem), die bessere frühere Fassung ersetzt. + """ + def score(index): + _, issues, review = candidates[index] + errors = sum(1 for i in issues if i.severity == CheckSeverity.ERROR) + return (not review.objective_check.matches, errors > 0, review.needs_revision, errors, -index) + + return min(range(len(candidates)), key=score) + + +def _understand(session, task: str, label: str, data_prompt: str = "") -> tuple: + """Verstehensstufe samt Prüfung; ein Nachfassen, wenn Zahlen oder Teilfragen fehlen.""" + if UNDERSTAND_RETRIES < 0: + return None, [] + log(f"[{label}] Lese die Aufgabe (Teilfragen, Zahlen) ...") + understanding = understand_task(session, task, data_prompt=data_prompt) + issues = check_understanding(understanding, task) + for versuch in range(UNDERSTAND_RETRIES): + if not issues: + break + log(f"[{label}] Verständnis unvollständig – fasse nach ({versuch + 1}/{UNDERSTAND_RETRIES}): " + + "; ".join(i.code for i in issues)) + understanding = understand_task(session, task, issues, data_prompt) + issues = check_understanding(understanding, task) + if understanding.sub_questions: + log(f" {len(understanding.sub_questions)} Teilfrage(n): " + + ", ".join(q.id for q in understanding.sub_questions) + + f"; {len(understanding.facts)} Zahlenwerte erfasst") + if not understanding.is_optimization_task: + log(f" WARNUNG: Das ist laut Verständnis keine Optimierungsaufgabe – {understanding.reason}") + return understanding, issues + + +def _model_attempt(session, task: str, max_revisions: int, label: str, hint: str = "", + spec: OptimizationModelSpecification | None = None, + understanding: TaskUnderstanding | None = None, data_prompt: str = "") -> dict: + """Ein Modellierungsversuch: Extraktion, Checks, Prüfung, Korrekturrunden, beste Runde. + + Mit `spec` wird die Extraktion übersprungen – Einstieg ab der Prüfung (siehe continue_pipeline). + """ + if spec is None: + log(f"[{label}] Extrahiere Modell ...") + spec = text_to_model(session, task + hint, understanding, data_prompt) + else: + log(f"[{label}] Übernehme gespeicherte Spezifikation ({len(spec.constraints)} Restriktionen) ...") + issues = check_spec(spec, task, understanding) + log(f"[{label}] Prüfe Annahmen ...") + review = review_assumptions(session, task, spec) + + candidates = [(spec, issues, review)] + revisions = 0 + while needs_work(review, issues) and revisions < max_revisions: + revisions += 1 + log(f"[{label}] Probleme gefunden – Korrekturrunde {revisions}/{max_revisions} ...") + spec = revise_model(session, task, spec, review, issues) + issues = check_spec(spec, task, understanding) + review = review_assumptions(session, task, spec) + candidates.append((spec, issues, review)) + + selected = select_best(candidates) + if selected != len(candidates) - 1: + log(f"[{label}] Spätere Korrekturrunden haben das Modell verschlechtert – verwende Stand aus Runde {selected}.") + spec, issues, review = candidates[selected] + return {"spec": spec, "issues": issues, "review": review, "revisions": revisions, "selected": selected} + + +def _unresolved(attempt: dict) -> List[str]: + """Probleme, die nach allen Korrekturrunden einen Neustart rechtfertigen.""" + problems = [] + if not attempt["review"].objective_check.matches: + oc = attempt["review"].objective_check + problems.append(f"Zielfunktion passt nicht zur Frage (gefragt: {oc.asked_quantity}; modelliert: {oc.modeled_quantity})") + problems += [f"[{i.code}] {i.message}" for i in attempt["issues"] if i.severity == CheckSeverity.ERROR] + return problems + + +INFEASIBLE_HINT = ( + "\n\nHINWEIS: Eine erste, unabhängige Modellierung dieser Aufgabe ergab '{status}'. " + "Modelliere die Aufgabe daher völlig neu und prüfe dabei besonders, ob eine Bedingung zu streng ausgelegt " + "wurde (z. B. eine Gleichung, wo der Text einen Spielraum zulässt, oder eine Grenze, die der Text nicht " + "verlangt). Ist die Aufgabe wirklich unlösbar, modelliere sie trotzdem getreu." +) + + +def _recheck_infeasible(session, task: str, model: str, result: PipelineResult, + max_revisions: int, solver_retries: int) -> PipelineResult: + """'unlösbar' nicht ungeprüft glauben: unabhängig neu modellieren und erneut lösen.""" + status = result.solver_run.status.value + if INFEASIBLE_RECHECK < 1: + result.reliable = False + result.reliability_issues.append(f"Solver meldet '{status}' – ohne Gegenprobe (INFEASIBLE_RECHECK=0)") + return result + + log(f"[{model}] Solver meldet '{status}' – Gegenprobe mit unabhängiger Modellierung ...") + zweiter = _model_attempt(session, task, max_revisions, f"{model} Gegenprobe", + hint=INFEASIBLE_HINT.format(status=status)) + if not zweiter["review"].objective_check.matches: + log(" Gegenprobe verworfen: Zielfunktion der neuen Modellierung passt nicht zur Frage.") + result.reliable = False + result.reliability_issues.append(f"Solver meldet '{status}'; die Gegenprobe war unbrauchbar") + return result + + spec = zweiter["spec"] + log(f"[{model}] Gegenprobe: Solver-Code erzeugen und starten ({spec.target_stack.value}) ...") + code, run = solve(session, task, spec, retries=solver_retries, log=log) + + if run.status in (SolverStatus.OPTIMAL, SolverStatus.FEASIBLE) and not run.feasibility: + log(f" Gegenprobe widerlegt '{status}': Zielwert {run.objective_value} – diese Lösung wird verwendet.") + result.specification, result.review, result.checks = spec, zweiter["review"], check_spec(spec, task) + result.solver_code, result.solver_run = code, run + result.restarts += 1 + return result + + if run.status.value == status: + log(f" Gegenprobe bestätigt '{status}' mit einer unabhängigen Modellierung.") + result.infeasible_confirmed = True + return result + + log(f" Gegenprobe nicht verwertbar (Status '{run.status.value}'), erste Modellierung bleibt.") + result.reliable = False + result.reliability_issues.append(f"Solver meldet '{status}'; die Gegenprobe endete mit '{run.status.value}'") + return result + + +def _solver_stage(session, task: str, result: PipelineResult, model: str, + max_revisions: int, solver_retries: int, data_files=(), data_prompt: str = "") -> PipelineResult: + """Solver-Code erzeugen, ausführen, prüfen – gemeinsam für run_pipeline und continue_pipeline.""" + spec = result.specification + if not result.review.objective_check.matches: + # Der Solver würde ein anderes als das gefragte Problem lösen – ein "optimal" wäre irreführend + log(f"[{model}] Solver wird nicht gestartet, weil die Zielfunktion nicht zur Frage passt.") + return result + + log(f"[{model}] Erzeuge und starte Solver-Code ({spec.target_stack.value}) ...") + result.solver_code, result.solver_run = solve(session, task, spec, retries=solver_retries, log=log, + understanding=result.understanding, + data_files=data_files, data_prompt=data_prompt) + run = result.solver_run + log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else "")) + if run.feasibility: + result.reliable = False + result.reliability_issues += [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility] + log("ERGEBNIS UNZUVERLÄSSIG – die Lösung hält das Modell nicht ein:") + for problem in run.feasibility: + log(f" {problem}") + elif run.constraints: + log(f" Zulässigkeit geprüft: {len(run.constraints)} Restriktionen, {len(run.variables)} Variablen – alles eingehalten.") + if run.undeclared_data: + result.reliable = False + result.reliability_issues += [f"Datenherkunft: {p}" for p in run.undeclared_data] + log("ERGEBNIS UNZUVERLÄSSIG – selbst erzeugte Eingabedaten sind nicht ausgewiesen.") + if result.solver_code.data_assumptions: + log(f" Mit angenommenen Daten gelöst ({len(result.solver_code.data_assumptions)} Annahmen).") + + if run.status in (SolverStatus.INFEASIBLE, SolverStatus.UNBOUNDED): + result = _recheck_infeasible(session, task, model, result, max_revisions, solver_retries) + return result + + +def _log_usage(session, result: PipelineResult) -> None: + result.usage = session.usage + cost = f", Kosten ${session.usage.cost_usd:.4f}" if session.usage.cost_usd is not None else "" + providers = f", Anbieter: {session.usage.providers}" if session.usage.providers else "" + log(f"LLM-Nutzung: {session.usage.calls} Aufrufe, {session.usage.prompt_tokens} Input-/" + f"{session.usage.completion_tokens} Output-Tokens (davon {session.usage.reasoning_tokens} Reasoning){cost}{providers}") + for step, data in session.usage.by_step().items(): + log(f" {step:18} {data['calls']}x {data['seconds']:6.1f} s {data['completion_tokens']:6} Output-Tokens " + f"(davon {data['reasoning_tokens']} Reasoning)") + + +def run_pipeline( + task: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS, + solve_model: bool = True, solver_retries: int = SOLVER_RETRIES, max_restarts: int = MAX_RESTARTS, + session: LLMSession | None = None, data_files=(), +) -> PipelineResult: + session = session or LLMSession(model) + profile = data_profile.profile_files(data_files) + data_prompt = data_profile.to_prompt(profile) + for p in profile: + log(f"Datei {p.name}: " + (p.error if p.error else f"{p.rows} Zeilen, {len(p.columns)} Spalten")) + for step, step_model in session.overrides().items(): + log(f"[{model}] Stufe '{step}' läuft mit abweichendem Modell: {step_model}") + + understanding, verstehen_issues = _understand(session, task, model, data_prompt) + + attempts = [_model_attempt(session, task, max_revisions, model, understanding=understanding, + data_prompt=data_prompt)] + # Beobachtet: das LLM modelliert gelegentlich ein Fremdproblem; Korrekturrunden reparieren das nicht, + # ein frischer Neustart der Extraktion schon eher. + while _unresolved(attempts[-1]) and len(attempts) <= max_restarts: + log(f"[{model}] Modell nach allen Korrekturrunden fehlerhaft – Neustart {len(attempts)}/{max_restarts}: " + + "; ".join(_unresolved(attempts[-1]))[:300]) + attempts.append(_model_attempt(session, task, max_revisions, f"{model} Neustart {len(attempts)}", + understanding=understanding, data_prompt=data_prompt)) + + best = attempts[select_best([(a["spec"], a["issues"], a["review"]) for a in attempts])] + spec, issues, review = best["spec"], best["issues"] + verstehen_issues, best["review"] + problems = _unresolved(best) + + for issue in issues: + log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") + if problems: + log("ERGEBNIS UNZUVERLÄSSIG: " + "; ".join(problems)) + elif review.needs_revision: + log("WARNUNG: Die Prüfung hat noch Anmerkungen zu Annahmen.") + else: + log("Prüfung ohne Beanstandung.") + + result = PipelineResult( + task=task, understanding=understanding, data_files=profile, specification=spec, review=review, revisions=best["revisions"], + selected_revision=best["selected"], restarts=len(attempts) - 1, checks=issues, + reliable=not problems, reliability_issues=problems, + ) + if solve_model: + result = _solver_stage(session, task, result, model, max_revisions, solver_retries, + data_files=data_files, data_prompt=data_prompt) + + _log_usage(session, result) + return result + + +STAGES = ("pruefung", "solver") + + +def continue_pipeline( + result_json: str, stage: str, model: str = DEFAULT_MODEL, max_revisions: int = MAX_REVISIONS, + solver_retries: int = SOLVER_RETRIES, session: LLMSession | None = None, +) -> PipelineResult: + """Gespeichertes Ergebnis ab einer Stufe fortsetzen – die davor liegenden LLM-Aufrufe entfallen. + + 'pruefung' behält die Spezifikation und prüft/korrigiert sie neu; 'solver' übernimmt auch den + Prüfbericht und erzeugt nur den Solver-Code neu. Aufgabentext kommt aus dem gespeicherten Ergebnis. + """ + if stage not in STAGES: + raise ValueError(f"Unbekannte Stufe '{stage}' (erlaubt: {', '.join(STAGES)})") + stored = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8")) + session = session or LLMSession(model) + task = stored.task + + log(f"Setze {result_json} ab Stufe '{stage}' fort – Extraktion entfällt.") + if stage == "solver": + result = stored.model_copy(update={ + "checks": check_spec(stored.specification, task), # deterministisch, kostet nichts + "solver_code": None, "solver_run": None, "usage": None, + "reliable": True, "reliability_issues": [], + }) + problems = _unresolved({"review": stored.review, "issues": result.checks}) + result.reliable, result.reliability_issues = not problems, problems + else: + attempt = _model_attempt(session, task, max_revisions, model, spec=stored.specification) + problems = _unresolved(attempt) + result = PipelineResult( + task=task, specification=attempt["spec"], review=attempt["review"], revisions=attempt["revisions"], + selected_revision=attempt["selected"], checks=attempt["issues"], + reliable=not problems, reliability_issues=problems, + ) + for issue in result.checks: + log(f" {issue.severity.value.upper()} [{issue.code}] {issue.message}") + if result.reliability_issues: + log("ERGEBNIS UNZUVERLÄSSIG: " + "; ".join(result.reliability_issues)) + + result = _solver_stage(session, task, result, model, max_revisions, solver_retries) + _log_usage(session, result) + return result + + +def resolve_result(result_json: str, data_file: str | None = None, solver_timeout: int = 60) -> PipelineResult: + """Gespeichertes Ergebnis mit (geänderten) Daten neu rechnen – ohne einen einzigen LLM-Aufruf. + + Das erzeugte Programm hält alle Eingabezahlen in DATA; nur diese werden ersetzt. + """ + result = PipelineResult.model_validate_json(Path(result_json).read_text(encoding="utf-8")) + if not result.solver_code: + raise ValueError(f"{result_json} enthält keinen Solver-Code (Lauf mit --no-solve?)") + data = json.loads(Path(data_file).read_text(encoding="utf-8")) if data_file else None + + log(f"Rechne {result_json} neu" + (f" mit Daten aus {data_file}" if data_file else "") + " – ohne LLM ...") + run = run_solver_code(result.solver_code.code, timeout=solver_timeout, data=data) + run.feasibility = check_feasibility(run, result.specification) + run.warnings += check_sensitivity(run, result.specification.objective.sense.value) + run.warnings += check_multistart(run, result.specification.objective.sense.value) + + result.solver_run = run + result.reliable = not run.feasibility + result.reliability_issues = [f"Zulässigkeitsprüfung: {p}" for p in run.feasibility] + result.usage = None # keine LLM-Nutzung in diesem Lauf + log(f"Solver: {run.status.value}, Zielwert: {run.objective_value}" + (f"\n {run.error}" if run.error else "")) + for problem in run.feasibility: + log(f" {problem}") + return result + + +def read_task(task_file: str | None) -> str: + if task_file: + task = Path(task_file).read_text(encoding="utf-8") + elif not sys.stdin.isatty(): + task = sys.stdin.read() + else: + task = "" + # Leere Eingabe würde das LLM ein beliebiges Problem erfinden lassen + return task if task.strip() else task_description + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Textaufgabe → Optimierungsmodell → Prüfung → Lösung") + parser.add_argument("task_file", nargs="?", help="Datei mit der Aufgabe (sonst stdin, sonst Beispielaufgabe)") + parser.add_argument("--no-solve", action="store_true", help="nur modellieren und prüfen, keinen Solver-Code ausführen") + parser.add_argument("--max-revisions", type=int, default=MAX_REVISIONS) + parser.add_argument("--max-restarts", type=int, default=MAX_RESTARTS, help="komplette Neustarts, wenn das Modell nach allen Korrekturrunden fehlerhaft ist") + parser.add_argument( + "--model", default=DEFAULT_MODEL, + help=f"Modell (Default: {DEFAULT_MODEL}, Umgebung: LLM_MODEL); mit '/' -> OpenRouter, sonst Gemini", + ) + parser.add_argument("--resolve", metavar="ERGEBNIS.JSON", + help="gespeichertes Ergebnis erneut rechnen, ohne LLM (z. B. nach Datenänderung)") + parser.add_argument("--von", nargs=2, metavar=("STUFE", "ERGEBNIS.JSON"), + help="gespeichertes Ergebnis ab einer Stufe fortsetzen, statt alles zu wiederholen: " + "'pruefung' (Spezifikation behalten, neu prüfen und korrigieren) oder " + "'solver' (auch den Prüfbericht behalten, nur Solver-Code neu erzeugen)") + parser.add_argument("--daten", nargs="+", metavar="DATEI", + help="beigelegte Datendateien (CSV/Excel); ihr Profil geht in die Prompts, " + "die Dateien selbst ins Arbeitsverzeichnis des Programms") + parser.add_argument("--data", metavar="DATEN.JSON", + help="mit --resolve: Eingabewerte (DATA) des Programms ganz oder teilweise ersetzen") + parser.add_argument("--report", metavar="DATEI", help="Report mit gerenderten Formeln und Solver-Code (.md oder .html)") + parser.add_argument("--code", metavar="DATEI", help="erzeugten Solver-Code als eigenständiges Python-Skript speichern") + args = parser.parse_args() + if args.report and Path(args.report).suffix.lower() not in (".md", ".html", ".htm"): + parser.error("--report braucht die Endung .md oder .html") + if args.code and args.no_solve: + parser.error("--code ist mit --no-solve nicht möglich (es wird kein Solver-Code erzeugt)") + if args.data and not args.resolve: + parser.error("--data wirkt nur zusammen mit --resolve") + if args.von: + if args.resolve: + parser.error("--von und --resolve schließen sich aus") + if args.von[0] not in STAGES: + parser.error(f"--von braucht eine Stufe aus {{{', '.join(STAGES)}}}, nicht '{args.von[0]}'") + if args.task_file: + parser.error("--von nimmt die Aufgabe aus dem gespeicherten Ergebnis – keine Aufgabendatei angeben") + if args.no_solve: + parser.error("--von endet immer mit der Solver-Stufe; --no-solve wäre wirkungslos") + + if args.resolve: + result = resolve_result(args.resolve, args.data) + elif args.von: + result = continue_pipeline(args.von[1], args.von[0], model=args.model, max_revisions=args.max_revisions) + else: + result = run_pipeline( + read_task(args.task_file), model=args.model, + max_revisions=args.max_revisions, solve_model=not args.no_solve, max_restarts=args.max_restarts, + data_files=args.daten or (), + ) + print(result.model_dump_json(indent=2)) + if args.report: + write_report(result, args.report) + log(f"Report geschrieben: {args.report}") + if args.code and result.solver_code: + Path(args.code).write_text(build_solver_script(result), encoding="utf-8") + Path(args.code).chmod(0o755) + log(f"Solver-Code geschrieben: {args.code} (ausführen: .venv/bin/python {args.code})") + elif args.code: + log(f"Kein Solver-Code geschrieben ({args.code}): Solver wurde nicht gestartet.") + if not result.reliable: + sys.exit(2) # für Skripte: Ergebnis vorhanden, aber unzuverlässig + +# Erwartet für das Beispiel: +# result.specification.target_stack -> TargetStack.OR_TOOLS_CPSAT +# result.specification.problem_class -> ProblemClass.CP diff --git a/Verstehen-Prompt.txt b/Verstehen-Prompt.txt new file mode 100644 index 0000000..a176b54 --- /dev/null +++ b/Verstehen-Prompt.txt @@ -0,0 +1,27 @@ +Du bist ein sorgfältiger Leser von Aufgabentexten aus Operations Research und Wirtschaftsmathematik. +Du erhältst einen Aufgabentext. Deine einzige Aufgabe ist es zu erfassen, WAS dort steht – gefragte Teilfragen, gegebene Zahlen, Machbarkeit. Du modellierst noch nichts und rechnest nichts. + +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. + + +1. IST DAS EINE AUFGABE? Setze is_optimization_task nur auf True, wenn der Text eine Entscheidung oder Optimierung verlangt (etwas soll maximiert, minimiert, zugeordnet, geplant, ausgewählt oder als kürzester/billigster Weg bestimmt werden). Eine bloße Sammlung von Zahlen, ein Einkaufszettel oder eine reine Wissensfrage ist keine. Begründe in reason in einem Satz, worum es geht. + +2. TEILFRAGEN: Erfasse JEDE Frage des Textes als eigenen Eintrag in sub_questions, in der Reihenfolge des Textes. + - id genau wie im Text: „a“, „b“, „Teil 2“, „3“. Gibt es keine Nummerierung, vergib „1“, „2“, … + - Jede id kommt nur EINMAL vor. Verlangt ein Aufgabenteil zweierlei („Formulieren Sie … und zeichnen Sie …“), sind das zwei Einträge mit den Kennungen „a1“ und „a2“ – sonst bleibt eine der beiden Forderungen unbeantwortet. + - quote ist die Frage WÖRTLICH aus dem Text, nicht umformuliert. + - kind: formulieren (ein Modell aufstellen), rechnen (eine Zahl bestimmen), vergleichen (Varianten gegeneinander), erklaeren (begründen, bewerten, interpretieren), zeichnen (Skizze, Graph, Diagramm). + - needs_solver ist True, wenn die Antwort eine berechnete Zahl braucht. + Auch eine Aufgabe mit nur einer einzigen Frage bekommt genau einen Eintrag. Fehlt eine Teilfrage, gilt die Aufgabe später als nicht erfüllt. [→ MISSING_SUB_QUESTIONS] + +3. ZAHLEN: Trage JEDE Zahl des Textes ein – entweder in facts (Eingabegröße des Modells) oder in ignored_numbers (keine Eingabegröße, mit Grund). + - facts: value genau wie im Text („8“, „0,05“, „12 %“), unit, meaning in der Sprache der Aufgabe, und quote als Fundstelle. + - Bei Tabellen genügt EIN Eintrag je Zeile oder Spalte, dessen quote die ganze Zeile enthält – die Zahlen müssen im Zitat vorkommen, nicht einzeln aufgelistet werden. + - ignored_numbers ist für Aufgabennummern („3.8“), Zahlen in Einheiten („in 1000 Stunden“), Jahreszahlen, Seitenzahlen, Nummern von Sätzen. Immer mit Grund. + Eine Zahl, die weder im einen noch im anderen steht, gilt als übersehen. [→ UNCAPTURED_NUMBERS] + +4. Erfinde nichts. Was der Text nicht sagt, gehört nicht in facts – fehlende Angaben werden später als Annahme ausgewiesen, nicht hier geraten. diff --git a/aufgaben/komplexes_Portfolio_Management.md b/aufgaben/komplexes_Portfolio_Management.md new file mode 100644 index 0000000..89acc0e --- /dev/null +++ b/aufgaben/komplexes_Portfolio_Management.md @@ -0,0 +1,43 @@ +Problem: Komplexes Portfolio-Management: + +Ein Investor möchte sein Geld 100000 Euro auf mindestens 5 und maximal 10 verschiedene Aktien aufteilen. + +Er möchte den erwarteten Nutzen maximieren und gleichzeitig das Risiko (die Varianz) minimieren. + +Maximieren Sie den Nutzen (Rendite abzüglich des Risikos und der Fixkosten)! + +Unter folgenden Nebenbedingungen: + +- 1. Kardinalitätsbedingungen: Er darf maximal in 10 verschiedene Aktien inverstieren, um Transaktionskosten zu sparen. +- 2. Er muss mindestens in 5 verschiedene Aktien investieren, um das Risiko zu verteilen. +- 3. Mindest- und Höchstgrenzen: Wenn er in eine Aktie investiert, dann müssen es mindestens 5 % und maximal 30 % des Gesamtkapitals sein. +- 4. Transaktionskosten: Beim Kauf fallen Transaktionskosten an: Mindestens 5 Euro und maximal 50 Euro, sonst den Prozentsatz aus der Tabelle vom Kaufpreis der Aktien. +- 5. Das gesamte Geld muss bis auf einen kleinen Restbetrag investiert werden. +- 6. Aktien lassen sich nicht stückeln; 0.5 Aktien kann man nicht kaufen. + + +Wir setzen folgende Rahmenbedingungen fest: + +Maximale Anzahl Aktien im Portfolio: 10 +Mindestgewichtung pro gewählte Aktie: 0.05 (Fünf Prozent) +Maximalgewichtung pro gewählte Aktie: 0.30 (Dreißig Prozent) +Risikoaversations-Faktor: 1.0 + + +Hier sind die erwarteten Renditen und die variablen Transaktionskosten und die eigene Varianz (Risiko) für die 10 Aktien: + +Aktie | Preis | Erwartete Rendite | Transaktionskosten | Varianz (Risiko) +--------|-------|---------------|---------------|-------------------------------- +A01 | 12.47 | 6,9 % | 0,68 % | 2,04 % +A02 | 28.91 | 14,4 % | 0,74 % | 1,63 % +A03 | 6.38 | 11,5 % | 0,39 % | 3,62 % +A04 | 19.75 | 9,8 % | 0,27 % | 3,01 % +A05 | 24.06 | 4,0 % | 0,34 % | 3,30 % +A06 | 15.22 | 4,0 % | 0,46 % | 3,33 % +A07 | 29.44 | 2,8 % | 0,69 % | 3,24 % +A08 | 8.63 | 13,3 % | 0,72 % | 1,97 % +A09 | 21.18 | 9,8 % | 0,20 % | 3,26 % +A10 | 10.95 | 11,2 % | 0,51 % | 2,52 % + + + diff --git a/aufgaben/komplexes_Portfolio_Management_Nr2.md b/aufgaben/komplexes_Portfolio_Management_Nr2.md new file mode 100644 index 0000000..d07d480 --- /dev/null +++ b/aufgaben/komplexes_Portfolio_Management_Nr2.md @@ -0,0 +1,87 @@ +**Fallstudie: Kardinalitätsbeschränkte Portfolio-Optimierung (MIQP)** + +### 1. Problemstellung und Kontext + +Aus einem Universum von $N = 100$ US-Aktien (S&P 100) soll ein Teilportfolio aus exakt $k = 20$ Titeln zusammengestellt werden. Ziel ist es, drei konkurrierende Eigenschaften in Einklang zu bringen: + +1. **Entwicklungspotenzial maximieren:** Hohe historische oder geschätzte Überrendite $\mu$. +2. **Risiko minimieren:** Geringe Portfoliovarianz $w^T \Sigma w$. +3. **Diversifikation maximieren:** Minimierung der durchschnittlichen paarweisen Korrelation zwischen den ausgewählten Werten. + +--- + +### 2. Datengrundlage & Vorbereitung via Yahoo Finance + +* **Aktienuniversum:** Die 100 Konstituenten des S&P 100 (Index-Kürzel: `^OEX`). +* **Datenquelle:** Yahoo Finance (über die Python-Bibliothek `yfinance`). +* **Erhebungszeitraum:** Tägliche bereinigte Schlusskurse (`Adj Close`) über einen Zeithorizont von 3 Jahren (z. B. 01.01.2023 bis 31.12.2025). +* **Berechnungsgrundlagen:** +* Tägliche logarithmische Renditen: $r_{i,t} = \ln(P_{i,t} / P_{i,t-1})$ +* Annualisierter Renditevektor: $\boldsymbol{\mu} \in \mathbb{R}^{100}$ mit $\mu_i = 252 \cdot \bar{r}_i$ +* Annualisierte Kovarianzmatrix: $\boldsymbol{\Sigma} \in \mathbb{R}^{100 \times 100}$ mit $\boldsymbol{\Sigma} = 252 \cdot \operatorname{Cov}(r)$ +* Korrelationsmatrix: $\mathbf{R} \in [-1, 1]^{100 \times 100}$ mit $\rho_{ij} = \frac{\sigma_{ij}}{\sigma_i \sigma_j}$ + + + +--- + +### 3. Mathematische Modellierung + +Das Problem wird als **Mixed-Integer Quadratic Program (MIQP)** formuliert. + +#### Variablen + +* $z_i \in \{0, 1\}$: Binäre Selektionsvariable ($z_i = 1$, wenn Aktie $i$ ins Portfolio aufgenommen wird; $0$ sonst). +* $w_i \ge 0$: Kapitalgewichtung der Aktie $i$ im Portfolio ($w \in \mathbb{R}^{100}$). + +#### Zielfunktion + +Maximierung der risikoadjustierten Rendite abzüglich der durchschnittlichen Korrelation der selektierten Titel (über die Risiko-Aversionsparameter $\lambda_1 > 0$ und $\lambda_2 > 0$): + +$$\max_{w, z} \quad \sum_{i=1}^{100} w_i \mu_i - \lambda_1 \sum_{i=1}^{100} \sum_{j=1}^{100} w_i w_j \sigma_{ij} - \lambda_2 \sum_{i=1}^{100} \sum_{j=1}^{100} z_i z_j \rho_{ij}$$ + +*(Hinweis: Der Korrelationsterm bestraft hohe paarweise Korrelationen direkt auf Selektionsebene).* + +#### Nebenbedingungen + +* **Kardinalitätsbedingung:** Exakt 20 Titel werden gewählt: + +$$\sum_{i=1}^{100} z_i = 20$$ + + +* **Vollinvestition (Budgetbedingung):** + +$$\sum_{i=1}^{100} w_i = 1$$ + + +* **Kein Leerverkauf & Allokationsgrenzen (Big-M-Kopplung):** +Jeder ausgewählte Wert muss ein Mindestgewicht von $1\%$ und darf maximal $10\%$ des Portfolios ausmachen: + +$$0{,}01 \cdot z_i \le w_i \le 0{,}10 \cdot z_i \quad \forall i \in \{1, \dots, 100\}$$ + + +* **Definitionsbereiche:** + +$$z_i \in \{0, 1\}, \quad w_i \ge 0 \quad \forall i \in \{1, \dots, 100\}$$ + + + +--- + +### 4. Arbeitsaufträge + +* **Teil 1: Pipeline & Kennzahlen (Python)** +Schreiben Sie ein Skript, das die Tickerliste des S&P 100 lädt, bereinigt (Ausschluss von Titeln mit unvollständigen Datenreihen) und den Vektor $\boldsymbol{\mu}$, die Matrix $\boldsymbol{\Sigma}$ sowie $\mathbf{R}$ berechnet. +* **Teil 2: Optimierung & Lösung** +Implementieren Sie das MIQP in Python (z. B. mit `cvxpy`, `Gurobi` oder `SCIP`). Testen Sie drei Konfigurationen: +1. Reine Varianzminimierung ($\lambda_1 = 1$, $\lambda_2 = 0$, $\mu$-Zielschranke). +2. Reine Korrelationsminimierung ($\lambda_1 = 0$, $\lambda_2 = 1$). +3. Ausgewogene Skalarisierung ($\lambda_1 = 0{,}5$, $\lambda_2 = 0{,}5$). + + +* **Teil 3: Evaluation** +Vergleichen Sie die Performance der resultierenden 20-Aktien-Portfolios gegen eine gleichgewichtete Benchmark ($1/100$ aller S&P 100 Titel) anhand von Out-of-Sample-Daten (z. B. Folgequartal): +* Realisierte Volatilität +* Sharpe Ratio +* Maximale Drawdowns + diff --git a/aufgaben/nur_formeln.md b/aufgaben/nur_formeln.md new file mode 100644 index 0000000..2406d76 --- /dev/null +++ b/aufgaben/nur_formeln.md @@ -0,0 +1,16 @@ +min z = 17*x1 + 23*x2 + 31*x3 + 19*x4 + 29*x5 + +s.t. + + 6*x1 + 4*x2 + 8*x3 + 5*x4 + 7*x5 >= 120 + 3*x1 + 9*x2 + 2*x3 + 6*x4 + 4*x5 >= 105 + 8*x1 + 5*x2 + 7*x3 + 3*x4 + 9*x5 >= 135 + 2*x1 + 7*x2 + 6*x3 + 8*x4 + 5*x5 >= 110 + + 5*x1 + 3*x2 + 4*x3 + 6*x4 + 2*x5 <= 100 + 4*x1 + 6*x2 + 3*x3 + 2*x4 + 5*x5 <= 90 + 7*x1 + 2*x2 + 5*x3 + 4*x4 + 3*x5 <= 95 + + x1 + x2 + x3 + x4 + x5 <= 28 + + x1, x2, x3, x4, x5 in Z_{\ge 0} diff --git a/benchmark.py b/benchmark.py new file mode 100644 index 0000000..1817cc9 --- /dev/null +++ b/benchmark.py @@ -0,0 +1,225 @@ +"""Benchmark: Referenzaufgaben mit bekanntem Optimum durch die Pipeline schicken und Modelle vergleichen. + +Beispiel: + .venv/bin/python benchmark.py --models deepseek/deepseek-v4-flash-0731 deepseek/deepseek-v4-pro-0813 --runs 2 +""" +import argparse +import hashlib +import json +import os +import subprocess +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +from datetime import datetime +from pathlib import Path + +from llm import DEFAULT_MODEL, is_fatal_error +from TextToModel import run_pipeline + +BENCHMARK_DIR = Path(__file__).parent / "benchmark" +CASES_DIR = BENCHMARK_DIR / "cases" +RESULTS_DIR = BENCHMARK_DIR / "results" + +# Nach dem ersten fatalen API-Fehler (Kontingent/Guthaben/Key) keine weiteren Läufe starten +quota_exhausted = threading.Event() + +# Dateien, die das LLM-Verhalten steuern: die drei System-Prompts und die Feldbeschreibungen +# (Field(description=...) geht als JSON-Schema mit und wirkt wie eine Prompt-Anweisung). +PROMPT_FILES = ("Routing-und-Extraktions-Prompt.txt", "Pruef-Prompt.txt", "Solver-Prompt.txt", "model_schema.py") + +# Umgebungsvariablen, die das Verhalten ändern – ohne sie ließe sich ein A/B-Lauf nicht zuordnen +CONFIG_VARS = ("LLM_MODEL", "LLM_MODEL_EXTRACTION", "LLM_MODEL_REVIEW", "LLM_MODEL_REVISION", "LLM_MODEL_SOLVER", + "UNDERSTAND_RETRIES", "MAX_REVISIONS", "SOLVER_RETRIES", "MAX_RESTARTS", "INFEASIBLE_RECHECK", + "LLM_REASONING_EFFORT", "SOLVER_SANDBOX") + + +def prompt_digest(paths=None) -> str: + """Kurzer Hash über alle Dateien, die das LLM-Verhalten steuern.""" + digest = hashlib.sha256() + for path in paths if paths is not None else [Path(__file__).parent / n for n in PROMPT_FILES]: + digest.update(Path(path).read_bytes()) + return digest.hexdigest()[:12] + + +@lru_cache(maxsize=1) +def prompt_state() -> dict: + """Womit wurde gemessen? Ohne diese Angaben lassen sich zwei Ergebnisdateien nicht vergleichen. + + Beobachtet: die Messlatte 19/22 ließ sich keinem Prompt-Stand zuordnen, weil nur Fall, Modell und + Laufnummer gespeichert wurden – jede Prompt-Änderung entwertete die Vergleichbarkeit still. + """ + state = {"prompts": prompt_digest()} + gesetzt = {name: os.environ[name] for name in CONFIG_VARS if os.environ.get(name)} + if gesetzt: + state["config"] = gesetzt + def git(*args): + return subprocess.run(["git", *args], cwd=Path(__file__).parent, capture_output=True, + text=True, timeout=10).stdout.strip() + try: + state["commit"] = git("rev-parse", "--short", "HEAD") or "unbekannt" + state["dirty"] = bool(git("status", "--porcelain")) + except (OSError, subprocess.SubprocessError): + state["commit"], state["dirty"] = "unbekannt", None + return state + + +def load_cases(names=None): + cases = [] + for case_dir in sorted(p for p in CASES_DIR.iterdir() if p.is_dir()): + if names and case_dir.name not in names: + continue + cases.append({ + "name": case_dir.name, + "task": (case_dir / "task.txt").read_text(encoding="utf-8"), + "expected": json.loads((case_dir / "expected.json").read_text(encoding="utf-8")), + # Alles außer task.txt/expected.json gilt als beigelegte Datendatei (--daten) + "data_files": sorted(str(f) for f in case_dir.iterdir() + if f.is_file() and f.name not in ("task.txt", "expected.json")), + }) + return cases + + +def result_correct(solver_run, expected: dict) -> bool: + """Status muss passen (Default optimal); bei optimal zusätzlich der Zielwert.""" + expected_status = expected.get("status", "optimal") + if solver_run is None or solver_run.status.value != expected_status: + return False + return expected_status != "optimal" or objective_matches(solver_run.objective_value, expected) + + +def objective_matches(actual, expected: dict) -> bool: + if actual is None: + return False + # Toleranz relativ, mindestens absolut (für Zielwerte < 1) + return abs(actual - expected["objective_value"]) <= expected["tolerance"] * max(1.0, abs(expected["objective_value"])) + + +def run_case(case: dict, model: str, run_no: int) -> dict: + expected = case["expected"] + row = {"case": case["name"], "model": model, "run": run_no, **prompt_state()} + if quota_exhausted.is_set(): + row.update(seconds=0, error="übersprungen: fataler API-Fehler in früherem Lauf") + return row + start = time.monotonic() + try: + result = run_pipeline(case["task"], model=model, data_files=case.get("data_files") or ()) + except Exception as exc: # API-/Validierungsfehler sollen den Benchmark nicht abbrechen + message = f"{type(exc).__name__}: {exc}" + if is_fatal_error(message): + quota_exhausted.set() + row.update(seconds=round(time.monotonic() - start), error=message[:500]) + return row + + spec, solver_run = result.specification, result.solver_run + # "Aufgabe erfüllt" statt nur "Zahl richtig": alle Teilfragen beantwortet, keine unbelegte Zahl + fragen = {q.id for q in result.understanding.sub_questions} if result.understanding else set() + antworten = {a.question_id for a in solver_run.answers} if solver_run else set() + abnahme = [w for w in (solver_run.warnings if solver_run else []) if "Antwort zu" in w or "Teilfragen ohne" in w] + objective_ok = result.reliable and result_correct(solver_run, expected) + row.update( + seconds=round(time.monotonic() - start), + problem_class=spec.problem_class.value, + target_stack=spec.target_stack.value, + class_ok=spec.problem_class.value == expected.get("problem_class", spec.problem_class.value), + stack_ok=spec.target_stack.value == expected.get("target_stack", spec.target_stack.value), + check_errors=sum(1 for i in result.checks if i.severity.value == "error"), + check_warnings=sum(1 for i in result.checks if i.severity.value == "warning"), + revisions=result.revisions, + review_ok=not result.review.needs_revision, + data_assumptions=len(result.solver_code.data_assumptions) if result.solver_code else 0, + solver_status=solver_run.status.value if solver_run else "nicht gestartet", + solver_attempts=solver_run.attempts if solver_run else 0, + objective_value=solver_run.objective_value if solver_run else None, + objective_ok=objective_ok, + questions=len(fragen), + answered=len(fragen & antworten), + answer_findings=len(abnahme), + task_ok=bool(objective_ok and fragen and fragen <= antworten and not abnahme + and len(fragen) >= expected.get("min_sub_questions", 1)), + solver_error=solver_run.error if solver_run else "; ".join(result.reliability_issues), + reliable=result.reliable, + restarts=result.restarts, + providers=result.usage.providers, + llm_calls=result.usage.calls, + reasoning_tokens=result.usage.reasoning_tokens, + cost_usd=result.usage.cost_usd, + result=json.loads(result.model_dump_json()), + ) + return row + + +def mark(value) -> str: + return {True: "ja", False: "NEIN", None: "-"}.get(value, str(value)) + + +def print_table(rows): + header = ["Fall", "Modell", "#", "Klasse", "Stack", "ChkErr", "Rev", "Review", "Solver", "Zielwert", "Zuverl.", "Neust.", "Treffer", "Fragen", "Antw.", "Erfüllt", "Datenann.", "Aufrufe", "Reasoning", "USD", "s"] + lines = [header] + for r in rows: + if "error" in r: + lines.append([r["case"], r["model"], str(r["run"]), "-", "-", "-", "-", "-", "ABBRUCH", "-", "-", "-", "NEIN", "-", "-", "NEIN", "-", "-", "-", "-", str(r["seconds"])]) + continue + objective = "-" if r["objective_value"] is None else f"{r['objective_value']:.6g}" + lines.append([ + r["case"], r["model"], str(r["run"]), mark(r["class_ok"]), mark(r["stack_ok"]), str(r["check_errors"]), + str(r["revisions"]), mark(r["review_ok"]), r["solver_status"], objective, mark(r["reliable"]), str(r["restarts"]), mark(r["objective_ok"]), + str(r.get("questions", 0)), str(r.get("answered", 0)), mark(r.get("task_ok")), + str(r["data_assumptions"]), str(r["llm_calls"]), str(r["reasoning_tokens"]), + "-" if r["cost_usd"] is None else f"{r['cost_usd']:.4f}", str(r["seconds"]), + ]) + widths = [max(len(line[i]) for line in lines) for i in range(len(header))] + for n, line in enumerate(lines): + print(" ".join(cell.ljust(w) for cell, w in zip(line, widths))) + if n == 0: + print(" ".join("-" * w for w in widths)) + + stand = prompt_state() + geaendert = " + nicht eingecheckte Änderungen" if stand.get("dirty") else "" + print(f"\nGemessener Stand: Commit {stand['commit']}{geaendert}, Prompt-Kennung {stand['prompts']} " + f"(über {', '.join(PROMPT_FILES)})") + if stand.get("config"): + print("Abweichende Einstellungen: " + ", ".join(f"{k}={v}" for k, v in stand["config"].items())) + print("\nZusammenfassung je Modell:") + for model in dict.fromkeys(r["model"] for r in rows): + mine = [r for r in rows if r["model"] == model] + hits = sum(1 for r in mine if r.get("objective_ok")) + erfuellt = sum(1 for r in mine if r.get("task_ok")) + routing = sum(1 for r in mine if r.get("class_ok") and r.get("stack_ok")) + seconds = sum(r["seconds"] for r in mine) / len(mine) + cost = sum(r.get("cost_usd") or 0 for r in mine) + print(f" {model}: Ergebnis korrekt {hits}/{len(mine)}, Aufgabe erfüllt {erfuellt}/{len(mine)}, Routing korrekt {routing}/{len(mine)}, " + f"Ø {seconds:.0f} s pro Lauf, Kosten gesamt ${cost:.4f} (ohne abgebrochene Läufe)") + + for r in rows: + if r.get("error") or r.get("solver_error"): + print(f"\n[{r['case']} | {r['model']} | #{r['run']}] {r.get('error') or r.get('solver_error')}") + if quota_exhausted.is_set(): + print("\nABBRUCH: fataler API-Fehler (Kontingent, Guthaben oder Key) – restliche Läufe wurden übersprungen.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--models", nargs="+", default=[DEFAULT_MODEL]) + parser.add_argument("--runs", type=int, default=1, help="Läufe je Fall und Modell (Streuung sichtbar machen)") + parser.add_argument("--cases", nargs="+", help="nur diese Fälle (Verzeichnisnamen)") + parser.add_argument("--jobs", type=int, default=1, help="parallele Läufe (Gemini Free Tier: 1)") + args = parser.parse_args() + + cases = load_cases(args.cases) + jobs = [(case, model, run_no) for model in args.models for case in cases for run_no in range(1, args.runs + 1)] + with ThreadPoolExecutor(max_workers=args.jobs) as pool: + rows = list(pool.map(lambda job: run_case(*job), jobs)) + + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + out_file = RESULTS_DIR / f"{datetime.now():%Y%m%d-%H%M%S}.json" + out_file.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8") + + print() + print_table(rows) + print(f"\nDetails: {out_file}") + + +if __name__ == "__main__": + main() diff --git a/benchmark/cases/daten_portfolio_csv/aktien.csv b/benchmark/cases/daten_portfolio_csv/aktien.csv new file mode 100644 index 0000000..e7a1835 --- /dev/null +++ b/benchmark/cases/daten_portfolio_csv/aktien.csv @@ -0,0 +1,11 @@ +Aktie;Rendite;Varianz;Gebuehr +A01;6,9;2,04;0,68 +A02;14,4;1,63;0,74 +A03;11,5;3,62;0,39 +A04;9,8;3,01;0,27 +A05;4,0;3,30;0,34 +A06;4,0;3,33;0,46 +A07;2,8;3,24;0,69 +A08;13,3;1,97;0,72 +A09;9,8;3,26;0,20 +A10;11,2;2,52;0,51 diff --git a/benchmark/cases/daten_portfolio_csv/expected.json b/benchmark/cases/daten_portfolio_csv/expected.json new file mode 100644 index 0000000..d473b9e --- /dev/null +++ b/benchmark/cases/daten_portfolio_csv/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Mixed-Integer Quadratic Programming (MIQP)", + "target_stack": "SCIP (pyscipopt)", + "sense": "maximize", + "objective_value": 12.03, + "tolerance": 1e-4, + "min_sub_questions": 2, + "source": "Unabhängig durch Enumeration aller 385 Teilmengen mit höchstens 4 Titeln verifiziert (je Teilmenge ein konvexes QP über scipy.optimize.minimize, Anteile in [0.05, 1] mit Summe 1). Optimum: nur A02 mit Anteil 1,0 -> 14,4 - 1,63 - 0,74 = 12,03. Zum Vergleich A02+A08: 11,634." +} diff --git a/benchmark/cases/daten_portfolio_csv/task.txt b/benchmark/cases/daten_portfolio_csv/task.txt new file mode 100644 index 0000000..1642438 --- /dev/null +++ b/benchmark/cases/daten_portfolio_csv/task.txt @@ -0,0 +1,15 @@ +Ein Investor verteilt sein gesamtes Kapital auf Aktien. Die Kennzahlen stehen in der beigelegten Datei aktien.csv mit den Spalten: Aktie, Rendite (erwartete Jahresrendite in Prozentpunkten), Varianz (Risikokennzahl in Prozentpunkten), Gebuehr (feste Kaufgebühr in Prozentpunkten des Kapitals, die einmal je gekaufter Aktie anfällt). + +Sei w_i der Kapitalanteil von Aktie i (0 ≤ w_i ≤ 1) und z_i = 1, falls Aktie i gekauft wird, sonst 0. + +Zu maximieren ist der Nutzen + + U = Summe über i von ( Rendite_i * w_i ) − 1,0 * Summe über i von ( Varianz_i * w_i^2 ) − Summe über i von ( Gebuehr_i * z_i ) + +unter diesen Bedingungen: +- Die Anteile summieren sich zu genau 1. +- Es dürfen höchstens 4 Aktien gekauft werden. +- Wird eine Aktie gekauft, beträgt ihr Anteil mindestens 5 % (w_i ≥ 0,05 * z_i); wird sie nicht gekauft, ist ihr Anteil 0 (w_i ≤ z_i). + +a) Bestimmen Sie das nutzenmaximale Portfolio: welche Aktien mit welchen Anteilen, und wie hoch ist der Nutzen? +b) Begründen Sie, warum die Gebühren dazu führen, dass nicht beliebig viele Aktien gekauft werden. diff --git a/benchmark/cases/graph_ersatz_dijkstra/expected.json b/benchmark/cases/graph_ersatz_dijkstra/expected.json new file mode 100644 index 0000000..668757c --- /dev/null +++ b/benchmark/cases/graph_ersatz_dijkstra/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Kürzeste Wege im Graphen (Shortest Path)", + "target_stack": "NetworkX (Dijkstra)", + "sense": "minimize", + "objective_value": 29, + "tolerance": 1e-06, + "min_sub_questions": 2, + "source": "Ersatzproblem als kürzester Weg. Optimum unabhängig durch Enumeration aller vier Wege von 0 nach 3 verifiziert: 0-1-3 = 8+21 = 29 (Minimum), 0-1-2-3 = 30, 0-2-3 = 30, 0-3 = 31. Die Aufgabe verlangt ausdrücklich Dijkstra, deshalb ist der Stack Teil der Erwartung." +} diff --git a/benchmark/cases/graph_ersatz_dijkstra/task.txt b/benchmark/cases/graph_ersatz_dijkstra/task.txt new file mode 100644 index 0000000..e3baf97 --- /dev/null +++ b/benchmark/cases/graph_ersatz_dijkstra/task.txt @@ -0,0 +1,16 @@ +## Ersatzplanung für ein Lieferfahrzeug + +Ein Kurierdienst braucht für die nächsten drei Jahre ein Lieferfahrzeug. Danach stellt er auf eine gemietete Elektroflotte um, das Fahrzeug wird dann nicht mehr benötigt. Weil das Fahrzeug im Stadtverkehr stark beansprucht wird, steigen Reparatur- und Betriebskosten mit dem Alter deutlich an. Es kann sich deshalb lohnen, das Fahrzeug nach einem und/oder nach zwei Jahren gegen ein neues gleichen Typs auszutauschen. + +Die folgende Kostenmatrix C gibt an, welche Kosten (Wertverlust, Reparatur- und Betriebskosten, in Tausend Euro) entstehen, wenn am Ende von Jahr i = 0,1,2 (also zu Beginn von Jahr i+1) ein neues Fahrzeug gekauft und bis zum Ende von Jahr j = 1,2,3 genutzt wird. + +Zu welchen Zeitpunkten sollte das Fahrzeug gegebenenfalls ersetzt werden, damit die Fahrzeugkosten der nächsten drei Jahre möglichst gering sind? + +| i \ j | 1 | 2 | 3 | +|---------|-----|-----|-----| +| 0 | 8 | 18 | 31 | +| 1 | - | 10 | 21 | +| 2 | - | - | 12 | + +- a) Beschreiben Sie die Situation als Kürzeste-Wege-Problem und zeichnen Sie den zugehörigen Graphen! +- b) Bestimmen Sie die Lösung mit dem Dijkstra-Algorithmus! diff --git a/benchmark/cases/hard_portfolio_kardinalitaet/expected.json b/benchmark/cases/hard_portfolio_kardinalitaet/expected.json new file mode 100644 index 0000000..ed979af --- /dev/null +++ b/benchmark/cases/hard_portfolio_kardinalitaet/expected.json @@ -0,0 +1,8 @@ +{ + "problem_class": "Mixed-Integer Quadratic Programming (MIQP)", + "target_stack": "SCIP (pyscipopt)", + "sense": "maximize", + "objective_value": 9.755769, + "tolerance": 0.0001, + "source": "Unabhängig durch Enumeration aller Teilmengen mit 5 bis 10 Titeln verifiziert (je Teilmenge ein konvexes QP über scipy.optimize.minimize, Anteile in [0.05, 0.30] mit Summe 1): 9.755769 Prozentpunkte mit A02, A03, A04, A08, A09. Identisch mit dem früher notierten Wert 0.097558 – das ist derselbe Optimalpunkt als Anteil statt in Prozentpunkten. Die Aufgabe nennt die Einheit seit 2026-09-17 ausdrücklich, weil beide Lesarten zulässig waren." +} diff --git a/benchmark/cases/hard_portfolio_kardinalitaet/task.txt b/benchmark/cases/hard_portfolio_kardinalitaet/task.txt new file mode 100644 index 0000000..99db886 --- /dev/null +++ b/benchmark/cases/hard_portfolio_kardinalitaet/task.txt @@ -0,0 +1,44 @@ +Problem: Komplexes Portfolio-Management: + +Ein Investor möchte sein Geld auf mindestens 5 und maximal 10 verschiedene Aktien aufteilen. + +Er möchte den erwarteten Nutzen maximieren und gleichzeitig das Risiko (die Varianz) minimieren. + +Maximieren Sie den Nutzen (Rendite abzüglich des Risikos und der Fixkosten)! +Alle Angaben der Tabelle sind Prozentpunkte des Kapitals; geben Sie den Nutzen in derselben Einheit an. + +Unter folgenden Nebenbedingungen: + +- 1. Kardinalitätsbedingungen: Er darf maximal 10 verschiedene Aktien auswählen (um Transaktionskosten zu sparen). +- 2. Er muss mindestens in 5 verschiedene Aktien investieren, um das Risiko zu verteilen. +- 3. Mindest- und Höchstgrenzen: Wenn er in eine Aktie investiert, dann müssen es mindestens 5 % und maximal 30 % des Gesamtkapitals sein. +- 4. Transaktionskosten: Beim Kauf fallen feste Grundgebühren (Transaktionskosten) an. +- 5. Das gesamte Geld muss investiert werden. + + +Wir setzen folgende Rahmenbedingungen fest: + +Maximale Anzahl Aktien im Portfolio: 10 +Mindestgewichtung pro gewählte Aktie: 0.05 (Fünf Prozent) +Maximalgewichtung pro gewählte Aktie: 0.30 (Dreißig Prozent) +Risikoaversations-Faktor: 1.0 + + +Hier sind die erwarteten Renditen und die fixen Transaktionskosten und die eigene Varianz (Risiko) für die 10 Aktien: + +Aktie | Erwartete Rendite | Transaktionskosten | Varianz (Risiko) +--------|---------------|---------------|-------------------------------- +A01 | 6,9 % | 0,68 % | 2,04 % +A02 | 14,4 % | 0,74 % | 1,63 % +A03 | 11,5 % | 0,39 % | 3,62 % +A04 | 9,8 % | 0,27 % | 3,01 % +A05 | 4,0 % | 0,34 % | 3,30 % +A06 | 4,0 % | 0,46 % | 3,33 % +A07 | 2,8 % | 0,69 % | 3,24 % +A08 | 13,3 % | 0,72 % | 1,97 % +A09 | 9,8 % | 0,20 % | 3,26 % +A10 | 11,2 % | 0,51 % | 2,52 % + + + + diff --git a/benchmark/cases/hard_portfolio_volatilitaet/expected.json b/benchmark/cases/hard_portfolio_volatilitaet/expected.json new file mode 100644 index 0000000..2310e63 --- /dev/null +++ b/benchmark/cases/hard_portfolio_volatilitaet/expected.json @@ -0,0 +1,7 @@ +{ + "target_stack": "CVXPY", + "sense": "minimize", + "objective_value": 0.1172905494, + "tolerance": 1e-06, + "source": "cvxpy und scipy SLSQP übereinstimmend: Volatilität 0.11729055 (Varianz 0.01375707 wäre falsch); Klasse QP oder SOCP beide zulässig | 2026-09-17 unabhängig mit cvxpy nachgerechnet: 0.1172905494 bei Anteilen [0.231939, 0.263325, 0.289207, 0.215529]. Die Aufgabe nennt die Einheit jetzt ausdrücklich, weil Anteil und Prozentpunkte beide zulässige Lesarten waren (Faktor 100)." +} diff --git a/benchmark/cases/hard_portfolio_volatilitaet/task.txt b/benchmark/cases/hard_portfolio_volatilitaet/task.txt new file mode 100644 index 0000000..ff10475 --- /dev/null +++ b/benchmark/cases/hard_portfolio_volatilitaet/task.txt @@ -0,0 +1,18 @@ +Eine Stiftung legt ihr gesamtes Kapital in vier Fonds A, B, C und D an. Leerverkäufe sind nicht erlaubt. +Erwartete Jahresrenditen: A 7 %, B 10 %, C 4 %, D 12 %. + +Kovarianzmatrix der Jahresrenditen (Reihenfolge A, B, C, D): + + A B C D +A 0.0225 0.0090 0.0012 0.0150 +B 0.0090 0.0400 0.0032 0.0250 +C 0.0012 0.0032 0.0064 0.0020 +D 0.0150 0.0250 0.0020 0.0625 + +Anlagerichtlinien: +- Kein Fonds darf mehr als 40 % des Kapitals erhalten. +- Die Fonds A und B stammen aus derselben Branche und dürfen zusammen höchstens 50 % ausmachen. +- Die erwartete Portfoliorendite muss mindestens 8 % betragen. + +Bestimme die Aufteilung mit der geringsten Volatilität, also der kleinsten Standardabweichung der Portfoliorendite, und gib diese Volatilität an. +Geben Sie die Volatilität als Anteil an (z. B. 0,1234), nicht in Prozent. diff --git a/benchmark/cases/hard_produktion_ruestkosten/expected.json b/benchmark/cases/hard_produktion_ruestkosten/expected.json new file mode 100644 index 0000000..a70d2ef --- /dev/null +++ b/benchmark/cases/hard_produktion_ruestkosten/expected.json @@ -0,0 +1,2 @@ +{"problem_class": "Mixed-Integer Linear Programming (MILP)", "target_stack": "HiGHS", "sense": "maximize", "objective_value": 3000, "tolerance": 1e-6, + "source": "Brute Force über alle ganzzahligen (P1, P2) mit optimalem P3; Optimum P1=80, P2=0, P3=50"} diff --git a/benchmark/cases/hard_produktion_ruestkosten/task.txt b/benchmark/cases/hard_produktion_ruestkosten/task.txt new file mode 100644 index 0000000..e23e0b5 --- /dev/null +++ b/benchmark/cases/hard_produktion_ruestkosten/task.txt @@ -0,0 +1,11 @@ +Ein Maschinenbaubetrieb plant die Produktion von drei Bauteilen P1, P2 und P3 für den kommenden Monat. Gefertigt werden nur ganze Stück. + +Bauteil | Deckungsbeitrag [€/Stück] | Maschine M1 [h/Stück] | Maschine M2 [h/Stück] | Rüstkosten [€] | Mindestlosgröße [Stück] | maximaler Absatz [Stück] +--------|---------------------------|-----------------------|-----------------------|----------------|-------------------------|------------------------- +P1 | 40 | 2 | 1 | 1200 | 30 | 80 +P2 | 55 | 3 | 2 | 2000 | 20 | 50 +P3 | 30 | 1 | 2 | 500 | 40 | 100 + +Maschine M1 steht im Monat 240 Stunden zur Verfügung, Maschine M2 180 Stunden. +Die Rüstkosten fallen einmalig an, wenn ein Bauteil im Monat überhaupt gefertigt wird. Wird ein Bauteil gefertigt, muss mindestens die Mindestlosgröße produziert werden; mehr als der maximale Absatz wird nicht produziert. +Welches Produktionsprogramm maximiert den Gesamtdeckungsbeitrag nach Abzug der Rüstkosten, und wie hoch ist dieser? diff --git a/benchmark/cases/hard_schichtplan_woche/expected.json b/benchmark/cases/hard_schichtplan_woche/expected.json new file mode 100644 index 0000000..fba1879 --- /dev/null +++ b/benchmark/cases/hard_schichtplan_woche/expected.json @@ -0,0 +1,2 @@ +{"problem_class": "Constraint Programming (CP)", "target_stack": "Google OR-Tools (CP-SAT)", "sense": "minimize", "objective_value": 475, "tolerance": 1e-6, + "source": "Brute Force über alle 3^12 Pläne (236 zulässig)"} diff --git a/benchmark/cases/hard_schichtplan_woche/task.txt b/benchmark/cases/hard_schichtplan_woche/task.txt new file mode 100644 index 0000000..411634c --- /dev/null +++ b/benchmark/cases/hard_schichtplan_woche/task.txt @@ -0,0 +1,20 @@ +Eine Apotheke plant die Besetzung für Montag, Dienstag und Mittwoch. Jeden Tag gibt es eine Frühschicht und eine Spätschicht. Vier Mitarbeitende stehen zur Verfügung: Anna, Ben, Clara und David. + +Regeln: +- Jede Frühschicht ist mit mindestens einer und höchstens zwei Personen besetzt. Jede Spätschicht ist mit mindestens einer Person besetzt. +- Niemand arbeitet an einem Tag mehr als eine Schicht. +- Über die drei Tage arbeitet jede Person mindestens eine und höchstens zwei Schichten. +- Wer an einem Tag Spätschicht hat, darf am folgenden Tag nicht Frühschicht haben. +- Anna und Ben dürfen nie in derselben Schicht arbeiten. +- Clara arbeitet nur an Tagen, an denen auch David arbeitet (egal in welcher Schicht). +- David ist am Mittwoch nicht verfügbar. + +Kosten je eingesetzter Schicht [€]: +Person | Frühschicht | Spätschicht +-------|-------------|------------ +Anna | 80 | 90 +Ben | 70 | 95 +Clara | 85 | 75 +David | 90 | 80 + +Erstelle einen Plan mit minimalen Gesamtkosten. diff --git a/benchmark/cases/hard_transport_lagerwahl/expected.json b/benchmark/cases/hard_transport_lagerwahl/expected.json new file mode 100644 index 0000000..97f9d3e --- /dev/null +++ b/benchmark/cases/hard_transport_lagerwahl/expected.json @@ -0,0 +1,2 @@ +{"problem_class": "Mixed-Integer Linear Programming (MILP)", "target_stack": "HiGHS", "sense": "minimize", "objective_value": 9900, "tolerance": 1e-6, + "source": "Enumeration der Lagerkombinationen + scipy linprog: nur L1 = 9900, nur L2 unzulässig, beide = 10750"} diff --git a/benchmark/cases/hard_transport_lagerwahl/task.txt b/benchmark/cases/hard_transport_lagerwahl/task.txt new file mode 100644 index 0000000..779db25 --- /dev/null +++ b/benchmark/cases/hard_transport_lagerwahl/task.txt @@ -0,0 +1,18 @@ +Ein Händler beliefert drei Großkunden aus zwei Werken. Jede Lieferung muss über eines von zwei möglichen Zwischenlagern laufen; Direktlieferungen vom Werk zum Kunden sind nicht möglich. Die Mengen sind beliebig teilbar. + +Werk W1 kann höchstens 500 Einheiten liefern, Werk W2 höchstens 400 Einheiten. Mit Werk W2 besteht ein Mindestabnahmevertrag: Aus W2 müssen mindestens 150 Einheiten abgenommen werden. +Kunde K1 benötigt genau 250 Einheiten, K2 genau 300 Einheiten und K3 genau 200 Einheiten. + +Die Lager müssen erst angemietet werden. Lager L1 kostet 3000 € Miete und kann höchstens 800 Einheiten umschlagen, Lager L2 kostet 2500 € Miete und kann höchstens 700 Einheiten umschlagen. Nicht angemietete Lager können nicht genutzt werden. Ein Lager gibt genau die Menge weiter, die es erhält. + +Transportkosten Werk → Lager [€/Einheit]: + | L1 | L2 +W1 | 4 | 6 +W2 | 5 | 3 + +Transportkosten Lager → Kunde [€/Einheit]: + | K1 | K2 | K3 +L1 | 3 | 5 | 7 +L2 | 6 | 4 | 3 + +Welche Lager sollen angemietet werden und wie sollen die Warenströme verlaufen, damit die Summe aus Miet- und Transportkosten minimal ist? diff --git a/benchmark/cases/hard_unloesbar_besetzung/expected.json b/benchmark/cases/hard_unloesbar_besetzung/expected.json new file mode 100644 index 0000000..c01b7ab --- /dev/null +++ b/benchmark/cases/hard_unloesbar_besetzung/expected.json @@ -0,0 +1,2 @@ +{"sense": "minimize", "status": "infeasible", + "source": "4 Projekte mit je genau einer Leitung, aber nur 3 Beraterinnen mit je höchstens einem Projekt -> unzulässig; das Modell darf die Aufgabe nicht umdeuten"} diff --git a/benchmark/cases/hard_unloesbar_besetzung/task.txt b/benchmark/cases/hard_unloesbar_besetzung/task.txt new file mode 100644 index 0000000..43c67ea --- /dev/null +++ b/benchmark/cases/hard_unloesbar_besetzung/task.txt @@ -0,0 +1,11 @@ +Eine Unternehmensberatung muss vier neue Kundenprojekte (Projekt 1 bis 4) besetzen. Zur Verfügung stehen drei Beraterinnen: Maria, Nina und Olga. +Jedes der vier Projekte muss von genau einer Beraterin geleitet werden; kein Projekt darf unbesetzt bleiben. Wegen der hohen Auslastung darf jede Beraterin höchstens ein Projekt übernehmen. + +Kosten der Projektleitung [Tsd. €]: +Beraterin | Projekt 1 | Projekt 2 | Projekt 3 | Projekt 4 +----------|-----------|-----------|-----------|---------- +Maria | 12 | 15 | 9 | 14 +Nina | 11 | 13 | 10 | 16 +Olga | 14 | 12 | 11 | 13 + +Bestimme die kostenminimale Besetzung der Projekte. diff --git a/benchmark/cases/implikation_falle/expected.json b/benchmark/cases/implikation_falle/expected.json new file mode 100644 index 0000000..7dc3170 --- /dev/null +++ b/benchmark/cases/implikation_falle/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Mixed-Integer Linear Programming (MILP)", + "target_stack": "HiGHS", + "sense": "maximize", + "objective_value": 85, + "tolerance": 1e-6, + "min_sub_questions": 1, + "source": "Falle im Modell: die Bedingung ist eine Implikation (Maschine -> Zubehör, also x_Maschine <= x_Zubehör), keine Äquivalenz. Beide Lesarten unabhängig durch Enumeration aller 16 Teilmengen verifiziert. RICHTIG (Implikation): 85 mit Zubehör + Prüfgerät + Werkbank, Kosten 95 - das Zubehör darf ohne die Maschine gekauft werden. FALSCH (Äquivalenz x_Maschine = x_Zubehör, verbietet Zubehör allein): 70 mit Maschine + Zubehör, Kosten 90. Ein gemeldeter Zielwert von 70 bedeutet genau diesen Modellierungsfehler; 70 ist zugleich die zweitbeste korrekte Auswahl, die Diagnose ist also eindeutig. Der Prüf-Prompt hat die Regel ('Wenn A, dann B ist eine Implikation, keine Äquivalenz'), bis zu diesem Fall prüfte sie kein Benchmarkfall." +} diff --git a/benchmark/cases/implikation_falle/task.txt b/benchmark/cases/implikation_falle/task.txt new file mode 100644 index 0000000..145cbf1 --- /dev/null +++ b/benchmark/cases/implikation_falle/task.txt @@ -0,0 +1,11 @@ +Eine Werkstatt hat ein Investitionsbudget von 100 Tausend Euro und kann aus vier Posten auswählen. Jeder Posten wird entweder ganz gekauft oder nicht gekauft; Teilkäufe sind nicht möglich. + +Posten | Nutzwert | Kosten in Tausend Euro +Maschine | 30 | 50 +Zubehör | 40 | 40 +Prüfgerät | 25 | 30 +Werkbank | 20 | 25 + +Für die Maschine gilt eine technische Bedingung: Wird die Maschine gekauft, dann muss auch das Zubehör gekauft werden, weil die Maschine ohne das Zubehör nicht betrieben werden kann. + +Welche Posten soll die Werkstatt kaufen, damit die Summe der Nutzwerte maximal wird, und welchen Gesamtnutzwert erreicht sie dabei? diff --git a/benchmark/cases/losgroessen_mehrperiodig/expected.json b/benchmark/cases/losgroessen_mehrperiodig/expected.json new file mode 100644 index 0000000..2f86cda --- /dev/null +++ b/benchmark/cases/losgroessen_mehrperiodig/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Mixed-Integer Linear Programming (MILP)", + "target_stack": "HiGHS", + "sense": "minimize", + "objective_value": 320, + "tolerance": 1e-6, + "min_sub_questions": 1, + "source": "Unabhängig durch Enumeration aller 8 zulässigen Rüstmuster verifiziert (Periode 1 muss produzieren, Startbestand 0): Optimum 320 Euro mit Rüstmuster (1,1,0,1) und Mengen [20, 60, 0, 40] = 3 Rüstvorgänge (300) + 10 Stück eine Periode auf Lager (20). Eindeutig, der nächstbeste Plan kostet 340." +} diff --git a/benchmark/cases/losgroessen_mehrperiodig/task.txt b/benchmark/cases/losgroessen_mehrperiodig/task.txt new file mode 100644 index 0000000..ab954d7 --- /dev/null +++ b/benchmark/cases/losgroessen_mehrperiodig/task.txt @@ -0,0 +1,7 @@ +Ein Betrieb plant die Produktion eines Artikels über vier Perioden. Der Bedarf beträgt in Periode 1: 20 Stück, Periode 2: 50 Stück, Periode 3: 10 Stück, Periode 4: 40 Stück. + +Jede Periode, in der überhaupt produziert wird, verursacht Rüstkosten von 100 Euro – unabhängig von der Menge. Die Produktionsmenge selbst ist unbegrenzt und verursacht keine weiteren Stückkosten. + +Was am Ende einer Periode auf Lager liegt, kostet 2 Euro je Stück und Periode. Der Bedarf einer Periode wird aus der Produktion derselben Periode oder aus dem Lager gedeckt. Der Lagerbestand ist am Anfang von Periode 1 und am Ende von Periode 4 jeweils null. + +In welchen Perioden soll produziert werden, mit welchen Mengen, und wie hoch sind die Gesamtkosten aus Rüst- und Lagerkosten in Euro? diff --git a/benchmark/cases/lp_klein/expected.json b/benchmark/cases/lp_klein/expected.json new file mode 100644 index 0000000..e57624c --- /dev/null +++ b/benchmark/cases/lp_klein/expected.json @@ -0,0 +1 @@ +{"problem_class": "Linear Programming (LP)", "target_stack": "SciPy (scipy.optimize.linprog)", "sense": "minimize", "objective_value": 9, "tolerance": 1e-6} diff --git a/benchmark/cases/lp_klein/task.txt b/benchmark/cases/lp_klein/task.txt new file mode 100644 index 0000000..d601d1d --- /dev/null +++ b/benchmark/cases/lp_klein/task.txt @@ -0,0 +1,4 @@ +Ein Betrieb kann ein Zwischenprodukt aus zwei Rohstoffen herstellen. Eine Tonne Rohstoff X kostet 3 Tsd. Euro, +eine Tonne Rohstoff Y kostet 2 Tsd. Euro. Insgesamt werden mindestens 4 Tonnen benötigt. Von X sind höchstens +3 Tonnen lieferbar, von Y ebenfalls höchstens 3 Tonnen. Die Mengen sind beliebig teilbar. +Wie viele Tonnen von X und Y sollen gekauft werden, damit die Einkaufskosten minimal sind? diff --git a/benchmark/cases/nlp_global_reaktor/expected.json b/benchmark/cases/nlp_global_reaktor/expected.json new file mode 100644 index 0000000..f982aa4 --- /dev/null +++ b/benchmark/cases/nlp_global_reaktor/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Nonlinear Programming (NLP)", + "target_stack": "SCIP (pyscipopt)", + "sense": "maximize", + "objective_value": 28.887312, + "tolerance": 1e-5, + "min_sub_questions": 1, + "source": "Unabhängig verifiziert durch Gittersuche über 4001x4001 Punkte auf [0,4]^2 (Maximum 28.887311 bei x = y = 2.7140) und anschließende lokale Nachoptimierung mit scipy SLSQP von acht Startpunkten (28.887312 bei x = y = 2.71415). Nichtkonvex mit mehreren lokalen Maxima, globales Optimum ausdrücklich verlangt – deshalb SCIP (räumliches Branch-and-Bound), nicht Ipopt." +} diff --git a/benchmark/cases/nlp_global_reaktor/task.txt b/benchmark/cases/nlp_global_reaktor/task.txt new file mode 100644 index 0000000..6322f1f --- /dev/null +++ b/benchmark/cases/nlp_global_reaktor/task.txt @@ -0,0 +1,11 @@ +Ein Reaktor wird über zwei Stellgrößen gefahren: die Temperaturkennzahl x (zulässig zwischen 0 und 4) und den Druck y in bar (zulässig zwischen 0 und 4). + +Die Ausbeute in kg folgt dem Zusammenhang + + A(x, y) = 20 + x * sin(3*x) + y * sin(3*y) + 0,5 * x * y + +wobei der Sinus im Bogenmaß zu nehmen ist. Aus Sicherheitsgründen darf die Summe aus Temperaturkennzahl und Druck höchstens 6 betragen. + +Die Zielfunktion hat mehrere lokale Maxima. Gesucht ist das nachweislich globale Maximum, nicht ein beliebiges lokales. + +Bei welchen Werten von x und y wird die Ausbeute maximal, und wie hoch ist sie dann in kg? diff --git a/benchmark/cases/portfolio_qp/expected.json b/benchmark/cases/portfolio_qp/expected.json new file mode 100644 index 0000000..475b253 --- /dev/null +++ b/benchmark/cases/portfolio_qp/expected.json @@ -0,0 +1,2 @@ +{"problem_class": "Quadratic Programming (QP)", "target_stack": "CVXPY", "sense": "minimize", "objective_value": 0.0157641, "tolerance": 1e-5, + "source": "cvxpy (0.01576409) und Gittersuche (Schrittweite 0.001) bestätigt; Zielwert = Varianz, nicht Standardabweichung"} diff --git a/benchmark/cases/portfolio_qp/task.txt b/benchmark/cases/portfolio_qp/task.txt new file mode 100644 index 0000000..4418e0f --- /dev/null +++ b/benchmark/cases/portfolio_qp/task.txt @@ -0,0 +1,9 @@ +Eine Anlegerin verteilt ihr gesamtes Kapital auf drei Fonds (Leerverkäufe sind nicht erlaubt). Erwartete Jahresrenditen: Fonds A 8 %, Fonds B 12 %, Fonds C 5 %. +Die Kovarianzmatrix der Renditen (Reihenfolge A, B, C) lautet: + + A B C +A 0.040 0.006 0.002 +B 0.006 0.090 0.004 +C 0.002 0.004 0.010 + +Die erwartete Portfoliorendite soll mindestens 8 % betragen. Bestimme die Anteile, bei denen die Varianz der Portfoliorendite minimal ist. diff --git a/benchmark/cases/reihenfolge_flowshop/expected.json b/benchmark/cases/reihenfolge_flowshop/expected.json new file mode 100644 index 0000000..5df96ed --- /dev/null +++ b/benchmark/cases/reihenfolge_flowshop/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Constraint Programming (CP)", + "target_stack": "Google OR-Tools (CP-SAT)", + "sense": "minimize", + "objective_value": 10, + "tolerance": 1e-6, + "min_sub_questions": 1, + "source": "Unabhängig durch Enumeration aller 6 Permutationen verifiziert: ABC = 10, BAC = 10, BCA = 11, CBA = 12, ACB = 13, CAB = 13. Der Zielwert 10 ist eindeutig, die optimale Reihenfolge nicht (ABC und BAC gleichwertig) – deshalb wird nur der Zielwert geprüft." +} diff --git a/benchmark/cases/reihenfolge_flowshop/task.txt b/benchmark/cases/reihenfolge_flowshop/task.txt new file mode 100644 index 0000000..cc7afc0 --- /dev/null +++ b/benchmark/cases/reihenfolge_flowshop/task.txt @@ -0,0 +1,10 @@ +Drei Aufträge A, B und C müssen nacheinander zwei Maschinen durchlaufen: erst Maschine 1, danach Maschine 2. Die Bearbeitungszeiten in Stunden: + +Auftrag | Maschine 1 | Maschine 2 +A | 3 | 2 +B | 2 | 4 +C | 4 | 1 + +Jede Maschine bearbeitet höchstens einen Auftrag gleichzeitig, und eine begonnene Bearbeitung wird nicht unterbrochen. Ein Auftrag kann auf Maschine 2 erst beginnen, wenn er auf Maschine 1 fertig ist. Alle Aufträge sind ab Zeitpunkt 0 verfügbar; die Reihenfolge ist auf beiden Maschinen dieselbe. + +Bestimmen Sie die Reihenfolge, bei der der letzte Auftrag so früh wie möglich fertig ist, und geben Sie diese Gesamtdurchlaufzeit in Stunden an. diff --git a/benchmark/cases/rucksack_milp/expected.json b/benchmark/cases/rucksack_milp/expected.json new file mode 100644 index 0000000..7d1a4ba --- /dev/null +++ b/benchmark/cases/rucksack_milp/expected.json @@ -0,0 +1,2 @@ +{"problem_class": "Mixed-Integer Linear Programming (MILP)", "target_stack": "HiGHS", "sense": "maximize", "objective_value": 51, "tolerance": 1e-6, + "source": "Brute Force über alle 64 Teilmengen"} diff --git a/benchmark/cases/rucksack_milp/task.txt b/benchmark/cases/rucksack_milp/task.txt new file mode 100644 index 0000000..af9acb1 --- /dev/null +++ b/benchmark/cases/rucksack_milp/task.txt @@ -0,0 +1,12 @@ +Ein Wanderer kann in seinem Rucksack höchstens 26 kg tragen. Zur Auswahl stehen sechs Gegenstände, die jeweils nur ganz oder gar nicht mitgenommen werden können: + +Gegenstand | Gewicht [kg] | Nutzen [Punkte] +-----------|--------------|---------------- +Zelt | 12 | 24 +Kocher | 7 | 13 +Schlafsack | 11 | 23 +Proviant | 8 | 15 +Kamera | 9 | 16 +Fernglas | 6 | 11 + +Welche Gegenstände soll er einpacken, damit der Gesamtnutzen maximal wird? diff --git a/benchmark/cases/schicht_cpsat/expected.json b/benchmark/cases/schicht_cpsat/expected.json new file mode 100644 index 0000000..62ee272 --- /dev/null +++ b/benchmark/cases/schicht_cpsat/expected.json @@ -0,0 +1,2 @@ +{"problem_class": "Constraint Programming (CP)", "target_stack": "Google OR-Tools (CP-SAT)", "sense": "minimize", "objective_value": 51, "tolerance": 1e-6, + "source": "Brute Force über alle 4^6 Zuordnungen"} diff --git a/benchmark/cases/schicht_cpsat/task.txt b/benchmark/cases/schicht_cpsat/task.txt new file mode 100644 index 0000000..1723e74 --- /dev/null +++ b/benchmark/cases/schicht_cpsat/task.txt @@ -0,0 +1,15 @@ +Ein Supermarkt plant einen Tag mit drei Schichten (Früh, Spät, Nacht). Sechs Mitarbeitende stehen zur Verfügung; jede Person arbeitet an diesem Tag in höchstens einer Schicht oder hat frei. +Die Frühschicht braucht mindestens 2 Personen, die Spätschicht mindestens 2 Personen und die Nachtschicht genau 1 Person. +Wenn Anna arbeitet, muss Ben in derselben Schicht arbeiten. Clara und David dürfen nicht in derselben Schicht eingeteilt werden. +Die Lohnkosten (in Euro) je Person und Schicht: + +Person | Früh | Spät | Nacht +-------|------|------|------ +Anna | 10 | 12 | 15 +Ben | 11 | 10 | 14 +Clara | 9 | 13 | 16 +David | 12 | 11 | 13 +Eva | 10 | 14 | 12 +Felix | 13 | 12 | 11 + +Freie Personen verursachen keine Kosten. Erstelle einen Schichtplan mit minimalen Lohnkosten. diff --git a/benchmark/cases/unloesbar_mischung/expected.json b/benchmark/cases/unloesbar_mischung/expected.json new file mode 100644 index 0000000..a073c7e --- /dev/null +++ b/benchmark/cases/unloesbar_mischung/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Linear Programming (LP)", + "status": "infeasible", + "sense": "minimize", + "objective_value": null, + "tolerance": 1e-6, + "min_sub_questions": 1, + "source": "Die Aufgabe ist unlösbar, und zwar beweisbar: der Proteingehalt jeder Mischung aus A (25 %) und B (20 %) liegt zwischen 20 % und 25 %, kann also die geforderten 30 % nie erreichen. Bestmöglich sind 100 kg A mit 25 kg Protein gegen 30 kg gefordert. Mit scipy linprog bestätigt (HiGHS model_status Infeasible). Erwartet wird der Status infeasible; ein gemeldeter Zielwert wäre ein Fehler." +} diff --git a/benchmark/cases/unloesbar_mischung/task.txt b/benchmark/cases/unloesbar_mischung/task.txt new file mode 100644 index 0000000..ee3fe6f --- /dev/null +++ b/benchmark/cases/unloesbar_mischung/task.txt @@ -0,0 +1,7 @@ +Ein Futtermittelhersteller soll 100 kg einer Mischung herstellen, die mindestens 30 Prozent Protein enthält. + +Zur Verfügung stehen zwei Rohstoffe: Rohstoff A mit einem Proteingehalt von 25 Prozent und Rohstoff B mit einem Proteingehalt von 20 Prozent. Von jedem Rohstoff sind bis zu 100 kg lieferbar. Rohstoff A kostet 4 Euro je kg, Rohstoff B kostet 3 Euro je kg. + +Die Mischung soll aus genau diesen beiden Rohstoffen bestehen und genau 100 kg wiegen. + +Bestimmen Sie die kostenminimale Mischung, die alle Anforderungen erfüllt. diff --git a/benchmark/cases/wagner_whitin_dijkstra/expected.json b/benchmark/cases/wagner_whitin_dijkstra/expected.json new file mode 100644 index 0000000..7291228 --- /dev/null +++ b/benchmark/cases/wagner_whitin_dijkstra/expected.json @@ -0,0 +1,9 @@ +{ + "problem_class": "Kürzeste Wege im Graphen (Shortest Path)", + "target_stack": "NetworkX (Dijkstra)", + "sense": "minimize", + "objective_value": 100, + "tolerance": 1e-06, + "min_sub_questions": 2, + "source": "Dynamische Losgröße nach Wagner-Whitin als kürzester Weg. Pfeil i->j = Bestellung in Woche i deckt den Bedarf der Wochen i..j-1; Bewertungen 1-2:25, 1-3:45, 1-4:90, 1-5:162, 2-3:30, 2-4:50, 2-5:92, 3-4:37, 3-5:55, 4-5:52. Optimum unabhängig verifiziert: von Hand per Dijkstra (Marken 0, 25, 45, 75, 100) und per Enumeration aller 8 Bestellpläne mit Bestellung in Woche 1 - 100 (Bestellung in Woche 1 und 3, je 110 Stück) ist eindeutig, Zweitbester 110. Fallen: c_4 steht in Klammern und ist wirkungslos; der Einkaufspreis ist nicht beziffert und darf nicht erfunden werden; die Aufgabe verlangt ausdrücklich Dijkstra, der Stack ist Teil der Erwartung." +} diff --git a/benchmark/cases/wagner_whitin_dijkstra/task.txt b/benchmark/cases/wagner_whitin_dijkstra/task.txt new file mode 100644 index 0000000..5b4cac9 --- /dev/null +++ b/benchmark/cases/wagner_whitin_dijkstra/task.txt @@ -0,0 +1,18 @@ +## Nachbestellung von Fahrradschläuchen + +Eine kleine Fahrradwerkstatt verkauft neben Reparaturen vor allem Ersatzschläuche eines bestimmten Herstellers. + +Der Inhaber kann den Bedarf b t an Schläuchen für die kommenden 4 Wochen t = 1,...,4 aus Erfahrung recht genau vorhersagen. Er möchte die in diesem Zeitraum benötigten Schläuche so einkaufen und lagern, dass die Gesamtkosten möglichst gering sind. + +| Woche t | 1 | 2 | 3 | 4 | +|-----------|-----|-----|-----|-------| +| b t | 70 | 40 | 50 | 60 | +| f t | 25 | 30 | 37 | 52 | +| c t | 0.5 | 0.4 | 0.3 | (0.3) | + +Der Großhändler hat zugesagt, dass der Einkaufspreis je Schlauch im gesamten Zeitraum gleich bleibt. Für jeden Schlauch, der nicht verkauft wird und im Lager bleibt, fallen variable Lagerkosten c t (GE je Stück und Woche) an. Vereinfachend wird angenommen, dass der Bedarf b t jeweils zu Beginn der Woche t anfällt; diese Schläuche müssen in Woche t also nicht mehr gelagert werden. Jede Bestellung beim Großhändler (jeweils zu Beginn einer Woche t) verursacht fixe Bestellkosten von f t GE. Die Lieferung kommt noch am selben Tag an und kann den Bedarf dieser Woche sofort decken. Alle Daten stehen in der Tabelle oben. + +Wie viele Schläuche sollten zu Beginn jeder der 4 Wochen bestellt werden (sofern überhaupt bestellt wird), damit die Gesamtkosten minimal sind? Das Lager ist zu Beginn und am Ende des Zeitraums leer. + +- a) Stellen Sie das Problem als gerichteten Graphen dar: je ein Knoten für jede Woche t = 1,...,4 (bei Bedarf zusätzlich ein Knoten für eine gedachte Periode danach) und je ein Pfeil für jede mögliche Bestellung. Verwenden Sie dabei die Optimalitätseigenschaft, dass sich eine Bestellung nur lohnt, wenn das Lager leer ist (Regenerationseigenschaft). Erklären Sie, wofür ein Pfeil steht, und geben Sie passende Pfeilbewertungen an! +- b) Lösen Sie das Problem mit dem Dijkstra-Algorithmus, indem Sie einen kürzesten Weg von der Quelle zur Senke des Graphen bestimmen! diff --git a/benchmark/cases/zuordnung_zwei_personen/expected.json b/benchmark/cases/zuordnung_zwei_personen/expected.json new file mode 100644 index 0000000..d628ac8 --- /dev/null +++ b/benchmark/cases/zuordnung_zwei_personen/expected.json @@ -0,0 +1,8 @@ +{ + "problem_class": "Mixed-Integer Linear Programming (MILP)", + "target_stack": "HiGHS", + "sense": "minimize", + "objective_value": 184, + "tolerance": 1e-06, + "source": "Zuordnung vier Aufgaben auf zwei Personen mit je genau zwei Aufgaben. Optimum 184 per Brute Force über alle 6 Aufteilungen und unabhängig als binäres LP bestätigt." +} diff --git a/benchmark/cases/zuordnung_zwei_personen/task.txt b/benchmark/cases/zuordnung_zwei_personen/task.txt new file mode 100644 index 0000000..0ffefd6 --- /dev/null +++ b/benchmark/cases/zuordnung_zwei_personen/task.txt @@ -0,0 +1,8 @@ +Die beiden Vereinsmitglieder Jana und Tobias bereiten gemeinsam das Sommerfest ihres Sportvereins vor. Es sind vier Aufgaben zu erledigen, die sie so aufteilen wollen, dass jede Person genau zwei Aufgaben übernimmt und die gesamte aufgewendete Zeit möglichst gering ist. Die beiden sind in den einzelnen Aufgaben unterschiedlich geübt, deshalb hängt die Bearbeitungszeit davon ab, wer eine Aufgabe übernimmt. Die folgende Tabelle nennt die vier Aufgaben und die Dauer (in Stunden), die jede Person für die jeweilige Aufgabe braucht. + +Dauer [h] | 1. Sponsoren anschreiben | 2. Programm zusammenstellen | 3. Plakate gestalten | 4. Abrechnung prüfen +-----------|--------------------------|-----------------------------|----------------------|--------------------- +Jana | 36 | 78 | 29 | 45 +Tobias | 43 | 72 | 31 | 49 + +Wie sieht die optimale Aufgabenverteilung aus, und wie groß ist der gemeinsame Zeitaufwand? diff --git a/charts.py b/charts.py new file mode 100644 index 0000000..d5b0f6f --- /dev/null +++ b/charts.py @@ -0,0 +1,188 @@ +"""Inline-SVG-Diagramme für den HTML-Report – ohne externe Bibliotheken, aus den geprüften Zahlen der Ergebnistabellen. + +Formen (siehe dataviz-Richtlinie): Magnitudenvergleich -> waagerechte Balken; Zerlegung eines Zielwerts +in Zu-/Abschläge -> Wasserfall. Farben über CSS-Rollen (--viz-*), damit Hell/Dunkel in einer Stelle umschaltet: +divergierendes Paar Blau/Rot (validiert), Gesamtwert in neutraler Textfarbe. +""" +import html + +BAR_HEIGHT = 22 # <= 24px, Balken füllt das Band nicht ganz +BAR_GAP = 10 # Luft zwischen Balken (>= 2px Surface-Gap) +LABEL_WIDTH = 150 +VALUE_WIDTH = 92 +PADDING = 12 +RADIUS = 4 # abgerundetes Datenende + + +def _fmt(value: float, unit: str = "") -> str: + text = f"{value:,.6g}".replace(",", " ") + return f"{text}{unit}" + + +def _text(x, y, content, role="ink", anchor="start", size=12, weight="normal"): + return (f'{html.escape(str(content))}') + + +def _bar(x, y, width, height, color_role, negative=False): + """Balken mit abgerundetem Datenende und eckigem Ende an der Nulllinie.""" + width = max(width, 1.0) + r = min(RADIUS, width / 2) + if negative: # wächst nach links -> linkes Ende gerundet + path = (f"M{x + width:.1f},{y:.1f} H{x + r:.1f} Q{x:.1f},{y:.1f} {x:.1f},{y + r:.1f} " + f"V{y + height - r:.1f} Q{x:.1f},{y + height:.1f} {x + r:.1f},{y + height:.1f} " + f"H{x + width:.1f} Z") + else: + path = (f"M{x:.1f},{y:.1f} H{x + width - r:.1f} Q{x + width:.1f},{y:.1f} {x + width:.1f},{y + r:.1f} " + f"V{y + height - r:.1f} Q{x + width:.1f},{y + height:.1f} {x + width - r:.1f},{y + height:.1f} " + f"H{x:.1f} Z") + return f'' + + +def _frame(width, height, title, body) -> str: + return (f'' + f"{body}") + + +def bar_chart(labels, values, title, unit="") -> str: + """Waagerechte Balken für Beiträge je Objekt; negative Werte links der Nulllinie (Blau/Rot).""" + if not labels: + return "" + width = 720 + plot_x = LABEL_WIDTH + PADDING + plot_width = width - plot_x - VALUE_WIDTH - PADDING + height = PADDING * 2 + len(labels) * (BAR_HEIGHT + BAR_GAP) + + low, high = min(0.0, min(values)), max(0.0, max(values)) + span = (high - low) or 1.0 + zero_x = plot_x + plot_width * (0 - low) / span + + parts = [f''] + for n, (label, value) in enumerate(zip(labels, values)): + y = PADDING + n * (BAR_HEIGHT + BAR_GAP) + length = plot_width * abs(value) / span + negative = value < 0 + x = zero_x - length if negative else zero_x + parts.append(f"{html.escape(f'{label}: {_fmt(value, unit)}')}") + parts.append(_bar(x, y, length, BAR_HEIGHT, "neg" if negative else "pos", negative)) + parts.append("") + parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end")) + value_x = x - 6 if negative else x + length + 6 + parts.append(_text(value_x, y + BAR_HEIGHT * 0.72, _fmt(value, unit), "ink", + anchor="end" if negative else "start")) + return _frame(width, height, title, "".join(parts)) + + +def waterfall_chart(labels, deltas, total_label, total_value, title, unit="") -> str: + """Zielwert-Zerlegung: Zu- und Abschläge als Stufen, Gesamtwert als neutraler Balken ab Null.""" + if not labels: + return "" + width = 720 + plot_x = LABEL_WIDTH + PADDING + plot_width = width - plot_x - VALUE_WIDTH - PADDING + rows = len(labels) + 1 + height = PADDING * 2 + rows * (BAR_HEIGHT + BAR_GAP) + + running, levels = 0.0, [] + for delta in deltas: + levels.append((running, running + delta)) + running += delta + bounds = [v for pair in levels for v in pair] + [0.0, total_value] + low, high = min(bounds), max(bounds) + span = (high - low) or 1.0 + scale = lambda v: plot_x + plot_width * (v - low) / span + + parts = [f''] + for n, (label, delta, (start, end)) in enumerate(zip(labels, deltas, levels)): + y = PADDING + n * (BAR_HEIGHT + BAR_GAP) + x0, x1 = sorted((scale(start), scale(end))) + negative = delta < 0 + parts.append(f"{html.escape(f'{label}: {_fmt(delta, unit)}')}") + parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "neg" if negative else "pos", negative)) + parts.append("") + parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end")) + parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(delta, unit), "ink")) + if n + 1 < len(labels): # Verbindungslinie zur nächsten Stufe + next_y = y + BAR_HEIGHT + BAR_GAP + parts.append(f'') + + y = PADDING + len(labels) * (BAR_HEIGHT + BAR_GAP) + x0, x1 = sorted((scale(0), scale(total_value))) + parts.append(f"{html.escape(f'{total_label}: {_fmt(total_value, unit)}')}") + parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "total", total_value < 0)) + parts.append("") + parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, total_label, "ink", anchor="end", weight="600")) + parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(total_value, unit), "ink", weight="600")) + return _frame(width, height, title, "".join(parts)) + + +# --- Graphenzeichnung (Knoten und Bögen) --- + +NODE_RADIUS = 17 +ARC_BOW = 34 # Wölbung je Spannweite, damit parallele Bögen sich nicht überdecken +GRAPH_PADDING = 26 + + +def _arrow_marker(name: str, role: str) -> str: + return (f'') + + +def graph_chart(nodes, arcs, path=(), title="Graph", unit="") -> str: + """Knoten-Bogen-Diagramm mit hervorgehobenem Weg. + + nodes: Knotennamen in Reihenfolge, arcs: (von, nach, gewicht), path: Knotenfolge des Weges. + Knoten liegen auf einer Waagerechten, Bögen wölben sich darüber (Rückwärtsbögen darunter); + die Wölbung wächst mit der Spannweite, damit sich parallele Bögen nicht überdecken. + """ + if not nodes or not arcs or len(nodes) > 14: + return "" + index = {name: i for i, name in enumerate(nodes)} + if any(a[0] not in index or a[1] not in index for a in arcs): + return "" + + weg = {(a, b) for a, b in zip(path, path[1:])} + step = max(96, min(150, 820 // max(len(nodes) - 1, 1))) + breite = 2 * GRAPH_PADDING + step * (len(nodes) - 1) + 2 * NODE_RADIUS + spannen_oben = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] > index[a]] + spannen_unten = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] < index[a]] + oben = ARC_BOW * max(spannen_oben, default=0) / 2 + 30 + unten = ARC_BOW * max(spannen_unten, default=0) / 2 + 30 + mitte = GRAPH_PADDING + oben + hoehe = mitte + unten + GRAPH_PADDING + + def x(name): + return GRAPH_PADDING + NODE_RADIUS + step * index[name] + + teile = [f"{_arrow_marker('pfeil', 'grid')}{_arrow_marker('pfeil-weg', 'pos')}"] + for start, ziel, gewicht in arcs: + auf_weg = (start, ziel) in weg + rolle, marker = ("pos", "pfeil-weg") if auf_weg else ("grid", "pfeil") + x1, x2 = x(start), x(ziel) + rueckwaerts = index[ziel] < index[start] + spanne = abs(index[ziel] - index[start]) + bogen = ARC_BOW * spanne * (1 if rueckwaerts else -1) + # Kreisrand statt Mittelpunkt als Anfang/Ende, damit die Pfeilspitze sichtbar bleibt + richtung = 1 if x2 > x1 else -1 + x1 += richtung * NODE_RADIUS + x2 -= richtung * NODE_RADIUS + steuer_y = mitte + bogen + teile.append(f'') + teile.append(_text((x1 + x2) / 2, mitte + bogen / 2 + (20 if rueckwaerts else -10), _fmt(gewicht, unit), + role=rolle if auf_weg else "ink", anchor="middle", size=12, + weight="bold" if auf_weg else "normal")) + + for name in nodes: + auf_weg = name in set(path) + teile.append(f'') + teile.append(_text(x(name), mitte + 4, name, anchor="middle", size=13, + weight="bold" if auf_weg else "normal")) + return _frame(breite, hoehe, title, "".join(teile)) diff --git a/data_profile.py b/data_profile.py new file mode 100644 index 0000000..4c21f3a --- /dev/null +++ b/data_profile.py @@ -0,0 +1,89 @@ +"""Profil beigelegter Datendateien – Zeilen, Spalten, Typen, Spannweiten, Lücken. + +Bewusst **ohne LLM**: Das Fremdprojekt MM-Agent lässt eine Datenbeschreibung vom Modell schreiben +(`DataDescription.summary`). Gerechnet ist billiger, reproduzierbar und nicht halluzinierbar – und +das Profil ist damit selbst eine geprüfte Tatsache, auf die sich die späteren Stufen berufen dürfen. +""" +from pathlib import Path + +import pandas as pd + +from model_schema import ColumnProfile, DataFileProfile + +SAMPLE_ROWS = 3 # so viele Beispielwerte je Spalte in den Prompt +MAX_COLUMNS = 40 # breitere Tabellen werden abgeschnitten, damit der Prompt nicht platzt + + +def _read(path: Path) -> pd.DataFrame: + """CSV (mit Trennzeichen-Erkennung) oder Excel einlesen.""" + if path.suffix.lower() in (".xlsx", ".xls"): + return pd.read_excel(path) + # sep=None + engine="python" erkennt Komma, Semikolon und Tabulator selbst + return pd.read_csv(path, sep=None, engine="python") + + +def _as_numbers(spalte: pd.Series) -> pd.Series: + """Zahlen einer Spalte, auch bei deutscher Schreibweise ('1.234,56' als Text eingelesen).""" + zahlen = pd.to_numeric(spalte, errors="coerce") + vorhanden = spalte.notna().sum() + if vorhanden and zahlen.notna().sum() < vorhanden and pd.api.types.is_string_dtype(spalte): + deutsch = pd.to_numeric( + spalte.astype(str).str.replace(".", "", regex=False).str.replace(",", ".", regex=False), + errors="coerce") + if deutsch.notna().sum() > zahlen.notna().sum(): + return deutsch + return zahlen + + +def _column(name: str, spalte: pd.Series) -> ColumnProfile: + zahlen = _as_numbers(spalte) + # Eine Spalte gilt als Zahlenspalte, wenn die große Mehrheit der belegten Werte Zahlen sind + vorhanden = max(int(spalte.notna().sum()), 1) + numerisch = zahlen.notna().sum() >= 0.8 * vorhanden and not pd.api.types.is_bool_dtype(spalte) + beispiele = [str(v) for v in spalte.dropna().unique()[:SAMPLE_ROWS]] + return ColumnProfile( + name=str(name), + dtype="Zahl" if numerisch else "Text", + missing=int(spalte.isna().sum()), + minimum=float(zahlen.min()) if numerisch and zahlen.notna().any() else None, + maximum=float(zahlen.max()) if numerisch and zahlen.notna().any() else None, + mean=float(zahlen.mean()) if numerisch and zahlen.notna().any() else None, + samples=beispiele, + ) + + +def profile_file(path) -> DataFileProfile: + """Eine Datei einlesen und ihr Profil bestimmen; unlesbare Dateien melden den Fehler.""" + path = Path(path) + try: + tabelle = _read(path) + except Exception as exc: # kaputte Datei darf den Lauf nicht abbrechen + return DataFileProfile(name=path.name, rows=0, columns=[], error=f"{type(exc).__name__}: {exc}") + spalten = [_column(name, tabelle[name]) for name in list(tabelle.columns)[:MAX_COLUMNS]] + return DataFileProfile(name=path.name, rows=int(len(tabelle)), columns=spalten, + truncated_columns=max(0, len(tabelle.columns) - MAX_COLUMNS)) + + +def profile_files(paths) -> list: + return [profile_file(p) for p in paths or []] + + +def to_prompt(profiles) -> str: + """Das Profil als knapper Text für die Prompts – Spaltennamen sind das Wichtigste.""" + if not profiles: + return "" + teile = ["BEIGELEGTE DATEN (deterministisch ermittelt, nicht geschätzt):"] + for p in profiles: + if p.error: + teile.append(f"- {p.name}: NICHT LESBAR ({p.error})") + continue + teile.append(f"- {p.name}: {p.rows} Zeilen, {len(p.columns)} Spalten" + + (f" (weitere {p.truncated_columns} Spalten nicht gezeigt)" if p.truncated_columns else "")) + for s in p.columns: + spanne = (f", von {s.minimum:g} bis {s.maximum:g}, Mittel {s.mean:g}" + if s.minimum is not None else "") + luecken = f", {s.missing} fehlend" if s.missing else "" + teile.append(f" {s.name} ({s.dtype}{spanne}{luecken}): {', '.join(s.samples)}") + teile.append("Lies diese Dateien im Programm über ihren Dateinamen (sie liegen im Arbeitsverzeichnis). " + "Jede abgeleitete Kennzahl wird aus den Daten berechnet, nie geschätzt.") + return "\n".join(teile) diff --git a/llm.py b/llm.py new file mode 100644 index 0000000..5afc376 --- /dev/null +++ b/llm.py @@ -0,0 +1,162 @@ +"""LLM-Zugriff: Client je Anbieter, Wiederholung bei vorübergehenden Fehlern, Nutzungs-/Kostenerfassung. + +Anbieter wird am Modellnamen erkannt: +- mit "/" (z. B. "deepseek/deepseek-v4-flash-0731") -> OpenRouter, Key: OPENROUTER_API_KEY +- sonst (z. B. "gemini-2.5-flash") -> Google Gemini, Key: GEMINI_API_KEY +""" +import os +import sys +import threading +import time + +import instructor + +from model_schema import CallRecord, LLMUsage + +DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash") +# Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code +# (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model). +STEP_MODEL_ENV = { + "Extraktion": "LLM_MODEL_EXTRACTION", + "Prüfung": "LLM_MODEL_REVIEW", + "Korrektur": "LLM_MODEL_REVISION", + "Solver-Code": "LLM_MODEL_SOLVER", + "Solver-Reparatur": "LLM_MODEL_SOLVER", +} +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +# Ohne Vorgabe wählt OpenRouter den billigsten Anbieter – bei DeepSeek V4 teils nur ~20 Tokens/s, +# bei zehntausenden Reasoning-Tokens also Minuten pro Aufruf. Weiterhin preisorientiert, aber mit Mindestdurchsatz. +# Gemessen am 16.09.: Fireworks ~178 Tokens/s gegen Parasail ~60 Tokens/s beim selben Modell – Faktor 3. +# Es ist eine Präferenz, kein harter Filter: gibt es keinen so schnellen Anbieter mit JSON-Schema, +# leitet OpenRouter trotzdem weiter (geprüft mit qwen3-coder -> SiliconFlow, ~25 Tokens/s). +OPENROUTER_MIN_THROUGHPUT = int(os.environ.get("OPENROUTER_MIN_THROUGHPUT", "75")) # Tokens/s (p50) +LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "420")) # Sekunden je Aufruf +# Ohne Vorgabe schneiden manche OpenRouter-Anbieter die Antwort ab (beobachtet: abgebrochene +# Modellspezifikation in einer Korrekturrunde). Großzügig setzen; bezahlt werden nur erzeugte Tokens. +LLM_MAX_TOKENS = int(os.environ.get("LLM_MAX_TOKENS", "32000")) +# Denkaufwand (OpenRouter): "low" | "medium" | "high"; leer = Modellvorgabe. +# Reasoning-Tokens machen den Großteil der Laufzeit aus (gemessen: 45.000 von 73.000 Output-Tokens). +REASONING_EFFORT = os.environ.get("LLM_REASONING_EFFORT", "").strip().lower() + +# Wartezeiten in Sekunden bei vorübergehenden Fehlern (Überlastung, Minutenlimit, Verbindung) +RETRY_WAITS = (10, 30, 60) + +# Fehler, bei denen Wiederholen nichts bringt, sondern nur Kontingent/Geld kostet +FATAL_MARKERS = ("RESOURCE_EXHAUSTED", "Error code: 401", "Error code: 402", "Error code: 403") +TRANSIENT_MARKERS = ( + "UNAVAILABLE", "Error code: 429", "Error code: 500", "Error code: 502", "Error code: 503", + "Error code: 504", "Connection error", "timed out", "Request timed out", + "incomplete due to a max_tokens", "IncompleteOutputException", # anderer Anbieter kann mehr liefern +) + + +def is_fatal_error(message: str) -> bool: + """Kontingent erschöpft, Guthaben leer oder Key ungültig.""" + return any(marker in message for marker in FATAL_MARKERS) + + +def is_transient_error(message: str) -> bool: + return not is_fatal_error(message) and any(marker in message for marker in TRANSIENT_MARKERS) + + +def provider_for(model: str) -> str: + return "openrouter" if "/" in model else "gemini" + + +def model_for_step(step: str, default: str) -> str: + """Modell dieser Pipeline-Stufe: Umgebungsvariable aus STEP_MODEL_ENV, sonst das Session-Modell.""" + return os.environ.get(STEP_MODEL_ENV.get(step, ""), "").strip() or default + + +def _make_client(provider: str): + if provider == "openrouter": + from openai import OpenAI + + return instructor.from_openai( + # max_retries=0: Wiederholungen übernimmt LLMSession.create (mit Fehlerklassifizierung) + OpenAI(base_url=OPENROUTER_BASE_URL, api_key=os.environ["OPENROUTER_API_KEY"], + timeout=LLM_TIMEOUT, max_retries=0), + mode=instructor.Mode.JSON_SCHEMA, + ) + from google import genai + + return instructor.from_genai( + client=genai.Client(), # liest GEMINI_API_KEY bzw. GOOGLE_API_KEY + mode=instructor.Mode.GENAI_STRUCTURED_OUTPUTS, + ) + + +class LLMSession: + """Ein Modell + Client; summiert Tokens und Kosten aller Aufrufe (thread-sicher).""" + + def __init__(self, model: str = DEFAULT_MODEL): + self.model = model + self.provider = provider_for(model) + self.step_models = {step: model_for_step(step, model) for step in STEP_MODEL_ENV} + # Clients hier anlegen, damit create() thread-sicher bleibt; ohne Stufen-Modell ist es genau einer + self._clients = {provider_for(m): _make_client(provider_for(m)) for m in {model, *self.step_models.values()}} + self.client = self._clients[self.provider] + self.usage = LLMUsage(model=model, provider=self.provider) + self._lock = threading.Lock() + + def overrides(self) -> dict: + """Stufen mit abweichendem Modell – für die Fortschrittsausgabe.""" + return {step: m for step, m in self.step_models.items() if m != self.model} + + def create(self, response_model, messages, step: str = "unbenannt"): + model = self.step_models.get(step, self.model) + provider = provider_for(model) + kwargs = {"model": model, "response_model": response_model, "messages": messages} + if provider == "openrouter": + kwargs["max_tokens"] = LLM_MAX_TOKENS + # nur Anbieter wählen, die strukturierte Ausgabe (response_format) wirklich unterstützen + kwargs["extra_body"] = {"provider": { + "require_parameters": True, + "preferred_min_throughput": {"p50": OPENROUTER_MIN_THROUGHPUT}, + }} + if REASONING_EFFORT: + kwargs["extra_body"]["reasoning"] = {"effort": REASONING_EFFORT} + + for wait in (*RETRY_WAITS, None): + started = time.monotonic() + try: + result, completion = self._clients[provider].chat.completions.create_with_completion(**kwargs) + self._record(completion, step, time.monotonic() - started, model) + return result + except Exception as exc: + if wait is None or not is_transient_error(str(exc)): + raise + print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr) + time.sleep(wait) + + def _record(self, completion, step: str = "unbenannt", seconds: float = 0.0, model: str = None) -> None: + before = (self.usage.completion_tokens, self.usage.reasoning_tokens) + with self._lock: + self.usage.calls += 1 + provider = getattr(completion, "provider", None) # OpenRouter: tatsächlich antwortender Anbieter + if provider: + self.usage.providers[provider] = self.usage.providers.get(provider, 0) + 1 + usage = getattr(completion, "usage", None) + if usage is not None: # OpenAI-Format (OpenRouter) + self.usage.prompt_tokens += usage.prompt_tokens or 0 + self.usage.completion_tokens += usage.completion_tokens or 0 + details = getattr(usage, "completion_tokens_details", None) + self.usage.reasoning_tokens += getattr(details, "reasoning_tokens", 0) or 0 + cost = getattr(usage, "cost", None) + if cost is not None: + self.usage.cost_usd = (self.usage.cost_usd or 0.0) + cost + self._log_call(step, seconds, before, getattr(completion, "provider", None), model) + return + meta = getattr(completion, "usage_metadata", None) + if meta is not None: # Gemini-Format, keine Kostenangabe + self.usage.prompt_tokens += meta.prompt_token_count or 0 + self.usage.completion_tokens += meta.candidates_token_count or 0 + self.usage.reasoning_tokens += meta.thoughts_token_count or 0 + self._log_call(step, seconds, before, getattr(completion, "provider", None), model) + + def _log_call(self, step, seconds, before, provider, model=None) -> None: + self.usage.call_records.append(CallRecord( + step=step, seconds=round(seconds, 1), provider=provider, model=model, + completion_tokens=self.usage.completion_tokens - before[0], + reasoning_tokens=self.usage.reasoning_tokens - before[1], + )) diff --git a/model_checks.py b/model_checks.py new file mode 100644 index 0000000..a5f859f --- /dev/null +++ b/model_checks.py @@ -0,0 +1,503 @@ +"""Deterministische Plausibilitätsprüfungen einer Modellspezifikation (ohne LLM). + +error -> löst in run_pipeline eine Korrekturrunde aus +warning -> wird nur berichtet (Heuristiken mit möglichen Fehlalarmen) +""" +import re +from typing import Iterable, List, Set + +from model_schema import ( + CheckIssue, + CheckSeverity, + OptimizationModelSpecification, + ProblemClass, + TargetStack, + VariableType, +) + +# Zulässige Ziel-Stacks je Problemklasse (muss zu den Routing-Regeln im Prompt passen) +ALLOWED_STACKS = { + ProblemClass.LP: {TargetStack.SCIPY, TargetStack.HIGHS}, + ProblemClass.MILP: {TargetStack.HIGHS}, + ProblemClass.CP: {TargetStack.OR_TOOLS_CPSAT}, + ProblemClass.QP: {TargetStack.CVXPY}, + ProblemClass.SOCP: {TargetStack.CVXPY}, + ProblemClass.MIQP: {TargetStack.SCIP}, + ProblemClass.NLP: {TargetStack.IPOPT, TargetStack.SCIP}, + # Kürzeste Wege: spezialisierter Graphalgorithmus, alternativ als Flussmodell (MILP) + ProblemClass.SHORTEST_PATH: {TargetStack.NETWORKX, TargetStack.HIGHS}, +} + +LINEAR_CLASSES = {ProblemClass.LP, ProblemClass.MILP, ProblemClass.CP, ProblemClass.SHORTEST_PATH} + +# Quadrat, Transponierte (w^T \Sigma w) oder Norm +NONLINEAR_PATTERN = re.compile(r"\^\s*\{?\s*(2|T)\b|\\\||\\lVert|\|\|") +# Grenzen von Summen/Produkten/Integralen: \sum_{i=0}^{2} ist keine Potenz, sondern ein Laufbereich +LIMIT_PATTERN = re.compile(r"\\(sum|prod|int|iint|oint|bigcup|bigcap|max|min|argmax|argmin)" + r"(\s*_\s*(\{[^{}]*\}|\\?\w))?(\s*\^\s*(\{[^{}]*\}|\\?\w))?") + +GREEK = { + "alpha", "beta", "gamma", "delta", "epsilon", "varepsilon", "zeta", "eta", "theta", "vartheta", + "iota", "kappa", "lambda", "mu", "nu", "xi", "pi", "rho", "sigma", "tau", "upsilon", "phi", + "varphi", "chi", "psi", "omega", "Gamma", "Delta", "Theta", "Lambda", "Xi", "Pi", "Sigma", + "Upsilon", "Phi", "Psi", "Omega", +} + +# Wörter, die in Formeln vorkommen dürfen, ohne deklariert zu sein +IGNORED_TOKENS = {"sum", "min", "max", "abs", "log", "exp", "sqrt", "for", "all", "if", "then", "and", "or", "not"} + +# Einheiten je Parameter (ParameterDefinition.unit) – nur zwei Klassen werden gegeneinander geprüft: +# Geldbeträge und dimensionslose Größen. Beobachtet: Risiko als \sum w_i^2 \sigma_i^2 (dimensionslos) +# neben Kosten in Euro – das Risiko war um den Faktor Kapital zu klein und damit wirkungslos. +CURRENCY_UNIT = re.compile(r"€|\bEUR\b|Euro|\$|\bUSD\b|Geldeinheit|Kosteneinheit|\bKE\b|\bGE\b", re.IGNORECASE) +RELATIVE_UNIT = re.compile(r"^\s*(-|–|1)?\s*$|%|Prozent|Anteil|Quote|Faktor|Verhältnis|dimensionslos|" + r"dimensionless|ohne Einheit|einheitenlos", re.IGNORECASE) + + +# Fachbegriffe, die ein Leser ohne OR-Ausbildung nicht mitbringt. Tauchen sie dort auf, wo er sie sieht +# (Restriktionsnamen, Tabellenspalten, Antwortsatz), müssen sie in executive_summary erklärt sein. +# Je Begriff mehrere Wortstämme, damit Beugungen ("Bogen"/"Bögen") als derselbe Begriff zählen. +JARGON = { + "Bogen": ("bogen", "bög"), + "Knoten": ("knoten",), + "Kante": ("kante",), + "Fluss": ("fluss", "flüss"), + "Quelle/Senke": ("senke",), + "Relaxation": ("relaxation", "relaxier"), + "Schlupf": ("schlupf", "slack"), + "Kardinalität": ("kardinal",), + "Epigraph": ("epigraph",), + "Schattenpreis": ("schattenpreis", "dualwert", "dualvariable"), + "Konvexität": ("konvex",), + "Markierung": ("marke", "markier"), + "Inzidenz": ("inzidenz",), + "Big-M": ("big-m", "big m"), + "Subtour": ("subtour",), + "Lagrange": ("lagrange",), +} + + +def unexplained_jargon(sichtbar: str, erklaerung: str) -> List[str]: + """Fachbegriffe, die im sichtbaren Text vorkommen, in der Erklärung aber nicht.""" + sichtbar, erklaerung = sichtbar.lower(), erklaerung.lower() + return sorted(name for name, staemme in JARGON.items() + if any(s in sichtbar for s in staemme) and not any(s in erklaerung for s in staemme)) + + +def check_jargon(spec: OptimizationModelSpecification) -> List[CheckIssue]: + """Fachbegriffe in Namen des Modells, die die Zusammenfassung nicht einführt (siehe Block ERKLÄREN).""" + sichtbar = " ".join([*(c.identifier for c in spec.constraints), *(v.name for v in spec.variables), + *spec.indices]) + offen = unexplained_jargon(sichtbar, spec.executive_summary) + if not offen: + return [] + return [CheckIssue( + severity=CheckSeverity.WARNING, code="UNEXPLAINED_JARGON", + message=f"Fachbegriffe im Modell, die executive_summary nicht erklärt: {', '.join(offen)}. " + f"Der Leser trifft sie in Restriktions- und Variablennamen, ohne zu wissen, was sie bedeuten.", + )] + + +# --- Verstehensstufe: Teilfragen und Zahlen des Aufgabentexts (ohne LLM) --- + +# Zahl im Fließtext: 1.234,56 / 0,05 / 12 / 3.8 – ohne Zahlen, die Teil eines Wortes sind (A02, x1) +NUMBER_PATTERN = re.compile(r"(?]*\**\s*(?:\(?([a-h])\)|(?:Teil|Aufgabe|Frage)\s+(\d+)|([1-9])\.)\s*[:.)]?\s+\S", + re.MULTILINE) + + +def normalize_number(roh: str) -> str: + """'1.234,56' -> '1234.56', '3.8' -> '3.8', '1.234' -> '1234'. + + Der Punkt ist mehrdeutig: Tausendertrenner (deutsch) oder Dezimalpunkt (englisch, Nummerierung). + Entschieden wird am Komma (dann ist der Punkt Tausendertrenner) und sonst an der Gruppenlänge: + genau drei Ziffern hinter dem letzten Punkt heißt Tausender, alles andere Dezimalpunkt. + """ + if "," in roh: + roh = roh.replace(".", "").replace(",", ".") + elif "." in roh: + teile = roh.split(".") + if all(len(t) == 3 for t in teile[1:]) and len(teile[0]) <= 3: + roh = "".join(teile) # 1.234 bzw. 12.345.678 + wert = float(roh) + return str(int(wert)) if wert.is_integer() else f"{wert:g}" # große Zahlen exakt, nicht 1.23457e+06 + + +def numbers_in(text: str) -> Set[str]: + """Zahlen eines Textes in einheitlicher Schreibweise.""" + gefunden = set() + for roh in NUMBER_PATTERN.findall(text or ""): + try: + gefunden.add(normalize_number(roh)) + except ValueError: + continue + return gefunden + + +def question_ids_in(text: str) -> Set[str]: + """Kennungen der Teilfragen, die im Aufgabentext erkennbar sind.""" + return {(a or b or c).lower() for a, b, c in QUESTION_PATTERN.findall(text or "")} + + +def _covers(genannt: str, erkannt: str) -> bool: + """'2a', '2.1', 'a1' decken die Teilfrage '2' bzw. 'a' ab – '21' deckt '2' nicht ab. + + Beobachtet an „Portfolio Nr. 2": Teil 2 wurde in 2a und 2b zerlegt, gemeldet wurde „es fehlt: 2". + """ + if genannt == erkannt: + return True + if not genannt.startswith(erkannt): + return False + naechstes = genannt[len(erkannt)] + return naechstes.isdigit() != erkannt[-1].isdigit() or not naechstes.isalnum() + + +def check_understanding(understanding, task: str) -> List[CheckIssue]: + """Prüft das Verständnis gegen den Aufgabentext: sind alle Zahlen und alle Teilfragen erfasst? + + Zahlen gelten als erfasst, wenn sie irgendwo im Verständnis auftauchen (Wert, Zitat, Bedeutung) + oder ausdrücklich als irrelevant erklärt sind – ein Zitat der Tabellenzeile deckt so eine ganze + Datenzeile ab, ohne dass jede Zahl einzeln aufgeführt werden muss. + """ + issues = [] + erfasst = numbers_in(understanding.model_dump_json()) + fehlend = sorted(numbers_in(task) - erfasst, key=float) + if fehlend: + issues.append(CheckIssue( + severity=CheckSeverity.WARNING, code="UNCAPTURED_NUMBERS", + message=f"Zahlen aus dem Aufgabentext, die weder als Fakt noch als irrelevant erfasst sind: " + f"{', '.join(fehlend[:12])}{' …' if len(fehlend) > 12 else ''}. " + f"Sie fehlen damit in der Spezifikation und können nicht geprüft werden.", + )) + + erkannt = question_ids_in(task) + genannt = {q.id.lower().removeprefix("teil ").removeprefix("aufgabe ").strip(" ().") for q in understanding.sub_questions} + offen = sorted(e for e in erkannt if not any(_covers(g, e) for g in genannt)) + if offen: + issues.append(CheckIssue( + severity=CheckSeverity.WARNING, code="MISSING_SUB_QUESTIONS", + message=f"Der Aufgabentext enthält die Teilfragen {', '.join(sorted(erkannt))}, " + f"das Verständnis nennt {', '.join(sorted(genannt)) or 'keine'} – es fehlen: {', '.join(offen)}.", + )) + if not understanding.sub_questions: + issues.append(CheckIssue( + severity=CheckSeverity.WARNING, code="NO_SUB_QUESTIONS", + message="Keine einzige Teilfrage erfasst – auch eine Aufgabe mit nur einer Frage braucht einen Eintrag.", + )) + return issues + + +def _strip_scripts(text: str) -> str: + """Entfernt Indizes/Exponenten: x_{i,j} -> x, C^{prod}_{p} -> C, x_1 -> x.""" + text = text.replace(r"\_", "_") + # Schriftbefehle samt Inhalt: \mathbb{Z} ist die Menge der ganzen Zahlen, kein Symbol namens Z + text = re.sub(r"\\(text|mathrm|operatorname|mbox|mathbb|mathcal|mathbf|mathsf|boldsymbol)\s*\{[^{}]*\}", " ", text) + previous = None + while previous != text: # verschachtelte Klammern von innen nach außen + previous = text + text = re.sub(r"[_^]\s*\{[^{}]*\}", "", text) + # einzelnes Zeichen als Index, aber nicht innerhalb von Namen wie min_staff + text = re.sub(r"[_^]\s*\\?[A-Za-z0-9](?![A-Za-z0-9])", "", text) + return text + + +def _symbols(text: str) -> Set[str]: + """Basis-Symbole einer LaTeX-Formel (Greek als Symbol, andere Befehle ignoriert).""" + text = _strip_scripts(text) + tokens = set() + for match in re.finditer(r"\\([A-Za-z]+)|([A-Za-z][A-Za-z0-9_]*)", text): + command, name = match.groups() + if command: + if command in GREEK: + tokens.add("\\" + command) + else: + tokens.add(name.rstrip("_")) + return tokens + + +def _declared_symbols(spec: OptimizationModelSpecification) -> Set[str]: + declared: Set[str] = set() + for text in _texts_declaring(spec): + declared |= _symbols(text) + return declared + + +def _texts_declaring(spec: OptimizationModelSpecification) -> Iterable[str]: + yield from (v.name for v in spec.variables) + yield from (p.symbol for p in spec.parameters) + for index in spec.indices: + # Formel-Begrenzer entfernen ("Tage: \(d \in D\)"), dann erläuternde Klammer am Ende + # ("i in I (Mitarbeiter)"); früher wurde am ersten "(" geschnitten – das traf auch "\(" + text = re.sub(r"\\[()\[\]]|\$", " ", index) + yield re.sub(r"\s\([^()]*\)\s*$", "", text) + + +def _objective_without_label(formula: str) -> str: + """'Z = 12 x': der Name links vom Gleichheitszeichen ist eine Beschriftung, kein Symbol des Modells. + + Nur für die Zielfunktion – bei Restriktionen ist die linke Seite Teil des Modells. + """ + # Nur ein Gleichheitszeichen auf oberster Klammerebene zählt: \sum_{i=0}^{2} enthält ebenfalls eines, + # und daran zerschnitten blieb von der Formel „0}^{2}\sum…“ übrig (führte zu NONLINEAR_TERM). + tiefe = 0 + for pos, zeichen in enumerate(formula): + if zeichen in "{([": + tiefe += 1 + elif zeichen in "})]": + tiefe -= 1 + elif zeichen == "=" and tiefe == 0: + left, right = formula[:pos], formula[pos + 1:] + return right if len(_symbols(left) - IGNORED_TOKENS) <= 1 else formula + return formula + + +def _formulas(spec: OptimizationModelSpecification) -> Iterable[tuple]: + yield "Zielfunktion", _objective_without_label(spec.objective.latex_formula) + for c in spec.constraints: + yield c.identifier, c.latex_formula + + +def check_routing(spec: OptimizationModelSpecification) -> List[CheckIssue]: + issues = [] + var_types = {v.var_type for v in spec.variables} + has_discrete = bool(var_types & {VariableType.INTEGER, VariableType.BINARY}) + has_continuous = VariableType.CONTINUOUS in var_types + + allowed = ALLOWED_STACKS[spec.problem_class] + if spec.target_stack not in allowed: + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="STACK_MISMATCH", + message=f"Problemklasse '{spec.problem_class.value}' passt nicht zu Stack '{spec.target_stack.value}' " + f"(erlaubt: {', '.join(s.value for s in allowed)}).", + )) + if has_discrete and spec.problem_class == ProblemClass.LP: + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="LP_WITH_DISCRETE_VARS", + message="Problemklasse LP, aber ganzzahlige/binäre Variablen vorhanden (→ MILP oder CP).", + )) + if has_discrete and spec.target_stack == TargetStack.CVXPY: + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="CVXPY_WITH_DISCRETE_VARS", + message="CVXPY ist hier nur für kontinuierliche QP/SOCP vorgesehen; quadratisch + ganzzahlig/binär " + "ist MIQP (Stack SCIP).", + )) + if spec.problem_class == ProblemClass.MIQP and not has_discrete: + issues.append(CheckIssue( + severity=CheckSeverity.WARNING, code="MIQP_WITHOUT_DISCRETE_VARS", + message="Problemklasse MIQP, aber keine ganzzahligen/binären Variablen (→ eigentlich QP/SOCP mit CVXPY).", + )) + if has_discrete and spec.target_stack == TargetStack.IPOPT: + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="IPOPT_WITH_DISCRETE_VARS", + message="Ipopt löst nur stetige Probleme; mit ganzzahligen/binären Variablen ist SCIP zuständig.", + )) + if has_discrete and spec.target_stack == TargetStack.SCIPY: + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="SCIPY_WITH_DISCRETE_VARS", + message="scipy.optimize.linprog unterstützt keine ganzzahligen/binären Variablen.", + )) + if has_continuous and spec.target_stack == TargetStack.OR_TOOLS_CPSAT: + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="CPSAT_WITH_CONTINUOUS_VARS", + message="CP-SAT unterstützt nur ganzzahlige/binäre Variablen, es gibt aber kontinuierliche.", + )) + if spec.problem_class == ProblemClass.MILP and not has_discrete: + issues.append(CheckIssue( + severity=CheckSeverity.WARNING, code="MILP_WITHOUT_DISCRETE_VARS", + message="Problemklasse MILP, aber keine ganzzahligen/binären Variablen (→ eigentlich LP).", + )) + return issues + + +def check_nonlinearity(spec: OptimizationModelSpecification) -> List[CheckIssue]: + if spec.problem_class not in LINEAR_CLASSES: + return [] + return [ + CheckIssue( + severity=CheckSeverity.WARNING, code="NONLINEAR_TERM", + message=f"'{name}' enthält einen möglicherweise nichtlinearen Term, " + f"Problemklasse ist aber '{spec.problem_class.value}': {formula}", + ) + for name, formula in _formulas(spec) + if NONLINEAR_PATTERN.search(LIMIT_PATTERN.sub(" ", formula)) + ] + + +def _is_implicit_product(symbol: str, declared: Set[str]) -> bool: + """'cx' in \\sin(cx) ist das Produkt c·x, kein eigener Name – wenn c und x deklariert sind.""" + return len(symbol) > 1 and all(zeichen in declared for zeichen in symbol) + + +def check_undeclared_symbols(spec: OptimizationModelSpecification) -> List[CheckIssue]: + declared = _declared_symbols(spec) + undeclared: dict = {} + for name, formula in _formulas(spec): + for symbol in _symbols(formula) - declared - IGNORED_TOKENS: + if _is_implicit_product(symbol, declared): + continue + undeclared.setdefault(symbol, []).append(name) + return [ + CheckIssue( + severity=CheckSeverity.WARNING, code="UNDECLARED_SYMBOL", + message=f"Symbol '{symbol}' wird in {', '.join(places)} verwendet, ist aber weder Variable, Parameter noch Index.", + ) + for symbol, places in sorted(undeclared.items()) + ] + + +def _unit_class(unit: str) -> str: + """'currency', 'relative' oder 'unbekannt'; nur die ersten beiden werden gegeneinander geprüft.""" + text = (unit or "").strip() + if CURRENCY_UNIT.search(text): + return "currency" + if RELATIVE_UNIT.search(text): + return "relative" + return "unbekannt" + + +def _additive_terms(formula: str) -> List[str]: + """Summanden einer Formel: Trennung an + und - auf oberster Klammerebene. + + Indizes stehen in Klammern (\\sum_{i \\in I}) und werden dadurch nicht zerschnitten. + """ + body = re.sub(r"\\operatorname\*?\s*\{[^{}]*\}|\\(?:min|max|left|right|quad|qquad)\b|\\[,;!]", " ", formula) + if "=" in body: # "\min z = \sum ..." -> nur die rechte Seite ist die Zielfunktion + body = body.rsplit("=", 1)[1] + terms, current, depth = [], "", 0 + for char in body: + if char in "{([": + depth += 1 + elif char in "})]": + depth -= 1 + if depth == 0 and char in "+-" and current.strip(): + terms.append(current) + current = "" + continue + current += char + return [t for t in (*terms, current) if t.strip()] + + +def _term_unit_class(term: str, parameter_units: dict) -> str: + """Einheit eines Summanden: ein Geldbetrag mal dimensionslosem Faktor bleibt ein Geldbetrag.""" + classes = {c for symbol in _symbols(term) for c in parameter_units.get(symbol, ())} + if "currency" in classes: + return "currency" + if classes == {"relative"}: + return "relative" + return "unbekannt" + + +def check_objective_units(spec: OptimizationModelSpecification) -> List[CheckIssue]: + """Mischt die Zielfunktion Geldbeträge mit dimensionslosen Größen? (aus ParameterDefinition.unit)""" + parameter_units: dict = {} + for parameter in spec.parameters: + for symbol in _symbols(parameter.symbol): + parameter_units.setdefault(symbol, set()).add(_unit_class(parameter.unit)) + + by_class: dict = {} + for term in _additive_terms(spec.objective.latex_formula): + unit_class = _term_unit_class(term, parameter_units) + if unit_class != "unbekannt": + by_class.setdefault(unit_class, []).append(term.strip()) + + if "currency" not in by_class or "relative" not in by_class: + return [] + return [CheckIssue( + severity=CheckSeverity.ERROR, code="MIXED_OBJECTIVE_UNITS", + message=f"Die Zielfunktion mischt Geldbeträge ({'; '.join(by_class['currency'])}) mit dimensionslosen " + f"Termen ({'; '.join(by_class['relative'])}). Der dimensionslose Term ist gegenüber den " + f"Geldbeträgen praktisch wirkungslos – mit dem Kapital bzw. Mengengerüst multiplizieren.", + )] + + +# Nennt der Aufgabentext ein Verfahren beim Namen, ist es Teil der Aufgabenstellung – ein gleichwertiges +# Modell erfüllt sie dann nicht (beobachtet: „Lösen Sie mit dem Dijkstra-Algorithmus“ wurde als +# MILP-Flussmodell gelöst, mathematisch richtig, als Aufgabe aber verfehlt). +REQUESTED_METHOD = { + "dijkstra": {TargetStack.NETWORKX}, + "bellman-ford": {TargetStack.NETWORKX, TargetStack.HIGHS}, + "kürzeste-wege-problem": {TargetStack.NETWORKX, TargetStack.HIGHS}, + "simplex": {TargetStack.SCIPY, TargetStack.HIGHS}, + "branch-and-bound": {TargetStack.HIGHS, TargetStack.SCIP}, + "branch-and-cut": {TargetStack.HIGHS, TargetStack.SCIP}, + "constraint programming": {TargetStack.OR_TOOLS_CPSAT}, +} + + +def check_requested_method(spec: OptimizationModelSpecification, task: str) -> List[CheckIssue]: + """Ein im Aufgabentext ausdrücklich verlangtes Verfahren muss auch verwendet werden.""" + text = (task or "").lower() + issues = [] + for name, stacks in REQUESTED_METHOD.items(): + if name in text and spec.target_stack not in stacks: + issues.append(CheckIssue( + severity=CheckSeverity.ERROR, code="REQUESTED_METHOD_IGNORED", + message=f"Die Aufgabe verlangt ausdrücklich '{name}', das Modell nutzt aber " + f"'{spec.target_stack.value}' (passend wäre: {', '.join(sorted(s.value for s in stacks))}). " + f"Ein gleichwertiges Modell erfüllt die Aufgabenstellung nicht.", + )) + return issues + + +def check_data_transfer(spec: OptimizationModelSpecification, understanding) -> List[CheckIssue]: + """Sind die erfassten Zahlen auch im Modell angekommen? + + Beobachtet: Die Verstehensstufe erfasste die vollständige Kostenmatrix, die Spezifikation führte + `extracted_values: None` – die Werte betraten die Pipeline erst im Solver-Code, also hinter allen + Prüfungen. Ohne Werte in der Spezifikation kann nichts Numerisches geprüft werden. + """ + if understanding is None or not understanding.facts: + return [] + # Nur die erklärten Werte, nicht die Zitate: eine zitierte Tabellenzeile enthält auch + # Zeilenbeschriftungen, die keine Parameterwerte sind. + erfasst = numbers_in(" ".join(f.value for f in understanding.facts)) + uebernommen = numbers_in(" ".join(p.extracted_values or "" for p in spec.parameters)) + if not erfasst or (erfasst & uebernommen): + return [] # bewusst tolerant: gemeldet wird nur die Spezifikation ganz ohne Zahlen + return [CheckIssue( + severity=CheckSeverity.WARNING, code="UNUSED_FACTS", + message=f"Die Verstehensstufe hat {len(erfasst)} Zahlenwerte erfasst " + f"({', '.join(sorted(erfasst, key=float)[:8])}), in extracted_values steht keiner davon. " + f"Ohne Werte ist die Spezifikation nicht nachrechenbar – die Zahlen erreichen die Pipeline " + f"sonst erst im Solver-Code, hinter allen Prüfungen.", + )] + + +def check_missing_data(spec: OptimizationModelSpecification) -> List[CheckIssue]: + missing = [p.symbol for p in spec.parameters if not (p.extracted_values or "").strip()] + if not missing: + return [] + return [CheckIssue( + severity=CheckSeverity.WARNING, code="MISSING_DATA", + message=f"Keine Werte im Aufgabentext für: {', '.join(missing)}. Lösung nur mit angenommenen Daten möglich.", + )] + + +def check_structure(spec: OptimizationModelSpecification) -> List[CheckIssue]: + issues = [] + if not spec.variables: + issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="NO_VARIABLES", message="Keine Entscheidungsvariablen definiert.")) + if not spec.constraints: + issues.append(CheckIssue(severity=CheckSeverity.ERROR, code="NO_CONSTRAINTS", message="Keine Restriktionen definiert.")) + return issues + + +def check_spec(spec: OptimizationModelSpecification, task: str = "", understanding=None) -> List[CheckIssue]: + """Alle deterministischen Prüfungen; `task` und `understanding` nur für Prüfungen, die sie brauchen.""" + return ( + check_structure(spec) + + check_routing(spec) + + check_nonlinearity(spec) + + check_undeclared_symbols(spec) + + check_objective_units(spec) + + check_jargon(spec) + + check_requested_method(spec, task) + + check_data_transfer(spec, understanding) + + check_missing_data(spec) + ) + + +def has_errors(issues: List[CheckIssue]) -> bool: + return any(i.severity == CheckSeverity.ERROR for i in issues) diff --git a/model_schema.py b/model_schema.py new file mode 100644 index 0000000..6c7befd --- /dev/null +++ b/model_schema.py @@ -0,0 +1,439 @@ +from enum import Enum +from typing import Dict, List, Literal, Optional, Union +from pydantic import BaseModel, Field + + +class ProblemClass(str, Enum): + LP = "Linear Programming (LP)" + MILP = "Mixed-Integer Linear Programming (MILP)" + CP = "Constraint Programming (CP)" + QP = "Quadratic Programming (QP)" + SOCP = "Second-Order Cone Programming (SOCP)" + MIQP = "Mixed-Integer Quadratic Programming (MIQP)" + NLP = "Nonlinear Programming (NLP)" + SHORTEST_PATH = "Kürzeste Wege im Graphen (Shortest Path)" + + +class TargetStack(str, Enum): + HIGHS = "HiGHS" + OR_TOOLS_CPSAT = "Google OR-Tools (CP-SAT)" + CVXPY = "CVXPY" + SCIPY = "SciPy (scipy.optimize.linprog)" + SCIP = "SCIP (pyscipopt)" + IPOPT = "Ipopt (cyipopt)" + NETWORKX = "NetworkX (Dijkstra)" + + +class ObjectiveSense(str, Enum): + MINIMIZE = "minimize" + MAXIMIZE = "maximize" + + +class VariableType(str, Enum): + CONTINUOUS = "continuous" + INTEGER = "integer" + BINARY = "binary" + + +class VariableDefinition(BaseModel): + name: str = Field(description="Mathematischer Variablenname, z. B. x_{i,j}") + var_type: VariableType + domain: str = Field(description="Definitionsbereich, z. B. >= 0, [0, 1] oder {0, 1}") + description: str = Field(description="Semantische Bedeutung für Entscheider") + + +class ParameterDefinition(BaseModel): + symbol: str = Field(description="Mathematisches Symbol, z. B. c_j oder \\Sigma") + dimension: str = Field(description="Dimension, z. B. Skalar, Vektor (n,), Matrix (n x n)") + unit: str = Field( + description="Physikalische Einheit des Werts, z. B. '€', '€/t', 'h', 'Stück', '%' oder 'dimensionslos'. " + "Wird maschinell geprüft: Terme der Zielfunktion dürfen Geldbeträge nicht mit " + "dimensionslosen Größen mischen" + ) + description: str = Field(description="Bedeutung (z. B. Kosten pro Tonne, Renditen)") + extracted_values: Optional[str] = Field( + default=None, description="Explizite Werte aus der Aufgabe, falls vorhanden" + ) + + +class ConstraintDefinition(BaseModel): + identifier: str = Field(description="Kurzname, z. B. 'Kapazitaetsgrenze_Lager'") + latex_formula: str = Field(description="Formel in LaTeX-Syntax, z. B. '\\sum_{j} a_j x_j \\le C'") + description: str = Field(description="Erklärung der Restriktion in Geschäftssprache") + is_hard_constraint: bool = Field(default=True) + + +class AssumptionDefinition(BaseModel): + unclear_point: str = Field(description="Unklare oder fehlende Stelle in der Aufgabenstellung") + assumption: str = Field(description="Getroffene Annahme, auf der das Modell beruht") + alternative: str = Field( + description="Die zweite plausible Lesart derselben Stelle, ausformuliert, und der Grund, warum das Modell " + "ihr nicht folgt. Nur wenn die Stelle wirklich eindeutig ist: 'keine zweite Lesart'" + ) + impact: str = Field(description="Auswirkung der Annahme auf Variablen, Parameter oder Restriktionen") + + +class ColumnProfile(BaseModel): + """Eine Spalte einer beigelegten Datei – gerechnet, nicht geschätzt.""" + name: str + dtype: str + missing: int = 0 + minimum: Optional[float] = None + maximum: Optional[float] = None + mean: Optional[float] = None + samples: List[str] = Field(default_factory=list) + + +class DataFileProfile(BaseModel): + """Profil einer beigelegten Datendatei (siehe data_profile.py).""" + name: str + rows: int + columns: List[ColumnProfile] = Field(default_factory=list) + truncated_columns: int = 0 + error: Optional[str] = None + + +class SubQuestion(BaseModel): + """Eine Teilfrage der Aufgabe. Jede muss am Ende beantwortet werden.""" + id: str = Field(description="Kennung wie im Text: 'a', 'b', 'Teil 2', '1'") + quote: str = Field(description="Die Frage wörtlich aus dem Aufgabentext") + kind: Literal["formulieren", "rechnen", "vergleichen", "erklaeren", "zeichnen"] = Field( + description="Was verlangt wird: ein Modell aufstellen, eine Zahl berechnen, Varianten vergleichen, " + "etwas begründen oder eine Skizze/einen Graphen") + needs_solver: bool = Field(description="True, wenn die Antwort eine berechnete Zahl braucht") + + +class Fact(BaseModel): + """Ein Zahlenwert aus dem Aufgabentext, mit seiner Fundstelle.""" + value: str = Field(description="Der Wert genau wie im Text, z. B. '8', '0,05', '12 %'") + unit: str = Field(description="Einheit, z. B. '€', 'h', 'Stück', '%', 'dimensionslos'") + meaning: str = Field(description="Wofür die Zahl steht, in der Sprache der Aufgabe") + quote: str = Field(description="Die Textstelle, in der die Zahl steht (Zeile oder Tabellenzeile)") + + +class IgnoredNumber(BaseModel): + """Eine Zahl im Text, die keine Modelldaten sind – mit Begründung.""" + value: str + reason: str = Field(description="Warum das keine Eingabegröße ist, z. B. 'Aufgabennummer', " + "'Teil der Einheit (in 1000 Stunden)', 'Seitenzahl'") + + +class TaskUnderstanding(BaseModel): + """Was in der Aufgabe steht – vor jeder Modellierung: Fragen, Zahlen, Machbarkeit.""" + is_optimization_task: bool = Field( + description="False, wenn der Text gar keine Optimierungs- oder Entscheidungsaufgabe stellt " + "(dann wird nicht modelliert)") + reason: str = Field(description="Begründung in einem Satz, worum es geht bzw. warum es keine Aufgabe ist") + sub_questions: List[SubQuestion] = Field( + description="Alle Teilfragen in der Reihenfolge des Textes; bei einer einzigen Frage genau ein Eintrag") + facts: List[Fact] = Field(description="Alle Zahlenwerte des Textes, die Eingabegrößen des Modells sind") + ignored_numbers: List[IgnoredNumber] = Field( + default_factory=list, description="Zahlen im Text, die keine Eingabegrößen sind, mit Begründung") + + +class ObjectiveDefinition(BaseModel): + sense: ObjectiveSense + latex_formula: str = Field(description="Zielfunktion in LaTeX, z. B. '\\min \\sum_i c_i x_i' oder '\\min w^T \\Sigma w'") + description: str = Field(description="Ökonomisches Ziel (z. B. Gesamtkosten senken)") + + +class OptimizationModelSpecification(BaseModel): + """Vollständige Zerlegung und Routing-Spezifikation einer Optimierungsaufgabe.""" + + # 1. Routing & Taxonomie + problem_class: ProblemClass + target_stack: TargetStack + routing_rationale: str = Field( + description="Präzise Begründung, warum dieser Stack für das Problem optimal ist." + ) + + # 2. Didaktische Zusammenfassung + executive_summary: str = Field( + description="Verständliche Zusammenfassung des Entscheidungsproblems für Manager." + ) + + # 3. Mathematische Bausteine + indices: List[str] = Field( + description="Mengen und Indizes, z. B. ['i in I (Mitarbeiter)', 't in T (Zeitschritte)']" + ) + parameters: List[ParameterDefinition] + variables: List[VariableDefinition] + objective: ObjectiveDefinition + constraints: List[ConstraintDefinition] + + # 4. Offene Annahmen + open_assumptions: List[AssumptionDefinition] = Field( + default_factory=list, + description="Annahmen zu unklaren oder fehlenden Angaben; leer, wenn die Aufgabe eindeutig ist", + ) + + # 5. Technische Schnittstelle + suggested_export_format: str = Field( + description="Zieldateityp: '.lp', '.mps', 'Python (CP-SAT)', oder 'Matrix-Dict'" + ) + + +# --- Deterministische Checks (ohne LLM, siehe model_checks.py) --- + +class CheckSeverity(str, Enum): + ERROR = "error" # löst Korrekturrunde aus + WARNING = "warning" # nur Report + + +class CheckIssue(BaseModel): + severity: CheckSeverity + code: str + message: str + + +# --- Solver-Stufe (siehe solver_stage.py) --- + +class SolverStatus(str, Enum): + OPTIMAL = "optimal" + FEASIBLE = "feasible" + INFEASIBLE = "infeasible" + UNBOUNDED = "unbounded" + ERROR = "error" # Laufzeitfehler, Timeout, fehlendes RESULT_JSON, Import-Verstoß + + +class SolverCode(BaseModel): + code: str = Field(description="Vollständiges, eigenständig ausführbares Python-Skript ohne Markdown-Codeblock") + data_assumptions: List[str] = Field( + default_factory=list, + description="Alle Datenwerte, die nicht im Aufgabentext stehen und für die Lösung angenommen wurden", + ) + + +class ResultTable(BaseModel): + """Verständliche Ergebnistabelle, vom Solver-Programm aus der tatsächlichen Lösung berechnet.""" + title: str + columns: List[str] + rows: List[List[Union[float, int, str, None]]] + objective_column: Optional[str] = Field( + default=None, description="Spalte mit dem Beitrag je Zeile zur Zielfunktion (im Maßstab von objective_value); Summe muss dem Zielwert entsprechen") + unit: Optional[str] = Field(default=None, description="Einheit der objective_column, z. B. '€' oder '%'") + note: Optional[str] = None + + +class ConstraintValue(BaseModel): + """Eine Restriktion, ausgewertet an der gefundenen Lösung (vom Solver-Programm gemeldet).""" + name: str = Field(description="Name der Restriktion, beginnt mit dem identifier aus der Spezifikation") + lhs: float = Field(description="Wert der linken Seite an der Lösung") + sense: Literal["<=", ">=", "=="] + rhs: float + + +class VariableValue(BaseModel): + """Eine Variable mit Wert, Typ und Schranken – Grundlage für Ganzzahligkeits- und Schrankenprüfung.""" + name: str + value: float + var_type: VariableType = VariableType.CONTINUOUS + lb: Optional[float] = None + ub: Optional[float] = None + + +class Sensitivity(BaseModel): + """Wirkung einer Lockerung/Verschärfung einer bindenden Restriktion, durch Neulösen ermittelt.""" + name: str = Field(description="Name der Restriktion wie in constraints") + delta: float = Field(description="Verschiebung der rechten Seite, > 0") + objective_plus: Optional[float] = Field(default=None, description="Zielwert bei rechter Seite + delta; None wenn dann unlösbar") + objective_minus: Optional[float] = Field(default=None, description="Zielwert bei rechter Seite - delta") + unit: Optional[str] = Field(default=None, description="Einheit des Zielwerts je Einheit der rechten Seite") + + +class Answer(BaseModel): + """Die Antwort auf eine Teilfrage – in der Sprache der Frage, nicht im Modelljargon.""" + question_id: str = Field(description="Kennung der Teilfrage wie im Verständnis ('a', 'b', 'Teil 2')") + text: str = Field(description="Antwortsatz, der die Frage beantwortet; nur tatsächlich berechnete Zahlen") + + +class Scenario(BaseModel): + """Eine Konfiguration im Vergleich – als Abweichung der Eingabedaten, nicht als eigenes Modell.""" + name: str = Field(description="Kurzer Name, z. B. 'Reine Varianzminimierung'") + description: str = Field(description="Was an dieser Konfiguration anders ist, in einem Satz") + data_override: dict = Field( + description="Die abweichenden Einträge von DATA, z. B. {'lambda_1': 1, 'lambda_2': 0}") + objective_value: Optional[float] = Field( + default=None, description="Zielwert dieser Konfiguration, vom Programm berechnet") + verified_value: Optional[float] = Field( + default=None, description="Vom Runner durch erneutes Ausführen bestätigter Zielwert (ohne LLM)") + + +class Multistart(BaseModel): + """Streuung über mehrere Startpunkte – bei nichtkonvexen Problemen der einzige Hinweis darauf, + ob das gefundene lokale Optimum stabil ist (Ipopt, siehe Solver-Prompt).""" + starts: int = Field(description="Anzahl versuchter Startpunkte") + converged: int = Field(description="Wie viele davon eine zulässige Lösung erreicht haben") + best: float = Field(description="Bester Zielwert (der gemeldete)") + worst: Optional[float] = Field(default=None, description="Schlechtester Zielwert der erfolgreichen Läufe") + mean: Optional[float] = Field(default=None, description="Mittelwert der erfolgreichen Zielwerte") + stddev: Optional[float] = Field(default=None, description="Standardabweichung der erfolgreichen Zielwerte") + + +class GraphArc(BaseModel): + """Ein Bogen des zugrunde liegenden Graphen, mit seiner fachlichen Bedeutung.""" + source: str = Field(description="Startknoten") + target: str = Field(description="Zielknoten") + weight: float = Field(description="Bogengewicht (Kosten, Dauer, Entfernung)") + + +class DijkstraStep(BaseModel): + """Ein Schritt des Markierungsverfahrens: welcher Knoten dauerhaft wurde und mit welcher Marke.""" + node: str + label: float = Field(description="Endgültige Entfernung dieses Knotens von der Quelle") + + +class GraphSolution(BaseModel): + """Der Graph, sein optimaler Weg und der Markierungsverlauf – Grundlage für Zeichnung und Prüfung.""" + nodes: List[str] + arcs: List[GraphArc] + path: List[str] = Field(default_factory=list, description="Knotenfolge des kürzesten Weges, von der Quelle zur Senke") + steps: List[DijkstraStep] = Field( + default_factory=list, description="Reihenfolge, in der Dijkstra die Knoten dauerhaft markiert hat") + node_meaning: str = Field(description="Was ein Knoten in der Aufgabe bedeutet, in einem Satz ohne Fachjargon") + arc_meaning: str = Field(description="Was ein Bogen in der Aufgabe bedeutet, in einem Satz ohne Fachjargon") + + +class SolverRun(BaseModel): + status: SolverStatus + objective_value: Optional[float] = None + headline: Optional[str] = Field(default=None, description="Ein Satz mit dem Ergebnis, nur berechnete Zahlen") + solution: dict = Field(default_factory=dict) + tables: List[ResultTable] = Field(default_factory=list) + constraints: List[ConstraintValue] = Field(default_factory=list) + variables: List[VariableValue] = Field(default_factory=list) + sensitivity: List[Sensitivity] = Field(default_factory=list) + data: dict = Field(default_factory=dict, description="Eingabedaten (DATA) des Programms – Grundlage für --resolve") + relaxation_bound: Optional[float] = Field( + default=None, description="Zielwert der stetigen Relaxation (nur bei ganzzahligen Modellen)") + multistart: Optional[Multistart] = Field( + default=None, description="Streuung über mehrere Startpunkte (nur bei lokalen Verfahren wie Ipopt)") + graph: Optional[GraphSolution] = Field( + default=None, description="Graph, kürzester Weg und Markierungsverlauf (nur bei Graphenproblemen)") + answers: List[Answer] = Field( + default_factory=list, description="Eine Antwort je Teilfrage der Aufgabe (Rückprojektion des Ergebnisses)") + scenarios: List[Scenario] = Field( + default_factory=list, description="Verglichene Konfigurationen (Datenvarianten desselben Modells)") + feasibility: List[str] = Field( + default_factory=list, description="Befunde der deterministischen Zulässigkeitsprüfung; leer = Lösung hält alles ein") + undeclared_data: List[str] = Field( + default_factory=list, description="Selbst erzeugte, nicht ausgewiesene Eingabedaten; leer = alles ausgewiesen") + warnings: List[str] = Field(default_factory=list, description="Deterministische Prüfungen der Ergebnistabellen") + error: Optional[str] = None + attempts: int = 1 + + +# --- Prüfschritt: Abgleich der Annahmen mit dem Aufgabentext --- + +class AssumptionVerdict(str, Enum): + JUSTIFIED = "justified" # Text ist unklar, Annahme plausibel und textkonform + UNNECESSARY = "unnecessary" # Text ist eindeutig, Annahme überflüssig (aber nicht falsch) + CONTRADICTS_TASK = "contradicts_task" # Annahme steht im Widerspruch zum Text + STRICTER_THAN_TASK = "stricter_than_task" # Modell schränkt stärker ein als der Text verlangt + WEAKER_THAN_TASK = "weaker_than_task" # Modell lässt mehr zu als der Text erlaubt + + +class AssumptionCheck(BaseModel): + unclear_point: str = Field(description="Die geprüfte Stelle (aus open_assumptions übernommen)") + verdict: AssumptionVerdict + task_evidence: str = Field(description="Wörtliches Zitat aus dem Aufgabentext, auf das sich das Urteil stützt") + explanation: str = Field(description="Begründung des Urteils, inkl. logischer Analyse (z. B. Implikation vs. Äquivalenz)") + correction: Optional[str] = Field( + default=None, + description="Korrigierte Annahme bzw. Formel in LaTeX; nur bei contradicts_task, stricter_than_task oder weaker_than_task", + ) + + +class ObjectiveCheck(BaseModel): + task_evidence: str = Field(description="Wörtliches Zitat aus dem Aufgabentext, das die zu optimierende bzw. anzugebende Größe benennt") + asked_quantity: str = Field(description="Gefragte Größe mit Einheit/Maßstab, z. B. 'Standardabweichung der Portfoliorendite' oder 'Gesamtkosten in €'") + modeled_quantity: str = Field(description="Größe, die objective.latex_formula tatsächlich berechnet") + matches: bool = Field( + description="True nur, wenn die Zielfunktion genau die gefragte Größe (inkl. Richtung min/max und Maßstab) liefert; " + "eine monoton transformierte Größe (z. B. Varianz statt Standardabweichung) ist False" + ) + units_consistent: bool = Field( + description="True nur, wenn alle Terme der Zielfunktion dieselbe Einheit haben (z. B. alle in Euro oder alle relativ); " + "ein dimensionsloser Term neben Euro-Beträgen ist False") + unit_explanation: str = Field(description="Einheit je Term der Zielfunktion, z. B. 'Rendite €, Risiko dimensionslos, Kosten €'") + explanation: str + correction: Optional[str] = Field(default=None, description="Korrigierte Zielfunktion in LaTeX; nur wenn matches oder units_consistent False ist") + + +class AssumptionReview(BaseModel): + """Prüfbericht: Zielgröße und offene Annahmen einer Modellspezifikation gegen den Aufgabentext.""" + + objective_check: ObjectiveCheck + checks: List[AssumptionCheck] + missing_assumptions: List[AssumptionDefinition] = Field( + default_factory=list, + description="Unklare Stellen im Text, die das Modell stillschweigend entschieden hat, ohne sie auszuweisen", + ) + + @property + def needs_revision(self) -> bool: + faulty = { + AssumptionVerdict.CONTRADICTS_TASK, + AssumptionVerdict.STRICTER_THAN_TASK, + AssumptionVerdict.WEAKER_THAN_TASK, + } + return ( + not self.objective_check.matches + or not self.objective_check.units_consistent + or bool(self.missing_assumptions) + or any(c.verdict in faulty for c in self.checks) + ) + + +class CallRecord(BaseModel): + """Ein LLM-Aufruf – für die Frage, welche Pipeline-Stufe wie lange dauert.""" + step: str + seconds: float + completion_tokens: int = 0 + reasoning_tokens: int = 0 + provider: Optional[str] = None + model: Optional[str] = Field(default=None, description="Tatsächlich benutztes Modell (kann je Stufe abweichen)") + + +class LLMUsage(BaseModel): + model: str + provider: str + calls: int = 0 + prompt_tokens: int = 0 + completion_tokens: int = Field(default=0, description="inkl. Reasoning-Tokens") + reasoning_tokens: int = 0 + cost_usd: Optional[float] = Field(default=None, description="nur OpenRouter liefert Kosten") + providers: Dict[str, int] = Field(default_factory=dict, description="Antwortender Anbieter -> Anzahl Aufrufe (OpenRouter)") + call_records: List[CallRecord] = Field(default_factory=list) + + def by_step(self) -> Dict[str, dict]: + """Sekunden und Tokens je Pipeline-Stufe.""" + summary: Dict[str, dict] = {} + for record in self.call_records: + entry = summary.setdefault(record.step, {"calls": 0, "seconds": 0.0, "completion_tokens": 0, "reasoning_tokens": 0}) + entry["calls"] += 1 + entry["seconds"] += record.seconds + entry["completion_tokens"] += record.completion_tokens + entry["reasoning_tokens"] += record.reasoning_tokens + return summary + + +class PipelineResult(BaseModel): + task: str = "" + data_files: List[DataFileProfile] = Field( + default_factory=list, description="Profile der beigelegten Datendateien (--daten)") + understanding: Optional[TaskUnderstanding] = Field( + default=None, description="Ergebnis der Verstehensstufe (Teilfragen, Fakten); None bei alten Ergebnissen") + specification: OptimizationModelSpecification + review: AssumptionReview + revisions: int = Field(description="Anzahl durchgeführter Korrekturrunden") + selected_revision: int = Field(default=0, description="Runde, deren Spezifikation verwendet wurde (0 = Erstentwurf)") + restarts: int = Field(default=0, description="Anzahl kompletter Neustarts der Modellierung") + reliable: bool = Field(default=True, description="False, wenn nach allen Runden/Neustarts Zielgröße oder Konsistenz nicht stimmen") + infeasible_confirmed: bool = Field( + default=False, description="True, wenn 'unlösbar' durch eine zweite, unabhängige Modellierung bestätigt wurde") + reliability_issues: List[str] = Field(default_factory=list) + checks: List[CheckIssue] = Field(default_factory=list) + solver_code: Optional[SolverCode] = None + solver_run: Optional[SolverRun] = None + usage: Optional[LLMUsage] = None diff --git a/report.py b/report.py new file mode 100644 index 0000000..35f2878 --- /dev/null +++ b/report.py @@ -0,0 +1,635 @@ +"""Report eines PipelineResult als Markdown (.md) oder HTML (.html) mit gerenderten Formeln und Solver-Code. + +Aufbau: build_document() erzeugt ein formatneutrales Dokument (Liste von Blöcken), +to_markdown() / to_html() geben es aus. Formeln: Markdown mit $…$ / $$…$$ (z. B. VS Code-Vorschau), +HTML mit MathJax (lädt aus dem Netz). +""" +import html +import re +from datetime import datetime +from pathlib import Path + +import charts +from model_schema import PipelineResult, SolverStatus +from script_export import build_solver_script +from solver_stage import _is_sum_row + + +class Math(str): + """LaTeX-Formel (inline in Tabellen, sonst abgesetzt).""" + + +class Code(str): + """Monospace-Text.""" + + +def _clean_latex(latex: str) -> str: + # LLM liefert Formeln teils mit eigenen Begrenzern ($…$, \(…\), \[…\]) + latex = latex.strip() + for left, right in (("$$", "$$"), ("$", "$"), (r"\[", r"\]"), (r"\(", r"\)")): + if latex.startswith(left) and latex.endswith(right) and len(latex) > len(left) + len(right): + latex = latex[len(left):-len(right)].strip() + return latex + + +# --- Formeln im Fließtext erkennen --- +# LLM-Texte enthalten Formeln mit \(…\), $…$, \begin{…}…\end{…} oder ganz ohne Begrenzer (häufigster Fall). + +_EXPLICIT_MATH = re.compile( + r"\\\((?P.+?)\\\)" + r"|\\\[(?P.+?)\\\]" + r"|\$\$(?P.+?)\$\$" + r"|\$(?P[^$\n]+?)\$" + r"|(?P\\begin\{(?P[A-Za-z*]+)\}.*?\\end\{(?P=envname)\})", + re.S, +) +_MATHY = re.compile(r"[\\_^{}=<>]") +# Wort ist Formel: LaTeX-Befehl, \{ \}, x_{..}/x^{..}, kurzes Symbol mit Index (r_A, x_ij, U_i) +_MATH_WORD = re.compile(r"\\[A-Za-z{}|]|[_^]\{|^[(\[]?[A-Za-z]{1,3}[_^][A-Za-z0-9]{1,4}\b") +# Füllwort innerhalb einer Formel: Operator, Zahl, einzelner Buchstabe +_GLUE_WORD = re.compile(r"[=<>≤≥+\-−*/·,:|()\[\]]+|[<>]=|[-+]?\d+(?:[.,]\d+)?%?|[A-Za-z]") + + +def _brace_code(word: str) -> str: + # r_min -> r_{min}: in LaTeX würde sonst nur der erste Buchstabe tiefgestellt; % leitet in TeX einen Kommentar ein + word = re.sub(r"(? list: + words = re.split(r"(\s+)", text) + tokens = words[0::2] + is_math = [bool(_MATH_WORD.search(t)) for t in tokens] + is_glue = [bool(_GLUE_WORD.fullmatch(t.strip(".,;:"))) for t in tokens] + parts, i = [], 0 + while i < len(tokens): + if not is_math[i]: + parts.append((False, tokens[i])) + i += 1 + continue + start = i + while start > 0 and is_glue[start - 1] and not is_math[start - 1] and parts and not parts[-1][0]: + start -= 1 # vorangehende Füllwörter (z. B. "i" in "i \in I") gehören zur Formel + parts.pop() + j = i + 1 + while j < len(tokens) and (is_math[j] or is_glue[j]): + j += 1 # Formelwörter und Füllwörter (Operatoren, Zahlen: "U_i = 0.40") gehören dazu + end = j - 1 + run = " ".join(tokens[start:end + 1]) + while run.count("{") > run.count("}") and end + 1 < len(tokens): + end += 1 + run += " " + tokens[end] + stripped = run.rstrip(".,;:") + tail = run[len(stripped):] + parts.append((True, _brace_code(stripped))) + if tail: + parts.append((False, tail)) + i = end + 1 + # Wörter wieder mit einfachen Leerzeichen verbinden + merged = [] + for math, value in parts: + if merged and not (not math and value[:1] in ".,;:" and merged[-1][0]): + merged.append((False, " ")) + merged.append((math, value)) + return _join_text(merged) + + +def _join_text(parts: list) -> list: + result = [] + for math, value in parts: + if result and not math and not result[-1][0]: + result[-1] = (False, result[-1][1] + value) + else: + result.append((math, value)) + return result + + +def split_math(text: str) -> list: + """Text in [(is_math, str), …] zerlegen; Formelteile ohne Begrenzer.""" + parts, pos = [], 0 + for match in _EXPLICIT_MATH.finditer(text): + if match.group("dollar") is not None and not _MATHY.search(match.group("dollar")) \ + and not re.fullmatch(r"\s*[A-Za-z]\s*", match.group("dollar")): + continue # z. B. Geldbetrag "$5 und $7" + if match.start() > pos: + parts += _split_bare(text[pos:match.start()]) + latex = next(match.group(g) for g in ("paren", "bracket", "ddollar", "dollar", "env") if match.group(g) is not None) + parts.append((True, latex.strip())) + pos = match.end() + if pos < len(text): + parts += _split_bare(text[pos:]) + return _join_text(parts) + + +def _graph_section(run) -> list: + """Zeichnung des Graphen mit hervorgehobenem Weg, darüber die Bedeutung von Knoten und Bogen.""" + g = run.graph + if g is None: + return [] + doc = [("p", f"Knoten des Graphen: {g.node_meaning} Ein Bogen: {g.arc_meaning}")] + zeichnung = charts.graph_chart([n for n in g.nodes], [(a.source, a.target, a.weight) for a in g.arcs], + path=list(g.path), title="Graph mit kürzestem Weg") + if zeichnung: + doc.append(("svg", zeichnung)) + if g.path and run.objective_value is not None: + auf_weg = set(zip(g.path, g.path[1:])) + summanden = " + ".join(_fmt_number(a.weight) for a in g.arcs if (a.source, a.target) in auf_weg) + doc.append(("p", "Hervorgehoben ist der kürzeste Weg " + " → ".join(g.path) + + f" mit der Länge {summanden} = {_fmt_number(run.objective_value)}.")) + if g.steps: + doc += [("h4", "Markierungsverlauf des Dijkstra-Algorithmus"), + ("table", ["Schritt", "Knoten wird endgültig", "Marke (Entfernung von der Quelle)"], + [[str(i), s.node, _fmt_number(s.label)] for i, s in enumerate(g.steps, 1)]), + ("p", "Dijkstra nimmt in jedem Schritt den noch nicht endgültigen Knoten mit der kleinsten Marke. " + "Weil die Marken dabei nie fallen, steht jede endgültige Marke schon fest, sobald sie " + "vergeben wird – das ist der Grund, warum ein Durchlauf genügt.")] + return doc + + +def _result_section(result) -> list: + """Ergebnis zuerst: Antwortsatz, Kennzahlen, verständliche Tabellen mit Diagramm, dann Rohwerte.""" + run, spec = result.solver_run, result.specification + if run is None: + return [] + doc = [("h2", "Ergebnis")] + if run.headline: + doc.append(("lead", run.headline)) + # Rückprojektion auf die gestellte Aufgabe: die Antworten stehen vor allen Kennzahlen + fragen = {q.id: q for q in (result.understanding.sub_questions if result.understanding else [])} + if run.answers: + doc.append(("h3", "Antworten auf die Aufgabe")) + for antwort in run.answers: + frage = fragen.get(antwort.question_id) + doc.append(("h4", f"{antwort.question_id}) {frage.quote}" if frage else str(antwort.question_id))) + doc.append(("p", antwort.text)) + doc.append(("table", ["Status", "Zielwert", "Versuche"], + [[run.status.value, "–" if run.objective_value is None else _fmt_number(run.objective_value), str(run.attempts)]])) + if run.error: + doc.append(("pre", run.error)) + if run.status == SolverStatus.INFEASIBLE and result.infeasible_confirmed: + doc.append(("p", "Die Unlösbarkeit wurde durch eine zweite, unabhängige Modellierung bestätigt.")) + for problem in run.feasibility: + doc.append(("warn", f"Zulässigkeitsprüfung: {problem}")) + for warning in run.warnings: + doc.append(("warn", warning)) + if run.constraints and not run.feasibility: + doc.append(("p", f"Zulässigkeit deterministisch geprüft: {len(run.constraints)} Restriktionen und " + f"{len(run.variables)} Variablen wurden aus der Lösung nachgerechnet und eingehalten.")) + # Erst erklären, dann Zahlen: die Fachbegriffe der Tabellen stehen sonst vor ihrer Erklärung + doc += [("h3", "Wie das Modell die Aufgabe sieht"), ("p", spec.executive_summary)] + doc += _graph_section(run) + for table in run.tables: + doc += [("h3", table.title), + ("table", table.columns, [[_fmt_cell(c) for c in row] for row in table.rows])] + chart = _chart_for_table(table) + if chart: + doc.append(("svg", chart)) + if table.note: + doc.append(("p", table.note)) + if run.scenarios: + rows = [[s.name, s.description, ", ".join(f"{k} = {v}" for k, v in s.data_override.items()), + _fmt_number(s.objective_value) if s.objective_value is not None else "–", + _fmt_number(s.verified_value) if s.verified_value is not None else "–"] + for s in run.scenarios] + doc += [("h3", "Verglichene Konfigurationen"), + ("table", ["Konfiguration", "Unterschied", "Geänderte Daten", "Zielwert", "Nachgerechnet"], rows), + ("p", "Jede Konfiguration ist dieselbe Rechnung mit geänderten Eingabedaten. Die Spalte " + "„Nachgerechnet“ entsteht durch erneutes Ausführen des Programms – ohne LLM.")] + doc += _sensitivity_section(run) + if result.solver_code and result.solver_code.data_assumptions: + doc += [("h3", "Angenommene Daten"), ("list", result.solver_code.data_assumptions)] + if run.solution: + doc.append(("h3", "Alle Variablenwerte (≠ 0)")) + for base, description, entries in _group_solution(run.solution, spec): + total = sum(v for _, v in entries if isinstance(v, (int, float)) and not isinstance(v, bool)) + rows = [[Code(k), _fmt_number(v)] for k, v in entries] + [["Summe", _fmt_number(total)]] + doc += [("h4", f"{base} – {description}" if description else base), ("table", ["Variable", "Wert"], rows)] + return doc + + +def _sensitivity_section(run) -> list: + """Was bringt es, eine bindende Grenze zu verschieben? (aus Neulösungen des Programms)""" + doc = [] + if run.sensitivity: + by_name = {c.name: c for c in run.constraints} + rows = [] + for s in run.sensitivity: + constraint = by_name.get(s.name) + sense = constraint.sense if constraint else "" + gelockert = s.objective_plus if sense != ">=" else s.objective_minus + wirkung = ("–" if gelockert is None or run.objective_value is None + else _fmt_number((gelockert - run.objective_value) / s.delta)) + rows.append([s.name, _fmt_number(s.delta), + "–" if s.objective_minus is None else _fmt_number(s.objective_minus), + "–" if s.objective_plus is None else _fmt_number(s.objective_plus), + wirkung + (f" {s.unit}" if s.unit else "")]) + doc += [("h3", "Sensitivität der bindenden Restriktionen"), + ("table", ["Restriktion", "Verschiebung δ", "Zielwert bei rhs−δ", "Zielwert bei rhs+δ", + "Wert einer Lockerung je Einheit"], rows), + ("p", "Ermittelt durch Neulösen mit verschobener rechter Seite, nicht aus Solver-Attributen " + "abgelesen. „–“ bedeutet: in dieser Richtung unlösbar.")] + if run.relaxation_bound is not None and run.objective_value is not None: + luecke = abs(run.relaxation_bound - run.objective_value) + doc += [("h3", "Preis der Ganzzahligkeit"), + ("p", f"Ohne Ganzzahligkeitsforderung wäre der Zielwert {_fmt_number(run.relaxation_bound)}; " + f"die ganzzahlige Lösung liegt um {_fmt_number(luecke)} daneben.")] + if run.multistart is not None: + m = run.multistart + stabil = m.stddev is not None and m.stddev <= 1e-6 * max(1.0, abs(m.best)) + rows = [["Startpunkte", f"{m.starts}"], ["davon erfolgreich", f"{m.converged}"], + ["bester Zielwert", _fmt_number(m.best)]] + rows += [[bezeichnung, _fmt_number(wert)] for bezeichnung, wert in + (("schlechtester Zielwert", m.worst), ("Mittelwert", m.mean), ("Standardabweichung", m.stddev)) + if wert is not None] + doc += [("h3", "Stabilität über mehrere Startpunkte"), + ("table", ["", ""], rows), + ("p", "Das Verfahren findet nur lokale Optima. " + + ("Alle erfolgreichen Startpunkte führten zum selben Wert – ein Hinweis (kein Beweis) " + "darauf, dass das Optimum global ist." + if stabil else + "Verschiedene Startpunkte führten zu verschiedenen Werten: das Ergebnis ist die beste " + "gefundene lokale Lösung, nicht nachweislich das globale Optimum."))] + return doc + + +def _chart_for_table(table): + """Balken für Beiträge je Objekt, Wasserfall für die Zielwert-Zerlegung (nur bei numerischer Beitragsspalte).""" + if not table.objective_column or table.objective_column not in table.columns: + return "" + col = table.columns.index(table.objective_column) + labels, values, total = [], [], None + for row in table.rows: + value = row[col] if col < len(row) else None + if not isinstance(value, (int, float)) or isinstance(value, bool): + continue + label = str(row[0]) if row else "" + if _is_sum_row(row): + total = (label, value) + else: + labels.append(label) + values.append(float(value)) + if len(values) < 2: + return "" + unit = table.unit or "" + if "zerlegung" in table.title.lower() or "bestandteil" in table.title.lower(): + label, value = total or ("Zielwert", sum(values)) + return charts.waterfall_chart(labels, values, label, float(value), table.title, unit) + return charts.bar_chart(labels, values, table.title, unit) + + +def _fmt_cell(value): + # Zahlen formatieren, Texte (evtl. mit Formeln) für split_math durchreichen + return _fmt_number(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else ("" if value is None else str(value)) + + +def _variable_base(name: str) -> str: + """'w[A02]' -> 'w', 'x_{p,d}' -> 'x', '\\alpha_i' -> 'alpha'""" + match = re.match(r"\\?([A-Za-z]+)", name.strip()) + return match.group(1) if match else name + + +def _group_solution(solution: dict, spec) -> list: + """Variablenwerte nach Basisname gruppieren, Bedeutung aus der Modellspezifikation.""" + descriptions = {_variable_base(v.name): v.description for v in spec.variables} + groups: dict = {} + for name, value in solution.items(): + groups.setdefault(_variable_base(name), []).append((name, value)) + return [(base, descriptions.get(base, ""), entries) for base, entries in groups.items()] + + +def _fmt_number(value) -> str: + if isinstance(value, float): + return f"{value:.6g}" + return str(value) + + +def build_document(result: PipelineResult, model: str = "") -> list: + spec, review = result.specification, result.review + doc = [("h1", "Optimierungsmodell")] + if not result.reliable: + doc.append(("warn", "Ergebnis unzuverlässig – bitte nicht verwenden. " + + " ".join(f"({n}) {p}" for n, p in enumerate(result.reliability_issues, 1)) + + (" Der Solver wurde nicht gestartet." if result.solver_run is None else ""))) + + meta = [f"Erstellt: {datetime.now():%d.%m.%Y %H:%M}"] + if result.usage: + meta.append(f"Modell: {result.usage.model}") + cost = f", Kosten USD {result.usage.cost_usd:.4f}" if result.usage.cost_usd is not None else "" + meta.append(f"LLM: {result.usage.calls} Aufrufe, {result.usage.completion_tokens} Output-Tokens " + f"(davon {result.usage.reasoning_tokens} Reasoning){cost}") + elif model: + meta.append(f"Modell: {model}") + meta.append(f"Korrekturrunden: {result.revisions}, verwendet: Runde {result.selected_revision}" + + (f", Neustarts: {result.restarts}" if result.restarts else "")) + if result.usage and result.usage.providers: + meta.append("Anbieter: " + ", ".join(f"{name} ({n}×)" for name, n in result.usage.providers.items())) + doc.append(("list", meta)) + + doc += _result_section(result) + + if result.task.strip(): + doc += [("h2", "Aufgabe"), ("pre", result.task.strip())] + + if result.data_files: + doc += [("h2", "Beigelegte Daten")] + for datei in result.data_files: + if datei.error: + doc += [("h3", datei.name), ("warn", f"Nicht lesbar: {datei.error}")] + continue + doc += [("h3", f"{datei.name} ({datei.rows} Zeilen)"), + ("table", ["Spalte", "Typ", "Kleinster", "Größter", "Mittel", "Fehlend", "Beispiele"], + [[s.name, s.dtype, + _fmt_number(s.minimum) if s.minimum is not None else "–", + _fmt_number(s.maximum) if s.maximum is not None else "–", + _fmt_number(s.mean) if s.mean is not None else "–", + str(s.missing), ", ".join(s.samples)] for s in datei.columns])] + doc += [("p", "Gerechnet, nicht geschätzt: Zeilen, Spannweiten und Lücken stammen aus der Datei selbst.")] + + if result.understanding is not None: + u = result.understanding + doc += [("h2", "Was in der Aufgabe steht")] + if u.sub_questions: + doc += [("h3", "Gestellte Fragen"), + ("table", ["Nr.", "Frage", "Verlangt", "Rechnung nötig"], + [[q.id, q.quote, q.kind, "ja" if q.needs_solver else "nein"] for q in u.sub_questions])] + if u.facts: + doc += [("h3", "Gegebene Zahlen"), + ("table", ["Wert", "Einheit", "Bedeutung", "Fundstelle"], + [[f.value, f.unit, f.meaning, f.quote] for f in u.facts]), + ("p", "Aus dem Aufgabentext übernommen, nicht gerechnet. Jede Zahl des Textes muss hier " + "oder als bewusst übergangene Zahl auftauchen – das wird maschinell geprüft.")] + if u.ignored_numbers: + doc += [("h3", "Übergangene Zahlen"), + ("table", ["Wert", "Grund"], [[n.value, n.reason] for n in u.ignored_numbers])] + + doc += [ + ("h2", "Einordnung"), + ("table", ["Problemklasse", "Solver-Stack", "Exportformat"], + [[spec.problem_class.value, spec.target_stack.value, spec.suggested_export_format]]), + ("p", spec.routing_rationale), + ("h2", "Mathematisches Modell"), + ("h3", "Mengen und Indizes"), + ("list", list(spec.indices)), # Formeln erkennt split_math (mit/ohne Begrenzer) + ("h3", "Parameter"), + ("table", ["Symbol", "Dimension", "Einheit", "Bedeutung", "Werte"], + [[Math(_clean_latex(p.symbol)), p.dimension, p.unit, p.description, p.extracted_values or "–"] + for p in spec.parameters]), + ("h3", "Entscheidungsvariablen"), + ("table", ["Variable", "Typ", "Bereich", "Bedeutung"], + [[Math(_clean_latex(v.name)), v.var_type.value, Math(_clean_latex(v.domain)), v.description] for v in spec.variables]), + ("h3", f"Zielfunktion ({spec.objective.sense.value})"), + ("math", _clean_latex(spec.objective.latex_formula)), + ("p", spec.objective.description), + ("h3", "Restriktionen"), + ] + for c in spec.constraints: + label = c.identifier + ("" if c.is_hard_constraint else " (weich)") + doc += [("h4", label), ("math", _clean_latex(c.latex_formula)), ("p", c.description)] + + if spec.open_assumptions: + doc += [("h3", "Offene Annahmen"), + ("table", ["Unklare Stelle", "Annahme", "Verworfene Lesart", "Auswirkung"], + [[a.unclear_point, a.assumption, a.alternative, a.impact] for a in spec.open_assumptions])] + + oc = review.objective_check + doc += [ + ("h2", "Prüfbericht"), + ("h3", "Zielgröße"), + ("table", ["", ""], [ + ["Gefragt (Zitat)", oc.task_evidence], + ["Gefragte Größe", oc.asked_quantity], + ["Modellierte Größe", oc.modeled_quantity], + ["Übereinstimmung", "ja" if oc.matches else "NEIN"], + ["Begründung", oc.explanation], + ] + ([["Korrektur", Math(_clean_latex(oc.correction))]] if oc.correction else [])), + ] + if review.checks: + doc += [("h3", "Annahmen"), + ("table", ["Stelle", "Urteil", "Beleg", "Begründung"], + [[c.unclear_point, c.verdict.value, c.task_evidence, c.explanation + (f" Korrektur: {c.correction}" if c.correction else "")] + for c in review.checks])] + if review.missing_assumptions: + doc += [("h3", "Nicht ausgewiesene Annahmen"), + ("list", [f"{a.unclear_point}: {a.assumption}" for a in review.missing_assumptions])] + doc += [("h3", "Automatische Konsistenzprüfung"), + ("list", [f"{i.severity.value.upper()} [{i.code}] {i.message}" for i in result.checks] or ["keine Auffälligkeiten"])] + + if result.solver_code: + doc += [("h2", "Solver-Programm (Python)"), + ("p", "Eigenständig lauffähig (python3 solve.py): Kopf mit Aufgabe, Modell und Algorithmus, " + "kommentierter Code mit main()."), + ("code", "python", build_solver_script(result))] + return doc + + +# --- Markdown --- + +def _md_inline(value, in_table: bool = False) -> str: + if isinstance(value, list): + return "".join(_md_inline(v, in_table) for v in value) + text = str(value).replace("\n", " ") + if isinstance(value, Math): + # | trennt Markdown-Tabellenspalten: Norm \| -> \Vert, Betrag | -> \vert + return f"${text.replace(chr(92) + '|', r'\Vert ').replace('|', r'\vert ')}$" if in_table else f"${text}$" + if isinstance(value, Code): + return f"`{text}`" + # Fließtext: erkannte Formeln als $…$, übriger Text unverändert + return "".join( + _md_inline(Math(part), in_table) if is_math else (part.replace("|", r"\|") if in_table else part) + for is_math, part in split_math(text) + ) + + +def to_markdown(doc: list) -> str: + out = [] + for block in doc: + kind = block[0] + if kind in ("h1", "h2", "h3", "h4"): + out.append("#" * int(kind[1]) + " " + block[1]) + elif kind == "p": + out.append(_md_inline(block[1])) + elif kind == "warn": + out.append(f"> **⚠ {_md_inline(block[1])}**") + elif kind == "lead": + out.append(f"**{_md_inline(block[1])}**") + elif kind == "svg": + pass # Diagramme nur im HTML-Report + elif kind == "list": + out.append("\n".join(f"- {_md_inline(item)}" for item in block[1])) + elif kind == "math": + out.append(f"$$\n{block[1]}\n$$") + elif kind == "pre": + out.append(f"```text\n{block[1]}\n```") + elif kind == "code": + out.append(f"```{block[1]}\n{block[2]}\n```") + elif kind == "table": + headers, rows = block[1], block[2] + lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)] + lines += ["| " + " | ".join(_md_inline(cell, in_table=True) for cell in row) + " |" for row in rows] + out.append("\n".join(lines)) + return "\n\n".join(out) + "\n" + + +# --- HTML --- + +HTML_HEAD = """ + + + + +Optimierungsmodell + + + + + +""" + + +COPY_ICON = ( + '' +) + +# Kopieren per Clipboard-API; Rückfall über execCommand, falls die API (z. B. bei file://) nicht verfügbar ist +COPY_SCRIPT = """""" + + +def _html_inline(value) -> str: + if isinstance(value, list): + return "".join(_html_inline(v) for v in value) + if isinstance(value, Math): + return f"\\({html.escape(str(value))}\\)" + if isinstance(value, Code): + return f"{html.escape(str(value))}" + # Fließtext: erkannte Formeln als \(…\) für MathJax, übriger Text escaped + return "".join( + f"\\({html.escape(part)}\\)" if is_math else html.escape(part) for is_math, part in split_math(str(value)) + ) + + +def to_html(doc: list) -> str: + out = [HTML_HEAD] + for block in doc: + kind = block[0] + if kind in ("h1", "h2", "h3", "h4"): + out.append(f"<{kind}>{html.escape(block[1])}") + elif kind == "p": + out.append(f"

{_html_inline(block[1])}

") + elif kind == "warn": + out.append(f'
⚠ {_html_inline(block[1])}
') + elif kind == "lead": + out.append(f'

{_html_inline(block[1])}

') + elif kind == "svg": + out.append(f'
{block[1]}
') + elif kind == "list": + out.append("
    " + "".join(f"
  • {_html_inline(item)}
  • " for item in block[1]) + "
") + elif kind == "math": + out.append(f'
\\[{html.escape(block[1])}\\]
') + elif kind == "pre": + out.append(f'
{html.escape(block[1])}
') + elif kind == "code": + out.append( + '
' + f'' + f'
{html.escape(block[2])}
' + ) + elif kind == "table": + headers, rows = block[1], block[2] + head = "".join(f"{html.escape(h)}" for h in headers) if any(headers) else "" + body = "".join("" + "".join(f"{_html_inline(c)}" for c in row) + "" for row in rows) + out.append(f"{'' + head + '' if head else ''}{body}
") + out.append(COPY_SCRIPT) + out.append("\n\n") + return "\n".join(out) + + +def write_report(result: PipelineResult, path: str) -> None: + target = Path(path) + doc = build_document(result) + if target.suffix.lower() in (".html", ".htm"): + content = to_html(doc) + elif target.suffix.lower() == ".md": + content = to_markdown(doc) + else: + raise ValueError(f"Unbekanntes Report-Format '{target.suffix}' (erlaubt: .md, .html)") + target.write_text(content, encoding="utf-8") + + +if __name__ == "__main__": + import argparse + import json + + parser = argparse.ArgumentParser(description="Report aus gespeichertem Ergebnis erzeugen (ohne LLM-Aufrufe)") + parser.add_argument("result_json", help="Ausgabe von TextToModel.py oder Datei aus benchmark/results/") + parser.add_argument("output", help="Zieldatei .md oder .html") + parser.add_argument("--case", help="Benchmark-Datei: Fallname") + parser.add_argument("--model", help="Benchmark-Datei: Modellname (Teilstring genügt)") + parser.add_argument("--run", type=int, default=1, help="Benchmark-Datei: Laufnummer (Default 1)") + args = parser.parse_args() + + data = json.loads(Path(args.result_json).read_text(encoding="utf-8")) + if isinstance(data, list): # Benchmark-Ergebnis + rows = [r for r in data if "result" in r and (not args.case or r["case"] == args.case) + and (not args.model or args.model in r["model"]) and r["run"] == args.run] + if len(rows) != 1: + available = sorted({(r["case"], r["model"], r["run"]) for r in data if "result" in r}) + parser.error(f"{len(rows)} passende Läufe – bitte mit --case/--model/--run eindeutig wählen. Verfügbar: {available}") + data = rows[0]["result"] + write_report(PipelineResult.model_validate(data), args.output) + print(f"Report geschrieben: {args.output}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..20b7ae5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +instructor[google-genai]>=1.17 +openai>=3.3 +google-genai>=1.66 +pydantic>=2.12 +highspy>=1.15 +ortools>=9.15 +cvxpy>=1.9 +scipy>=1.18 +pyscipopt>=6.2 +cyipopt>=1.7 +networkx>=3.6 +pandas>=2.2 +numpy +pytest diff --git a/script_export.py b/script_export.py new file mode 100644 index 0000000..94e1344 --- /dev/null +++ b/script_export.py @@ -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 \"\"\" + + + if __name__ == "__main__": + main() + +Das JSON-Ergebnis enthält weiterhin nur den rohen generierten Code (solver_code.code). +""" +import re +import textwrap + +from model_schema import PipelineResult, ProblemClass, TargetStack +from 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 .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) diff --git a/solver_stage.py b/solver_stage.py new file mode 100644 index 0000000..d992ac8 --- /dev/null +++ b/solver_stage.py @@ -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 llm import LLMSession +from model_checks import numbers_in, unexplained_jargon +from 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 diff --git a/test_charts.py b/test_charts.py new file mode 100644 index 0000000..25346fb --- /dev/null +++ b/test_charts.py @@ -0,0 +1,138 @@ +"""Inline-SVG-Diagramme: gültiges SVG, korrekte Geometrie, Einbindung in den Report.""" +import xml.etree.ElementTree as ET + +import pytest + +import charts +from model_schema import PipelineResult, ResultTable, SolverRun, SolverStatus +from report import build_document, to_html, to_markdown +from test_model_checks import make_spec +from test_result_tables import PORTFOLIO_TABLE +from test_review import make_review + +LABELS = ["A02", "A08", "A03"] +VALUES = [0.03433, 0.03093, 0.02587] + + +def parse(svg: str): + assert svg.startswith(" widths[1] + heights = {float(p.get("d").split("V")[1].split(" ")[0]) for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path")} + assert charts.BAR_HEIGHT <= 24 + + +def test_negative_values_use_the_other_colour_role(): + svg = charts.bar_chart(["Ertrag", "Kosten"], [1.0, -0.4], "Test") + fills = [p.get("fill") for p in parse(svg).findall(".//{http://www.w3.org/2000/svg}path")] + assert fills == ["var(--viz-pos)", "var(--viz-neg)"] + + +def test_waterfall_has_step_per_term_plus_total(): + svg = charts.waterfall_chart(["Rendite", "Risiko", "Kosten"], [0.1277, -0.0052, -0.0256], + "Zielwert", 0.0969, "Zielwert-Zerlegung", " ") + root = parse(svg) + fills = [p.get("fill") for p in root.findall(".//{http://www.w3.org/2000/svg}path")] + assert fills == ["var(--viz-pos)", "var(--viz-neg)", "var(--viz-neg)", "var(--viz-total)"] + assert "Zielwert" in [t.text for t in root.findall(".//{http://www.w3.org/2000/svg}text")] + + +def test_tooltip_per_mark(): + root = parse(charts.bar_chart(LABELS, VALUES, "Portfolio")) + titles = [t.text for t in root.findall(".//{http://www.w3.org/2000/svg}title")] + assert len(titles) == len(LABELS) and titles[0].startswith("A02: ") + + +def make_result(tables): + return PipelineResult( + task="Aufgabe", specification=make_spec(), review=make_review(), revisions=0, + solver_run=SolverRun(status=SolverStatus.OPTIMAL, objective_value=0.09756, tables=tables, + headline="Das Portfolio erreicht 9,76 %.", solution={"w[A02]": 0.3}), + ) + + +def test_html_report_embeds_chart_and_markdown_does_not(): + doc = build_document(make_result([ResultTable.model_validate(PORTFOLIO_TABLE)])) + page = to_html(doc) + assert '
' in page and "' in to_html(doc) + + +@pytest.mark.parametrize("table", [ + ResultTable(title="Ohne Zahlen", columns=["A", "B"], rows=[["x", "1,5 %"]], objective_column="B"), + ResultTable(title="Nur eine Zeile", columns=["A", "B"], rows=[["x", 1.0]], objective_column="B"), + ResultTable(title="Ohne Beitragsspalte", columns=["A", "B"], rows=[["x", 1.0], ["y", 2.0]]), +]) +def test_no_chart_without_usable_numbers(table): + assert "= 2 # Weg und Knoten des Weges hervorgehoben + + +def test_graph_chart_refuses_unknown_nodes_and_huge_graphs(): + from charts import graph_chart + assert graph_chart(["a"], [("a", "b", 1)]) == "" # Bogen ins Nichts + assert graph_chart([str(i) for i in range(20)], [("0", "1", 1)]) == "" # zu groß zum Zeichnen + assert graph_chart([], []) == "" + + +def test_graph_section_explains_before_it_shows(): + from model_schema import GraphSolution, SolverRun, SolverStatus + from report import _graph_section + run = SolverRun(status=SolverStatus.OPTIMAL, objective_value=29, graph=GraphSolution.model_validate({ + "nodes": ["0", "1", "3"], + "arcs": [{"source": "0", "target": "1", "weight": 8}, {"source": "1", "target": "3", "weight": 21}], + "path": ["0", "1", "3"], + "steps": [{"node": "0", "label": 0}, {"node": "1", "label": 8}, {"node": "3", "label": 29}], + "node_meaning": "ein Zeitpunkt – das Ende von Jahr i.", + "arc_meaning": "Kauf am Ende von Jahr i und Nutzung bis Ende von Jahr j.", + })) + doc = _graph_section(run) + arten = [block[0] for block in doc] + assert arten[0] == "p" and "Ende von Jahr" in doc[0][1] # Erklärung steht vor der Zeichnung + assert "svg" in arten + assert any(b[0] == "table" and "Markierungsverlauf" in str(doc[arten.index("table") - 1]) for b in doc) + assert any("8 + 21 = 29" in str(b) for b in doc) # Rechenweg ausgeschrieben diff --git a/test_data_profile.py b/test_data_profile.py new file mode 100644 index 0000000..5178ae4 --- /dev/null +++ b/test_data_profile.py @@ -0,0 +1,49 @@ +"""Profil beigelegter Datendateien – deterministisch, ohne LLM.""" +import pytest + +from data_profile import profile_file, profile_files, to_prompt + + +@pytest.fixture +def kurse(tmp_path): + datei = tmp_path / "kurse.csv" + datei.write_text("Aktie;Rendite;Risiko;Gebuehr\nA01;6,9;2,04;0,68\nA02;14,4;1,63;0,74\nA03;11,5;;0,39\n", + encoding="utf-8") + return datei + + +def test_german_numbers_are_recognised(kurse): + # deutsche Daten sind der Normalfall: "6,9" ist eine Zahl, keine Zeichenkette + profil = profile_file(kurse) + assert profil.rows == 3 and len(profil.columns) == 4 + rendite = next(s for s in profil.columns if s.name == "Rendite") + assert rendite.dtype == "Zahl" + assert rendite.minimum == pytest.approx(6.9) and rendite.maximum == pytest.approx(14.4) + + +def test_text_columns_and_gaps(kurse): + profil = profile_file(kurse) + assert next(s for s in profil.columns if s.name == "Aktie").dtype == "Text" + assert next(s for s in profil.columns if s.name == "Risiko").missing == 1 + + +def test_separator_is_detected(tmp_path): + komma = tmp_path / "en.csv" + komma.write_text("name,value\nfoo,1.5\nbar,2.5\n") + profil = profile_file(komma) + assert [s.name for s in profil.columns] == ["name", "value"] + assert next(s for s in profil.columns if s.name == "value").mean == pytest.approx(2.0) + + +def test_unreadable_file_is_reported_not_raised(tmp_path): + profil = profile_file(tmp_path / "gibtsnicht.csv") + assert profil.error and "FileNotFoundError" in profil.error + assert profil.rows == 0 + + +def test_prompt_names_columns_and_ranges(kurse): + text = to_prompt(profile_files([kurse])) + assert "kurse.csv: 3 Zeilen, 4 Spalten" in text + assert "Rendite (Zahl, von 6.9 bis 14.4" in text + assert "1 fehlend" in text + assert to_prompt([]) == "" diff --git a/test_feasibility.py b/test_feasibility.py new file mode 100644 index 0000000..6090fce --- /dev/null +++ b/test_feasibility.py @@ -0,0 +1,145 @@ +"""Deterministische Zulässigkeitsprüfung: Lösung einsetzen, Schranken, Ganzzahligkeit, Namensvertrag.""" +import pytest + +from model_schema import PipelineResult, Sensitivity, SolverRun, SolverStatus +from report import build_document, to_html +from solver_stage import check_constraint_coverage, check_feasibility, check_sensitivity +from test_model_checks import make_spec +from test_review import make_review + +SPEC = make_spec( + constraints=[{"identifier": "Budget", "latex_formula": "\\sum_j p_j n_j \\le B", "description": "-"}, + {"identifier": "Mindestanzahl", "latex_formula": "\\sum_j y_j \\ge 5", "description": "-"}], + variables=[{"name": "n_j", "var_type": "integer", "domain": ">= 0", "description": "-"}], +) + + +def run_with(constraints=(), variables=(), status=SolverStatus.OPTIMAL): + return SolverRun(status=status, objective_value=1.0, constraints=list(constraints), variables=list(variables)) + + +BUDGET_OK = {"name": "Budget", "lhs": 99999.99, "sense": "<=", "rhs": 100000.0} +BUDGET_8_CENT = {"name": "Budget", "lhs": 100000.08, "sense": "<=", "rhs": 100000.0} +ANZAHL_OK = {"name": "Mindestanzahl", "lhs": 5, "sense": ">=", "rhs": 5} + + +def test_clean_solution_has_no_findings(): + run = run_with([BUDGET_OK, ANZAHL_OK], [{"name": "n[A02]", "value": 1037, "var_type": "integer", "lb": 0}]) + assert check_feasibility(run, SPEC) == [] + + +def test_eight_cent_budget_violation_is_found(): + # genau der beobachtete Fall: SCIP meldet "optimal", 8 Cent über dem Budget (8e-7 relativ) + problems = check_feasibility(run_with([BUDGET_8_CENT, ANZAHL_OK]), SPEC) + assert len(problems) == 1 and "Budget" in problems[0] and "verletzt" in problems[0] + + +def test_floating_point_noise_is_tolerated(): + noisy = {"name": "Budget", "lhs": 100000.0000000001, "sense": "<=", "rhs": 100000.0} + assert check_feasibility(run_with([noisy, ANZAHL_OK]), SPEC) == [] + + +@pytest.mark.parametrize("constraint, expected", [ + ({"name": "Mindestanzahl", "lhs": 4, "sense": ">=", "rhs": 5}, "Mindestanzahl"), + ({"name": "Budget", "lhs": 99999.0, "sense": "==", "rhs": 100000.0}, "Budget"), +]) +def test_other_senses_are_checked(constraint, expected): + problems = check_feasibility(run_with([constraint, BUDGET_OK, ANZAHL_OK][:2] + [constraint]), SPEC) + assert any(expected in p for p in problems) + + +@pytest.mark.parametrize("variable, expected", [ + ({"name": "n[A02]", "value": 1037.4, "var_type": "integer", "lb": 0}, "nicht ganzzahlig"), + ({"name": "n[A02]", "value": -1, "var_type": "integer", "lb": 0}, "Untergrenze"), + ({"name": "n[A02]", "value": 11, "var_type": "integer", "lb": 0, "ub": 10}, "Obergrenze"), + ({"name": "y[A02]", "value": 2, "var_type": "binary"}, "weder 0 noch 1"), +]) +def test_variable_violations_are_found(variable, expected): + problems = check_feasibility(run_with([BUDGET_OK, ANZAHL_OK], [variable]), SPEC) + assert any(expected in p for p in problems) + + +def test_missing_constraints_report_is_a_finding(): + assert "keine Restriktionen" in check_feasibility(run_with(), SPEC)[0] + + +def test_infeasible_run_is_not_checked(): + assert check_feasibility(run_with(status=SolverStatus.INFEASIBLE), SPEC) == [] + + +# --- Namensvertrag --- + +class Named: + def __init__(self, name): self.name = name + + +def test_missing_constraint_in_program_is_found(): + problems = check_constraint_coverage([Named("Budget")], SPEC) + assert problems == ["Restriktion 'Mindestanzahl' aus der Spezifikation fehlt im Programm"] + + +def test_invented_constraint_is_found(): + problems = check_constraint_coverage([Named("Budget"), Named("Mindestanzahl"), Named("Erfunden")], SPEC) + assert any("'Erfunden'" in p and "passt zu keiner" in p for p in problems) + + +@pytest.mark.parametrize("name", ["Budget", "Budget_A02", "Budget[A02]"]) +def test_indexed_names_are_accepted(name): + assert check_constraint_coverage([Named(name), Named("Mindestanzahl")], SPEC) == [] + + +# --- Sensitivität (Punkt 3): Plausibilität der Neulösungen --- + +KAPAZITAET = {"name": "Budget", "lhs": 100000.0, "sense": "<=", "rhs": 100000.0} + + +def sens_run(sensitivity, objective=12000.0, relaxation=None, constraints=(KAPAZITAET,)): + return SolverRun(status=SolverStatus.OPTIMAL, objective_value=objective, constraints=list(constraints), + sensitivity=[Sensitivity.model_validate(s) for s in sensitivity], relaxation_bound=relaxation) + + +def test_plausible_sensitivity_has_no_warning(): + # Maximierung: mehr Budget (rhs+δ) darf nicht schlechter sein als weniger (rhs−δ) + run = sens_run([{"name": "Budget", "delta": 1000, "objective_plus": 12100.0, "objective_minus": 11900.0}]) + assert check_sensitivity(run, "maximize") == [] + + +def test_implausible_direction_is_warned(): + run = sens_run([{"name": "Budget", "delta": 1000, "objective_plus": 11900.0, "objective_minus": 12100.0}]) + warnings = check_sensitivity(run, "maximize") + assert len(warnings) == 1 and "unplausibel" in warnings[0] + + +def test_greater_equal_constraint_direction_is_handled(): + # Minimierung mit ">=": Lockern heißt rhs senken; niedrigere Kosten dort sind plausibel + bedarf = {"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0} + run = sens_run([{"name": "Bedarf", "delta": 1, "objective_plus": 11.0, "objective_minus": 9.0}], + objective=10.0, constraints=(bedarf,)) + assert check_sensitivity(run, "minimize") == [] + falsch = sens_run([{"name": "Bedarf", "delta": 1, "objective_plus": 9.0, "objective_minus": 11.0}], + objective=10.0, constraints=(bedarf,)) + assert any("unplausibel" in w for w in check_sensitivity(falsch, "minimize")) + + +def test_unknown_constraint_and_bad_delta_are_warned(): + run = sens_run([{"name": "Gibt es nicht", "delta": 1, "objective_plus": 1.0, "objective_minus": 1.0}, + {"name": "Budget", "delta": 0, "objective_plus": 1.0, "objective_minus": 1.0}]) + warnings = check_sensitivity(run, "maximize") + assert any("keine solche Restriktion" in w for w in warnings) and any("delta" in w for w in warnings) + + +def test_relaxation_bound_must_not_be_worse_than_integer_solution(): + gut = sens_run([], objective=12000.0, relaxation=12500.0) # Relaxation ist Schranke nach oben + schlecht = sens_run([], objective=12000.0, relaxation=11500.0) + assert check_sensitivity(gut, "maximize") == [] + assert any("Relaxationsschranke" in w for w in check_sensitivity(schlecht, "maximize")) + + +def test_report_shows_sensitivity_and_integrality_price(): + run = sens_run([{"name": "Budget", "delta": 1000, "objective_plus": 12100.0, "objective_minus": 11900.0, + "unit": "€ je €"}], relaxation=12500.0) + result = PipelineResult(task="Aufgabe", specification=make_spec(), review=make_review(), revisions=0, + solver_run=run) + page = to_html(build_document(result)) + assert "Sensitivität der bindenden Restriktionen" in page and "Preis der Ganzzahligkeit" in page + assert "0,1" in page.replace(".", ",") or "0.1" in page # (12100-12000)/1000 = 0.1 diff --git a/test_llm.py b/test_llm.py new file mode 100644 index 0000000..df0ee42 --- /dev/null +++ b/test_llm.py @@ -0,0 +1,116 @@ +import threading + +import pytest + +import llm +from llm import is_fatal_error, is_transient_error, model_for_step, provider_for +from model_schema import LLMUsage + + +class FakeCompletions: + def __init__(self): self.kwargs = None + + def create_with_completion(self, **kwargs): + self.kwargs = kwargs + return "ok", object() + + +def fake_session(model="deepseek/deepseek-v4.1-flash", step_models=None): + """LLMSession ohne echten Client – der Konstruktor würde einen API-Key verlangen.""" + session = llm.LLMSession.__new__(llm.LLMSession) + session.model, session.provider = model, provider_for(model) + session.step_models = step_models or {} + session.usage = LLMUsage(model=model, provider=session.provider) + session._lock = threading.Lock() + completions = {} + session._clients = {} + for provider in {session.provider, *(provider_for(m) for m in session.step_models.values())}: + completions[provider] = FakeCompletions() + session._clients[provider] = type("C", (), {"chat": type("Chat", (), {"completions": completions[provider]})()})() + session.client = session._clients[session.provider] + return session, completions + + +@pytest.mark.parametrize("message", [ + "429 RESOURCE_EXHAUSTED. {'error': {'code': 429, 'message': 'You exceeded your current quota'}}", # Gemini-Kontingent + "Error code: 402 - {'error': {'message': 'Insufficient credits'}}", # OpenRouter-Guthaben + "Error code: 401 - {'error': {'message': 'No auth credentials found'}}", +]) +def test_fatal_errors_are_not_retried(message): + assert is_fatal_error(message) + assert not is_transient_error(message) + + +@pytest.mark.parametrize("message", [ + "503 UNAVAILABLE. {'error': {'code': 503, 'message': 'model is currently experiencing high demand'}}", + "Error code: 429 - {'error': {'message': 'Rate limit exceeded'}}", + "Error code: 502 - Bad Gateway", + "Connection error.", +]) +def test_transient_errors_are_retried(message): + assert is_transient_error(message) + + +def test_validation_error_is_neither(): + message = "1 validation error for OptimizationModelSpecification" + assert not is_fatal_error(message) and not is_transient_error(message) + + +def test_provider_detection(): + assert provider_for("deepseek/deepseek-v4-flash-0731") == "openrouter" + assert provider_for("gemini-2.5-flash") == "gemini" + + +def test_reasoning_effort_is_sent_only_when_configured(monkeypatch): + def make_session(effort): + monkeypatch.setattr(llm, "REASONING_EFFORT", effort) + session, completions = fake_session() + session.create(str, [{"role": "user", "content": "x"}]) + return completions["openrouter"].kwargs["extra_body"] + + assert "reasoning" not in make_session("") + assert make_session("low")["reasoning"] == {"effort": "low"} + + +def test_truncated_answer_is_retried(): + # beobachtet: ein Anbieter schnitt die Modellspezifikation ab (max_tokens-Vorgabe des Anbieters) + message = "IncompleteOutputException: The output is incomplete due to a max_tokens length limit." + assert is_transient_error(message) and not is_fatal_error(message) + + +def test_max_tokens_is_sent_to_openrouter(): + session, completions = fake_session() + session.create(str, [{"role": "user", "content": "x"}]) + assert completions["openrouter"].kwargs["max_tokens"] == llm.LLM_MAX_TOKENS >= 16000 + + +# --- Modell je Pipeline-Stufe (LLM_MODEL_EXTRACTION, LLM_MODEL_SOLVER, ...) --- + +def test_step_model_falls_back_to_session_model(monkeypatch): + monkeypatch.delenv("LLM_MODEL_SOLVER", raising=False) + assert model_for_step("Solver-Code", "a/b") == "a/b" + assert model_for_step("unbenannt", "a/b") == "a/b" # Stufe ohne Umgebungsvariable + + +def test_step_model_from_environment(monkeypatch): + monkeypatch.setenv("LLM_MODEL_SOLVER", "schnell/modell") + assert model_for_step("Solver-Code", "a/b") == "schnell/modell" + assert model_for_step("Solver-Reparatur", "a/b") == "schnell/modell" # dieselbe Variable + assert model_for_step("Extraktion", "a/b") == "a/b" + + +def test_create_uses_step_model_and_records_it(): + session, completions = fake_session(step_models={"Extraktion": "stark/modell", "Solver-Code": "gemini-2.5-flash"}) + session.create(str, [{"role": "user", "content": "x"}], step="Extraktion") + assert completions["openrouter"].kwargs["model"] == "stark/modell" + + # anderer Anbieter je Stufe: Gemini bekommt keine OpenRouter-Parameter + session.create(str, [{"role": "user", "content": "x"}], step="Solver-Code") + assert completions["gemini"].kwargs["model"] == "gemini-2.5-flash" + assert "extra_body" not in completions["gemini"].kwargs + assert [r.model for r in session.usage.call_records] == ["stark/modell", "gemini-2.5-flash"] + + +def test_overrides_lists_only_deviating_steps(): + session, _ = fake_session(model="a/b", step_models={"Extraktion": "a/b", "Solver-Code": "schnell/modell"}) + assert session.overrides() == {"Solver-Code": "schnell/modell"} diff --git a/test_model_checks.py b/test_model_checks.py new file mode 100644 index 0000000..235036d --- /dev/null +++ b/test_model_checks.py @@ -0,0 +1,447 @@ +from model_checks import check_spec, has_errors +from model_schema import OptimizationModelSpecification + + +def make_spec(**overrides) -> OptimizationModelSpecification: + data = { + "problem_class": "Linear Programming (LP)", + "target_stack": "SciPy (scipy.optimize.linprog)", + "routing_rationale": "-", + "executive_summary": "-", + "indices": ["j \\in J (Produkte)"], + "parameters": [ + {"symbol": "c_j", "dimension": "Vektor", "unit": "€/t", "description": "Kosten", "extracted_values": "[3, 2]"}, + {"symbol": "b", "dimension": "Skalar", "unit": "t", "description": "Bedarf", "extracted_values": "4"}, + ], + "variables": [ + {"name": "x_{j}", "var_type": "continuous", "domain": ">= 0", "description": "Menge"}, + ], + "objective": {"sense": "minimize", "latex_formula": "\\min \\sum_{j \\in J} c_j x_j", "description": "-"}, + "constraints": [ + {"identifier": "Bedarf", "latex_formula": "\\sum_{j \\in J} x_j \\ge b", "description": "-"}, + ], + "suggested_export_format": "Matrix-Dict", + } + data.update(overrides) + return OptimizationModelSpecification.model_validate(data) + + +def codes(spec): + return {issue.code for issue in check_spec(spec)} + + +def test_valid_spec_has_no_issues(): + assert check_spec(make_spec()) == [] + + +def test_binary_variable_in_lp_is_error(): + spec = make_spec(variables=[{"name": "x_j", "var_type": "binary", "domain": "{0,1}", "description": "-"}]) + assert {"LP_WITH_DISCRETE_VARS", "SCIPY_WITH_DISCRETE_VARS"} <= codes(spec) + assert has_errors(check_spec(spec)) + + +def test_stack_mismatch_is_error(): + spec = make_spec(problem_class="Quadratic Programming (QP)") + assert "STACK_MISMATCH" in codes(spec) + + +def test_continuous_variable_with_cpsat_is_error(): + spec = make_spec(problem_class="Constraint Programming (CP)", target_stack="Google OR-Tools (CP-SAT)") + assert "CPSAT_WITH_CONTINUOUS_VARS" in codes(spec) + + +def test_quadratic_term_in_lp_is_warning(): + spec = make_spec(objective={"sense": "minimize", "latex_formula": "\\min \\sum_j c_j x_j^2", "description": "-"}) + issues = check_spec(spec) + assert "NONLINEAR_TERM" in codes(spec) + assert not has_errors(issues) + + +def test_undeclared_symbol_is_warning(): + spec = make_spec(constraints=[ + {"identifier": "Kapazitaet", "latex_formula": "\\sum_{j} a_j x_j \\le K", "description": "-"}, + ]) + messages = [i.message for i in check_spec(spec) if i.code == "UNDECLARED_SYMBOL"] + assert any("'a'" in m for m in messages) + assert any("'K'" in m for m in messages) + + +def test_superscript_and_escaped_underscore_names_are_recognized(): + spec = make_spec( + parameters=[ + {"symbol": "C^{prod}_{j}", "dimension": "Vektor", "unit": "€", "description": "-", "extracted_values": "1"}, + {"symbol": "min\\_staff_1", "dimension": "Skalar", "unit": "Personen", "description": "-", "extracted_values": "3"}, + {"symbol": "\\Sigma", "dimension": "Matrix", "unit": "dimensionslos", "description": "-", "extracted_values": "I"}, + ], + objective={"sense": "minimize", "latex_formula": "\\min \\sum_{j} C^{prod}_{j} x_{j}", "description": "-"}, + constraints=[ + {"identifier": "A", "latex_formula": "\\sum_{j} x_j \\ge min\\_staff_1 \\quad \\forall j \\in J", "description": "-"}, + {"identifier": "B", "latex_formula": "x^T \\Sigma x \\le 1 \\text{ (Risiko)}", "description": "-"}, + ], + problem_class="Quadratic Programming (QP)", + target_stack="CVXPY", + ) + assert "UNDECLARED_SYMBOL" not in codes(spec) + + +def test_missing_parameter_values_is_warning(): + spec = make_spec(parameters=[{"symbol": "c_j", "dimension": "Vektor", "unit": "€", "description": "-"}]) + assert "MISSING_DATA" in codes(spec) + + +def test_no_constraints_is_error(): + assert "NO_CONSTRAINTS" in codes(make_spec(constraints=[])) + + +def test_indices_with_inline_math_delimiters_are_declared(): + # beobachtet: "Mitarbeitende: \(p \in P\)" führte zu Fehlalarmen für p, P, d, D, Mo, Di … + spec = make_spec( + indices=["Mitarbeitende: \\(p \\in P = \\{A,B\\}\\)", "Tage: \\(d \\in D = \\{Mo,Di\\}\\) (Folgetag d+1)"], + variables=[{"name": "x_{p,d}", "var_type": "continuous", "domain": ">= 0", "description": "-"}], + objective={"sense": "minimize", "latex_formula": "\\min \\sum_{p \\in P} \\sum_{d \\in D} x_{p,d}", "description": "-"}, + constraints=[{"identifier": "Folgetag", "latex_formula": "x_{p,Mo} + x_{p,Di} \\le 1 \\quad \\forall p \\in P", "description": "-"}], + ) + assert "UNDECLARED_SYMBOL" not in codes(spec) + + +def test_miqp_with_scip_is_consistent(): + spec = make_spec( + problem_class="Mixed-Integer Quadratic Programming (MIQP)", target_stack="SCIP (pyscipopt)", + variables=[{"name": "w_j", "var_type": "continuous", "domain": "[0,1]", "description": "-"}, + {"name": "y_j", "var_type": "binary", "domain": "{0,1}", "description": "-"}], + objective={"sense": "maximize", "latex_formula": "\\max \\sum_j c_j w_j - \\sum_j c_j w_j^2 - b \\sum_j y_j", "description": "-"}, + constraints=[{"identifier": "Kopplung", "latex_formula": "w_j \\le y_j", "description": "-"}], + ) + assert check_spec(spec) == [] # insbesondere kein NONLINEAR_TERM, kein Routing-Fehler + + +def test_quadratic_with_binary_on_cvxpy_is_error(): + spec = make_spec( + problem_class="Quadratic Programming (QP)", target_stack="CVXPY", + variables=[{"name": "y_j", "var_type": "binary", "domain": "{0,1}", "description": "-"}], + ) + assert "CVXPY_WITH_DISCRETE_VARS" in codes(spec) + assert has_errors(check_spec(spec)) + + +def test_miqp_without_discrete_vars_is_warning(): + spec = make_spec(problem_class="Mixed-Integer Quadratic Programming (MIQP)", target_stack="SCIP (pyscipopt)") + assert "MIQP_WITHOUT_DISCRETE_VARS" in codes(spec) + assert not has_errors(check_spec(spec)) + + +def test_every_problem_class_has_allowed_stacks(): + from model_checks import ALLOWED_STACKS + from model_schema import ProblemClass + assert set(ALLOWED_STACKS) == set(ProblemClass) + + +def test_nlp_with_ipopt_is_consistent(): + spec = make_spec(problem_class="Nonlinear Programming (NLP)", target_stack="Ipopt (cyipopt)") + assert not has_errors(check_spec(spec)) + + +def test_ipopt_with_integer_vars_is_error(): + spec = make_spec(problem_class="Nonlinear Programming (NLP)", target_stack="Ipopt (cyipopt)", + variables=[{"name": "n_j", "var_type": "integer", "domain": ">= 0", "description": "-"}]) + assert "IPOPT_WITH_DISCRETE_VARS" in codes(spec) + + +# --- Einheiten der Zielfunktion (ParameterDefinition.unit, ohne LLM) --- + +def portfolio_spec(risk_unit: str, objective: str): + """Portfolio: Rendite und Kosten in Euro, Risiko wahlweise dimensionslos oder in Euro.""" + return make_spec( + problem_class="Quadratic Programming (QP)", target_stack="CVXPY", + objective={"sense": "maximize", "latex_formula": objective, "description": "-"}, + parameters=[ + {"symbol": "\\mu_i", "dimension": "Vektor", "unit": "€", "description": "Ertrag", "extracted_values": "[1]"}, + {"symbol": "\\lambda", "dimension": "Skalar", "unit": "dimensionslos", "description": "Risikoaversion", + "extracted_values": "0.5"}, + {"symbol": "\\Sigma", "dimension": "Matrix", "unit": risk_unit, "description": "Kovarianz", + "extracted_values": "[[1]]"}, + ], + variables=[{"name": "w_i", "var_type": "continuous", "domain": "[0, 1]", "description": "Anteil"}], + constraints=[{"identifier": "Budget", "latex_formula": "\\sum_i w_i \\le 1", "description": "-"}], + ) + + +def test_dimensionless_risk_next_to_euro_amounts_is_error(): + # beobachtet: Risiko als Summe über Anteile ist um den Faktor Kapital zu klein und damit wirkungslos + spec = portfolio_spec("dimensionslos", "\\max \\sum_i \\mu_i w_i - \\lambda w^T \\Sigma w") + assert "MIXED_OBJECTIVE_UNITS" in codes(spec) + assert has_errors(check_spec(spec)) + + +def test_risk_scaled_to_euro_is_accepted(): + spec = portfolio_spec("€", "\\max \\sum_i \\mu_i w_i - \\lambda w^T \\Sigma w") + assert "MIXED_OBJECTIVE_UNITS" not in codes(spec) + + +def test_single_term_objective_is_never_mixed(): + spec = portfolio_spec("dimensionslos", "\\min w^T \\Sigma w") + assert "MIXED_OBJECTIVE_UNITS" not in codes(spec) + + +def test_index_range_is_not_split_into_terms(): + # \sum_{i \in I} enthält kein Minus, wohl aber Klammern – der Term darf nicht zerschnitten werden + from model_checks import _additive_terms + assert len(_additive_terms("\\min \\sum_{j \\in J} c_j x_j")) == 1 + assert len(_additive_terms("\\max \\sum_i \\mu_i w_i - \\lambda w^T \\Sigma w")) == 2 + assert len(_additive_terms("\\min z = a x + b y")) == 2 # linke Seite zählt nicht mit + + +def test_unknown_units_produce_no_finding(): + # Stunden neben Stück: kein Fehlalarm, geprüft werden nur Geld gegen dimensionslos + spec = make_spec(parameters=[ + {"symbol": "c_j", "dimension": "Vektor", "unit": "h", "description": "Zeit", "extracted_values": "[3]"}, + {"symbol": "b", "dimension": "Skalar", "unit": "Stück", "description": "Bedarf", "extracted_values": "4"}, + ], objective={"sense": "minimize", "latex_formula": "\\min \\sum_j c_j x_j + b", "description": "-"}) + assert "MIXED_OBJECTIVE_UNITS" not in codes(spec) + + +def test_objective_label_is_not_an_undeclared_symbol(): + # beobachtet: Zielfunktion "Z = 12\,x" meldete Z als undeklariertes Symbol + spec = make_spec(objective={"sense": "maximize", "latex_formula": "Z = 12\\,x_j", "description": "-"}) + assert "UNDECLARED_SYMBOL" not in codes(spec) + + +def test_symbol_right_of_the_equals_sign_is_still_checked(): + spec = make_spec(objective={"sense": "maximize", "latex_formula": "Z = q \\cdot x_j", "description": "-"}) + assert "UNDECLARED_SYMBOL" in codes(spec) + + +def test_number_sets_are_not_symbols(): + # beobachtet: Restriktion "x \in \mathbb{Z}" meldete Z als undeklariertes Symbol + spec = make_spec(constraints=[ + {"identifier": "ganzzahligkeit", "latex_formula": "x_j \\in \\mathbb{Z}", "description": "-"}, + {"identifier": "vorzeichen", "latex_formula": "x_j \\in \\mathbb{R}_{\\ge 0}", "description": "-"}, + ]) + assert "UNDECLARED_SYMBOL" not in codes(spec) + + +def test_implicit_products_are_not_undeclared(): + # beobachtet: "\sin(cx)" meinte c*x, der Check las 'cx' als eigenen Namen (ebenso 'xy') + spec = make_spec( + parameters=[{"symbol": "c", "dimension": "Skalar", "unit": "1/K", "description": "-", "extracted_values": "3"}, + {"symbol": "q", "dimension": "Skalar", "unit": "dimensionslos", "description": "-", "extracted_values": "0.5"}], + variables=[{"name": "x", "var_type": "continuous", "domain": "[0, 4]", "description": "-"}, + {"name": "y", "var_type": "continuous", "domain": "[0, 4]", "description": "-"}], + objective={"sense": "maximize", "latex_formula": "\\max x\\sin(cx) + y\\sin(cy) + q\\,xy", "description": "-"}, + constraints=[{"identifier": "Sicherheit", "latex_formula": "x + y \\le 6", "description": "-"}], + ) + assert "UNDECLARED_SYMBOL" not in codes(spec) + + +def test_real_names_are_still_reported(): + # Gegenprobe: 'Kapazitaet' besteht nicht nur aus deklarierten Einzelsymbolen + spec = make_spec(constraints=[ + {"identifier": "K1", "latex_formula": "x_j \\le Kapazitaet", "description": "-"}, + ]) + assert "UNDECLARED_SYMBOL" in codes(spec) + + +# --- Fachbegriffe müssen erklärt sein (Block ERKLÄREN im Routing-Prompt) --- + +def test_unexplained_jargon_is_reported(): + # beobachtet: Restriktion "Flusserhaltung_Jahr1", Zusammenfassung erwähnt "Fluss" nirgends + spec = make_spec( + executive_summary="Der Bauer entscheidet, wann er die Maschine ersetzt.", + constraints=[{"identifier": "Flusserhaltung_Jahr1", "latex_formula": "\\sum_j x_j = 1", "description": "-"}, + {"identifier": "Quelle_Startkauf", "latex_formula": "\\sum_j x_j = 1", "description": "-"}], + ) + meldungen = [i.message for i in check_spec(spec) if i.code == "UNEXPLAINED_JARGON"] + assert meldungen and "Fluss" in meldungen[0] + assert not has_errors(check_spec(spec)) # Warnung, kein Fehler: sonst gälte ein richtiges Ergebnis als unzuverlässig + + +def test_explained_jargon_is_accepted(): + spec = make_spec( + executive_summary="Ein Bogen i → j bedeutet Kauf am Ende von Jahr i. Die Flusserhaltung sorgt dafür, " + "dass die Kette nicht abreißt: zu jedem Knoten gehört genau ein Nachfolger.", + constraints=[{"identifier": "Flusserhaltung_Jahr1", "latex_formula": "\\sum_j x_j = 1", "description": "-"}], + indices=["Knoten j \\in J (Jahresenden)"], + ) + assert "UNEXPLAINED_JARGON" not in codes(spec) + + +def test_everyday_words_are_not_jargon(): + from model_checks import unexplained_jargon + assert unexplained_jargon("Menge je Produkt und Lager, Kosten in Euro", "Eine schlichte Zusammenfassung.") == [] + + +# --- Im Aufgabentext verlangtes Verfahren (check_requested_method) --- + +DIJKSTRA_AUFGABE = "a) Formulieren Sie das Problem als Kürzeste-Wege-Problem!\nb) Lösen Sie es mit dem Dijkstra-Algorithmus!" + + +def test_requested_dijkstra_with_flow_model_is_error(): + # beobachtet: Fall graph_ersatz_dijkstra wurde als MILP-Flussmodell geloest - richtig gerechnet, Aufgabe verfehlt + spec = make_spec(problem_class="Mixed-Integer Linear Programming (MILP)", target_stack="HiGHS", + variables=[{"name": "x_{ij}", "var_type": "binary", "domain": "{0,1}", "description": "-"}]) + issues = check_spec(spec, DIJKSTRA_AUFGABE) + assert "REQUESTED_METHOD_IGNORED" in {i.code for i in issues} + assert has_errors(issues) # loest eine Korrekturrunde aus, die neu routen kann + + +def test_requested_dijkstra_with_networkx_is_accepted(): + spec = make_spec(problem_class="Kürzeste Wege im Graphen (Shortest Path)", target_stack="NetworkX (Dijkstra)", + variables=[{"name": "x_{ij}", "var_type": "binary", "domain": "{0,1}", "description": "-"}]) + assert "REQUESTED_METHOD_IGNORED" not in {i.code for i in check_spec(spec, DIJKSTRA_AUFGABE)} + + +def test_method_check_stays_quiet_without_a_named_method(): + # ohne Aufgabentext und ohne genanntes Verfahren darf die Pruefung nichts melden + spec = make_spec() + assert "REQUESTED_METHOD_IGNORED" not in codes(spec) + assert "REQUESTED_METHOD_IGNORED" not in {i.code for i in check_spec(spec, "Minimiere die Kosten.")} + + +def test_every_checker_marker_in_the_prompts_exists(): + """Jede [→ X]-Kennung in den Prompts muss einem echten Check-Code oder einer Prüffunktion entsprechen. + + Verhindert, dass eine Regel eine Prüfung behauptet, die es nicht (mehr) gibt. + """ + import re + from pathlib import Path + import model_checks + import solver_stage + + quelle = Path(model_checks.__file__).read_text() + Path(solver_stage.__file__).read_text() + bekannt = set(re.findall(r'code="([A-Z_]+)"', quelle)) | set(re.findall(r"^def (\w+)", quelle, re.M)) + marker = set() + for datei in Path(model_checks.__file__).parent.glob("*-Prompt.txt"): + for treffer in re.findall(r"\[→ ([^\]]+)\]", datei.read_text(encoding="utf-8")): + marker |= {teil.strip() for teil in treffer.split(",") if teil.strip() != "…"} # Legende ausnehmen + assert marker, "keine Prüfer-Kennungen in den Prompts gefunden" + assert marker <= bekannt, f"Kennung ohne zugehörige Prüfung: {sorted(marker - bekannt)}" + + +# --- Verstehensstufe: Zahlen und Teilfragen des Aufgabentexts --- + +def make_understanding(**overrides): + from model_schema import TaskUnderstanding + daten = {"is_optimization_task": True, "reason": "-", "facts": [], "ignored_numbers": [], + "sub_questions": [{"id": "1", "quote": "-", "kind": "rechnen", "needs_solver": True}]} + daten.update(overrides) + return TaskUnderstanding.model_validate(daten) + + +def test_numbers_are_normalised_once(): + from model_checks import normalize_number + assert normalize_number("1.234,56") == "1234.56" # deutsch: Punkt trennt Tausender + assert normalize_number("3.8") == "3.8" # Aufgabennummer, kein Tausendertrenner + assert normalize_number("1.234") == "1234" + assert normalize_number("0,05") == "0.05" + assert normalize_number("100.000") == "100000" + + +def test_numbers_inside_words_are_not_numbers(): + from model_checks import numbers_in + assert numbers_in("Aktie A02, Variable x1, Schicht 3") == {"3"} + + +def test_uncaptured_numbers_are_reported(): + from model_checks import check_understanding + # beobachtet: 0 von 14 Zahlen des Aufgabentexts landeten in der Spezifikation + aufgabe = "Der Deckungsbeitrag ist 12 € je Stück. Mindestens 100 Stück sind abzusetzen." + codes = {i.code for i in check_understanding(make_understanding(), aufgabe)} + assert "UNCAPTURED_NUMBERS" in codes + + +def test_numbers_quoted_in_a_fact_count_as_captured(): + # eine Tabellenzeile als Zitat deckt alle Zahlen dieser Zeile ab + from model_checks import check_understanding + aufgabe = "| A01 | 6,9 % | 0,68 % | 2,04 % |" + verstanden = make_understanding(facts=[{ + "value": "6,9 %", "unit": "%", "meaning": "Rendite von A01", "quote": "| A01 | 6,9 % | 0,68 % | 2,04 % |"}]) + assert "UNCAPTURED_NUMBERS" not in {i.code for i in check_understanding(verstanden, aufgabe)} + + +def test_explicitly_ignored_numbers_count_as_captured(): + from model_checks import check_understanding + aufgabe = "## Aufgabe 2.7: Der Preis ist 8 €." + verstanden = make_understanding( + facts=[{"value": "8", "unit": "€", "meaning": "Preis", "quote": "Der Preis ist 8 €."}], + ignored_numbers=[{"value": "2.7", "reason": "Aufgabennummer"}]) + assert "UNCAPTURED_NUMBERS" not in {i.code for i in check_understanding(verstanden, aufgabe)} + + +def test_missing_sub_questions_are_reported(): + from model_checks import check_understanding + aufgabe = "- a) Formulieren Sie das Problem!\n- b) Lösen Sie es mit Dijkstra!" + verstanden = make_understanding(sub_questions=[{"id": "a", "quote": "-", "kind": "formulieren", "needs_solver": False}]) + meldungen = [i.message for i in check_understanding(verstanden, aufgabe) if i.code == "MISSING_SUB_QUESTIONS"] + assert meldungen and "b" in meldungen[0] + + +def test_complete_sub_questions_are_accepted(): + from model_checks import check_understanding + aufgabe = "- a) Formulieren Sie das Problem!\n- b) Lösen Sie es mit Dijkstra!" + verstanden = make_understanding(sub_questions=[ + {"id": "a", "quote": "-", "kind": "formulieren", "needs_solver": False}, + {"id": "b", "quote": "-", "kind": "rechnen", "needs_solver": True}]) + assert "MISSING_SUB_QUESTIONS" not in {i.code for i in check_understanding(verstanden, aufgabe)} + + +def test_split_sub_questions_cover_their_parent(): + # beobachtet an "Portfolio Nr. 2": Teil 2 als 2a/2b erfasst, gemeldet wurde "es fehlen: 2" + from model_checks import check_understanding + aufgabe = "* **Teil 1: Daten**\n* **Teil 2: Optimierung**\n* **Teil 3: Evaluation**" + verstanden = make_understanding(sub_questions=[ + {"id": i, "quote": "-", "kind": "rechnen", "needs_solver": True} for i in ("Teil 1", "Teil 2a", "Teil 2b", "Teil 3")]) + assert "MISSING_SUB_QUESTIONS" not in {i.code for i in check_understanding(verstanden, aufgabe)} + # "21" ist eine andere Teilfrage, keine Zerlegung von "2" + verstanden = make_understanding(sub_questions=[ + {"id": i, "quote": "-", "kind": "rechnen", "needs_solver": True} for i in ("1", "21", "3")]) + assert "MISSING_SUB_QUESTIONS" in {i.code for i in check_understanding(verstanden, aufgabe)} + + +def test_captured_facts_must_reach_the_specification(): + # beobachtet: Verstehensstufe erfasste die ganze Kostenmatrix, extracted_values blieb None + from model_checks import check_data_transfer + verstanden = make_understanding(facts=[ + {"value": "8", "unit": "€", "meaning": "Kosten 0->1", "quote": "| 0 | 8 | 18 | 31 |"}]) + ohne = make_spec(parameters=[{"symbol": "c_{ij}", "dimension": "Matrix", "unit": "€", "description": "Kosten"}]) + meldungen = [i.message for i in check_data_transfer(ohne, verstanden)] + assert meldungen and "8" in meldungen[0] + + mit = make_spec(parameters=[{"symbol": "c_{ij}", "dimension": "Matrix", "unit": "€", "description": "Kosten", + "extracted_values": "8, 18, 31"}]) + assert check_data_transfer(mit, verstanden) == [] + + +def test_data_transfer_tolerates_partially_transferred_values(): + # Indexwerte und Zeilenbeschriftungen muessen nicht in extracted_values stehen - kein Fehlalarm + from model_checks import check_data_transfer + verstanden = make_understanding(facts=[ + {"value": "0,1,2", "unit": "Jahr", "meaning": "moegliche Kaufzeitpunkte", "quote": "i = 0,1,2"}, + {"value": "8", "unit": "€", "meaning": "Kosten", "quote": "| 0 | 8 |"}]) + spec = make_spec(parameters=[{"symbol": "c", "dimension": "Matrix", "unit": "€", "description": "Kosten", + "extracted_values": "8"}]) + assert check_data_transfer(spec, verstanden) == [] + + +def test_data_transfer_check_is_quiet_without_understanding(): + from model_checks import check_data_transfer + assert check_data_transfer(make_spec(), None) == [] + + +def test_sum_limits_are_not_an_objective_label(): + # beobachtet: "\sum_{i=0}^{2}..." wurde am "=" der Summengrenze zerschnitten -> NONLINEAR_TERM + spec = make_spec( + problem_class="Kürzeste Wege im Graphen (Shortest Path)", target_stack="NetworkX (Dijkstra)", + variables=[{"name": "x_{i,j}", "var_type": "binary", "domain": "{0,1}", "description": "-"}], + parameters=[{"symbol": "c_{i,j}", "dimension": "Matrix", "unit": "€", "description": "Kosten", + "extracted_values": "8"}], + objective={"sense": "minimize", "description": "-", + "latex_formula": "\\sum_{i=0}^{2}\\sum_{j=i+1}^{3} c_{i,j}\\, x_{i,j}"}, + constraints=[{"identifier": "Weg", "latex_formula": "\\sum_j x_{0,j} = 1", "description": "-"}], + ) + assert "NONLINEAR_TERM" not in codes(spec) + + +def test_objective_label_is_still_stripped(): + from model_checks import _objective_without_label + assert _objective_without_label("Z = 12 x").strip() == "12 x" + assert _objective_without_label("\\sum_{i=0}^{2} c_i x_i").startswith("\\sum") diff --git a/test_pipeline.py b/test_pipeline.py new file mode 100644 index 0000000..8c19d9a --- /dev/null +++ b/test_pipeline.py @@ -0,0 +1,545 @@ +"""run_pipeline ohne LLM: Neustart bei Fremdproblem und Kennzeichnung unzuverlässiger Ergebnisse.""" +from benchmark import result_correct +from model_schema import ( + AssumptionReview, + LLMUsage, + OptimizationModelSpecification, + SolverCode, + SolverStatus, + SubQuestion, + TaskUnderstanding, +) +from TextToModel import run_pipeline +from test_model_checks import make_spec +from test_review import make_review +from test_script_export import STRUCTURED_CODE + + +# Standardverstaendnis: eine Frage, keine Zahlen - reicht fuer Tests, die das Verstehen nicht pruefen +EINFACHES_VERSTAENDNIS = TaskUnderstanding( + is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)]) + + +class FakeSession: + """Liefert vorgegebene Antworten je Antwort-Typ in fester Reihenfolge.""" + + def __init__(self, specs, reviews, codes=(), understandings=None): + self.queues = { + OptimizationModelSpecification: list(specs), + AssumptionReview: list(reviews), + SolverCode: list(codes), + TaskUnderstanding: list(understandings if understandings is not None else []), + } + self.usage = LLMUsage(model="fake", provider="fake") + self.calls = {kind: 0 for kind in self.queues} + self.steps = [] + self.tasks = [] + self.letzter_prompt = "" + + def overrides(self): + return {} + + def create(self, response_model, messages, step="unbenannt"): + self.letzter_prompt = messages[-1]["content"] + if step == "Extraktion": + self.tasks.append(messages[-1]["content"]) + self.calls[response_model] += 1 + self.usage.calls += 1 + self.steps.append(step) + warteschlange = self.queues[response_model] + if response_model is TaskUnderstanding and not warteschlange: + return EINFACHES_VERSTAENDNIS # Standard, solange ein Test nichts anderes vorgibt + return warteschlange.pop(0) + + +HALLUCINATED = make_spec(executive_summary="Produktionsplanung mit Drehen und Fräsen") # Fremdproblem +GOOD = make_spec(executive_summary="Rohstoffeinkauf mit minimalen Kosten") +MISMATCH = make_review(matches=False) +OK = make_review() + + +def test_clean_first_attempt_needs_no_restart(): + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) + assert result.reliable and result.restarts == 0 and result.revisions == 0 + assert result.solver_run.status == SolverStatus.OPTIMAL + + +def test_restart_recovers_from_hallucinated_problem(): + # Versuch 1: Fremdproblem, beide Korrekturrunden erfolglos; Neustart liefert das richtige Modell + session = FakeSession( + specs=[HALLUCINATED, HALLUCINATED, HALLUCINATED, GOOD], + reviews=[MISMATCH, MISMATCH, MISMATCH, OK], + codes=[SolverCode(code=STRUCTURED_CODE)], + ) + result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) + assert result.restarts == 1 + assert result.reliable and result.reliability_issues == [] + assert result.specification.executive_summary == GOOD.executive_summary + assert result.solver_run.status == SolverStatus.OPTIMAL and result.solver_run.objective_value == 9 + + +def test_persistent_mismatch_is_unreliable_and_not_solved(): + # beobachtet im Benchmark: Fremdproblem wurde als "optimal, 2550" ausgegeben + session = FakeSession(specs=[HALLUCINATED] * 6, reviews=[MISMATCH] * 6) + result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) + assert result.restarts == 1 + assert not result.reliable + assert any("Zielfunktion passt nicht" in issue for issue in result.reliability_issues) + assert result.solver_run is None and result.solver_code is None + assert session.calls[SolverCode] == 0 # kein Solver-Code für ein falsches Problem + assert not result_correct(result.solver_run, {"objective_value": 9, "tolerance": 1e-6}) + + +def test_better_earlier_attempt_wins_over_worse_restart(): + # Neustart ist schlechter (Zielgröße falsch) als Versuch 1 (nur Check-Error) -> Versuch 1 verwenden + stack_error = make_spec(executive_summary="richtig, aber Stack falsch", problem_class="Quadratic Programming (QP)") + session = FakeSession( + specs=[stack_error, stack_error, stack_error, HALLUCINATED, HALLUCINATED, HALLUCINATED], + reviews=[OK, OK, OK, MISMATCH, MISMATCH, MISMATCH], + codes=[SolverCode(code=STRUCTURED_CODE)], + ) + result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) + assert result.specification.executive_summary == "richtig, aber Stack falsch" + assert not result.reliable # STACK_MISMATCH bleibt bestehen + assert any("STACK_MISMATCH" in issue for issue in result.reliability_issues) + assert result.solver_run is not None # Zielgröße passt -> Solver läuft, Ergebnis aber als unzuverlässig markiert + + +def test_no_restart_when_disabled(): + session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3) + result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0) + assert result.restarts == 0 and not result.reliable + + +def test_report_shows_prominent_warning_for_unreliable_result(): + from report import build_document, to_html, to_markdown + session = FakeSession(specs=[HALLUCINATED] * 3, reviews=[MISMATCH] * 3) + result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0) + doc = build_document(result) + assert doc[1][0] == "warn" # direkt unter der Überschrift + assert '
⚠ Ergebnis unzuverlässig' in to_html(doc) + assert "> **⚠ Ergebnis unzuverlässig" in to_markdown(doc) + assert "Der Solver wurde nicht gestartet." in doc[1][1] + + +def test_report_shows_the_rejected_reading_of_each_assumption(): + from report import build_document, to_markdown + spec = make_spec(open_assumptions=[{ + "unclear_point": "„aufeinanderfolgend“", + "assumption": "Kalendertage, auch über das Wochenende", + "alternative": "Nur Werktage; verworfen, weil der Text durchgehenden Betrieb nennt", + "impact": "Restriktion Ruhezeit greift auch am Wochenende", + }]) + session = FakeSession([spec], [OK], [SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, solve_model=False) + markdown = to_markdown(build_document(result)) + assert "Verworfene Lesart" in markdown + assert "verworfen, weil der Text durchgehenden Betrieb nennt" in markdown + + +def test_pipeline_labels_every_llm_call_for_the_timing_breakdown(): + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) + run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=1) + assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Solver-Code"] + + +def test_revision_and_repair_steps_are_labelled(): + session = FakeSession(specs=[HALLUCINATED, GOOD], reviews=[MISMATCH, OK], + codes=[SolverCode(code="x = 1"), SolverCode(code=STRUCTURED_CODE)]) + run_pipeline("Aufgabe", session=session, max_revisions=1, max_restarts=0) + assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Korrektur", "Prüfung", "Solver-Code", "Solver-Reparatur"] + + +# --- Zulässigkeitsprüfung im Zusammenspiel --- + +VIOLATING_CODE = ''' +def solve_model(): + """Liefert eine Lösung, die das Budget um 8 Cent überschreitet (beobachteter Fall).""" + return 100000.08 + + +def main(): + """Meldet die Lösung samt Restriktionen zur Nachprüfung.""" + invested = solve_model() + report("optimal", 12041.74, {"x": invested}, + constraints=[{"name": "Bedarf", "lhs": invested, "sense": "<=", "rhs": 100000.0}], + variables=[{"name": "x", "value": invested, "var_type": "continuous", "lb": 0}]) + + +if __name__ == "__main__": + main() +''' + + +def test_infeasible_solution_triggers_repair_and_is_marked_unreliable(): + session = FakeSession([GOOD], [OK], codes=[SolverCode(code=VIOLATING_CODE)] * 3) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=2) + assert session.calls[SolverCode] == 3 # Erstversuch + 2 Reparaturen + assert result.solver_run.feasibility and "Bedarf" in result.solver_run.feasibility[0] + assert not result.reliable + assert any("Zulässigkeitsprüfung" in issue for issue in result.reliability_issues) + + +def test_feasible_solution_stays_reliable(): + session = FakeSession([GOOD], [OK], codes=[SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert result.solver_run.feasibility == [] + assert result.reliable and session.calls[SolverCode] == 1 + + +# --- Gegenprobe bei "unlösbar" --- + +INFEASIBLE_CODE = ''' +def main(): + """Meldet das Modell als unlösbar.""" + report("infeasible", None, {}) + + +if __name__ == "__main__": + main() +''' + + +def test_infeasible_is_rechecked_and_can_be_refuted(): + # beobachtet bei qwen3-235b: "unlösbar" war ein Modellierungsfehler, die Aufgabe ist lösbar + session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK], + codes=[SolverCode(code=INFEASIBLE_CODE), SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert result.solver_run.status == SolverStatus.OPTIMAL and result.solver_run.objective_value == 9 + assert not result.infeasible_confirmed and result.restarts == 1 + assert "HINWEIS" in session.tasks[1] and "unabhängige Modellierung" in session.tasks[1] + + +def test_infeasible_confirmed_by_second_modelling(): + session = FakeSession(specs=[GOOD, GOOD], reviews=[OK, OK], + codes=[SolverCode(code=INFEASIBLE_CODE)] * 2) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert result.solver_run.status == SolverStatus.INFEASIBLE + assert result.infeasible_confirmed and result.reliable + + +def test_infeasible_without_recheck_is_flagged(monkeypatch): + import TextToModel + monkeypatch.setattr(TextToModel, "INFEASIBLE_RECHECK", 0) + session = FakeSession([GOOD], [OK], codes=[SolverCode(code=INFEASIBLE_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert not result.reliable and any("ohne Gegenprobe" in i for i in result.reliability_issues) + + +# --- Wiedereinstieg ab einer Stufe (continue_pipeline / CLI --von) --- + +def stored_result(tmp_path, spec=GOOD, review=OK): + """Ergebnis eines früheren Laufs als Datei – Grundlage für --von.""" + session = FakeSession([spec], [review], [SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Rohstoffeinkauf", session=session, max_revisions=0, max_restarts=0, solve_model=False) + path = tmp_path / "ergebnis.json" + path.write_text(result.model_dump_json(indent=2), encoding="utf-8") + return path + + +def test_continue_from_solver_skips_extraction_and_review(tmp_path): + from TextToModel import continue_pipeline + path = stored_result(tmp_path) + session = FakeSession([], [], [SolverCode(code=STRUCTURED_CODE)]) + result = continue_pipeline(str(path), "solver", session=session) + assert session.steps == ["Solver-Code"] # kein Extraktions-, kein Prüfaufruf + assert result.specification.executive_summary == GOOD.executive_summary + assert result.task == "Rohstoffeinkauf" # Aufgabentext kommt aus dem Ergebnis + assert result.solver_run.status == SolverStatus.OPTIMAL + + +def test_continue_from_review_keeps_specification_but_checks_again(tmp_path): + from TextToModel import continue_pipeline + path = stored_result(tmp_path) + session = FakeSession([], [OK], [SolverCode(code=STRUCTURED_CODE)]) + result = continue_pipeline(str(path), "pruefung", session=session) + assert session.steps == ["Prüfung", "Solver-Code"] # Extraktion entfällt + assert result.specification.executive_summary == GOOD.executive_summary + + +def test_continue_from_review_can_still_revise(tmp_path): + from TextToModel import continue_pipeline + path = stored_result(tmp_path) + session = FakeSession([GOOD], [MISMATCH, OK], [SolverCode(code=STRUCTURED_CODE)]) + result = continue_pipeline(str(path), "pruefung", session=session, max_revisions=1) + assert session.steps == ["Prüfung", "Korrektur", "Prüfung", "Solver-Code"] + assert result.revisions == 1 + + +def test_continue_from_solver_refuses_when_objective_does_not_match(tmp_path): + # gespeicherte Zielgrößen-Abweichung bleibt bindend: kein Solver, kein LLM-Aufruf + from TextToModel import continue_pipeline + path = stored_result(tmp_path, review=MISMATCH) + session = FakeSession([], [], []) + result = continue_pipeline(str(path), "solver", session=session) + assert session.steps == [] + assert result.solver_run is None and not result.reliable + + +def test_unknown_stage_is_rejected(tmp_path): + import pytest + from TextToModel import continue_pipeline + path = stored_result(tmp_path) + with pytest.raises(ValueError, match="Unbekannte Stufe"): + continue_pipeline(str(path), "extraktion", session=FakeSession([], [], [])) + + +def test_warnings_reach_the_revision_prompt(): + # Warnungen loesen keine Runde aus, muessen aber mitkorrigiert werden, wenn ohnehin eine stattfindet + from model_schema import CheckIssue, CheckSeverity + from TextToModel import revise_model + session = FakeSession([GOOD], [], []) + revise_model(session, "Aufgabe", GOOD, OK, issues=[ + CheckIssue(severity=CheckSeverity.WARNING, code="UNEXPLAINED_JARGON", message="Fachbegriffe: Fluss"), + CheckIssue(severity=CheckSeverity.ERROR, code="STACK_MISMATCH", message="passt nicht"), + ]) + prompt = session.letzter_prompt + assert "STACK_MISMATCH" in prompt.split("HINWEISE")[0] # Fehler im Fehlerteil + assert "UNEXPLAINED_JARGON" in prompt.split("HINWEISE")[1] # Warnung im Hinweisteil + + +# --- Benchmark haelt fest, womit gemessen wurde --- + +def test_benchmark_records_the_prompt_state(): + from benchmark import PROMPT_FILES, prompt_state + stand = prompt_state() + assert {"prompts", "commit", "dirty"} <= set(stand) + assert len(stand["prompts"]) == 12 and stand["commit"] + assert "Routing-und-Extraktions-Prompt.txt" in PROMPT_FILES + assert "model_schema.py" in PROMPT_FILES # Feldbeschreibungen wirken wie Prompt-Anweisungen + + +def test_deviating_settings_are_recorded(monkeypatch): + # Ohne die Umgebung liesse sich ein A/B-Lauf (Stufe abgeschaltet, anderes Modell) nicht zuordnen + import benchmark + benchmark.prompt_state.cache_clear() + monkeypatch.setenv("UNDERSTAND_RETRIES", "-1") + monkeypatch.setenv("LLM_MODEL_SOLVER", "billig/modell") + stand = benchmark.prompt_state() + assert stand["config"] == {"UNDERSTAND_RETRIES": "-1", "LLM_MODEL_SOLVER": "billig/modell"} + benchmark.prompt_state.cache_clear() + + +def test_prompt_digest_changes_with_the_prompt(tmp_path): + from benchmark import prompt_digest + a, b = tmp_path / "eins.txt", tmp_path / "zwei.txt" + a.write_text("Regel A"), b.write_text("Regel B") + vorher = prompt_digest([a, b]) + assert prompt_digest([a, b]) == vorher # gleicher Inhalt, gleiche Kennung + b.write_text("Regel B, nun schaerfer") + assert prompt_digest([a, b]) != vorher # eine geaenderte Regel aendert die Kennung + assert prompt_digest([b, a]) != prompt_digest([a, b]) # Reihenfolge ist Teil der Kennung + + +def test_unexplained_jargon_triggers_a_revision_but_stays_reliable(): + # Erklaeren ist Kernziel: die Warnung ist eine Runde wert, macht das Ergebnis aber nicht unzuverlaessig + from model_checks import check_spec + unerklaert = make_spec( + executive_summary="Der Betrieb entscheidet ueber Mengen.", + constraints=[{"identifier": "Flusserhaltung", "latex_formula": "\\sum_j x_j = 1", "description": "-"}]) + assert "UNEXPLAINED_JARGON" in {i.code for i in check_spec(unerklaert)} + + session = FakeSession(specs=[unerklaert, GOOD], reviews=[OK, OK], codes=[SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=1, max_restarts=0) + assert session.steps == ["Verstehen", "Extraktion", "Prüfung", "Korrektur", "Prüfung", "Solver-Code"] + assert result.revisions == 1 + assert result.reliable # Warnung, kein Fehler + + +def test_clean_model_still_needs_no_revision(): + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=2, max_restarts=0) + assert result.revisions == 0 + + +# --- Verstehensstufe im Ablauf --- + +def test_understanding_runs_first_and_is_kept_in_the_result(): + from model_schema import Fact + verstanden = TaskUnderstanding( + is_optimization_task=True, reason="Kostenminimierung", ignored_numbers=[], + sub_questions=[SubQuestion(id="a", quote="Wie viel?", kind="rechnen", needs_solver=True)], + facts=[Fact(value="12", unit="€", meaning="Deckungsbeitrag je Stueck", quote="12 € je Stueck")]) + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[verstanden]) + result = run_pipeline("Der Deckungsbeitrag ist 12 € je Stueck.", session=session, + max_revisions=0, max_restarts=0) + assert session.steps[0] == "Verstehen" + assert result.understanding.facts[0].value == "12" + assert [q.id for q in result.understanding.sub_questions] == ["a"] + + +def test_incomplete_understanding_is_asked_again(): + # erste Antwort uebersieht die Zahl, zweite erfasst sie + from model_schema import Fact + luecke = TaskUnderstanding(is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id="1", quote="-", kind="rechnen", needs_solver=True)]) + vollstaendig = luecke.model_copy(update={ + "facts": [Fact(value="12", unit="€", meaning="Deckungsbeitrag", quote="12 € je Stueck")]}) + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], + understandings=[luecke, vollstaendig]) + result = run_pipeline("12 € je Stueck.", session=session, max_revisions=0, max_restarts=0) + assert session.steps[:2] == ["Verstehen", "Verstehen"] # genau ein Nachfassen + assert result.understanding.facts[0].value == "12" + + +def test_report_shows_questions_and_numbers(): + from model_schema import Fact + from report import build_document, to_markdown + verstanden = TaskUnderstanding( + is_optimization_task=True, reason="-", + sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Problem!", kind="formulieren", needs_solver=False), + SubQuestion(id="b", quote="Loesen Sie es!", kind="rechnen", needs_solver=True)], + facts=[Fact(value="8", unit="€", meaning="Kosten im ersten Jahr", quote="Zeile 0: 8 | 18 | 31")], + ignored_numbers=[]) + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[verstanden]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + markdown = to_markdown(build_document(result)) + assert "Gestellte Fragen" in markdown and "Formulieren Sie das Problem!" in markdown + assert "Gegebene Zahlen" in markdown and "Kosten im ersten Jahr" in markdown + + +def test_report_puts_the_answers_before_the_numbers(): + from report import build_document, to_markdown + code = ''' +def main(): + """Loest und beantwortet.""" + report("optimal", 9.0, {"x": 1.0}, + headline="Die Kosten betragen 9 Geldeinheiten.", + constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], + variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], + answers=[{"question_id": "a", "text": "Das Modell ist ein lineares Programm."}, + {"question_id": "b", "text": "Die minimalen Kosten betragen 9 Geldeinheiten."}]) + + +if __name__ == "__main__": + main() +''' + verstanden = TaskUnderstanding( + is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id="a", quote="Formulieren Sie das Modell!", kind="formulieren", needs_solver=False), + SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)]) + session = FakeSession([GOOD], [OK], [SolverCode(code=code)], understandings=[verstanden]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + markdown = to_markdown(build_document(result)) + assert "Antworten auf die Aufgabe" in markdown + assert markdown.index("Formulieren Sie das Modell!") < markdown.index("| Status |") + assert result.solver_run.warnings == [] # Abnahme ohne Befund + + +def test_unanswered_sub_question_is_a_warning_not_a_wrong_number(): + code = ''' +def main(): + """Beantwortet nur die zweite Frage.""" + report("optimal", 9.0, {"x": 1.0}, + constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], + variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], + answers=[{"question_id": "b", "text": "Die minimalen Kosten betragen 9 Geldeinheiten."}]) + + +if __name__ == "__main__": + main() +''' + verstanden = TaskUnderstanding( + is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id="a", quote="Zeichnen Sie den Graphen!", kind="zeichnen", needs_solver=False), + SubQuestion(id="b", quote="Berechnen Sie die Kosten!", kind="rechnen", needs_solver=True)]) + session = FakeSession([GOOD], [OK], [SolverCode(code=code)] * 3, understandings=[verstanden]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + assert any("Teilfragen ohne Antwort" in w for w in result.solver_run.warnings) + assert result.solver_run.objective_value == 9.0 # die Zahl bleibt richtig + + +def test_report_shows_data_profile_and_scenarios(tmp_path): + from model_schema import Scenario + from report import build_document, to_markdown + datei = tmp_path / "werte.csv" + datei.write_text("name,menge\nA,3\nB,4\n") + code = ''' +DATA = {"faktor": 1.0} + + +def main(): + """Meldet zwei Datenvarianten.""" + report("optimal", 7.0, {"x": 1.0}, + constraints=[{"name": "Bedarf", "lhs": 4.0, "sense": ">=", "rhs": 4.0}], + variables=[{"name": "x", "value": 1.0, "var_type": "continuous", "lb": 0}], + scenarios=[{"name": "Doppelt", "description": "Faktor verdoppelt", + "data_override": {"faktor": 2.0}, "objective_value": 7.0}]) + + +if __name__ == "__main__": + main() +''' + session = FakeSession([GOOD], [OK], [SolverCode(code=code)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, data_files=[datei]) + markdown = to_markdown(build_document(result)) + assert "Beigelegte Daten" in markdown and "werte.csv (2 Zeilen)" in markdown + assert "Verglichene Konfigurationen" in markdown and "Doppelt" in markdown + assert result.data_files[0].rows == 2 + assert result.solver_run.scenarios[0].verified_value == 7.0 # ohne LLM nachgerechnet + + +# --- Benchmark misst "Aufgabe erfuellt", nicht nur "Zahl richtig" --- + +def test_benchmark_counts_unanswered_questions_as_not_fulfilled(monkeypatch): + import benchmark + from model_schema import Answer, SubQuestion, TaskUnderstanding + + def ergebnis(antworten, warnungen=()): + session = FakeSession([GOOD], [OK], [SolverCode(code=STRUCTURED_CODE)], understandings=[TaskUnderstanding( + is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id=f, quote="-", kind="rechnen", needs_solver=True) for f in ("a", "b")])]) + r = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + r.solver_run.answers = [Answer(question_id=k, text="-") for k in antworten] + r.solver_run.warnings = list(warnungen) + return r + + fall = {"name": "test", "task": "Aufgabe", "expected": {"objective_value": 9, "tolerance": 1e-6}} + for antworten, warnungen, erwartet in [ + (["a", "b"], [], True), # alles beantwortet + (["b"], ["Teilfragen ohne Antwort: a"], False), # eine Frage offen + (["a", "b"], ["Antwort zu 'b' nennt Zahlen …"], False), # unbelegte Zahl + ]: + monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: ergebnis(antworten, warnungen)) + zeile = benchmark.run_case(fall, "fake", 1) + assert zeile["objective_ok"] is True # die Zahl stimmt immer + assert zeile["task_ok"] is erwartet, (antworten, warnungen) + + +def test_benchmark_case_data_files_are_collected(tmp_path, monkeypatch): + import benchmark + fall = tmp_path / "cases" / "mit_daten" + fall.mkdir(parents=True) + (fall / "task.txt").write_text("Aufgabe") + (fall / "expected.json").write_text('{"objective_value": 1, "tolerance": 1e-6}') + (fall / "werte.csv").write_text("a,b\n1,2\n") + monkeypatch.setattr(benchmark, "CASES_DIR", tmp_path / "cases") + faelle = benchmark.load_cases() + assert [p.split("/")[-1] for p in faelle[0]["data_files"]] == ["werte.csv"] + + +# Spezifikation ohne Kostendaten, Programm erfindet sie per Zufallsgenerator (Muster "Portfolio Nr. 2") +OHNE_DATEN = make_spec(parameters=[ + {"symbol": "c_j", "dimension": "Vektor", "unit": "€/t", "description": "Kosten", "extracted_values": ""}, + {"symbol": "b", "dimension": "Skalar", "unit": "t", "description": "Bedarf", "extracted_values": "4"}]) +ERFUNDENE_DATEN = STRUCTURED_CODE.replace( + "COSTS = [3, 2]", "import numpy as np\nCOSTS = [float(v) for v in np.random.default_rng(0).integers(2, 4, 2)]") + + +def test_undeclared_generated_data_is_repaired(): + session = FakeSession([OHNE_DATEN], [OK], [ + SolverCode(code=ERFUNDENE_DATEN), + SolverCode(code=ERFUNDENE_DATEN, data_assumptions=["c_j: zufällig erzeugt (Seed 0), keine Daten im Text"])]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=1) + assert session.calls[SolverCode] == 2 # der Befund hat eine Reparatur ausgelöst + assert result.reliable and result.solver_run.undeclared_data == [] + + +def test_persistently_undeclared_data_is_unreliable(): + # Nachmessung "Portfolio Nr. 2": data_assumptions leer, Antwort behauptete geladene Kursdaten + session = FakeSession([OHNE_DATEN], [OK], [SolverCode(code=ERFUNDENE_DATEN)] * 2) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0, solver_retries=1) + assert not result.reliable + assert any(p.startswith("Datenherkunft") for p in result.reliability_issues) diff --git a/test_report_math.py b/test_report_math.py new file mode 100644 index 0000000..7cf40f0 --- /dev/null +++ b/test_report_math.py @@ -0,0 +1,54 @@ +import pytest + +from report import Math, _html_inline, _md_inline, split_math + + +def marked(text: str) -> str: + """Formelteile mit «…» markieren – kompakt vergleichbar.""" + return "".join(f"«{part}»" if is_math else part for is_math, part in split_math(text)) + + +@pytest.mark.parametrize("text, expected", [ + # ohne Begrenzer (häufigster Fall in LLM-Texten, beobachtet in gespeicherten Läufen) + ("U_i = 0.40 für alle i \\in I", "«U_i = 0.40» für alle «i \\in I»"), + ("R_{\\min} = 0.08", "«R_{\\min} = 0.08»"), + ("r_A=0.07, r_B=0.10", "«r_A=0.07, r_B=0.10»"), + ("lautet \\sqrt{\\mathbf{w}^\\top \\Sigma \\mathbf{w}}. Das ist konvex.", + "lautet «\\sqrt{\\mathbf{w}^\\top \\Sigma \\mathbf{w}}». Das ist konvex."), + ("j \\in I = \\{A,B,C,D\\} (für Kovarianzterme)", "«j \\in I = \\{A,B,C,D\\}» (für Kovarianzterme)"), + ("x_{p,d,s} = 1, wenn Person p arbeitet", "«x_{p,d,s} = 1», wenn Person p arbeitet"), + # Mehrbuchstaben-Index klammern, % maskieren + ("Mindestrendite r_min = 0.08 gilt", "Mindestrendite «r_{min} = 0.08» gilt"), + ("bei w_A = 23.21%.", "bei «w_A = 23.21\\%»."), + # explizite Begrenzer + ("Mitarbeitende: \\(p \\in P\\)", "Mitarbeitende: «p \\in P»"), + ("Fonds $i \\in I$ gesamt", "Fonds «i \\in I» gesamt"), + ("\\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}", "«\\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}»"), +]) +def test_formulas_are_detected(text, expected): + assert marked(text) == expected + + +@pytest.mark.parametrize("text", [ + "Erwartete Jahresrendite von Fonds i", + "Kapazitaet_M1 ist ausgelastet", + "WARNING [UNDECLARED_SYMBOL] Symbol 'h' fehlt", + "Kosten $5 bzw. $7", + "1, wenn Person p am Tag d in Schicht s arbeitet, sonst 0.", + "(F = Frühschicht, S = Spätschicht)", + "Second-Order Cone Programming (SOCP)", +]) +def test_plain_text_stays_text(text): + assert split_math(text) == [(False, text)] + + +def test_html_free_text_wraps_formulas_and_escapes_text(): + rendered = _html_inline("Anteil w_i <= 0.4 & mehr") + assert rendered == "Anteil \\(w_i <= 0.4\\) & mehr" + + +def test_markdown_table_cell_escapes_pipes_outside_and_inside_math(): + rendered = _md_inline("Norm \\(\\|w\\|\\) | Rest", in_table=True) + assert rendered == "Norm $\\Vert w\\Vert $ \\| Rest" + assert _md_inline(Math("|x|"), in_table=True) == "$\\vert x\\vert $" + assert _md_inline(Math("x"), in_table=False) == "$x$" diff --git a/test_resolve.py b/test_resolve.py new file mode 100644 index 0000000..a74a109 --- /dev/null +++ b/test_resolve.py @@ -0,0 +1,98 @@ +"""Punkt 1: dasselbe Programm mit geänderten Daten neu rechnen – ohne LLM-Aufruf.""" +import pytest + +from model_schema import SolverStatus +from solver_stage import run_solver_code + +# Programm nach der Vorgabe des Solver-Prompts: alle Zahlen stehen in DATA +DATA_CODE = ''' +from scipy.optimize import linprog + +DATA = { + "kosten": [3, 2], # Kosten je Tonne [Tsd. €] + "bedarf": 4, # Mindestmenge [t] + "obergrenze": 3, # je Rohstoff lieferbar [t] +} + + +def solve_model(): + """Baut das LP aus DATA auf und löst es.""" + grenze = DATA["obergrenze"] + return linprog(DATA["kosten"], A_ub=[[-1, -1]], b_ub=[-DATA["bedarf"]], + bounds=[(0, grenze), (0, grenze)], method="highs") + + +def main(): + """Löst das Modell und meldet Ergebnis samt Restriktionen.""" + res = solve_model() + x, y = float(res.x[0]), float(res.x[1]) + report("optimal" if res.status == 0 else "error", float(res.fun), {"x": x, "y": y}, + constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": DATA["bedarf"]}], + variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": DATA["obergrenze"]}]) + + +if __name__ == "__main__": + main() +''' + + +def test_run_without_override_uses_own_data(): + run = run_solver_code(DATA_CODE) + assert run.status == SolverStatus.OPTIMAL + assert run.objective_value == pytest.approx(9) # 1t * 3 + 3t * 2 + assert run.data["bedarf"] == 4 + + +def test_changed_data_is_used_without_touching_the_code(): + run = run_solver_code(DATA_CODE, data={"bedarf": 5}) + assert run.status == SolverStatus.OPTIMAL + assert run.objective_value == pytest.approx(12) # 2t * 3 + 3t * 2 + assert run.data["bedarf"] == 5 and run.data["kosten"] == [3, 2] # nur der geänderte Wert wird ersetzt + + +def test_constraints_follow_the_changed_data(): + run = run_solver_code(DATA_CODE, data={"bedarf": 5}) + assert run.constraints[0].rhs == 5 + + +def test_infeasible_data_is_reported_not_crashing(): + run = run_solver_code(DATA_CODE, data={"bedarf": 99}) + assert run.status in (SolverStatus.ERROR, SolverStatus.INFEASIBLE) + + +# --- CLI-Modus: gespeichertes Ergebnis neu rechnen --- + +def gespeichertes_ergebnis(tmp_path): + import json + from model_schema import PipelineResult, SolverCode + from test_model_checks import make_spec + from test_review import make_review + result = PipelineResult(task="Aufgabe", specification=make_spec(), review=make_review(), revisions=0, + solver_code=SolverCode(code=DATA_CODE)) + pfad = tmp_path / "ergebnis.json" + pfad.write_text(result.model_dump_json(indent=2), encoding="utf-8") + return pfad + + +def test_resolve_without_data_reproduces_the_result(tmp_path): + from TextToModel import resolve_result + neu = resolve_result(str(gespeichertes_ergebnis(tmp_path))) + assert neu.solver_run.objective_value == pytest.approx(9) + assert neu.reliable and neu.usage is None # kein LLM-Aufruf + + +def test_resolve_with_changed_data(tmp_path): + import json + from TextToModel import resolve_result + daten = tmp_path / "daten.json" + daten.write_text(json.dumps({"bedarf": 5}), encoding="utf-8") + neu = resolve_result(str(gespeichertes_ergebnis(tmp_path)), str(daten)) + assert neu.solver_run.objective_value == pytest.approx(12) + assert neu.solver_run.data["bedarf"] == 5 + + +def test_resolve_keeps_specification_and_checks_feasibility(tmp_path): + from TextToModel import resolve_result + neu = resolve_result(str(gespeichertes_ergebnis(tmp_path))) + assert neu.specification.problem_class.value.startswith("Linear") + assert neu.solver_run.feasibility == [] diff --git a/test_result_tables.py b/test_result_tables.py new file mode 100644 index 0000000..a05f2df --- /dev/null +++ b/test_result_tables.py @@ -0,0 +1,207 @@ +"""Verständliche Ergebnistabellen: Ausgabe im Programm, Übernahme ins Ergebnis, Summenprüfung, Report.""" +import pytest + +from model_schema import PipelineResult, SolverRun, SolverStatus +from report import build_document, to_html +from solver_stage import parse_result_tables, run_solver_code +from test_model_checks import make_spec +from test_review import make_review + +# Portfolio-Ergebnis (hard_portfolio_kardinalitaet); Beiträge summieren sich zum Zielwert 0.09756 +PORTFOLIO_TABLE = { + "title": "Portfolio", + "columns": ["Aktie", "Anteil", "Rendite", "Beitrag zum Ziel"], + "rows": [ + ["A02", "30,00 %", "14,4 %", 0.03433], + ["A08", "30,00 %", "13,3 %", 0.03093], + ["A03", "28,43 %", "11,5 %", 0.02587], + ["A04", "6,20 %", "9,8 %", 0.00326], + ["A09", "5,37 %", "9,8 %", 0.00317], + ["Summe", "100 %", "", 0.09756], + ], + "objective_column": "Beitrag zum Ziel", + "note": "Anteil = Kapitalanteil je gewählter Aktie.", +} + + +def test_table_is_printed_and_parsed_without_warnings(): + code = f"report('optimal', 0.09756, {{'w[A02]': 0.3}}, tables=[{PORTFOLIO_TABLE!r}])" + run = run_solver_code(code) + assert run.status == SolverStatus.OPTIMAL + assert run.warnings == [] + assert run.tables[0].title == "Portfolio" and len(run.tables[0].rows) == 6 + + +def test_standalone_output_shows_box_table(tmp_path): + import subprocess, sys + from solver_stage import assemble_script + path = tmp_path / "solve.py" + path.write_text(assemble_script(f"report('optimal', 0.09756, {{}}, tables=[{PORTFOLIO_TABLE!r}])"), encoding="utf-8") + out = subprocess.run([sys.executable, str(path)], capture_output=True, text=True, timeout=60).stdout + assert "┌" in out and "│ A02 │ 30,00 % │" in out and "Anteil = Kapitalanteil" in out + + +def test_sum_mismatch_is_warned(): + tables, warnings = parse_result_tables([PORTFOLIO_TABLE], objective_value=0.2) + assert len(tables) == 1 + assert any("weicht vom Zielwert" in w for w in warnings) + + +def test_inconsistent_sum_row_is_warned(): + table = dict(PORTFOLIO_TABLE, rows=PORTFOLIO_TABLE["rows"][:-1] + [["Summe", "100 %", "", 0.5]]) + _, warnings = parse_result_tables([table], objective_value=0.09756) + assert any("Summenzeile" in w for w in warnings) + + +@pytest.mark.parametrize("table, expected", [ + (dict(PORTFOLIO_TABLE, objective_column="Gibt es nicht"), "existiert nicht"), + (dict(PORTFOLIO_TABLE, rows=[["A02", "30 %", "14,4 %", "0,034"]]), "nicht nur Zahlen"), + ({"title": "kaputt"}, "ungültig"), +]) +def test_faulty_tables_are_warnings_not_errors(table, expected): + _, warnings = parse_result_tables([table], objective_value=0.09756) + assert any(expected in w for w in warnings) + + +def test_non_additive_objective_without_objective_column_is_fine(): + table = dict(PORTFOLIO_TABLE, objective_column=None) + _, warnings = parse_result_tables([table], objective_value=0.117) # z. B. Volatilität + assert warnings == [] + + +def test_report_shows_tables_and_grouped_variables_with_meaning(): + spec = make_spec(variables=[ + {"name": "w_i", "var_type": "continuous", "domain": "[0,1]", "description": "Kapitalanteil in Aktie i"}, + {"name": "y_i", "var_type": "binary", "domain": "{0,1}", "description": "1, falls Aktie i ausgewählt wird"}, + ]) + tables, warnings = parse_result_tables([PORTFOLIO_TABLE], 0.09756) + result = PipelineResult( + task="Aufgabe", specification=spec, review=make_review(), revisions=0, + solver_run=SolverRun(status=SolverStatus.OPTIMAL, objective_value=0.09756, tables=tables, warnings=warnings, + solution={"w[A02]": 0.6, "y[A02]": 1.0, "w[A08]": 0.4, "y[A08]": 1.0}), + ) + doc = build_document(result) + headings = [b[1] for b in doc if b[0] in ("h3", "h4")] + assert headings.index("Portfolio") < headings.index("Alle Variablenwerte (≠ 0)") + assert "w – Kapitalanteil in Aktie i" in headings and "y – 1, falls Aktie i ausgewählt wird" in headings + group_tables = [b for b in doc if b[0] == "table" and b[1] == ["Variable", "Wert"]] + assert [t[2][-1] for t in group_tables] == [["Summe", "1"], ["Summe", "2"]] + assert "Anteil = Kapitalanteil" in to_html(doc) + + +def test_report_shows_table_warning_prominently(): + tables, warnings = parse_result_tables([PORTFOLIO_TABLE], 0.2) + result = PipelineResult( + task="Aufgabe", specification=make_spec(), review=make_review(), revisions=0, + solver_run=SolverRun(status=SolverStatus.OPTIMAL, objective_value=0.2, tables=tables, warnings=warnings), + ) + assert '
⚠ Tabelle' in to_html(build_document(result)) + + +DECOMPOSITION = { # beobachtet: Summenzeile heißt "Zielwert", nicht "Summe" + "title": "Zielwert-Zerlegung", + "columns": ["Bestandteil", "Wert"], + "rows": [["Erwartete Rendite", 0.127133], ["Risiko (lambda * Varianz)", -0.006376], + ["Fixkosten (Transaktion)", -0.0232], ["Zielwert", 0.097557]], + "objective_column": "Wert", +} + + +def test_sum_row_recognised_by_label_zielwert(): + tables, warnings = parse_result_tables([DECOMPOSITION], objective_value=0.097557) + assert warnings == [] and len(tables) == 1 + + +def test_sum_row_recognised_by_value_when_label_is_unusual(): + table = dict(DECOMPOSITION, rows=DECOMPOSITION["rows"][:-1] + [["Netto nach Abzügen", 0.097557]]) + _, warnings = parse_result_tables([table], objective_value=0.097557) + assert warnings == [] + + +def test_real_sum_error_is_still_found(): + table = dict(DECOMPOSITION, rows=[["Rendite", 0.2], ["Kosten", -0.05], ["Zielwert", 0.15]]) + _, warnings = parse_result_tables([table], objective_value=0.097557) + assert any("weicht vom Zielwert" in w for w in warnings) + + +def test_negligible_term_hints_at_mixed_units(): + # beobachtet: Risiko 0,0066 (dimensionslos) neben Rendite 12.808 € und Gebühren 186 € + table = {"title": "Zielwert-Zerlegung", "columns": ["Bestandteil", "Wert"], + "rows": [["Erwartete Rendite", 12808.80], ["Risiko", -0.0066], ["Gebühren", -185.62], + ["Zielwert", 12623.17]], + "objective_column": "Wert"} + _, warnings = parse_result_tables([table], objective_value=12623.17) + assert any("Größenordnungen kleiner" in w and "Risiko" in w for w in warnings) + + +def test_normal_decomposition_has_no_magnitude_warning(): + _, warnings = parse_result_tables([DECOMPOSITION], objective_value=0.097557) + assert warnings == [] + + +def test_report_shows_multistart_stability(): + from model_schema import Multistart, SolverRun + from report import build_document, to_markdown + from test_pipeline import FakeSession + from TextToModel import run_pipeline + from test_review import make_review + from test_script_export import STRUCTURED_CODE + from model_schema import SolverCode + + session = FakeSession([make_spec()], [make_review()], [SolverCode(code=STRUCTURED_CODE)]) + result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0) + result.solver_run.multistart = Multistart(starts=5, converged=4, best=1.5, worst=9.0, mean=3.2, stddev=2.8) + markdown = to_markdown(build_document(result)) + assert "Stabilität über mehrere Startpunkte" in markdown + assert "nicht nachweislich das globale Optimum" in markdown + + +def test_transliterated_umlauts_are_reported(): + # beobachtet: "Der guenstigste Ersatzplan", Tabelle "Gewaehlte Nutzungszeitraeume" + from model_schema import ResultTable, SolverRun, SolverStatus + from solver_stage import check_text_quality + run = SolverRun(status=SolverStatus.OPTIMAL, headline="Der guenstigste Plan kostet 29 €.", + tables=[ResultTable(title="Gewaehlte Zeitraeume", columns=["Kauf"], rows=[])]) + befunde = check_text_quality(run) + assert any("guenstigste" in b for b in befunde) + assert any("Gewaehlte" in b and "Zeitraeume" in b for b in befunde) + + +def test_correct_german_is_not_flagged(): + # Gegenprobe: echte Wörter mit ue/oe/ae dürfen keinen Fehlalarm auslösen + from model_schema import ResultTable, SolverRun, SolverStatus + from solver_stage import check_text_quality + run = SolverRun(status=SolverStatus.OPTIMAL, + headline="Die Quelle liefert neue Koeffizienten für tägliche Werte.", + tables=[ResultTable(title="Gewählte Zeiträume", columns=["Menge", "Beitrag"], rows=[])]) + assert check_text_quality(run) == [] + + +def test_transliterations_are_repaired_after_being_reported(): + # Die Warnung dokumentiert den Prompt-Verstoss, der Report soll trotzdem lesbar sein + from model_schema import Answer, ResultTable, SolverRun, SolverStatus + from solver_stage import check_text_quality, fix_transliterations, repair_texts + run = SolverRun(status=SolverStatus.OPTIMAL, headline="Der guenstigste Plan kostet 29 €.", + answers=[Answer(question_id="a", text="Die Loesung waehlt zwei Aktien.")], + tables=[ResultTable(title="Gewaehlte Zeitraeume", columns=["Naechste Periode"], + rows=[["gewaehlt", 3]], note="Gemaess Aufgabe.")]) + assert check_text_quality(run) # erst melden + repair_texts(run) # dann beheben + assert run.headline == "Der günstigste Plan kostet 29 €." + assert run.answers[0].text == "Die Lösung wählt zwei Aktien." + assert run.tables[0].title == "Gewählte Zeiträume" + assert run.tables[0].columns == ["Nächste Periode"] + assert run.tables[0].rows == [["gewählt", 3]] + assert run.tables[0].note == "Gemäß Aufgabe." + + +def test_repair_leaves_names_and_english_alone(): + from solver_stage import fix_transliterations + assert fix_transliterations("Mueller AG, Quelle, value, Koeffizient") == "Mueller AG, Quelle, value, Koeffizient" + assert fix_transliterations("") == "" + + +def test_repair_covers_stems_seen_in_the_lot_sizing_case(): + # beobachtet in wagner_whitin_dijkstra: "110 Baelle", "Das heisst:" im Antworttext + from solver_stage import fix_transliterations + assert fix_transliterations("Das heisst: 110 Baelle, der Ball") == "Das heißt: 110 Bälle, der Ball" diff --git a/test_review.py b/test_review.py new file mode 100644 index 0000000..867e9ea --- /dev/null +++ b/test_review.py @@ -0,0 +1,66 @@ +from model_schema import AssumptionReview, CheckIssue, CheckSeverity +from TextToModel import select_best + + +def make_review(matches=True, verdict="justified", missing=(), units_consistent=True): + return AssumptionReview.model_validate({ + "objective_check": { + "task_evidence": "Bestimme die Aufteilung mit der geringsten Volatilität", + "asked_quantity": "Standardabweichung der Portfoliorendite", + "modeled_quantity": "Standardabweichung" if matches else "Varianz", + "matches": matches, + "units_consistent": units_consistent, + "unit_explanation": "Rendite €, Risiko €, Kosten €" if units_consistent else "Rendite €, Risiko dimensionslos, Kosten €", + "explanation": "-", + }, + "checks": [{"unclear_point": "-", "verdict": verdict, "task_evidence": "-", "explanation": "-"}], + "missing_assumptions": list(missing), + }) + + +def test_clean_review_needs_no_revision(): + assert not make_review().needs_revision + + +def test_objective_mismatch_triggers_revision(): + assert make_review(matches=False).needs_revision + + +def test_faulty_assumption_triggers_revision(): + assert make_review(verdict="stricter_than_task").needs_revision + + +def test_missing_assumption_triggers_revision(): + missing = [{"unclear_point": "-", "assumption": "-", "alternative": "-", "impact": "-"}] + assert make_review(missing=missing).needs_revision + + +# --- Auswahl der besten Korrekturrunde (TextToModel.select_best) --- + +ERROR = CheckIssue(severity=CheckSeverity.ERROR, code="CPSAT_WITH_CONTINUOUS_VARS", message="-") +WARNING = CheckIssue(severity=CheckSeverity.WARNING, code="UNDECLARED_SYMBOL", message="-") + + +def test_later_hallucinated_round_is_discarded(): + # beobachtet in portfolio_qp: Runde 1 sauber, Runde 2 beschreibt ein Fremdproblem mit Check-Error + candidates = [ + (None, [], make_review(matches=False)), + (None, [WARNING], make_review()), + (None, [ERROR, WARNING], make_review(matches=False)), + ] + assert select_best(candidates) == 1 + + +def test_clean_later_round_wins_tie(): + candidates = [(None, [], make_review()), (None, [WARNING], make_review())] + assert select_best(candidates) == 1 + + +def test_fewer_errors_preferred_when_all_have_errors(): + candidates = [(None, [ERROR], make_review(matches=False)), (None, [ERROR, ERROR], make_review(matches=False))] + assert select_best(candidates) == 0 + + +def test_mixed_units_trigger_revision(): + # beobachtet: Rendite/Kosten in Euro, Risiko als dimensionslose Summe w_i^2 sigma_i^2 (Faktor 100.000 zu klein) + assert make_review(units_consistent=False).needs_revision diff --git a/test_script_export.py b/test_script_export.py new file mode 100644 index 0000000..b3d6dc1 --- /dev/null +++ b/test_script_export.py @@ -0,0 +1,142 @@ +import ast +import subprocess +import sys +import warnings + +import pytest + +from model_schema import PipelineResult, SolverCode, SolverRun, SolverStatus +from report import build_document, to_html +from script_export import build_solver_script +from solver_stage import CodeRejected, assemble_script, run_solver_code, validate_structure +from test_model_checks import make_spec +from test_review import make_review + +# min 3x + 2y s.t. x + y >= 4, 0 <= x, y <= 3 -> Zielwert 9 +STRUCTURED_CODE = ''' +from scipy.optimize import linprog + +# Kosten je Tonne Rohstoff X und Y [Tsd. €] +COSTS = [3, 2] + + +def solve_model(): + """Baut das LP auf und löst es.""" + # Bedarf: x + y >= 4 -> -x - y <= -4 + return linprog(COSTS, A_ub=[[-1, -1]], b_ub=[-4], bounds=[(0, 3), (0, 3)], method="highs") + + +def main(): + """Löst das Modell und gibt das Ergebnis aus.""" + res = solve_model() + x, y = float(res.x[0]), float(res.x[1]) + report( + "optimal" if res.status == 0 else "error", res.fun, {"x": x, "y": y}, + constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}], + variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3}, + {"name": "y", "value": y, "var_type": "continuous", "lb": 0, "ub": 3}], + ) + + +if __name__ == "__main__": + main() +''' + + +def make_result(task="Minimiere die Kosten für mindestens 4 t Rohstoff.", code=STRUCTURED_CODE): + return PipelineResult( + task=task, + specification=make_spec(), + review=make_review(), + revisions=0, + solver_code=SolverCode(code=code, data_assumptions=["Lieferfrist: keine (nicht im Text)"]), + solver_run=SolverRun(status=SolverStatus.OPTIMAL, objective_value=9.0), + ) + + +def test_script_layout_order(): + script = build_solver_script(make_result()) + assert script.startswith('#!/usr/bin/env python3\nr"""\n') + tree = ast.parse(script) + docstring = ast.get_docstring(tree) + positions = [docstring.index(h) for h in ("AUFGABE", "MATHEMATISCHES MODELL", "ALGORITHMUS")] + assert positions == sorted(positions) + assert "Minimiere die Kosten" in docstring + assert "\\sum_{j \\in J} x_j \\ge b" in docstring # Restriktion in LaTeX + assert "Branch-and-Cut" not in docstring # SciPy-Stack, nicht HiGHS-Text + assert "linprog" in docstring + assert "Lieferfrist" in docstring # angenommene Daten + # Imports vor report(), report() vor dem abschließenden main-Guard + names = [type(n).__name__ + ":" + getattr(n, "name", "") for n in tree.body] + guard_index = len(names) - 1 + assert names.index("ImportFrom:") < names.index("FunctionDef:main") < names.index("FunctionDef:report") < guard_index + assert isinstance(tree.body[guard_index], ast.If) and script.rstrip().endswith("main()") + + +def test_script_is_standalone_runnable(tmp_path): + path = tmp_path / "solve.py" + path.write_text(build_solver_script(make_result()), encoding="utf-8") + proc = subprocess.run([sys.executable, str(path)], capture_output=True, text=True, timeout=60) + assert proc.returncode == 0, proc.stderr + assert "Status: optimal" in proc.stdout + assert "Zielwert: 9.0" in proc.stdout + assert proc.stdout.strip().splitlines()[-1].startswith("RESULT_JSON:") + + +def test_latex_backslashes_and_quotes_do_not_break_script(): + task = 'Text mit \\sum, \\( x \\), """ Anführungszeichen """ und Backslash am Ende \\' + script = build_solver_script(make_result(task=task)) + with warnings.catch_warnings(): + warnings.simplefilter("error") # SyntaxWarning (ungültige Escapes) wäre ein Fehler + compile(script, "solve.py", "exec") + + +def test_run_solver_code_with_structured_code(): + run = run_solver_code(STRUCTURED_CODE) + assert run.status == SolverStatus.OPTIMAL + assert run.objective_value == pytest.approx(9) + + +def test_assemble_without_guard_prepends_helpers(): + script = assemble_script("x = 1") + assert "def report(" in script and "_apply_data_override(globals())" in script + assert script.index("def report(") < script.index("x = 1") + + +def test_valid_structure_passes(): + validate_structure(STRUCTURED_CODE) + + +@pytest.mark.parametrize("code, reason", [ + ("def solve():\n pass\n\nif __name__ == '__main__':\n solve()\n", "main()"), + ("def main():\n pass\n\nmain()\n", "__main__"), + ("def main():\n pass\n\nif __name__ == '__main__':\n main()\n\nx = 1\n", "__main__"), +]) +def test_invalid_structure_is_rejected(code, reason): + with pytest.raises(CodeRejected, match=reason.replace("(", r"\(").replace(")", r"\)")): + validate_structure(code) + + +def test_html_report_has_copy_button_with_full_script(): + page = to_html(build_document(make_result())) + assert page.count('class="copy-btn"') == 1 + assert "navigator.clipboard" in page and "execCommand('copy')" in page + assert "#!/usr/bin/env python3" in page + assert "if __name__ == "__main__":" in page + + +def test_inline_math_delimiters_are_removed_from_docstring(): + spec = make_spec(indices=["Tage: \\(d \\in D = \\{Mo,Di\\}\\)"]) + result = make_result() + result.specification = spec + docstring = ast.get_docstring(ast.parse(build_solver_script(result))) + assert "Tage: d \\in D = \\{Mo,Di\\}" in docstring + assert "\\(" not in docstring and "\\)" not in docstring + + +def test_every_stack_and_class_has_algorithm_text(): + # Synchron-Regel: neuer TargetStack/ProblemClass braucht Texte in script_export + from model_schema import ProblemClass, TargetStack + from script_export import ALGORITHMS, CLASS_FIT + assert set(ALGORITHMS) == set(TargetStack) + assert set(CLASS_FIT) == set(ProblemClass) diff --git a/test_solver_stage.py b/test_solver_stage.py new file mode 100644 index 0000000..de033e6 --- /dev/null +++ b/test_solver_stage.py @@ -0,0 +1,755 @@ +import sys + +import pytest + +from model_schema import SolverStatus +from solver_stage import CodeRejected, clean_generated_code, run_solver_code, validate_imports + +# min 3x + 2y s.t. x + y >= 4, 0 <= x, y <= 3 -> x=1, y=3, Zielwert 9 +HIGHS_CODE = """ +import highspy +h = highspy.Highs(); h.silent() +x = h.addVariable(lb=0, ub=3) +y = h.addVariable(lb=0, ub=3) +h.addConstr(x + y >= 4) +h.minimize(3 * x + 2 * y) +ok = h.getModelStatus() == highspy.HighsModelStatus.kOptimal +report("optimal" if ok else "error", h.getInfo().objective_function_value, {"x": h.val(x), "y": h.val(y)}) +""" + +CPSAT_CODE = """ +from ortools.sat.python import cp_model +m = cp_model.CpModel() +x = m.new_int_var(0, 3, "x"); y = m.new_int_var(0, 3, "y") +m.add(x + y >= 4) +m.minimize(3 * x + 2 * y) +s = cp_model.CpSolver(); st = s.solve(m) +report("optimal" if st == cp_model.OPTIMAL else "error", s.objective_value, {"x": s.value(x), "y": s.value(y)}) +""" + +CVXPY_CODE = """ +import cvxpy as cp +x = cp.Variable(2) +prob = cp.Problem(cp.Minimize(3 * x[0] + 2 * x[1]), [cp.sum(x) >= 4, x >= 0, x <= 3]) +prob.solve() +report("optimal" if prob.status == cp.OPTIMAL else "error", prob.value, {"x": x.value[0], "y": x.value[1]}) +""" + +SCIPY_CODE = """ +from scipy.optimize import linprog +res = linprog([3, 2], A_ub=[[-1, -1]], b_ub=[-4], bounds=[(0, 3), (0, 3)], method="highs") +report("optimal" if res.status == 0 else "error", res.fun, {"x": res.x[0], "y": res.x[1]}) +""" + + +@pytest.mark.parametrize("code", [HIGHS_CODE, CPSAT_CODE, CVXPY_CODE, SCIPY_CODE], ids=["highs", "cpsat", "cvxpy", "scipy"]) +def test_all_stacks_report_optimum(code): + run = run_solver_code(code) + assert run.error is None + assert run.status == SolverStatus.OPTIMAL + assert run.objective_value == pytest.approx(9, abs=1e-6) + + +VOLATILITY_SETUP = """ +import math +import numpy as np +import cvxpy as cp +mu = np.array([0.07, 0.10, 0.04, 0.12]) +Sigma = np.array([[0.0225, 0.0090, 0.0012, 0.0150], [0.0090, 0.0400, 0.0032, 0.0250], + [0.0012, 0.0032, 0.0064, 0.0020], [0.0150, 0.0250, 0.0020, 0.0625]]) +w = cp.Variable(4, nonneg=True) +cons = [cp.sum(w) == 1, w <= 0.4, w[0] + w[1] <= 0.5, mu @ w >= 0.08] +""" + + +@pytest.mark.parametrize("formulation", [ + "p = cp.Problem(cp.Minimize(cp.quad_form(w, Sigma)), cons); p.solve()\nreport('optimal', math.sqrt(p.value), {})", + "L = np.linalg.cholesky(Sigma)\np = cp.Problem(cp.Minimize(cp.norm(L.T @ w, 2)), cons); p.solve()\nreport('optimal', p.value, {})", +], ids=["quad_form_sqrt", "cholesky_LT"]) +def test_volatility_formulations_from_solver_prompt(formulation): + # Beide im Solver-Prompt empfohlenen Varianten liefern das verifizierte Optimum (hard_portfolio_volatilitaet) + run = run_solver_code(VOLATILITY_SETUP + formulation) + assert run.objective_value == pytest.approx(0.1172905, abs=1e-5) + + +def test_cholesky_without_transpose_is_wrong(): + # Dokumentiert den im Benchmark beobachteten Fehler: ||L w|| ist nicht die Standardabweichung + code = VOLATILITY_SETUP + "L = np.linalg.cholesky(Sigma)\np = cp.Problem(cp.Minimize(cp.norm(L @ w, 2)), cons); p.solve()\nreport('optimal', p.value, {})" + assert run_solver_code(code).objective_value != pytest.approx(0.1172905, abs=1e-3) + + +def test_infeasible_is_reported_not_error(): + code = "import highspy\nh = highspy.Highs(); h.silent()\nx = h.addVariable(lb=0, ub=1)\nh.addConstr(x >= 2)\nh.minimize(x)\n" \ + "st = h.getModelStatus()\nreport('infeasible' if st == highspy.HighsModelStatus.kInfeasible else str(st), None, {})" + assert run_solver_code(code).status == SolverStatus.INFEASIBLE + + +@pytest.mark.parametrize("code", [ + "import os\nos.listdir('.')", + "from subprocess import run", + "open('x.txt', 'w')", + "def report(*a): pass", + "def broken(:", +]) +def test_forbidden_code_is_rejected(code): + with pytest.raises(CodeRejected): + validate_imports(code) + assert run_solver_code(code).status == SolverStatus.ERROR + + +def test_runtime_error_is_captured(): + run = run_solver_code("import math\nmath.sqrt(-1)") + assert run.status == SolverStatus.ERROR + assert "ValueError" in run.error + + +def test_missing_report_is_error(): + assert run_solver_code("x = 1").status == SolverStatus.ERROR + + +def test_timeout(): + run = run_solver_code("while True:\n pass", timeout=2) + assert run.status == SolverStatus.ERROR + assert "Timeout" in run.error + + +# --- SCIP (pyscipopt): Muster aus Solver-Prompt.txt --- + +SCIP_PORTFOLIO_SETUP = """ +from pyscipopt import Model, quicksum +MU = [x / 100 for x in [6.9, 14.4, 11.5, 9.8, 4.0, 4.0, 2.8, 13.3, 9.8, 11.2]] +FEE = [x / 100 for x in [0.68, 0.74, 0.39, 0.27, 0.34, 0.46, 0.69, 0.72, 0.20, 0.51]] +VAR = [x / 100 for x in [2.04, 1.63, 3.62, 3.01, 3.30, 3.33, 3.24, 1.97, 3.26, 2.52]] +m = Model(); m.hideOutput(); m.setParam("limits/time", 30) +w = [m.addVar(f"w{i}", vtype="C", lb=0, ub=1) for i in range(10)] +y = [m.addVar(f"y{i}", vtype="B") for i in range(10)] +m.addCons(quicksum(w) == 1); m.addCons(quicksum(y) >= 5); m.addCons(quicksum(y) <= 10) +for i in range(10): + m.addCons(w[i] >= 0.05 * y[i]); m.addCons(w[i] <= 0.30 * y[i]) +utility = quicksum(MU[i] * w[i] - VAR[i] * w[i] * w[i] - FEE[i] * y[i] for i in range(10)) +""" + + +def test_scip_miqp_with_epigraph_matches_verified_optimum(): + # Optimum 0.097558 unabhängig per Enumeration aller Auswahlen verifiziert (benchmark hard_portfolio_kardinalitaet) + code = SCIP_PORTFOLIO_SETUP + ( + "z = m.addVar('z', lb=None, ub=None)\nm.addCons(z <= utility)\nm.setObjective(z, 'maximize')\nm.optimize()\n" + "report(m.getStatus(), m.getObjVal(), {f'y{i}': round(m.getVal(y[i])) for i in range(10) if m.getVal(y[i]) > 0.5})" + ) + run = run_solver_code(code) + assert run.status == SolverStatus.OPTIMAL, run.error + assert run.objective_value == pytest.approx(0.097558, abs=1e-4) + assert sorted(run.solution) == ["y1", "y2", "y3", "y7", "y8"] # A02, A03, A04, A08, A09 + + +def test_scip_rejects_quadratic_objective_directly(): + # Dokumentiert, warum der Solver-Prompt die Epigraph-Formulierung vorschreibt + code = SCIP_PORTFOLIO_SETUP + "m.setObjective(utility, 'maximize')\nreport('optimal', 0.0, {})" + run = run_solver_code(code) + assert run.status == SolverStatus.ERROR and "nonlinear objective" in run.error + + +def test_scip_volatility_as_quadratic_constraint(): + code = VOLATILITY_SETUP.replace("import cvxpy as cp", "from pyscipopt import Model, quicksum").split("w = cp.Variable")[0] + ( + "m = Model(); m.hideOutput()\n" + "w = [m.addVar(f'w{i}', lb=0, ub=0.4) for i in range(4)]\n" + "m.addCons(quicksum(w) == 1); m.addCons(w[0] + w[1] <= 0.5)\n" + "m.addCons(quicksum(float(mu[i]) * w[i] for i in range(4)) >= 0.08)\n" + "t = m.addVar('t', lb=0)\n" + "m.addCons(t * t >= quicksum(float(Sigma[i][j]) * w[i] * w[j] for i in range(4) for j in range(4)))\n" + "m.setObjective(t, 'minimize'); m.optimize()\n" + "report(m.getStatus(), m.getObjVal(), {})" + ) + run = run_solver_code(code) + assert run.status == SolverStatus.OPTIMAL, run.error + assert run.objective_value == pytest.approx(0.1172905, abs=1e-4) + + +# --- Bereinigung von Markdown-Resten im generierten Code --- + +CLEAN = 'import math\n\n\ndef main():\n report("optimal", math.pi, {})\n\n\nif __name__ == "__main__":\n main()\n' + + +@pytest.mark.parametrize("raw", [ + CLEAN, + "python\n" + CLEAN, # beobachtet im Benchmark (Rest von ```python) + "```python\n" + CLEAN + "```", + "```\n" + CLEAN + "\n```\n", + "\n\nPython\n\n" + CLEAN, +], ids=["sauber", "python-zeile", "fence-python", "fence-leer", "gross-leerzeilen"]) +def test_markdown_remnants_are_removed(raw): + assert clean_generated_code(raw) == CLEAN + assert run_solver_code(clean_generated_code(raw)).status == SolverStatus.OPTIMAL + + +def test_code_mentioning_python_later_is_untouched(): + code = "# python-Kommentar\nx = 'python'\n" + assert clean_generated_code(code) == code + + +@pytest.mark.parametrize("rest", ["}", "}\n", '"}', "}\n]", "```"], ids=["klammer", "klammer-nl", "quote", "zwei", "fence"]) +def test_trailing_json_artifacts_are_removed(rest): + # beobachtet im Benchmark (lp_klein, beide Läufe): schließende JSON-Klammer nach dem main-Guard + assert clean_generated_code(CLEAN + rest) == CLEAN + assert run_solver_code(clean_generated_code(CLEAN + rest)).status == SolverStatus.OPTIMAL + + +def test_broken_code_keeps_its_real_syntax_error(): + # kein blindes Abschneiden: der Reparaturversuch soll den echten Fehler sehen + broken = 'def main(:\n pass\n}\n' + assert clean_generated_code(broken).strip().endswith("}") + + +def test_data_with_tuple_keys_still_reports_a_result(): + # beobachtet (hard_transport_lagerwahl): Transportkosten je Werk-Lager-Paar als Tupel-Schlüssel + code = ( + 'DATA = {"c": {("W1", "L1"): 4}}\n\n\n' + 'def main():\n' + ' report("optimal", 4.0, {"x": 1})\n\n\n' + 'if __name__ == "__main__":\n main()\n' + ) + run = run_solver_code(code) + assert run.status == SolverStatus.OPTIMAL, run.error + assert run.objective_value == 4.0 + assert run.data == {} # nicht übernommen, dafür als Warnung gemeldet + assert any("nicht JSON-fähig" in w for w in run.warnings) + + +# --- Ipopt (cyipopt): Muster aus Solver-Prompt.txt --- + +IPOPT_CONVEX = """ +import numpy as np +from cyipopt import minimize_ipopt +# min (x-1)^2 + (y-2.5)^2 u.d.N. x + y <= 2, x,y >= 0 -> konvex, Optimum (0.25, 1.75), Wert 1.125 +res = minimize_ipopt(lambda v: (v[0] - 1) ** 2 + (v[1] - 2.5) ** 2, x0=np.array([0.0, 0.0]), + bounds=[(0, None), (0, None)], + constraints=[{"type": "ineq", "fun": lambda v: 2 - v[0] - v[1]}], + options={"print_level": 0, "sb": "yes"}) +report("optimal" if res.success else "error", float(res.fun), {"x": float(res.x[0]), "y": float(res.x[1])}) +""" + +IPOPT_MULTISTART = """ +import numpy as np +from cyipopt import minimize_ipopt +# min x*y auf dem Einheitskreis, x,y >= 0: nichtkonvex, globales Minimum 0 an den Achsen +best = None +for x0 in ([0.5, 0.5], [0.9, 0.1], [0.1, 0.9], [0.0, 1.0], [1.0, 0.0]): + res = minimize_ipopt(lambda v: v[0] * v[1], x0=np.array(x0, dtype=float), bounds=[(0, None)] * 2, + constraints=[{"type": "eq", "fun": lambda v: v[0] ** 2 + v[1] ** 2 - 1}], + options={"print_level": 0, "sb": "yes"}) + if res.success and (best is None or res.fun < best): + best = float(res.fun) +report("feasible", best, {}) +""" + + +def test_ipopt_convex_problem_reports_optimum(): + run = run_solver_code(IPOPT_CONVEX) + assert run.status == SolverStatus.OPTIMAL, run.error + assert run.objective_value == pytest.approx(1.125, abs=1e-5) + + +def test_ipopt_multistart_finds_global_minimum_of_nonconvex_problem(): + # ein einzelner Startpunkt (0.5, 0.5) liefert 0.5 statt 0 – daher verlangt der Prompt mehrere Startpunkte + run = run_solver_code(IPOPT_MULTISTART) + assert run.status == SolverStatus.FEASIBLE + assert run.objective_value == pytest.approx(0.0, abs=1e-6) + + +# --- Sandbox: der erzeugte Code läuft ohne Netz und ohne Schreibrechte außerhalb seines Laufs --- + +import shutil + +from solver_stage import sandbox_command + +ESCAPE_CODE = """ +import numpy as np + + +def main(): + ziel = "{ziel}" + try: + np.savetxt(ziel, np.zeros(1)) + report("optimal", 1.0, {{"geschrieben": ziel}}) + except OSError as exc: + report("optimal", 0.0, {{"blockiert": type(exc).__name__}}) + + +if __name__ == "__main__": + main() +""" + + +@pytest.mark.skipif(not shutil.which("bwrap"), reason="bubblewrap nicht installiert") +def test_sandbox_blocks_writing_outside_the_run(tmp_path): + # numpy ist erlaubt und savetxt kein verbotener Aufruf – validate_imports sieht das nicht, + # die Sandbox schon. Genau dafür ist sie da. + ziel = tmp_path / "ausbruch.txt" + run = run_solver_code(ESCAPE_CODE.format(ziel=ziel)) + assert run.status == SolverStatus.OPTIMAL, run.error + assert "blockiert" in run.solution, f"Schreiben nach {ziel} war möglich" + assert not ziel.exists() + + +@pytest.mark.skipif(not shutil.which("bwrap"), reason="bubblewrap nicht installiert") +def test_sandbox_allows_writing_inside_the_run(): + # das Programm darf in seinem eigenen Arbeitsverzeichnis arbeiten (relativer Pfad) + run = run_solver_code(ESCAPE_CODE.format(ziel="zwischenergebnis.txt")) + assert run.status == SolverStatus.OPTIMAL, run.error + assert "geschrieben" in run.solution + + +def test_sandbox_command_uses_bwrap_when_available(monkeypatch, tmp_path): + import solver_stage + monkeypatch.setattr(solver_stage, "SANDBOX", "auto") + monkeypatch.setattr(solver_stage.shutil, "which", lambda name: "/usr/bin/bwrap") + befehl = sandbox_command(tmp_path / "solve.py", str(tmp_path)) + assert befehl[0] == "bwrap" + assert "--unshare-all" in befehl # kein Netz + assert befehl[befehl.index("--ro-bind") + 1:][:2] == ["/", "/"] # Dateisystem nur lesbar + assert str(tmp_path) in befehl # Ausnahme: das Arbeitsverzeichnis + + +def test_sandbox_can_be_switched_off(monkeypatch, tmp_path): + import solver_stage + monkeypatch.setattr(solver_stage, "SANDBOX", "aus") + assert sandbox_command(tmp_path / "solve.py", str(tmp_path))[0] == sys.executable + + +def test_missing_bwrap_is_reported_when_required(monkeypatch, tmp_path): + import solver_stage + monkeypatch.setattr(solver_stage, "SANDBOX", "bwrap") + monkeypatch.setattr(solver_stage.shutil, "which", lambda name: None) + with pytest.raises(CodeRejected, match="bubblewrap"): + sandbox_command(tmp_path / "solve.py", str(tmp_path)) + + +def test_missing_bwrap_falls_back_when_automatic(monkeypatch, tmp_path): + import solver_stage + monkeypatch.setattr(solver_stage, "SANDBOX", "auto") + monkeypatch.setattr(solver_stage.shutil, "which", lambda name: None) + assert sandbox_command(tmp_path / "solve.py", str(tmp_path))[0] == sys.executable + + +# --- Mehrstart-Streuung (Ipopt): check_multistart --- + +from model_schema import Multistart +from solver_stage import check_multistart + + +def multistart_run(**felder): + from model_schema import SolverRun + werte = {"starts": 5, "converged": 5, "best": 1.125, "worst": 1.125, "mean": 1.125, "stddev": 0.0} + werte.update(felder) + return SolverRun(status=SolverStatus.FEASIBLE, objective_value=werte["best"], + multistart=Multistart(**werte)) + + +def test_identical_starts_give_no_warning(): + assert check_multistart(multistart_run(), "minimize") == [] + + +def test_scattered_starts_are_reported_as_local(): + warnungen = check_multistart(multistart_run(worst=4.0, mean=2.0, stddev=1.2), "minimize") + assert any("nicht nachweislich global" in w for w in warnungen) + + +def test_mean_better_than_best_is_impossible(): + # bei Minimierung darf kein Mittelwert unter dem gemeldeten besten Wert liegen + warnungen = check_multistart(multistart_run(mean=0.5, stddev=0.0), "minimize") + assert any("mean" in w and "unmöglich" in w for w in warnungen) + + +def test_maximization_direction_is_respected(): + assert check_multistart(multistart_run(worst=0.5, mean=0.8, stddev=0.0), "maximize") == [] + assert any("worst" in w for w in check_multistart(multistart_run(worst=2.0, mean=1.5, stddev=0.0), "maximize")) + + +def test_single_successful_start_cannot_show_stability(): + warnungen = check_multistart(multistart_run(converged=1, worst=None, mean=None, stddev=None), "minimize") + assert any("nicht beurteilbar" in w for w in warnungen) + + +def test_no_multistart_means_no_check(): + from model_schema import SolverRun + assert check_multistart(SolverRun(status=SolverStatus.OPTIMAL), "minimize") == [] + + +def test_ipopt_program_reports_its_multistart(): + code = """ +import numpy as np +from cyipopt import minimize_ipopt + + +def main(): + # min (x-1)^2 u.d.N. x >= 0 – konvex, jeder Startpunkt landet bei x = 1 + werte = [] + for start in [0.0, 0.5, 2.0, 5.0, 10.0]: + res = minimize_ipopt(lambda v: (v[0] - 1) ** 2, x0=np.array([start]), bounds=[(0, 20)], + options={"print_level": 0, "sb": "yes"}) + if res.success: + werte.append(float(res.fun)) + report("optimal", min(werte), {"x": 1.0}, + multistart={"starts": 5, "converged": len(werte), "best": min(werte), "worst": max(werte), + "mean": float(np.mean(werte)), "stddev": float(np.std(werte))}) + + +if __name__ == "__main__": + main() +""" + run = run_solver_code(code) + assert run.status == SolverStatus.OPTIMAL, run.error + assert run.multistart is not None and run.multistart.converged == 5 + assert run.multistart.stddev == pytest.approx(0.0, abs=1e-6) + assert check_multistart(run, "minimize") == [] + + +# --- Graphenprobleme (NetworkX/Dijkstra): API aus Solver-Prompt.txt und check_graph --- + +from model_schema import GraphSolution +from solver_stage import check_graph + +DIJKSTRA_CODE = ''' +import networkx as nx + +DATA = { + # Kostenmatrix c[i][j]: Kauf am Ende von Jahr i, Nutzung bis Ende Jahr j + "kosten": {"0": {"1": 8, "2": 18, "3": 31}, "1": {"2": 10, "3": 21}, "2": {"3": 12}}, + "quelle": "0", + "ziel": "3", +} + + +def loesen(): + """Kuerzester Weg von der Quelle zur Senke mit dem Dijkstra-Algorithmus.""" + G = nx.DiGraph() + for i, ziele in DATA["kosten"].items(): + for j, c in ziele.items(): + G.add_edge(i, j, weight=c) + laenge, pfad = nx.single_source_dijkstra(G, DATA["quelle"], DATA["ziel"], weight="weight") + marken = nx.single_source_dijkstra_path_length(G, DATA["quelle"], weight="weight") + return G, laenge, pfad, marken + + +def main(): + G, laenge, pfad, marken = loesen() + boegen = [{"source": u, "target": v, "weight": d["weight"]} for u, v, d in G.edges(data=True)] + report("optimal", float(laenge), {f"x[{u},{v}]": 1.0 for u, v in zip(pfad, pfad[1:])}, + graph={"nodes": sorted(G.nodes()), "arcs": boegen, "path": list(pfad), + "steps": [{"node": k, "label": float(w)} for k, w in marken.items()], + "node_meaning": "ein Zeitpunkt - das Ende von Jahr i", + "arc_meaning": "Kauf am Ende von Jahr i und Nutzung bis Ende von Jahr j"}) + + +if __name__ == "__main__": + main() +''' + + +def test_dijkstra_program_from_solver_prompt(): + # Fall graph_ersatz_dijkstra: verifiziertes Optimum 29 ueber den Weg 0 -> 1 -> 3 + run = run_solver_code(DIJKSTRA_CODE) + assert run.status == SolverStatus.OPTIMAL, run.error + assert run.objective_value == 29 + assert run.graph is not None + assert run.graph.path == ["0", "1", "3"] + assert [s.node for s in run.graph.steps] == ["0", "1", "2", "3"] # Reihenfolge der Markierung + assert [s.label for s in run.graph.steps] == [0, 8, 18, 29] + assert check_graph(run, "minimize") == [] + + +def graph_run(**felder): + from model_schema import SolverRun + werte = {"nodes": ["0", "1", "2"], + "arcs": [{"source": "0", "target": "1", "weight": 4}, + {"source": "1", "target": "2", "weight": 5}, + {"source": "0", "target": "2", "weight": 12}], + "path": ["0", "1", "2"], "steps": [{"node": "0", "label": 0}, {"node": "1", "label": 4}, + {"node": "2", "label": 9}], + "node_meaning": "-", "arc_meaning": "-"} + werte.update(felder) + return SolverRun(status=SolverStatus.OPTIMAL, objective_value=felder.pop("objective_value", 9), + graph=GraphSolution.model_validate(werte)) + + +def test_consistent_graph_needs_no_warning(): + assert check_graph(graph_run(), "minimize") == [] + + +def test_path_must_use_declared_arcs(): + warnungen = check_graph(graph_run(path=["0", "2", "1"]), "minimize") + assert any("nicht deklariert" in w for w in warnungen) + + +def test_path_length_must_match_objective(): + from model_schema import SolverRun + run = graph_run() + run.objective_value = 7.0 # 4 + 5 = 9 + assert any("weicht vom Zielwert" in w for w in check_graph(run, "minimize")) + + +def test_negative_weights_are_reported_for_dijkstra(): + # geprueft: networkx meldet hier keinen Fehler, liefert aber ein falsches Ergebnis + arcs = [{"source": "0", "target": "1", "weight": 4}, {"source": "1", "target": "2", "weight": -5}, + {"source": "0", "target": "2", "weight": 12}] + run = graph_run(arcs=arcs) + run.objective_value = -1.0 + assert any("negative Bogengewichte" in w for w in check_graph(run, "minimize")) + + +def test_falling_labels_contradict_dijkstra(): + steps = [{"node": "0", "label": 0}, {"node": "1", "label": 9}, {"node": "2", "label": 4}] + assert any("fallen zwischendurch" in w for w in check_graph(graph_run(steps=steps), "minimize")) + + +# --- Abnahme: jede Teilfrage beantwortet, jede Zahl belegt (check_answers) --- + +from solver_stage import check_answers + + +def verstaendnis(*fragen): + from model_schema import SubQuestion, TaskUnderstanding + return TaskUnderstanding( + is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], + sub_questions=[SubQuestion(id=f, quote=f"Frage {f}", kind="rechnen", needs_solver=True) for f in fragen]) + + +def geloest(antworten, **felder): + from model_schema import Answer, SolverRun, VariableValue + werte = {"status": SolverStatus.OPTIMAL, "objective_value": 29.0, + "variables": [VariableValue(name="x", value=1.0)], + "answers": [Answer(question_id=k, text=t) for k, t in antworten]} + werte.update(felder) + return SolverRun(**werte) + + +def test_all_questions_answered_is_clean(): + run = geloest([("a", "Der Graph hat vier Knoten."), ("b", "Die Kosten betragen 29 Geldeinheiten.")]) + assert check_answers(run, verstaendnis("a", "b")) == [] + + +def test_missing_answer_is_reported(): + # Fall graph_ersatz_dijkstra: Zielwert richtig, Teil a) unbeantwortet + run = geloest([("b", "Die Kosten betragen 29 Geldeinheiten.")]) + befunde = check_answers(run, verstaendnis("a", "b")) + assert befunde and "a" in befunde[0] and "nicht vollständig beantwortet" in befunde[0] + + +def test_invented_numbers_in_the_answer_are_caught(): + # 47 wurde nie berechnet und steht nicht in der Aufgabe + run = geloest([("a", "Die Ersparnis liegt bei 47 Geldeinheiten.")]) + befunde = check_answers(run, verstaendnis("a"), task="Die Kosten betragen 8, 18 und 31.") + assert any("47" in b for b in befunde) + + +def test_comparison_numbers_belong_in_a_table(): + # beobachtet: die Antwort nannte den Vergleichsweg "kostet 30" - richtig gerechnet, + # aber in keinem gemeldeten Feld, also fuer den Leser nicht nachvollziehbar + from model_schema import ResultTable + run = geloest([("b", "Der beste Weg kostet 29, die Alternative 30.")]) + assert any("30" in b for b in check_answers(run, verstaendnis("b"))) + mit_tabelle = geloest([("b", "Der beste Weg kostet 29, die Alternative 30.")], + tables=[ResultTable(title="Alle Wege", columns=["Weg", "Kosten"], + rows=[["0-1-3", 29], ["0-2-3", 30]])]) + assert check_answers(mit_tabelle, verstaendnis("b")) == [] + + +def test_numbers_from_the_task_are_allowed_in_the_answer(): + run = geloest([("a", "Die Maschine wird am Ende von Jahr 1 ersetzt, Kosten 8 + 21 = 29.")]) + assert check_answers(run, verstaendnis("a"), task="| 0 | 8 | 18 | 31 |\n| 1 | - | 10 | 21 |") == [] + + +def test_no_understanding_means_no_acceptance_check(): + assert check_answers(geloest([]), None) == [] + + +def test_program_reports_answers_end_to_end(): + code = ''' +def main(): + """Loest die Aufgabe und beantwortet beide Teilfragen.""" + report("optimal", 29.0, {"x": 1.0}, + answers=[{"question_id": "a", "text": "Der Graph hat vier Knoten und sechs Boegen."}, + {"question_id": "b", "text": "Der kuerzeste Weg kostet 29 Geldeinheiten."}]) + + +if __name__ == "__main__": + main() +''' + run = run_solver_code(code) + assert run.status == SolverStatus.OPTIMAL, run.error + assert [a.question_id for a in run.answers] == ["a", "b"] + assert check_answers(run, verstaendnis("a", "b")) == [] + + +def test_attached_data_file_is_readable_in_the_sandbox(tmp_path): + # Die Datei muss im Laufverzeichnis landen - es ist das einzige, das die Sandbox beschreibbar bindet + datei = tmp_path / "werte.csv" + datei.write_text("name,menge\nA,3\nB,4\n") + code = ''' +import pandas as pd + +DATA = {"dateien": {"werte": "werte.csv"}} + + +def main(): + """Liest die beigelegte Datei und meldet ihre Summe.""" + tabelle = pd.read_csv(DATA["dateien"]["werte"]) + report("optimal", float(tabelle["menge"].sum()), {"zeilen": float(len(tabelle))}) + + +if __name__ == "__main__": + main() +''' + run = run_solver_code(code, data_files=[datei]) + assert run.status == SolverStatus.OPTIMAL, run.error + assert run.objective_value == 7.0 and run.solution["zeilen"] == 2.0 + + +def test_without_the_attachment_the_program_fails_cleanly(tmp_path): + code = ''' +import pandas as pd + + +def main(): + """Liest eine Datei, die nicht beigelegt wurde.""" + report("optimal", float(pd.read_csv("werte.csv")["menge"].sum()), {}) + + +if __name__ == "__main__": + main() +''' + run = run_solver_code(code) + assert run.status == SolverStatus.ERROR and "FileNotFound" in (run.error or "") + + +# --- Szenarien: Datenvarianten desselben Programms, ohne LLM nachgerechnet --- + +SZENARIO_CODE = ''' +DATA = {"preis": 10.0, "menge": 3.0} + + +def loesen(daten): + """Zielwert dieser Datenvariante.""" + return daten["preis"] * daten["menge"] + + +def main(): + """Rechnet das Grundszenario und zwei Varianten.""" + basis = loesen(DATA) + szenarien = [] + for name, abweichung in [("doppelter Preis", {"preis": 20.0}), ("halbe Menge", {"menge": 1.5})]: + szenarien.append({"name": name, "description": "-", "data_override": abweichung, + "objective_value": loesen({**DATA, **abweichung})}) + report("optimal", basis, {"wert": basis}, scenarios=szenarien) + + +if __name__ == "__main__": + main() +''' + + +def test_scenarios_are_recomputed_and_confirmed(): + run = run_solver_code(SZENARIO_CODE) + assert run.objective_value == 30.0 + assert [s.name for s in run.scenarios] == ["doppelter Preis", "halbe Menge"] + from solver_stage import verify_scenarios + assert verify_scenarios(run, SZENARIO_CODE) == [] # gemeldet = nachgerechnet + assert [s.verified_value for s in run.scenarios] == [60.0, 15.0] + + +def test_wrong_scenario_value_is_caught(): + from solver_stage import verify_scenarios + run = run_solver_code(SZENARIO_CODE) + run.scenarios[0].objective_value = 99.0 # das Programm hat sich verrechnet + befunde = verify_scenarios(run, SZENARIO_CODE) + assert any("doppelter Preis" in b and "99" in b for b in befunde) + + +def test_scenario_without_override_is_reported(): + from model_schema import Scenario + from solver_stage import verify_scenarios + run = run_solver_code(SZENARIO_CODE) + run.scenarios = [Scenario(name="leer", description="-", data_override={}, objective_value=1.0)] + assert any("keine Datenabweichung" in b for b in verify_scenarios(run, SZENARIO_CODE)) + + +def test_rounded_numbers_in_the_answer_are_accepted(): + # beobachtet: "9.65" fuer den berechneten Wert 9.647868... - Antworten runden, Ergebnisse nicht + run = geloest([("b", "Der Nutzen betraegt 9.65 Prozentpunkte.")], objective_value=9.647868721241633) + assert check_answers(run, verstaendnis("b")) == [] + + +def test_rounding_tolerance_does_not_hide_invented_numbers(): + run = geloest([("b", "Der Nutzen betraegt 47 Prozentpunkte.")], objective_value=9.647868721241633) + assert any("47" in b for b in check_answers(run, verstaendnis("b"))) + + +def test_unit_conversion_in_the_answer_is_accepted(): + # beobachtet: "9,00 Tsd. € (also 9000 €)" fuer den Zielwert 9 - eine Umrechnung, keine neue Zahl + run = geloest([("1", "Die Kosten betragen 9,00 Tsd. € (also 9000 €).")], objective_value=9.0) + assert check_answers(run, verstaendnis("1")) == [] + + +def test_percent_and_fraction_are_the_same_number(): + run = geloest([("1", "Der Nutzen betraegt 9.76 % des Kapitals.")], objective_value=0.0975577) + assert check_answers(run, verstaendnis("1")) == [] + + +def test_invented_number_survives_the_scale_tolerance(): + run = geloest([("1", "Die Ersparnis betraegt 47 €.")], objective_value=9.0) + assert any("47" in b for b in check_answers(run, verstaendnis("1"), task="Kosten 3 und 2.")) + + +def test_numbers_from_the_attached_data_are_traceable(): + # Bei beigelegten Dateien stehen die Eingabewerte NICHT im Aufgabentext - trotzdem sind sie belegt + run = geloest([("a", "Aktie A02 hat die hoechste Rendite mit 14.4 Prozentpunkten.")], objective_value=12.03) + run.data = {"dateien": {"aktien": "aktien.csv"}} + befunde = check_answers(run, verstaendnis("a"), task="Die Kennzahlen stehen in aktien.csv.", + data_summary="Rendite (Zahl, von 2.8 bis 14.4, Mittel 8.77): 6,9, 14,4, 11,5") + assert befunde == [] + + +def test_duplicate_question_ids_still_need_one_answer_each(): + from model_schema import SubQuestion, TaskUnderstanding + u = TaskUnderstanding(is_optimization_task=True, reason="-", facts=[], ignored_numbers=[], sub_questions=[ + SubQuestion(id="a1", quote="Formulieren Sie …", kind="formulieren", needs_solver=False), + SubQuestion(id="a2", quote="Zeichnen Sie …", kind="zeichnen", needs_solver=False)]) + run = geloest([("a1", "Das Modell ist ein Graph.")]) + assert any("a2" in b for b in check_answers(run, u)) + + +def test_self_generated_data_must_be_declared(): + # beobachtet an "Portfolio Nr. 2": synthetischer Markt mit festem Seed, data_assumptions leer + from model_schema import SolverCode + from solver_stage import check_generated_data + code = "import numpy as np\nrng = np.random.default_rng(42)\nmu = rng.normal(0.1, 0.05, 25)\n" + assert check_generated_data(code, SolverCode(code=code, data_assumptions=[])) + assert check_generated_data(code, SolverCode(code=code, data_assumptions=["mu, Sigma: synthetisch, Seed 42"])) == [] + + +def test_fixed_seed_with_complete_data_is_not_generated_data(): + # Ipopt-Mehrstart mit festem Zufallsstartwert: alle Werte stehen in der Spezifikation + from model_schema import SolverCode + from solver_stage import check_generated_data + from test_model_checks import make_spec + code = "import numpy as np\nrng = np.random.default_rng(42)\nstarts = rng.uniform(0, 1, (5, 2))\n" + ipopt = make_spec(problem_class="Nonlinear Programming (NLP)", target_stack="Ipopt (cyipopt)") + assert check_generated_data(code, SolverCode(code=code, data_assumptions=[]), ipopt) == [] + + +def test_prose_instead_of_values_does_not_hide_generated_data(): + # Nachmessung "Portfolio Nr. 2": extracted_values war nicht leer, sondern Text ohne Werte + from model_schema import SolverCode + from solver_stage import check_generated_data + from test_model_checks import make_spec + spec = make_spec(parameters=[{"symbol": "\\mu_i", "dimension": "Vektor", "unit": "1/Jahr", "description": "Rendite", + "extracted_values": "aus Kursdaten berechnet (Teil 1); im Text nicht numerisch vorgegeben"}]) + code = "import numpy as np\nrng = np.random.default_rng(0)\nmu = rng.normal(0.1, 0.05, 25)\n" + assert check_generated_data(code, SolverCode(code=code, data_assumptions=[]), spec) + + +def test_ordinary_programs_are_not_suspected(): + from model_schema import SolverCode + from solver_stage import check_generated_data + code = "import highspy\nh = highspy.Highs()\nDATA = {'kosten': [3, 2]}\n" + assert check_generated_data(code, SolverCode(code=code, data_assumptions=[])) == []