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

@ -109,6 +109,20 @@ def test_prepare_image_enlarges_small_formulas(epub2md_dir: Path) -> None:
data = (epub2md_dir / "media" / "images" / "0001-inline.jpg").read_bytes()
with Image.open(io.BytesIO(prepare_image(data, "x.jpg"))) as im:
assert im.height >= 96 and im.mode == "L"
big = Image.new("L", (4000, 900), 255) # 600-dpi box: shrunk so it fits the model's context
big.paste(0, (100, 100, 3900, 800))
buf = io.BytesIO()
big.save(buf, "PNG")
with Image.open(io.BytesIO(prepare_image(buf.getvalue(), "x.png"))) as im:
assert max(im.size) <= 1600 * 1.4
def test_verify_refuses_huge_crops() -> None:
import numpy as np
from formula_img2latex.verify import verify
v = verify(np.ones((2500, 2000), np.float32), "x", True, "cm") # 5 MP: a figure, not a formula
assert not v.ok and "zu groß" in v.reason
# ------------------------------------------------------------------------------- consensus
@ -208,6 +222,14 @@ def test_pair_tiebreak_rule() -> None:
assert "Stichwahl fehlt" in decide_pair_tiebreak([q, p, None], [], veto, True, 1.4, th)[1]
assert decide_pair_tiebreak([q, p, g], [(1, 2, "normalform")], veto, True, 1.4, th)[::2] == ("auto_accepted", 1)
assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, th)[0] == "needs_review"
# image arbiter (off by default): Qwen = Gemma against pix2text, accepted only with enough image evidence
assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, th, 99.0)[0] == "needs_review"
arb = consensus_thresholds({"arbiter": 1, "arbiter_margin": 5.0})
assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, arb, 4.0)[0] == "needs_review"
assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, arb, 6.0)[::2] == ("auto_accepted", 0)
assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], bad, True, 1.4, arb, 6.0)[0] == "needs_review"
gb = _reading("local2", "G", r"\boldsymbol{x}^2") # the agreeing reading with more bold markup is used
assert decide_pair_tiebreak([q, p, gb], [(0, 2, "normalform")], veto, True, 1.4, arb, 6.0)[::2] == ("auto_accepted", 2)
assert decide_pair_tiebreak([q, p, g], [], veto, True, 1.4, th)[0] == "needs_review"
assert "fehlt" in decide_pair_tiebreak([q, None, None], [], veto, True, 1.4, th)[1]
nq, ng = _reading("local", "Q", None, is_formula=False), _reading("local2", "G", None, is_formula=False)
@ -260,8 +282,9 @@ def test_calibration_rules_with_four_engines() -> None:
res([(0, 2), (0, 3), (2, 3)], [True, False, True, True])] # Gemma differs: 2:1 decided by p2t
s = _summarize_consensus("t", results, names, consensus_thresholds, decide_consensus)
got = [(r["accepted"], r["wrong"]) for r in s["rules"]]
# Qwen = Gemma | + GLM | B (+ p2t) | GLM or p2t confirms | A (3 of 4) | C (all four) | standard rule p2t
assert got == [(2, 1), (1, 0), (2, 1), (2, 1), (3, 1), (1, 0), (3, 1)]
# Qwen = Gemma | + GLM | B (+ p2t) | GLM or p2t confirms | A (3 of 4) | C (all four) | standard rule p2t |
# + image arbiter (no arbiter values here: same as the standard rule)
assert got == [(2, 1), (1, 0), (2, 1), (2, 1), (3, 1), (1, 0), (3, 1), (3, 1)]
def test_canonical_form() -> None:
@ -279,8 +302,56 @@ def test_canonical_form() -> None:
differ = [(r"x_{2,k}", r"x_{2;k}"), ("x^{2}", "x_{2}"), (r"\mathbb{R}", "R"), ("a_{n+1}", "a_n+1"),
(r"\frac{a}{b+c}", r"\frac{a}{b}+c"), (r"x \in t", r"x \int"), (r"\sqrt[3]{x}", r"\sqrt{x}"),
(r"\ddot{u}", "ü"), (r"\mathrm{lin}", r"\lim"), (r"f^{\prime 2}", "f'"), (r"f^{\prime}", "f")]
assert all(canonical(a) == canonical(b) for a, b in same)
assert all(canonical(a) != canonical(b) for a, b in differ)
# a group after a group or after \Delta is no argument (only commands in _ARITY take one)
same += [(r"t^{2}\,{\sqrt{2}}", r"t^2\sqrt{2}"), (r"\Delta{x_{n}}", r"\Delta x_n"),
(r"a_1+\cdot\cdot\cdot+a_n", r"a_1+\cdots+a_n"), (r"1,2,...,n", r"1,2,\ldots,n"),
(r"\root3\of{x}", r"\sqrt[3]{x}"),
# multi-line: same rows, any frame
(r"\begin{aligned}a&=b\\[2mm]c&=d\\\end{aligned}", r"a=b\\c=d"),
(r"f(x)=\begin{cases}1,&x>0\\0,&x\le0\end{cases}", r"f(x)=\left\{\begin{array}{ll}1,&x>0\\0,&x\le0\end{array}\right."),
(r"f(x)=\begin{cases}1,&x>0\\0,&x\le0\end{cases}",
r"f(x)=\left\{\begin{aligned}{}&{{}1,}&{x>0}\\{}&{{}0,}&{x\le0}\\\end{aligned}\right."),
(r"\begin{pmatrix}1&2\\3&4\end{pmatrix}", r"\left(\begin{array}{cc}1&2\\3&4\end{array}\right)"),
(r"\begin{aligned}\displaystyle a&\displaystyle=0\,.\\ \displaystyle\end{aligned}", r"a=0."),
# pix2text's \cfrac; model readings with literal \n line breaks (JSON escape gone wrong)
(r"\cfrac{df}{dx}=\cfrac{df}{du}\cdot\cfrac{du}{dx}", r"\frac{df}{dx}=\frac{df}{du}\cdot\frac{du}{dx}"),
(r"\begin{aligned}\n\lim_{x\to0} f &= 1 \\\nk_2 &= 2\n\end{aligned}", r"\lim_{x\to 0}f=1\\k_2=2")]
differ += [(r"\begin{pmatrix}1&2\end{pmatrix}", r"\begin{pmatrix}12\end{pmatrix}"), (r"a\\b\\c", r"a\\bc"),
(r"f(x)=\begin{cases}1\\0\end{cases}", r"f(x)=\begin{aligned}1\\0\end{aligned}"),
(r"x^{2}y", r"x^{2y}"), (r"\frac{a}{b}c", r"\frac{a}{bc}"), (r"\cdot\cdot", r"\dots"),
(r"\nu", "u"), (r"a\neq b", "a b"), (r"a\\\nu", r"a\\u")] # real \n commands stay
assert [p for p in same if canonical(p[0]) != canonical(p[1])] == []
assert [p for p in differ if canonical(p[0]) == canonical(p[1])] == []
def test_structural_equal() -> None:
pytest.importorskip("sympy")
pytest.importorskip("lark")
from formula_img2latex.consensus import same_reading
from formula_img2latex.structure import structural_equal, structural_key
same = [(r"\frac{a}{b}", "a/b"), (r"a\cdot b", "ab"), (r"a \div b", "a/b"), (r"\frac{1}{2}\cdot x", r"\frac12 x"),
(r"\sin(x)", r"\sin x"), (r"(a+b)c", r"(a+b)\cdot c"), (r"2\pi r", r"2 \cdot \pi \cdot r"),
(r"f'(x)>0", r"f^{\prime}(x) > 0"),
(r"H_0:\mu\le\mu_0\quad A:\mu>\mu_0", r"H_{0} : \mu \leq \mu_{0} \qquad A : \mu > \mu_{0}"),
(r"\lim_{x\to0}\frac{\sin x}{x}=1", r"\lim\limits_{x \to 0} \frac{\sin(x)}{x} = 1"),
# Göllmann: canonical() takes {\sqrt{2}} after t^{2} for a second argument
(r"|{\dot{\boldsymbol{y}}}(t)|=3t^{2}\,{\sqrt{2}}", r"|\dot{\boldsymbol{y}}(t)|=3t^{2}\sqrt{2}")]
differ = [(r"v=\begin{pmatrix}3\\4\end{pmatrix}", r"\nu=\begin{pmatrix}3\\4\end{pmatrix}"), # sympy: both False
("1+1=2", "2+2=4"), ("2x=4", "x=2"), ("1+1", "2"), ("a+b", "b+a"), ("a<b", "b>a"),
(r"\frac{1}{2}x", r"\frac{x}{2}"), ("a-b", "a+(-b)"), ("(a)+b", "a+b"), # no reordering, brackets count
(r"\varepsilon_r", r"\epsilon_r"), (r"\varepsilon_r", r"\mathcal{E}_r"), (r"\mathbb{R}", "R"), # glyphs
("X'", "x'"), ("v'''", "v''"), ("y_1", "y_i"), ("x^2", "x_2"), ("a_{n+1}", "a_n+1"),
(r"\sin(x)", "sin(x)"), (r"\exp(x)", "exp(x)"), ("e^x", r"\exp(x)"), (r"\sqrt[3]{x}", r"\sqrt{x}"),
(r"a \times b", r"a \cdot b"), ("0{,}5", "0.5"), ("(1,2)", "(1.2)"), (r"\frac{a}{b+c}", r"\frac{a}{b}+c")]
assert [p for p in same if not structural_equal(*p)] == []
assert [p for p in differ if structural_equal(*p)] == []
assert not structural_equal(r"\boldsymbol{v}", "v", bold=True) and structural_equal(r"\boldsymbol{v}", "v")
assert structural_equal("x=1", r"x=1\tag{3}") is False
for tex in (r"\begin{pmatrix}1\\2\end{pmatrix}", r"a \pm b", r"[a,b]", r"\int_0^1 x\,dx", "x" * 400, r"\frac{"):
with pytest.raises(Exception): # not covered: not comparable, the caller falls back to render
structural_key(tex)
assert same_reading(r"\frac{a}{b}", "a/b", False, structural=True) == "strukturell"
assert same_reading(r"\frac{a}{b}", "a/b", False, structural=False) != "strukturell"
def test_tidy_p2t() -> None:
@ -294,6 +365,7 @@ def test_tidy_p2t() -> None:
r"A \cdot\mathrm{\boldmath~ v ~}=0": r"A\cdot\boldsymbol{v}=0", # Pix2Text's bold
r"{\mathrm{{\bf~ r o t}}} \, v": r"{\mathbf{rot}}\,v",
r"\mathrm{\bf~ r o t} \, E": r"\mathbf{rot}\,E",
r"\cfrac{d f}{d x}": r"\dfrac{d f}{d x}", # Pandoc has no \cfrac
}
for raw, tidy in cases.items():
assert tidy_p2t(raw) == tidy, (raw, tidy_p2t(raw))