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

142
test_script_export.py Normal file
View file

@ -0,0 +1,142 @@
import ast
import subprocess
import sys
import warnings
import pytest
from model_schema import PipelineResult, SolverCode, SolverRun, SolverStatus
from report import build_document, to_html
from script_export import build_solver_script
from solver_stage import CodeRejected, assemble_script, run_solver_code, validate_structure
from test_model_checks import make_spec
from test_review import make_review
# min 3x + 2y s.t. x + y >= 4, 0 <= x, y <= 3 -> Zielwert 9
STRUCTURED_CODE = '''
from scipy.optimize import linprog
# Kosten je Tonne Rohstoff X und Y [Tsd. €]
COSTS = [3, 2]
def solve_model():
"""Baut das LP auf und löst es."""
# Bedarf: x + y >= 4 -> -x - y <= -4
return linprog(COSTS, A_ub=[[-1, -1]], b_ub=[-4], bounds=[(0, 3), (0, 3)], method="highs")
def main():
"""Löst das Modell und gibt das Ergebnis aus."""
res = solve_model()
x, y = float(res.x[0]), float(res.x[1])
report(
"optimal" if res.status == 0 else "error", res.fun, {"x": x, "y": y},
constraints=[{"name": "Bedarf", "lhs": x + y, "sense": ">=", "rhs": 4}],
variables=[{"name": "x", "value": x, "var_type": "continuous", "lb": 0, "ub": 3},
{"name": "y", "value": y, "var_type": "continuous", "lb": 0, "ub": 3}],
)
if __name__ == "__main__":
main()
'''
def make_result(task="Minimiere die Kosten für mindestens 4 t Rohstoff.", code=STRUCTURED_CODE):
return PipelineResult(
task=task,
specification=make_spec(),
review=make_review(),
revisions=0,
solver_code=SolverCode(code=code, data_assumptions=["Lieferfrist: keine (nicht im Text)"]),
solver_run=SolverRun(status=SolverStatus.OPTIMAL, objective_value=9.0),
)
def test_script_layout_order():
script = build_solver_script(make_result())
assert script.startswith('#!/usr/bin/env python3\nr"""\n')
tree = ast.parse(script)
docstring = ast.get_docstring(tree)
positions = [docstring.index(h) for h in ("AUFGABE", "MATHEMATISCHES MODELL", "ALGORITHMUS")]
assert positions == sorted(positions)
assert "Minimiere die Kosten" in docstring
assert "\\sum_{j \\in J} x_j \\ge b" in docstring # Restriktion in LaTeX
assert "Branch-and-Cut" not in docstring # SciPy-Stack, nicht HiGHS-Text
assert "linprog" in docstring
assert "Lieferfrist" in docstring # angenommene Daten
# Imports vor report(), report() vor dem abschließenden main-Guard
names = [type(n).__name__ + ":" + getattr(n, "name", "") for n in tree.body]
guard_index = len(names) - 1
assert names.index("ImportFrom:") < names.index("FunctionDef:main") < names.index("FunctionDef:report") < guard_index
assert isinstance(tree.body[guard_index], ast.If) and script.rstrip().endswith("main()")
def test_script_is_standalone_runnable(tmp_path):
path = tmp_path / "solve.py"
path.write_text(build_solver_script(make_result()), encoding="utf-8")
proc = subprocess.run([sys.executable, str(path)], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, proc.stderr
assert "Status: optimal" in proc.stdout
assert "Zielwert: 9.0" in proc.stdout
assert proc.stdout.strip().splitlines()[-1].startswith("RESULT_JSON:")
def test_latex_backslashes_and_quotes_do_not_break_script():
task = 'Text mit \\sum, \\( x \\), """ Anführungszeichen """ und Backslash am Ende \\'
script = build_solver_script(make_result(task=task))
with warnings.catch_warnings():
warnings.simplefilter("error") # SyntaxWarning (ungültige Escapes) wäre ein Fehler
compile(script, "solve.py", "exec")
def test_run_solver_code_with_structured_code():
run = run_solver_code(STRUCTURED_CODE)
assert run.status == SolverStatus.OPTIMAL
assert run.objective_value == pytest.approx(9)
def test_assemble_without_guard_prepends_helpers():
script = assemble_script("x = 1")
assert "def report(" in script and "_apply_data_override(globals())" in script
assert script.index("def report(") < script.index("x = 1")
def test_valid_structure_passes():
validate_structure(STRUCTURED_CODE)
@pytest.mark.parametrize("code, reason", [
("def solve():\n pass\n\nif __name__ == '__main__':\n solve()\n", "main()"),
("def main():\n pass\n\nmain()\n", "__main__"),
("def main():\n pass\n\nif __name__ == '__main__':\n main()\n\nx = 1\n", "__main__"),
])
def test_invalid_structure_is_rejected(code, reason):
with pytest.raises(CodeRejected, match=reason.replace("(", r"\(").replace(")", r"\)")):
validate_structure(code)
def test_html_report_has_copy_button_with_full_script():
page = to_html(build_document(make_result()))
assert page.count('class="copy-btn"') == 1
assert "navigator.clipboard" in page and "execCommand('copy')" in page
assert "#!/usr/bin/env python3" in page
assert "if __name__ == &quot;__main__&quot;:" in page
def test_inline_math_delimiters_are_removed_from_docstring():
spec = make_spec(indices=["Tage: \\(d \\in D = \\{Mo,Di\\}\\)"])
result = make_result()
result.specification = spec
docstring = ast.get_docstring(ast.parse(build_solver_script(result)))
assert "Tage: d \\in D = \\{Mo,Di\\}" in docstring
assert "\\(" not in docstring and "\\)" not in docstring
def test_every_stack_and_class_has_algorithm_text():
# Synchron-Regel: neuer TargetStack/ProblemClass braucht Texte in script_export
from model_schema import ProblemClass, TargetStack
from script_export import ALGORITHMS, CLASS_FIT
assert set(ALGORITHMS) == set(TargetStack)
assert set(CLASS_FIT) == set(ProblemClass)