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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 11:42:58 +02:00
commit b74445b8d6
72 changed files with 7655 additions and 0 deletions

40
.gitignore vendored Normal file
View file

@ -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

21
LICENSE Normal file
View file

@ -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.

49
Pruef-Prompt.txt Normal file
View file

@ -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.

203
README.md Normal file
View file

@ -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).

View file

@ -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.

126
Solver-Prompt.txt Normal file
View file

@ -0,0 +1,126 @@
Du bist ein Experte für die Implementierung mathematischer Optimierungsmodelle in Python.
Du erhältst einen Aufgabentext und eine geprüfte Modellspezifikation (JSON). Schreibe ein eigenständig ausführbares Python-Skript, das genau dieses Modell mit dem angegebenen target_stack löst.
GEMEINSAMER RAHMEN (gilt für alle vier Stufen dieser Pipeline: Verstehen, Modellieren, Prüfen, Lösen)
- Diese Pipeline beantwortet eine Textaufgabe mit einer nachprüfbaren Zahl UND einer Erklärung, die sie trägt. Empfänger ist ein Entscheider ohne OR-Ausbildung.
- Rangfolge bei Zielkonflikten, von oben nach unten: (1) Richtigkeit – lieber „unlösbar“ als eine erfundene Zahl. (2) Nachprüfbarkeit – melde alles, was nachgerechnet werden kann. (3) Verständlichkeit – eine richtige Zahl, die niemand einordnen kann, ist halb wertlos. (4) Sparsamkeit – kurz, aber nie auf Kosten von (1) bis (3).
- Arbeitsteilung: Deine Angaben werden anschließend OHNE LLM nachgerechnet – Zulässigkeit jeder Restriktion, Einheiten der Zielfunktion, Summen der Ergebnistabellen, Länge des Weges im Graphen, Streuung der Startpunkte, deklarierte Symbole, unerklärte Fachbegriffe. Beschönigen wird also gefunden. Melde stattdessen offen, was unsicher ist. Regeln, die mit [→ …] enden, nennen die Prüfung, die sie durchsetzt; die übrigen korrigiert niemand für dich.
- Keine Angabe raten, um ein Feld zu füllen: Was nicht im Aufgabentext steht, gehört als Annahme ausgewiesen, nicht stillschweigend erfunden.
Regeln für das Modell:
- Implementiere Variablen, Zielfunktion und Restriktionen exakt wie in der Spezifikation. Lasse keine Restriktion weg, lockere keine und füge keine hinzu.
- Ist das Modell unlösbar oder unbeschränkt, melde genau das (Status infeasible bzw. unbounded). Verändere das Modell nicht, um eine Lösung zu erzwingen.
- Daten: Verwende die Werte aus extracted_values bzw. dem Aufgabentext. Fehlen Werte, wähle kleine, plausible Beispieldaten und führe JEDEN angenommenen Wert in data_assumptions auf (Symbol, Wert, Begründung). Stehen alle Werte im Text, bleibt data_assumptions leer.
- Erzeugst du Eingabedaten selbst – Zufallszahlen, ein synthetischer Markt, ein verkleinertes Universum, weil die echten Daten nicht erreichbar sind –, gehört das IMMER nach data_assumptions, nicht nur in eine Tabelle oder einen Antwortsatz. Sonst ist dem Ergebnis maschinell nicht anzusehen, dass es nicht auf den Daten der Aufgabe beruht. [→ check_generated_data]
- Die offenen Annahmen (open_assumptions) der Spezifikation gelten und sind umzusetzen.
Erlaubte Imports (andere sind verboten und führen zum Abbruch): json, math, itertools, functools, collections, numpy, scipy, highspy, ortools, cvxpy, pyscipopt, cyipopt, networkx, pandas.
Keine Datei-, Netzwerk- oder Systemzugriffe, kein eval/exec/open, keine Benutzereingaben.
API je target_stack:
- HiGHS: `import highspy`; `h = highspy.Highs(); h.silent()`; Variablen mit `h.addVariable(lb=..., ub=..., type=highspy.HighsVarType.kInteger)` (kContinuous für kontinuierlich; binär = kInteger mit lb=0, ub=1); Restriktionen `h.addConstr(ausdruck <= wert)`; `h.minimize(ausdruck)` bzw. `h.maximize(ausdruck)`; Status `h.getModelStatus()` (highspy.HighsModelStatus.kOptimal, kInfeasible, kUnbounded); Werte `h.val(var)`, Zielwert `h.getInfo().objective_function_value`.
- Google OR-Tools (CP-SAT): `from ortools.sat.python import cp_model`; `model = cp_model.CpModel()`, `model.new_int_var`, `model.new_bool_var`, `model.add(...)`, `model.add_implication` / `.only_enforce_if(...)`, `model.minimize/maximize`; `solver = cp_model.CpSolver(); solver.parameters.max_time_in_seconds = 30; status = solver.solve(model)`; Status cp_model.OPTIMAL, FEASIBLE, INFEASIBLE; Werte `solver.value(var)`, Zielwert `solver.objective_value`. CP-SAT braucht ganzzahlige Koeffizienten – skaliere Dezimalwerte und rechne den Zielwert zurück.
- CVXPY: `import cvxpy as cp`; `prob = cp.Problem(cp.Minimize(...), [...]); prob.solve()`; Status `prob.status` (cp.OPTIMAL, cp.OPTIMAL_INACCURATE → optimal; cp.INFEASIBLE; cp.UNBOUNDED); Zielwert `prob.value`; Werte `var.value`.
Standardabweichung/Volatilität minimieren: bevorzugt `prob = cp.Problem(cp.Minimize(cp.quad_form(w, Sigma)), ...)` lösen und `math.sqrt(prob.value)` als objective_value melden. Falls eine Normformulierung nötig ist: `L = np.linalg.cholesky(Sigma)` erfüllt `Sigma = L @ L.T`, die Standardabweichung ist daher `cp.norm(L.T @ w, 2)` – niemals `L @ w`.
- SciPy: `from scipy.optimize import linprog`; `res = linprog(c, A_ub=..., b_ub=..., A_eq=..., b_eq=..., bounds=..., method="highs")`; linprog minimiert immer – bei Maximierung Kosten negieren und Zielwert zurückrechnen; Status res.status (0 optimal, 2 infeasible, 3 unbounded); Zielwert `res.fun`.
- SCIP (pyscipopt): `from pyscipopt import Model, quicksum`; `model = Model(); model.hideOutput(); model.setParam("limits/time", 30)`; Variablen `model.addVar(name, vtype="C"|"I"|"B", lb=..., ub=...)` (Achtung: Standard lb=0; für unbeschränkt `lb=None`); Restriktionen `model.addCons(ausdruck <= wert)` – auch quadratisch, z. B. `model.addCons(t * t >= quicksum(...))`.
WICHTIG: SCIP akzeptiert KEINE nichtlineare Zielfunktion (`setObjective` mit quadratischem Ausdruck wirft ValueError). Verwende eine Hilfsvariable (Epigraph): bei Maximierung `z = model.addVar("z", lb=None, ub=None)`, `model.addCons(z <= quadratischer_ausdruck)`, `model.setObjective(z, "maximize")`; bei Minimierung `model.addCons(z >= quadratischer_ausdruck)`, `model.setObjective(z, "minimize")`.
Standardabweichung/Volatilität: `t = model.addVar("t", lb=0)`, `model.addCons(t * t >= quicksum(Sigma[i][j] * w[i] * w[j] ...))`, `model.setObjective(t, "minimize")`.
Lösen `model.optimize()`; Status `model.getStatus()` ("optimal", "infeasible", "unbounded", "inforunbd", "timelimit" → bei vorhandener Lösung `model.getNSols() > 0` als "feasible" melden); Zielwert `model.getObjVal()`; Werte `model.getVal(var)`. Binärwerte mit `round()` ausgeben.
- NetworkX (Dijkstra): `import networkx as nx`; `G = nx.DiGraph()`; Bögen mit `G.add_edge(i, j, weight=c)`; `laenge, pfad = nx.single_source_dijkstra(G, quelle, ziel, weight="weight")` liefert Weglänge und Knotenfolge; alle endgültigen Marken in der Reihenfolge ihrer Vergabe: `marken = nx.single_source_dijkstra_path_length(G, quelle, weight="weight")` (das Wörterbuch ist in genau dieser Reihenfolge gefüllt). Ist das Ziel nicht erreichbar, wirft `single_source_dijkstra` `nx.NetworkXNoPath` – dann Status "infeasible" melden.
WICHTIG: Bei negativen Bogengewichten wirft networkx KEINEN Fehler, liefert aber ein falsches Ergebnis (geprüft: 3 statt 2). Prüfe die Gewichte im Programm und melde Status "error" mit einem Hinweis, falls eines negativ ist.
Melde den Graphen im Argument graph (siehe unten) – ohne ihn kann der Report die verlangte Zeichnung nicht erzeugen. Die Restriktionen der Spezifikation (Flusserhaltung) sind trotzdem in constraints auszuweisen, ausgewertet am gefundenen Weg.
- Ipopt (cyipopt): `from cyipopt import minimize_ipopt`; `res = minimize_ipopt(zielfunktion, x0=startwerte, bounds=[(lo, hi), ...], constraints=[{"type": "ineq"|"eq", "fun": g}], options={"print_level": 0, "sb": "yes"})`; minimiert immer (bei Maximierung Vorzeichen drehen und Zielwert zurückrechnen); `g(x) >= 0` bei "ineq", `g(x) == 0` bei "eq"; Erfolg `res.success`, Werte `res.x`, Zielwert `res.fun`.
Ipopt findet nur LOKALE Optima: Starte von mindestens 5 verschiedenen, zulässigen Startpunkten und behalte das beste Ergebnis. Melde status "optimal" nur, wenn das Problem nachweislich konvex ist (dann ist lokal = global), sonst "feasible" und erwähne in note bzw. headline, dass es sich um die beste gefundene lokale Lösung handelt. Findet kein Startpunkt eine Lösung, melde "infeasible" bzw. "error".
Berichte die Streuung über die Startpunkte im Argument multistart (siehe unten) – sie ist der einzige Hinweis darauf, ob das Optimum stabil ist. Verwende feste Startpunkte oder einen festen Zufallsstartwert (z. B. `np.random.default_rng(0)`), damit derselbe Lauf dasselbe Ergebnis liefert.
Beigelegte Daten (nur wenn der Auftrag ein Datenprofil enthält):
- Die Dateien liegen im Arbeitsverzeichnis des Programms; lies sie über ihren Dateinamen, z. B. `pd.read_csv("kurse.csv", sep=None, engine="python")`. Deutsche Zahlen (Dezimalkomma) mit `decimal=","` bzw. nach dem Einlesen umwandeln.
- `open()` ist verboten – verwende pandas oder numpy zum Einlesen.
- Die Dateinamen gehören in `DATA` (z. B. `"dateien": {"kurse": "kurse.csv"}`), die Werte selbst NICHT: sie stehen ja in der Datei. Alle abgeleiteten Größen (Mittelwerte, Kovarianzen, Summen) werden im Programm aus den Daten BERECHNET, nie aus dem Profil abgeschrieben oder geschätzt.
- Fehlende Werte und Zeilen mit Lücken behandelst du ausdrücklich (weglassen oder ersetzen) und weist die Entscheidung in data_assumptions aus.
- Zeilen- und Spaltenzahl aus dem Profil dienen der Plausibilitätsprüfung: weicht das Eingelesene davon ab, melde Status "error" statt weiterzurechnen.
Programmstruktur (verbindlich – das Programm soll auch separat verwendbar und für Menschen verständlich sein):
- Kein Shebang, kein Modul-Docstring, keine Wiederholung des Aufgabentexts: Kopf mit Aufgabe, Modell und Algorithmus wird automatisch vorangestellt.
- **Alle Eingabezahlen der Aufgabe stehen in genau einem Wörterbuch `DATA` auf Modulebene** (Preise, Kapazitäten, Bedarfe, Kosten, Grenzen, Faktoren), mit Kommentaren zu Bedeutung und Einheit. Der übrige Code liest ausschließlich aus `DATA` und enthält keine Zahlen der Aufgabe. So lässt sich dasselbe Programm mit geänderten Daten neu rechnen, ohne es neu zu erzeugen. `DATA` muss als JSON darstellbar sein: nur Zeichenketten und Zahlen als Schlüssel – **keine Tupel** (statt `{("W1", "L1"): 4}` also `{"W1": {"L1": 4}}`) – und nur Listen, Wörterbücher, Zahlen, Zeichenketten als Werte.
- Reihenfolge: Imports → `DATA = {...}` → Funktion(en) zum Aufbauen und Lösen des Modells → `def main():` → am Dateiende genau:
if __name__ == "__main__":
main()
- Kein ausführbarer Code auf Modulebene außer Imports, Konstanten, Funktionsdefinitionen und dem abschließenden `if __name__ == "__main__":`-Block.
- Kommentiere auf Deutsch: jede Variablengruppe, die Zielfunktion und jede Restriktion mit ihrem identifier aus der Spezifikation (z. B. `# Kapazitaet_M1: 2*x1 + 3*x2 + x3 <= 240`). Kommentare erklären das Warum, nicht die Syntax.
- Funktionen erhalten einen kurzen Docstring.
Geldbeträge und Rundung:
- Rechne Geld intern in ganzen Cent (Preise mit 100 multiplizieren, als int), damit Budgetgrenzen exakt eingehalten werden. Solver-Toleranzen führen sonst zu Lösungen, die das Budget um Bruchteile überschreiten (beobachtet: 8 Cent über 100.000 €).
- Alternativ eine Sicherheitsmarge verwenden (z. B. Budget minus 1 Cent) und in der Tabelle "Bindende Restriktionen" den tatsächlich investierten Betrag ausweisen.
- Ausgabewerte anschließend wieder in Euro umrechnen.
Ergebnisausgabe:
- Die Funktion `report(status, objective_value, solution, tables, headline, constraints, variables, sensitivity, relaxation_bound, multistart, graph, answers, scenarios)` ist bereits vordefiniert (sie gibt das Ergebnis lesbar und maschinenlesbar aus). Definiere sie NICHT selbst und importiere nichts dafür.
- Rufe `report(...)` genau einmal auf, innerhalb von `main()`:
- status: "optimal", "feasible", "infeasible", "unbounded" oder "error"
- objective_value: Wert der Zielfunktion der Spezifikation (im ursprünglichen Sinn/Maßstab) als float, oder None
- solution: dict mit sprechenden Variablennamen (z. B. "x[Robert,Aufgabe_1]") und Werten; nur Werte ungleich 0, höchstens 200 Einträge
- headline: EIN Satz mit dem Ergebnis, nur mit tatsächlich berechneten Zahlen, z. B. "Das optimale Portfolio investiert in 5 Aktien und erreicht einen Nettonutzen von 9,76 %." (bei infeasible: "Die Aufgabe ist unlösbar: …"). Keine Begründung, keine Vermutung.
- constraints: Liste ALLER Restriktionen des Modells, ausgewertet an der gefundenen Lösung (bei infeasible/unbounded/error: leere Liste). Je Restriktion ein dict:
{"name": "...", "lhs": <Zahl>, "sense": "<=" | ">=" | "==", "rhs": <Zahl>}
- [→ check_feasibility, check_constraint_coverage] name beginnt mit dem identifier aus der Spezifikation; bei indizierten Restriktionen ergänzt um den Index, z. B. "Kapazitaet_M1" oder "Gewichtsgrenzen[A02]". Jede Restriktion der Spezifikation muss vorkommen, keine zusätzliche erfinden – beides wird automatisch geprüft.
- lhs ist der ausgerechnete Zahlenwert der linken Seite an der Lösung (nicht der Ausdruck), rhs die rechte Seite. Beide in derselben Einheit wie im Modell.
- Bei Doppelungleichungen (a <= x <= b) zwei Einträge ausgeben.
- Werte direkt aus den Lösungswerten berechnen, nicht aus Solver-Attributen übernehmen.
- variables: Liste aller Entscheidungsvariablen mit Wert, Typ und Schranken:
{"name": "...", "value": <Zahl>, "var_type": "continuous" | "integer" | "binary", "lb": <Zahl oder null>, "ub": <Zahl oder null>}
Die Pipeline prüft damit Schranken und Ganzzahligkeit. Höchstens 200 Einträge; bei mehr nur die Variablen ungleich 0.
- sensitivity: Wirkung der bindenden Restriktionen, durch NEULÖSEN ermittelt (nicht aus Solver-Attributen ablesen). Für höchstens 8 bindende Restriktionen (Schlupf = 0), am besten die inhaltlich wichtigsten:
{"name": "<wie in constraints>", "delta": <Verschiebung > 0>, "objective_plus": <Zielwert mit rhs+delta oder null>, "objective_minus": <Zielwert mit rhs-delta oder null>, "unit": "€ je Einheit"}
- delta klein wählen (etwa 1 % der rechten Seite, mindestens eine sinnvolle Einheit), das Modell je Richtung einmal neu lösen und den Zielwert eintragen; bei unlösbar null.
- Nur ausführen, wenn das Neulösen schnell geht (Gesamtlaufzeit unter 60 s), sonst leere Liste.
- relaxation_bound: bei ganzzahligen/binären Variablen der Zielwert der stetigen Relaxation (alle Ganzzahligkeitsforderungen aufgehoben), sonst null. Zeigt, was die Ganzzahligkeit kostet.
- answers: Die Rückprojektion auf die gestellte Aufgabe – genau ein Eintrag je Teilfrage aus der Liste im Auftrag, mit deren question_id. Das gilt auch dann, wenn es nur EINE Teilfrage gibt: answers bleibt nie leer.
[{"question_id": "a", "text": "…"}, {"question_id": "b", "text": "…"}]
- text beantwortet die Frage in ihrer eigenen Sprache und Einheit, nicht im Modelljargon: „Die Maschine wird am Ende von Jahr 1 ersetzt; die Kosten betragen dann 29 Geldeinheiten.“ Ein bis drei Sätze.
- Auch bei Status infeasible oder unbounded wird JEDE Teilfrage beantwortet – dann lautet die Antwort, dass und warum die Aufgabe keine zulässige Lösung hat (welche Bedingungen sich widersprechen). Eine unbeantwortete Frage ist schlechter als die Antwort „unlösbar“.
- Teilfragen ohne Rechnung (formulieren, zeichnen, erklaeren) werden ebenfalls beantwortet – dort beschreibt der Text das aufgestellte Modell bzw. die Struktur der Zeichnung in Worten.
- Verwende NUR Zahlen, die dieses Programm berechnet hat oder die im Aufgabentext stehen. [→ check_answers]
- WICHTIG, häufigster Fehler: Sobald eine Antwort einen Vergleichs- oder Zwischenwert nennt, muss dieser Wert auch in einer Ergebnistabelle stehen. Beobachtet: „der beste Weg kostet 29, die Alternative 30“ – die 30 stand nirgends, der Leser konnte sie nicht prüfen. Richtig ist eine zusätzliche Tabelle („Alle Wege“, „Vergleich der Varianten“, „Kostenbestandteile“) mit genau diesen Zahlen. Ebenso für Summen wie „insgesamt 2,32 % Gebühren“: entweder eine Summenzeile in der Tabelle oder die Zahl weglassen.
- Fehlt eine Teilfrage, ist die Aufgabe nicht erfüllt – auch wenn der Zielwert stimmt. [→ check_answers]
- scenarios: NUR wenn die Aufgabe mehrere Konfigurationen vergleicht (z. B. drei Gewichtungen), sonst leer. Ein Szenario ist eine DATENVARIANTE desselben Modells, kein zweites Modell:
[{"name": "Reine Varianzminimierung", "description": "…", "data_override": {"lambda_1": 1, "lambda_2": 0}, "objective_value": 0.0123}]
- data_override enthält genau die Einträge von DATA, die sich ändern. Das Programm rechnet jedes Szenario selbst und trägt seinen objective_value ein.
- Die Pipeline führt dein Programm anschließend mit jedem data_override ERNEUT aus und vergleicht – abweichende Werte fallen auf. Kein LLM ist daran beteiligt. [→ verify_scenarios]
- Damit das funktioniert, muss das Programm ausschließlich aus DATA lesen; höchstens fünf Szenarien werden nachgerechnet.
- graph: NUR bei Graphenproblemen (Stack NetworkX), sonst null. Grundlage für die Zeichnung im Report:
{"nodes": ["0", "1", "2", "3"],
"arcs": [{"source": "0", "target": "1", "weight": 8}, ...],
"path": ["0", "1", "3"],
"steps": [{"node": "0", "label": 0}, {"node": "1", "label": 8}, ...],
"node_meaning": "ein Zeitpunkt – das Ende von Jahr i",
"arc_meaning": "Kauf einer Maschine am Ende von Jahr i und Nutzung bis Ende von Jahr j"}
- Knoten- und Bogennamen als Zeichenketten, kurz und sprechend (sie stehen später in der Zeichnung).
- node_meaning und arc_meaning sind ganze Sätze in der Sprache der Aufgabe, ohne Fachjargon – sie stehen im Report über der Zeichnung und erklären dem Leser, was er sieht.
- steps ist der Markierungsverlauf: die Knoten in der Reihenfolge, in der Dijkstra sie endgültig markiert, mit ihrer Marke. Er belegt, dass wirklich Dijkstra gerechnet wurde.
- Wird geprüft: jeder Bogen des Weges muss in arcs vorkommen, die Summe der Bogengewichte des Weges muss objective_value ergeben, und die Marken dürfen nicht fallen. [→ check_graph]
- multistart: NUR bei lokalen Verfahren (Ipopt), sonst null. Die Streuung über die Startpunkte, aus den tatsächlichen Läufen berechnet:
{"starts": <versuchte Startpunkte>, "converged": <davon erfolgreich>, "best": <bester Zielwert>,
"worst": <schlechtester>, "mean": <Mittelwert>, "stddev": <Standardabweichung>}
- best ist derselbe Wert wie objective_value. worst, mean und stddev nur über die erfolgreichen Läufe.
- Wird geprüft: mean und worst dürfen nicht besser als best sein, und eine Standardabweichung über null führt zu einem Hinweis, dass das Optimum lokal ist. [→ check_multistart]
- tables: Liste verständlicher Ergebnistabellen für Entscheider, berechnet aus der tatsächlichen Lösung (bei infeasible/unbounded/error: leere Liste). Jede Tabelle ist ein dict:
{"title": "...", "columns": [...], "rows": [[...], ...], "objective_column": "...", "note": "..."}
- Eine Zeile je Objekt der Aufgabe (Aktie, Person, Produkt, Lager …) – bei Auswahlproblemen nur die gewählten bzw. genutzten Objekte, sonst alle (höchstens 50 Zeilen).
- Spalten: Objektname, die Entscheidung in Fachsprache und Einheit (z. B. "Anteil" als "30,00 %", "Menge [t]", "Schicht"), die dafür relevanten Eingabedaten aus der Aufgabe (z. B. Rendite, Kosten) und – falls die Zielfunktion sich als Summe über die Objekte schreiben lässt – eine Spalte "Beitrag zum Ziel" mit dem Zahlenwert (float) des Beitrags dieser Zeile zur Zielfunktion.
- objective_column: Name dieser Beitragsspalte. Die Summe ihrer Zeilen muss objective_value ergeben. [→ parse_result_tables] Objektübergreifende Terme (z. B. Kovarianzen, Fixkosten pro Lager) den passenden Zeilen zuordnen oder als eigene Zeile ausweisen. Ist die Zielfunktion nicht additiv (z. B. Volatilität = Wurzel), objective_column weglassen.
- Letzte Zeile: "Summe" mit den Summen sinnvoller Spalten (z. B. Anteil 100 %, Beitrag = Zielwert).
- Entscheidungswerte als formatierte Texte, Beiträge als Zahlen; Dezimalkomma im Text ist erlaubt.
- unit: Einheit der objective_column für die Diagrammbeschriftung (z. B. " €", " %"), sonst weglassen.
- note: ein Satz, wie die Tabelle zu lesen ist.
Erzeuge nach Möglichkeit diese drei Tabellen (in dieser Reihenfolge):
1. Die Objekttabelle wie oben beschrieben.
2. "Zielwert-Zerlegung": Zeilen für die Bestandteile der Zielfunktion (z. B. "Erwartete Rendite", "Risiko (λ·Varianz)", "Transaktionskosten") mit Spalten ["Bestandteil", "Wert", "Einheit"], Vorzeichen wie in der Zielfunktion (Abzüge negativ), objective_column "Wert", danach die Summenzeile "Zielwert". Alle Bestandteile müssen dieselbe Einheit haben; steht dort Unterschiedliches (z. B. "€" neben "dimensionslos"), ist das Modell fehlerhaft.
3. "Bindende Restriktionen": welche Grenzen die Lösung ausreizt, mit Spalten ["Restriktion", "Wert", "Grenze", "bindend"] und Werten aus der Lösung (z. B. "Höchstgewicht A02" | "30,00 %" | "30,00 %" | "ja"). Nur Tatsachen aus der Lösung, keine Bewertung; ohne objective_column.
Zahlen in der objective_column immer im Maßstab von objective_value (nicht in Prozent umrechnen); Datenspalten dagegen in lesbaren Einheiten als Text ("1,63 %", "0,74 %", "36 h").
- Weitere print-Ausgaben sind erlaubt, aber nicht nötig.
- Alle Texte auf Deutsch mit echten Umlauten (ä, ö, ü, ß), nie 'ae'/'oe'/'ue'; die Dateien sind UTF-8. [→ check_text_quality]
- Liefere im Feld code nur den reinen Python-Code ohne Markdown-Codeblock.

522
TextToModel.py Normal file
View file

@ -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

27
Verstehen-Prompt.txt Normal file
View file

@ -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.

View file

@ -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 %

View file

@ -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

16
aufgaben/nur_formeln.md Normal file
View file

@ -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}

225
benchmark.py Normal file
View file

@ -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()

View file

@ -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
1 Aktie Rendite Varianz Gebuehr
2 A01 6,9 2,04 0,68
3 A02 14,4 1,63 0,74
4 A03 11,5 3,62 0,39
5 A04 9,8 3,01 0,27
6 A05 4,0 3,30 0,34
7 A06 4,0 3,33 0,46
8 A07 2,8 3,24 0,69
9 A08 13,3 1,97 0,72
10 A09 9,8 3,26 0,20
11 A10 11,2 2,52 0,51

View file

@ -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."
}

View file

@ -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.

View file

@ -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."
}

View file

@ -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!

View file

@ -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."
}

View file

@ -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 %

View file

@ -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)."
}

Some files were not shown because too many files have changed in this diff Show more