feat: 0.4.0 – mehrzeilige Normalform, erstes ganzes PDF-Buch

Normalform (Göllmann, 400 Formeln: +17 automatisch, weiterhin 0 falsch):
- mehrzeilige Formeln gleich bei gleichen Zeilen, egal in welchem Rahmen
  (aligned/&/\\, cases = \left\{array\right. = \left\{aligned\right.),
  leere letzte Zeile zählt nicht; Matrizen behalten & als Spaltentrenner
- Argumente nach Argumentzahl des Befehls (t^{2}\,{\sqrt{2}} = t^2\sqrt{2})
- \cdot\cdot\cdot = \dots, \root3\of{x} = \sqrt[3]{x}, \left./\right. entfallen,
  \cfrac = \frac; wörtliche \n aus Modellantworten werden zu Leerraum

Gemessen und abgeschaltet bzw. verworfen:
- Strukturvergleich mit sympy (structure.py, optional, FORMULA_STRUCTURAL=1):
  nach der neuen Normalform kein Gewinn mehr
- Bild-Schiedsrichter (verify.image_arbiter, arbiter=0): der gemeinsame
  Fehler \mathcal{E}_r bekam Beleg 7,2 – Abstand zu knapp
- Prompt-Stilvorgabe: kein Gewinn, Prompt-Version bleibt 2

Ganzes PDF-Buch (Corral, Elementary Calculus): 429 von 1123 automatisch.
- Bilder für die Modelle höchstens 1600 Pixel (Qwen lehnte große ab),
  Bildvergleich nur bis 4 MP (ganzseitige Abbildung brachte ihn zum Absturz)
- fehlgeschlagene Modellanfragen werden wiederholt
- pix2text: \cfrac -> \dfrac (Pandoc kennt \cfrac nicht)

Werkzeuge: tools/buch_bericht.py (Ergebnisseite eines Buchs),
tools/vergleich_bericht.py (Vorher/Nachher), regeltest --refresh/--arbiter,
calibrate misst Schiedsrichter und strukturelle Treffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-09 22:30:15 +02:00
commit 2478bc5cb9

View file

@ -39,7 +39,7 @@ from formula_img2latex.engines.p2t import tidy_p2t # noqa: E402
from formula_img2latex.imageio import load_ink # noqa: E402
from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.tex import strip_delimiters # noqa: E402
from formula_img2latex.verify import verify # noqa: E402
from formula_img2latex.verify import image_arbiter, verify # noqa: E402
TOOLS = [("local", "Qwen"), ("p2t", "pix2text"), ("local2", "Gemma")]
@ -63,6 +63,10 @@ def main() -> int:
ap.add_argument("--n", type=int, default=50)
ap.add_argument("--name", default="regeltest")
ap.add_argument("--reuse", type=Path, default=None, help="Lesarten aus einem früheren ergebnis.json")
ap.add_argument("--refresh", default="",
help="diese Engines trotz --reuse neu fragen, z. B. local,local2 (neuer Prompt)")
ap.add_argument("--arbiter", type=float, default=None,
help="Bild-Schiedsrichter einschalten mit dieser Schwelle (Qwen = Gemma gegen pix2text)")
ap.add_argument("--exclude", type=Path, action="append", default=[],
help="Formeln aus diesem früheren ergebnis.json auslassen (mehrfach möglich)")
args = ap.parse_args()
@ -78,9 +82,10 @@ def main() -> int:
smp = random.Random(42).sample(items, min(args.n + 1, len(items)))
formulas, warm = smp[:args.n], smp[args.n]
preset = _best_preset(smp[:12])
th = consensus_thresholds()
th = consensus_thresholds({"arbiter": 1, "arbiter_margin": args.arbiter} if args.arbiter is not None else None)
refresh = {e for e in args.refresh.split(",") if e}
eng = {}
for key, label in ([] if cached else TOOLS):
for key, label in ([t for t in TOOLS if t[0] in refresh] if cached else TOOLS):
e = make_engine(key)
t0 = time.time()
e.check()
@ -94,25 +99,37 @@ def main() -> int:
if cached and old is None:
raise SystemExit(f"{name} fehlt in {args.reuse} (andere Stichprobe?)")
reads: list[dict | None] = [None, None, None]
def read(pos: int) -> dict | None:
key = TOOLS[pos][0]
if old and key not in refresh:
return old[pos]
return eng[key].recognize(data, name, "", disp).to_json()
for pos in (1, 0): # pix2text first, then Qwen
reads[pos] = old[pos] if old else eng[TOOLS[pos][0]].recognize(data, name, "", disp).to_json()
reads[pos] = read(pos)
texes = [r and r.get("tex") for r in reads]
agree = agreements(texes, disp, preset)
if not any((i, j) == (0, 1) for i, j, _ in agree): # disputed: ask Gemma
reads[2] = old[2] if old else eng["local2"].recognize(data, name, "", disp).to_json()
reads[2] = read(2)
texes = [r and r.get("tex") for r in reads]
agree = agreements(texes, disp, preset)
_st, _why, use = decide_pair_tiebreak(reads, agree, None, True, None, th)
arbiter = None # Qwen = Gemma against pix2text: image evidence for Qwen's reading
pairs = {(i, j) for i, j, _ in agree}
if (0, 2) in pairs and not pairs & {(0, 1), (1, 2)} and texes[0] and texes[1]:
arbiter = image_arbiter(load_ink(data, name), texes[0], texes[1], disp, preset)
_st, _why, use = decide_pair_tiebreak(reads, agree, None, True, None, th, arbiter)
veto = None
pandoc = True
if use is not None and texes[use]:
v = verify(load_ink(data, name), texes[use], disp, preset, check_variants=False)
veto = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
pandoc = pandoc_mathml_ok([(texes[use], disp)])[0]
status, reason, use = decide_pair_tiebreak(reads, agree, veto, pandoc, None, th)
ok = [bool(t) and bool(same_reading(t, truth, disp, preset)) for t in texes]
status, reason, use = decide_pair_tiebreak(reads, agree, veto, pandoc, None, th, arbiter)
ok_how = [same_reading(t, truth, disp, preset) if t else None for t in texes]
ok = [bool(h) for h in ok_how]
accepted = status == "auto_accepted"
cases.append({"name": name, "truth": truth, "display": disp, "readings": reads, "ok": ok, "agree": agree,
cases.append({"name": name, "truth": truth, "display": disp, "readings": reads, "ok": ok, "ok_how": ok_how,
"agree": agree, "arbiter": arbiter,
"status": status, "reason": reason, "use": use, "veto": veto,
"right": bool(accepted and ok[use])})
mark = ("✓" if ok[use] else "✗ FALSCH") if accepted else "Review"
@ -130,7 +147,10 @@ def main() -> int:
"mean_s_per_formula": statistics.mean(per_formula),
"mean_s": {k: statistics.mean(v) if v else None for k, v in secs.items()},
"correct_alone": {key: sum(c["ok"][p] for c in cases if c["readings"][p])
for p, (key, _l) in enumerate(TOOLS)}}
for p, (key, _l) in enumerate(TOOLS)},
# matches found only by the structural comparison (check by hand: the truth comparison uses it too)
"structural": {"agree": sum(how == "strukturell" for c in cases for _i, _j, how in c["agree"]),
"truth": sum(h == "strukturell" for c in cases for h in c["ok_how"])}}
out = Path(__file__).resolve().parents[1] / "vergleich" / args.name
out.mkdir(parents=True, exist_ok=True)
@ -160,6 +180,8 @@ def main() -> int:
f"<tr><th>davon FALSCH</th><td><b>{len(wrong)}</b></td></tr>"
f"<tr><th>Review (Mensch)</th><td>{len(review)} ({len(review) / n:.0%})</td></tr>"
f"<tr><th>Gemma gefragt</th><td>{gemma} ({gemma / n:.0%})</td></tr>"
f"<tr><th>nur strukturell gleich (Paare / gegen Wahrheit)</th><td>{summary['structural']['agree']} / "
f"{summary['structural']['truth']}</td></tr>"
f"<tr><th>Ø Zeit pro Formel (alle Tools nacheinander)</th><td>{summary['mean_s_per_formula']:.2f} s</td></tr>"
+ "".join(f"<tr><th>Ø {lab[k]}</th><td>{v:.2f} s</td></tr>" for k, v in summary["mean_s"].items() if v)
+ "".join(f"<tr><th>{lab[k]} allein richtig</th><td>{v}/{len(secs[k])}</td></tr>"