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
20 changed files with 1126 additions and 59 deletions

View file

@ -226,9 +226,10 @@ Nein. Die Entscheidungen hängen am Inhalt der Bilder, nicht an Dateinamen. `sca
**Warum landet ein Teil im Review?**
Ersetzt wird nur, was pix2text liest und Qwen (oder bei Uneinigkeit Gemma) bestätigt. Sind Qwen und Gemma
gemeinsam anderer Meinung als pix2text, entscheidest du. Häufige Gründe für Review: mehrzeilige Rechnungen
mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen (die Modelle schreiben sie unterschiedlich), sehr
kleine oder unscharfe Bilder, oder ein Modell liest ein Zeichen anders. Im Review stehen die Lesarten
gemeinsam anderer Meinung als pix2text, entscheidest du. Häufige Gründe für Review: ein Modell liest ein
Zeichen anders (oft pix2text: `\nu` statt fettem v, Wörter wie „fir“ statt „für“), sehr kleine oder
unscharfe Bilder, mehrzeilige Rechnungen mit anderen Zeilenumbrüchen oder `\underbrace`. Verschiedene
Rahmen derselben Zeilen (`aligned`, `cases`, `&`) gelten seit 0.4.0 als gleich. Im Review stehen die Lesarten
nebeneinander; oft genügt ein Blick und „LaTeX übernehmen“.
**Wie sicher ist „automatisch ersetzt“?**

View file

@ -1,5 +1,49 @@
# Changelog
## 0.4.0 – 2026-10-09
Bessere Normalform: Lesarten, die sich nur in der Schreibweise unterscheiden, gelten als gleich.
Göllmann (400 Formeln mit bekannter Lösung, gleiche Lesarten): **+17 automatisch, weiterhin 0 falsch**.
- **Mehrzeilige Formeln:** gleich bei gleichen Zeilen mit gleichen Zeichen, egal in welchem Rahmen
(`aligned`/`gathered`/`split` mit `&` oder nur `\\`; `cases` = `\left\{array\right.` =
`\left\{aligned\right.`); eine leere letzte Zeile zählt nicht. In Matrizen trennt `&` weiter Spalten.
- **Argumente nach Argumentzahl:** Eine Gruppe ist nur Argument, wenn der Befehl noch eines erwartet
(`t^{2}\,{\sqrt{2}}` = `t^2\sqrt{2}`, `\Delta{x_{n}}` = `\Delta x_n`).
- **Weitere Gleichsetzungen:** `\cdot\cdot\cdot` und `...` = `\dots`, `\root3\of{x}` = `\sqrt[3]{x}`,
`\left.`/`\right.` entfallen.
- **Messung genauer:** Der Wahrheitsvergleich nutzt dieselbe Normalform; richtige mehrzeilige Lesarten
zählten bisher als falsch (pix2text allein richtig: Band 2 jetzt 45/50 und 47/50).
- **Ergebnis:** Band 1 89 % / 88 % automatisch (bisher 86 % / 85 %), Band 2 44 und 45 von 50 (bisher 39
und 43), 0 falsch.
- **Strukturvergleich mit sympy** (`structure.py`, neu, optional, aus): gleicher unausgewerteter
Ausdrucksbaum, nichts umgerechnet; Relationen nie an sympy, Zeichen durch Platzhalter geschützt.
Brachte vor der neuen Normalform +1 Formel, danach nichts – `structural` = 1 bzw.
`FORMULA_STRUCTURAL=1` schaltet ihn ein (`pip install sympy lark`).
- **Bild-Schiedsrichter** (`verify.image_arbiter`, gemessen, aus): bei „Qwen = Gemma gegen pix2text“
entscheidet der Bildbeleg. +11 Formeln ab Schwelle 8, aber der gemeinsame Fehler `\mathcal{E}_r`
bekam Beleg 7,2 – zu knapp. Einstellungen `arbiter`, `arbiter_margin`.
- **Prompt-Stilvorgabe** getestet und verworfen (Band 2: 44/44 statt 44/45; die Modelle schrieben nach
`\\` ein wörtliches `\n`). Prompt-Version bleibt 2, gespeicherte Lesarten bleiben gültig.
- **Werkzeuge:** `tools/vergleich_bericht.py` (Vorher/Nachher-Seite zweier Läufe), `regeltest.py
--refresh` (Engines trotz `--reuse` neu fragen) und `--arbiter`; `calibrate` misst den
Schiedsrichter (Werte je Fall, Schwellen) und listet nur strukturell gefundene Übereinstimmungen;
`tools/buch_bericht.py` (Ergebnisseite eines ganzen Buchs: jede Formel mit Original, gesetztem
LaTeX, Status, Begründung und allen Lesarten, filterbar).
- **Erstes ganzes PDF-Buch** (Corral, *Elementary Calculus*, 346 Seiten, pdf2md): 1150 Ausschnitte,
**429 automatisch ersetzt (37 %)**, 633 Review, 61 keine Formel. Gefundene und behobene Probleme:
- pix2text schreibt hier Brüche als `\cfrac`: in der Normalform = `\frac`, beim Bereinigen zu
`\dfrac` (Pandoc kennt `\cfrac` nicht und ließ die Formel als TeX-Text stehen).
- Qwen und Gemma schreiben Zeilenumbrüche als wörtliches `\n` (`\begin{aligned}\n…`): wird zu
Leerraum (`tex.fix_literal_newlines`), echte Befehle wie `\nu`, `\neq` bleiben.
- 600-dpi-Ausschnitte bis 3800 × 2700 Pixel: Qwen lehnte sie ab („400 Bad Request“, Kontext voll),
eine ganzseitige Abbildung (33 MP) brachte den Bildvergleich zum Absturz. Jetzt: Bilder für die
Modelle höchstens 1600 Pixel lang, Bildvergleich nur bis 4 MP (größte übernommene Formel: 1,7 MP).
- Fehlgeschlagene Modellanfragen werden beim nächsten Lauf wiederholt (zählten als „schon gefragt“).
- Göllmann danach unverändert: 0 falsch.
- Grenze: pdf2md schneidet in diesem Buch auch Merkkästen mit Text, Tabellen und ganze
Herleitungsblöcke als „Formel“ aus; sie bilden den Großteil des Reviews.
## 0.3.0 – 2026-10-09
Neue Standardregel `p2t`: **pix2text liest zuerst, Qwen bestätigt, Gemma nur bei Uneinigkeit**; GLM (Cloud)

View file

@ -34,9 +34,27 @@ Die frühere Regel `vlm3` (Qwen = Gemma, bestätigt von **GLM-4.6V** über OpenR
„Dieselbe Formel“ prüft `consensus.py`: gleiche Normalform (Abstände, `\left`/`\right`, überflüssige
Klammern, `\vec`/`\overrightarrow`, `\frac`/`\dfrac`, `\iff`/`\Leftrightarrow`, `pmatrix`/`\left(\begin{matrix}`,
TeX-Umlaute, `\atop`/`\substack`, kursiv/aufrecht, fett/normal …) oder – als Rückfallebene – zeichengleiches
Rendering der beiden Lesarten (Render gegen Render, nicht gegen das verrauschte Original). Eine
Gleichungsnummer muss in allen Lesarten übereinstimmen und wird als Text hinter die Formel gesetzt.
TeX-Umlaute, `\atop`/`\substack`, `\cdot\cdot\cdot`/`\cdots`, `\root3\of{x}`/`\sqrt[3]{x}`, kursiv/aufrecht,
fett/normal …) oder – als Rückfallebene – zeichengleiches Rendering der beiden Lesarten (Render gegen
Render, nicht gegen das verrauschte Original). Eine Gleichungsnummer muss in allen Lesarten
übereinstimmen und wird als Text hinter die Formel gesetzt.
**Mehrzeilige Formeln** (seit 0.4.0): gleich, wenn sie dieselben Zeilen mit denselben Zeichen haben,
egal in welchem Rahmen. `aligned`/`gathered`/`split` und ihr `&` fallen weg (`a\\b` =
`\begin{aligned}a&\\b\end{aligned}`), eine leere letzte Zeile zählt nicht; `cases`,
`\left\{\begin{array}{ll}…\right.` und pix2texts `\left\{\begin{aligned}…\right.` sind dasselbe.
Matrizen bleiben Matrizen: Dort trennt `&` Spalten (`1&2` ≠ `12`). Ob eine Gruppe ein Argument ist,
entscheidet die Argumentzahl des Befehls (`\frac` 2, `\sqrt` 1, `\Delta` 0), nicht ihre Stellung:
`t^{2}\,{\sqrt{2}}` = `t^2\sqrt{2}`, `\Delta{x_{n}}` = `\Delta x_n`.
**Gleiche Struktur** (`structure.py`, sympy, **optional, standardmäßig aus**): Beide Lesarten ergeben
denselben *unausgewerteten* Ausdrucksbaum (`\frac{a}{b}` = `a/b`, `a\cdot b` = `ab`); nichts wird
umgerechnet oder umgestellt (`a+b` ≠ `b+a`, `2x=4` ≠ `x=2`, `a-b` ≠ `a+(-b)`). Relationen gehen nie an
sympy (es wertet `v=(3,4)ᵀ` und `\nu=(3,4)ᵀ` beide zu `False` aus), jedes Zeichen wird durch einen
Platzhalter geschützt (sympy macht aus `\varepsilon` und `\epsilon` ein Symbol), sichtbare Klammern
zählen, Ungedecktes fällt auf den Render-Vergleich zurück. Gemessen: vor der mehrzeiligen Normalform
+1 Formel bei 400, danach **nichts mehr** – deshalb aus. Einschalten: `structural` = 1 oder
`FORMULA_STRUCTURAL=1`; braucht `pip install sympy lark`.
Sind sich die Modelle uneinig oder widerspricht das Veto, entscheidet ein Mensch im Review.
@ -54,6 +72,11 @@ Göllmann Band 1, 150 echte Bilder und 150 Nachbildungen im Gehrke-Stil, Lesarte
| + Stichwahl auch Qwen = Gemma gegen pix2text | 92 % / 1 | 91 % / 2 |
| Qwen = Gemma = GLM (Regel `vlm3`) | 78 % / 0 | 70 % / 0 |
Ab 0.4.0 mit mehrzeiliger Normalform, gleiche Lesarten (`--reuse`): **89 % / 0** (134) und **88 % / 0**
(132); Band 2 (`tools/regeltest.py`, zwei Stichproben zu 50): **44 und 45** automatisch statt 39 und 43,
**0 falsch**, 6 bzw. 5 Review. Zusammen +17 Formeln bei 400. Die Tabelle und die folgenden Absätze
zeigen den Stand 0.3.0.
Allein richtig: pix2text 90 % / 85 %, Qwen 86 % / 84 %, Gemma 86 % / 80 %, GLM 86 % / 83 %.
Zeit je Formel (seriell, 15 Formeln): pix2text 0,10 s (GPU) bzw. 0,51 s (CPU), Qwen 0,72 s, GLM 1,19 s,
Gemma 1,77 s. Gegenprobe an Göllmann Band 2 (`tools/regeltest.py`, zwei getrennte Stichproben zu 50
@ -63,6 +86,17 @@ pix2text ist nicht unfehlbar: In den Reviews standen auch Fälle, in denen nur p
(fehlende Wurzelexponenten, zerfallene Vektoren, `\nu` statt fettem v) – deshalb bleibt die Bestätigung
durch Qwen Pflicht.
**Bild-Schiedsrichter** (0.4.0, gemessen, **aus**): Bei „Qwen = Gemma gegen pix2text“ misst
`verify.image_arbiter`, wie deutlich das Original Qwens Lesart zeigt (gleiches Kontrastmaß wie der
Variantentest). In 16 solchen Fällen (Band 1 und 2) hatte Qwen 14-mal recht, mit Belegen von 3 bis 50.
Aber: Beim gemeinsamen Fehler `\mathcal{E}_r` statt `\varepsilon_r` sprach das Bild mit 7,2 sogar für
die falsche Lesart. Ab Schwelle 8 wären es +11 Formeln bei 0 falsch – der Abstand ist bei zwei
Gegenbeispielen nicht belastbar. Einschalten: `arbiter` = 1, `arbiter_margin` (5).
**Prompt-Stilvorgabe** (0.4.0, gemessen, verworfen): feste Schreibweisen für `aligned`, `pmatrix`,
`cases`, `\dots`, `\text`. Band 2: 44 und 44 statt 44 und 45 – die Normalform gleicht die Schreibweisen
schon aus, und mit der Zeilenvorgabe schrieben beide Modelle nach `\\` ein wörtliches `\n`.
### Regel `vlm3` (Standard bis 0.2.0)
An Formelbildern mit bekanntem TeX (Göllmann, Springer: TeX im alt-Text), je 150 echte Bilder und 150
@ -166,7 +200,8 @@ Sicherheit: Kandidaten kommen von Modellen und gelten als unsicher. Vor pdflatex
Die Datensätze hängen am SHA-256 des Bildes: Ein erneuter epub2md-Lauf verliert keine Entscheidungen.
Einstellungen in `workspace.json` → `settings.consensus_thresholds`: `require_third` (1),
`bold_relevant` (0), `veto_ncc` (0,65), `veto_scale_dev` (0 = aus), `min_p_mean`/`min_p_min` (0).
`bold_relevant` (0), `structural` (0), `arbiter` (0), `arbiter_margin` (5), `veto_ncc` (0,65),
`veto_scale_dev` (0 = aus), `min_p_mean`/`min_p_min` (0).
## Quellen
@ -184,16 +219,30 @@ sind nicht Teil dieser Version.
`vergleich/<name>/index.html` mit Original, gerenderten Lesarten, Entscheidung und Veto-Overlay.
- `tools/konsens_bericht.py BERICHT.json EPUB` – Übersichtsseite zu `calibrate --consensus --report`.
- `tools/zeittest.py EPUB` – Zeit und Trefferquote je Tool, seriell an 15 Formeln mit bekanntem TeX.
- `tools/regeltest.py EPUB [--n 50] [--reuse ergebnis.json]` – die Standardregel an Formeln mit bekanntem
TeX, wie `run`; Seite mit Original, wahrer Formel, allen Lesarten und Entscheidung.
- `tools/regeltest.py EPUB [--n 50] [--reuse ergebnis.json] [--refresh local,local2] [--arbiter 8]` – die
Standardregel an Formeln mit bekanntem TeX, wie `run`; Seite mit Original, wahrer Formel, allen
Lesarten und Entscheidung. `--refresh` fragt trotz `--reuse` diese Engines neu (Prompt-Test).
- `tools/buch_bericht.py AUSGABE` – Ergebnisseite eines ganzen Buchs nach `run`: jede Formel mit
Original, gesetztem LaTeX, Status, Begründung und allen Lesarten, filterbar; liegt in
`<arbeitsordner>/bericht/index.html` (Buchausschnitte, nicht versioniert).
- `tools/vergleich_bericht.py --regeltest VORHER:NACHHER --kalibrierung VORHER.json:NACHHER.json` –
Vorher/Nachher-Seite zweier Läufe auf denselben Lesarten: Zahlen, jede geänderte Entscheidung und
jedes geänderte Urteil gegen die Wahrheit, mit Original und gerenderten Lesarten.
- `calibrate --consensus --engines local,local2,cloud,p2t --report X.json` misst alle Regeln;
`--reuse X.json` rechnet sie ohne Modellabfragen neu.
- `vergleich/` ist nicht versioniert (Ausschnitte aus Büchern).
## Stand
Version 0.3.0 (Regel `p2t`) ist an Göllmann Band 1 und 2 gemessen (siehe [Messung](#messung)), aber noch
**nicht am ganzen Gehrke** gelaufen. Die folgenden Zahlen stammen von Version 0.2.0 (Regel `vlm3`, mit GLM).
Version 0.4.0 (Regel `p2t`, mehrzeilige Normalform) ist an Göllmann Band 1 und 2 gemessen (siehe
[Messung](#messung)), aber noch **nicht am ganzen Gehrke** gelaufen.
**Erstes ganzes PDF-Buch** (0.4.0): Corral, *Elementary Calculus* (346 Seiten, TeX-PDF, pdf2md mit
600-dpi-Neuausschnitt): 1150 Ausschnitte → **429 automatisch ersetzt (37 %)**, 633 Review, 61 keine
Formel, 27 Abbildungen. EPUB (`pdf2md --from-md`, jetzt mit `--mathml`): 429 Formeln als MathML, kein
TeX-Rest. Laufzeit rund 4 Stunden (pix2text Ø 3,9 s, Qwen Ø 8,1 s, Gemma Ø 15 s je Anfrage – große
Ausschnitte). Das Review besteht überwiegend aus Ausschnitten, die keine einzelne Formel sind
(Merkkästen mit Text, Tabellen, Diagramme, ganze Herleitungen) – das muss pdf2md besser trennen. Die folgenden Zahlen stammen von Version 0.2.0 (Regel `vlm3`, mit GLM).
Version 0.2.0 (Konsens), getestet am Gehrke, Brückenkurs Mathematik (EPUB, CM-Formeln als kleine JPGs):
@ -219,8 +268,8 @@ Version 0.2.0 (Konsens), getestet am Gehrke, Brückenkurs Mathematik (EPUB, CM-F
Grenzen und offene Punkte:
- Mehrzeilige Rechnungen mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen schreiben die Modelle
unterschiedlich – sie bilden den Großteil des Reviews.
- Mehrzeilige Rechnungen: Rahmen und `&` gleicht die Normalform seit 0.4.0 aus; verschiedene
Zeilenumbrüche, `\underbrace` und fehlende Integrationsgrenzen bleiben im Review.
- Formeln auf farbig hinterlegten Kästen verlieren beim Ersetzen die Hinterlegung.
- Fettdruck wird nicht geprüft (Vorgabe).
- Die Messung umfasst 2 × 150 Formeln eines Buches ohne Textausschnitt; „0 Fehler“ bedeutet eine

View file

@ -1,3 +1,3 @@
"""formula_img2latex: Formelbilder in Markdown geprüft durch LaTeX ersetzen."""
__version__ = "0.3.0"
__version__ = "0.4.0"

View file

@ -38,7 +38,7 @@ from .decide import decide, thresholds
from .imageio import load_ink
from .render import PRESETS, render_many
from .tex import strip_delimiters, unsafe_reason
from .verify import verify
from .verify import image_arbiter, verify
SYNTH_PX_PER_PT = 1.41
@ -246,7 +246,8 @@ def _consensus_eval(args: tuple) -> dict[str, Any]:
name, data, truth, display, preset, readings = args
os.environ.setdefault("OMP_NUM_THREADS", "1")
texes = [r.get("tex") or "" for r in readings]
ok = [bool(same_reading(t, truth, display, preset)) for t in texes]
ok_how = [same_reading(t, truth, display, preset) for t in texes]
ok = [bool(h) for h in ok_how]
agree: list[tuple[int, int, str]] = []
for i in range(len(texes)):
for j in range(i + 1, len(texes)):
@ -263,9 +264,18 @@ def _consensus_eval(args: tuple) -> dict[str, Any]:
if (k < 2 or readings[k].get("engine") == "p2t") and any(k in (i, j) for i, j, _ in agree):
v = verify(load_ink(data, name), texes[k], display, preset, check_variants=False)
vetos[k] = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
# rule p2t, Qwen = Gemma against pix2text: image evidence for Qwen's reading (decide_pair_tiebreak arbiter)
arbiter = None
eng = {r.get("engine"): k for k, r in enumerate(readings)}
q, g, p = eng.get("local"), eng.get("local2"), eng.get("p2t")
if None not in (q, g, p) and texes[q] and texes[p]:
pairs = {frozenset((i, j)) for i, j, _ in agree}
if frozenset((q, g)) in pairs and not pairs & {frozenset((q, p)), frozenset((g, p))}:
arbiter = image_arbiter(load_ink(data, name), texes[q], texes[p], display, preset)
pair = next((how for i, j, how in agree if (i, j) == (0, 1)), None)
# "a", "b", "agree", "veto": the two primary models (read by tools/konsens_bericht.py)
return {"name": name, "truth": truth, "display": display, "readings": readings, "ok": ok, "agree_all": agree,
return {"name": name, "truth": truth, "display": display, "readings": readings, "ok": ok, "ok_how": ok_how,
"agree_all": agree, "arbiter": arbiter,
"vetos": vetos, "a": readings[0], "b": readings[1], "a_ok": ok[0], "b_ok": ok[1], "agree": pair,
"veto": vetos.get(0) if pair else None}
@ -365,7 +375,9 @@ CONSENSUS_RULES: list[tuple[str, str, list[tuple[str, ...]]]] = [
[("local", "local2", "cloud"), ("local", "local2", "p2t"), ("local", "cloud", "p2t"), ("local2", "cloud", "p2t")]),
("C: Qwen = Gemma = GLM = p2t", "all", [("local", "local2", "cloud"), ("local", "local2", "p2t")]),
("Standard: p2t = Qwen, Stichwahl Gemma = p2t", "tiebreak", [("local", "p2t", "local2")]),
("Standard + Bild-Schiedsrichter", "arbiter", [("local", "p2t", "local2")]),
]
ARBITER_SWEEP = (1.0, 2.0, 3.0, 5.0, 8.0, 12.0, 20.0)
def _rule_outcome(r: dict[str, Any], idx: list[int], th: dict[str, float], decide_fn: Any,
@ -375,11 +387,13 @@ def _rule_outcome(r: dict[str, Any], idx: list[int], th: dict[str, float], decid
pos = {k: n for n, k in enumerate(idx)}
agree = [(pos[i], pos[j], how) for i, j, how in r["agree_all"] if i in pos and j in pos]
t = th if tiebreak else dict(th, require_third=1 if len(idx) > 2 else 0)
_st, _why, use = decide_fn(sub, agree, None, True, None, t) # which reading would be used
extra = {"arbiter": r.get("arbiter")} if tiebreak else {}
_st, _why, use = decide_fn(sub, agree, None, True, None, t, **extra) # which reading would be used
if use is None:
return None
veto = r["vetos"].get(idx[use])
st, _why, use = decide_fn(sub, agree, veto, True, None, t)
vetos = {int(k): v for k, v in r["vetos"].items()} # string keys after a JSON round trip
veto = vetos.get(idx[use])
st, _why, use = decide_fn(sub, agree, veto, True, None, t, **extra)
return idx[use] if st == "auto_accepted" else None
@ -395,6 +409,13 @@ def _summarize_consensus(label: str, results: list[dict[str, Any]], names: list[
"rules": [], "sweep": []}
out["agree_wrong_examples"] = [{"truth": r["truth"], "a": r["a"].get("tex"), "b": r["b"].get("tex")}
for r in results if r["agree"] and not r["a_ok"]][:10]
# every match found only by the structural comparison, for a human check: the truth comparison uses
# the same rule, so a wrong structural match would not show up as "falsch"
out["structural_examples"] = (
[{"kind": "Wahrheit", "reading": r["readings"][k].get("tex"), "other": r["truth"]}
for r in results for k, h in enumerate(r.get("ok_how", [])) if h == "strukturell"]
+ [{"kind": f"{names[i]}={names[j]}", "reading": r["readings"][i].get("tex"), "other": r["readings"][j].get("tex")}
for r in results for i, j, how in r["agree_all"] if how == "strukturell"])
th0 = consensus_thresholds()
for rule, mode, groups in CONSENSUS_RULES:
if any(e not in names for g in groups for e in g):
@ -402,8 +423,9 @@ def _summarize_consensus(label: str, results: list[dict[str, Any]], names: list[
acc = wrong = 0
examples = []
for r in results:
if mode == "tiebreak":
used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_pair_tiebreak, True)
if mode in ("tiebreak", "arbiter"):
th = dict(th0, arbiter=1) if mode == "arbiter" else th0
used = [_rule_outcome(r, [names.index(e) for e in g], th, decide_pair_tiebreak, True)
for g in groups]
else:
used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_consensus) for g in groups]
@ -417,6 +439,24 @@ def _summarize_consensus(label: str, results: list[dict[str, Any]], names: list[
examples.append({"truth": r["truth"], "used": r["readings"][use].get("tex"),
"readings": {e: rd.get("tex") for e, rd in zip(names, r["readings"])}})
out["rules"].append({"rule": rule, "accepted": acc, "wrong": wrong, "wrong_examples": examples[:5]})
# image arbiter: evidence per case (Qwen = Gemma against pix2text) and accepted/wrong per margin
if all(e in names for e in ("local", "p2t", "local2")):
idx = [names.index(e) for e in ("local", "p2t", "local2")]
out["arbiter_cases"] = [{"value": r["arbiter"], "qwen_ok": r["ok"][idx[0]], "p2t_ok": r["ok"][idx[1]],
"qwen": r["readings"][idx[0]].get("tex"), "p2t": r["readings"][idx[1]].get("tex"),
"truth": r["truth"]} for r in results if r.get("arbiter") is not None]
out["arbiter_sweep"] = []
for m in ARBITER_SWEEP:
th = dict(th0, arbiter=1, arbiter_margin=m)
acc = wrong = 0
for r in results:
if r.get("arbiter") is None:
continue
use = _rule_outcome(r, idx, th, decide_pair_tiebreak, True)
if use is not None and _rule_outcome(r, idx, th0, decide_pair_tiebreak, True) is None:
acc += 1
wrong += not r["ok"][use]
out["arbiter_sweep"].append({"margin": m, "extra_accepted": acc, "wrong": wrong})
for pm in (0.0, 0.8, 0.9, 0.95, 0.98):
for pn in (0.0, 0.2, 0.3, 0.5):
th = consensus_thresholds({"min_p_mean": pm, "min_p_min": pn, "require_third": 0})
@ -436,11 +476,23 @@ def _print_consensus(s: dict[str, Any], log: Any) -> None:
log(f" {s['label']}: {s['formulas']} Formeln")
log(" allein richtig: " + ", ".join(f"{e} {c / n:.0%}" for e, c in s["correct"].items())
+ f"; Qwen und Gemma einig {s['agree'] / n:.0%}, davon FALSCH {s['agree_wrong']}")
st = s.get("structural_examples", [])
if st:
log(f" nur strukturell gleich (bitte prüfen): {len(st)}")
for ex in st[:10]:
log(f" {ex['kind']}: {ex['reading']} ≡ {ex['other']}")
log(" Regel → automatisch angenommen / davon falsch:")
for row in s["rules"]:
log(f" {row['rule']:<42} {row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}")
for ex in row["wrong_examples"][:3]:
log(f" falsch: {ex['used']} (richtig: {ex['truth']})")
if s.get("arbiter_cases"):
log(" Bild-Schiedsrichter (Qwen = Gemma gegen pix2text): Beleg für Qwen, wer richtig ist:")
for ex in sorted(s["arbiter_cases"], key=lambda e: -(e["value"] or 0)):
who = "Qwen" if ex["qwen_ok"] else "pix2text" if ex["p2t_ok"] else "keiner"
log(f" {ex['value']:7.2f} richtig: {who:8s} Qwen: {ex['qwen']} pix2text: {ex['p2t']}")
log(" Schwelle → zusätzlich angenommen / davon falsch: " + ", ".join(
f"{row['margin']:g}: {row['extra_accepted']}/{row['wrong']}" for row in s["arbiter_sweep"]))
log(" Qwen = Gemma mit Schwellen (Mittel/Minimum) → automatisch angenommen / davon falsch:")
for row in s["sweep"]:
log(f" p_mean ≥ {row['min_p_mean']:.2f}, p_min ≥ {row['min_p_min']:.2f}: "

View file

@ -77,7 +77,7 @@ GROUPS: list[tuple[str, ...]] = [
# Bold vs. normal and \\mathbb/\\mathcal/\\mathfrak vs. normal stay distinct (vector vs. scalar, ℝ vs. R).
EQUIVALENT: list[set[str]] = [
{"\\vec", "\\overrightarrow"}, {"\\bar", "\\overline"}, {"\\hat", "\\widehat"}, {"\\tilde", "\\widetilde"},
{"\\to", "\\rightarrow", "\\longrightarrow"}, {"\\frac", "\\dfrac", "\\tfrac"}, {"\\le", "\\leq"},
{"\\to", "\\rightarrow", "\\longrightarrow"}, {"\\frac", "\\dfrac", "\\tfrac", "\\cfrac"}, {"\\le", "\\leq"},
{"\\ge", "\\geq"}, {"\\ne", "\\neq"}, {"\\dots", "\\ldots"}, {"'", "\\prime"},
{"\\boldsymbol", "\\mathbf", "\\bm"}, {"\\mathrm", "\\mathit"},
]

View file

@ -5,12 +5,15 @@ others; spacing and lengths may differ. Two readings therefore count as the same
1. their canonical forms are equal (spacing, \\left/\\right, braces, upright vs. italic, length
variants such as \\vec/\\overrightarrow removed or unified), or
2. they render to the same picture (render against render: exact, no noisy original involved),
2. they have the same mathematical structure (structure.py: same unevaluated sympy tree, e.g.
\\frac{a}{b} vs. a/b, a\\cdot b vs. ab; nothing computed or reordered), or
3. they render to the same picture (render against render: exact, no noisy original involved),
e.g. \\sin x vs. \\operatorname{sin} x.
"""
from __future__ import annotations
import os
import re
import numpy as np
@ -19,7 +22,7 @@ from .compare import _register
from .confusables import tokenize
from .imageio import trim
from .render import render_many
from .tex import prepare_for_render, split_tag, strip_delimiters
from .tex import fix_literal_newlines, prepare_for_render, split_tag, strip_delimiters
# tokens that carry no symbol
_DROP = {"\\,", "\\;", "\\:", "\\!", "\\ ", "~", "\\quad", "\\qquad", "\\left", "\\right", "\\big", "\\Big", "\\bigg",
@ -30,7 +33,7 @@ _UNWRAP = {"\\mathrm", "\\mathit", "\\operatorname", "\\mathop", "\\textrm", "\\
"\\textnormal", "\\textup"}
_SYNONYM = {
"\\overrightarrow": "\\vec", "\\overline": "\\bar", "\\widehat": "\\hat", "\\widetilde": "\\tilde",
"\\rightarrow": "\\to", "\\longrightarrow": "\\to", "\\dfrac": "\\frac", "\\tfrac": "\\frac",
"\\rightarrow": "\\to", "\\longrightarrow": "\\to", "\\dfrac": "\\frac", "\\tfrac": "\\frac", "\\cfrac": "\\frac",
"\\leq": "\\le", "\\geq": "\\ge", "\\neq": "\\ne", "\\ldots": "\\dots", "\\prime": "'",
"\\mathbf": "\\boldsymbol", "\\bm": "\\boldsymbol", "\\lbrace": "\\{", "\\rbrace": "\\}", "\\vert": "|",
"\\lvert": "|", "\\rvert": "|", "\\Vert": "\\|", "\\lVert": "\\|", "\\rVert": "\\|", "\\gets": "\\leftarrow",
@ -54,9 +57,20 @@ _OPERATORS = {"sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arc
# upright text groups; one level of nested braces (\mathrm{f \ddot{u} r})
_TEXT_GROUP = re.compile(r"\\(operatorname\*?|mathrm|text|textrm|mbox|textnormal|textup)\s*\{((?:[^{}]|\{[^{}]*\})*)\}")
_BOLD = {"\\boldsymbol", "\\mathbf", "\\bm", "\\pmb"}
# commands that never take an argument: a group right after them is free-standing
_NOARG = {"\\cdot", "\\times", "\\pm", "\\mp", "\\le", "\\ge", "\\ne", "\\in", "\\to", "\\Rightarrow",
"\\Leftrightarrow", "\\approx", "\\equiv", "\\sim", "\\quad", "\\qquad", "\\cup", "\\cap", "\\setminus"}
# number of arguments of a command (after synonyms); every other command takes none, so a group after
# \Delta or after another group is free-standing (\Delta{x_{n}} = \Delta x_n, t^{2}\,{\sqrt{2}} = t^2\sqrt{2})
_ARITY = {c: 2 for c in ("\\frac", "\\cfrac", "\\binom", "\\dbinom", "\\tbinom", "\\overset", "\\underset",
"\\stackrel", "\\sideset")}
_ARITY.update({c: 1 for c in (
"\\sqrt", "\\vec", "\\bar", "\\hat", "\\tilde", "\\dot", "\\ddot", "\\dddot", "\\check", "\\breve", "\\acute",
"\\grave", "\\mathring", "\\underline", "\\overleftarrow", "\\overleftrightarrow", "\\overbrace", "\\underbrace",
"\\mathbb", "\\mathcal", "\\mathfrak", "\\mathscr", "\\mathsf", "\\mathtt", "\\boldsymbol", "\\pmb",
"\\begin", "\\end", "\\substack", "\\phantom", "\\hphantom", "\\vphantom", "\\boxed", "\\fbox",
"\\xrightarrow", "\\xleftarrow", "^", "_")})
# multi-line environments whose & only aligns (dropped); in a matrix & separates columns (kept)
_ALIGN_ENVS = {"aligned", "align", "align*", "gathered", "gather", "gather*", "split", "alignedat", "alignat",
"alignat*", "eqnarray", "eqnarray*", "multline", "multline*", "flalign", "flalign*"}
_CASES_ENVS = {"cases", "dcases", "rcases"}
def canonical(tex: str, bold: bool = False) -> str:
@ -64,13 +78,18 @@ def canonical(tex: str, bold: bool = False) -> str:
*bold*: whether bold vs. normal counts (False: \\boldsymbol{v} and v are the same reading).
"""
body, tag = split_tag(strip_delimiters(tex))
body, tag = split_tag(strip_delimiters(fix_literal_newlines(tex)))
body = prepare_for_render(body)
body = _TEXT_GROUP.sub(_text_group, body)
body = re.sub(r'\{?\\"\{?([aouAOU])\}?\}?', lambda m: _UMLAUT[m.group(1)], body) # f{\"u}r -> für
body = re.sub(r"\^\s*\{\s*((?:\\prime\s*)+)\}", lambda m: "'" * m.group(1).count("\\prime"), body) # f^{\prime}
body = re.sub(r"\^\s*\\prime(?![A-Za-z])", "'", body)
body = re.sub(r"\\\\\s*\[[^\]]*\]", r"\\\\", body) # \\[2mm]: row spacing only
body = re.sub(r"\\\\\s*(?=\\end\{)", "", body) # a final \\ before \end{...} adds no row
body = re.sub(r"\\(left|right)\s*\.", "", body) # empty delimiter of \left\{ ... \right.
body = re.sub(r"\\cdot\s*\\cdot\s*\\cdot(?![A-Za-z])|(?<!\\)\.\s*\.\s*\.", r"\\dots ", body)
body = re.sub(r"\\root\s*(\{[^{}]*\}|[^\s{}\\])\s*\\of(?![A-Za-z])",
lambda m: f"\\sqrt[{m.group(1).strip('{}')}]", body) # plain TeX \root3\of{x}
body = re.sub(r"\{?\\ss\{?\}?\}?", "ß", body)
body = re.sub(r"\{([^{}]*)\}\s*\\atop\s*\{([^{}]*)\}", r"\\substack{\1\\\\\2}", body)
body = re.sub(r"\\begin\{array\}\s*(?:\[[^\]]*\])?\s*\{[^{}]*\}", r"\\begin{matrix}", body) # [t]: position only
@ -81,6 +100,7 @@ def canonical(tex: str, bold: bool = False) -> str:
body = re.sub(r"\\not\s*\\in(?![A-Za-z])", r"\\notin ", body)
toks = [t for t in tokenize(body) if not t.isspace()]
toks = [_SYNONYM.get(t, t) for t in toks if t not in _DROP]
toks = _environments(toks)
unwrap = _UNWRAP if bold else _UNWRAP | _BOLD
toks = [t for t in toks if t not in unwrap] # keep the argument group, unwrap below
toks = _flatten_groups(toks)
@ -97,6 +117,53 @@ def _text_group(m: re.Match[str]) -> str:
return f"\\{m.group(1)}{{{inner}}}"
def _environments(toks: list[str]) -> list[str]:
"""One spelling for multi-line formulas: the same rows with the same symbols are the same reading.
Alignment environments (aligned, gathered, ...) are dissolved and their & dropped: a\\\\b equals
\\begin{aligned}a&\\\\b\\end{aligned}. Case distinctions (cases, \\left\\{array or aligned\\right.) become
\\{ followed by their rows. Matrices stay (pmatrix etc. are MATRIX after the string replacements);
an array or matrix counts as a matrix if a bracket precedes it, else as an alignment.
"""
out: list[str] = []
stack: list[str] = []
i = 0
while i < len(toks):
t = toks[i]
if t in ("\\begin", "\\end") and i + 1 < len(toks) and toks[i + 1] == "{" and "}" in toks[i + 2:]:
j = toks.index("}", i + 2)
name = "".join(toks[i + 2:j])
while t == "\\end" and out and out[-1] == "\\\\": # an empty last row is no row
out.pop()
if t == "\\begin":
prev = out[-1] if out else None
if name in _CASES_ENVS:
kind = "cases"
elif name in _ALIGN_ENVS:
kind = "align"
elif name in ("MATRIX", "matrix"):
kind = "matrix" if prev in ("(", "[", "|", "\\|") else "align"
else:
kind = "other"
stack.append(kind)
if kind == "cases" and prev != "\\{":
out.append("\\{")
else:
kind = stack.pop() if stack else "other"
if kind in ("matrix", "other"):
out.extend(toks[i:j + 1])
i = j + 1
continue
if t == "&" and stack and stack[-1] in ("align", "cases"):
i += 1
continue
out.append(t)
i += 1
while out and out[-1] == "\\\\":
out.pop()
return out
def _parse(toks: list[str], i: int = 0) -> tuple[list, int]:
"""Token list -> nested lists for {...} groups."""
out: list = []
@ -114,20 +181,32 @@ def _parse(toks: list[str], i: int = 0) -> tuple[list, int]:
def _emit(nodes: list) -> list[str]:
"""Serialize: argument groups keep braces (if longer than one token), free groups dissolve."""
"""Serialize: argument groups keep braces (if longer than one token), free groups dissolve.
A group is an argument only while the last command still expects one (_ARITY); a single token
can be an argument too (\\frac12). Tokens of an optional [...] after \\sqrt do not count.
"""
out: list[str] = []
pending = 0 # arguments the last command still expects
optional = False # inside \\sqrt[...]
prev: object = None
for n in nodes:
if isinstance(n, list):
inner = _emit(n)
is_arg = isinstance(prev, list) or prev in ("^", "_", "]") or (
isinstance(prev, str) and prev.startswith("\\") and prev[1:].isalpha() and prev not in _NOARG)
if len(inner) <= 1 or not is_arg:
out.extend(inner)
else:
if len(inner) > 1 and pending > 0 and not optional:
out.extend(["{", *inner, "}"])
else:
out.extend(inner)
if not optional:
pending = max(0, pending - 1)
else:
out.append(n)
if n == "[" and prev == "\\sqrt":
optional = True
elif n == "]" and optional:
optional, pending = False, 1
elif not optional:
pending = _ARITY.get(n, max(0, pending - 1))
prev = n
return out
@ -154,12 +233,22 @@ def render_equal(a: str, b: str, display: bool, preset: str = "cm", zoom: float
return int(diff.sum()) <= max(2, int(0.002 * (ca > 0.5).sum()))
def same_reading(a: str, b: str, display: bool, preset: str = "cm", bold: bool = False) -> str | None:
"""'normalform' or 'render' if both readings mean the same, else None."""
def same_reading(a: str, b: str, display: bool, preset: str = "cm", bold: bool = False,
structural: bool | None = None) -> str | None:
"""'normalform', 'strukturell' or 'render' if both readings mean the same, else None.
*structural*: use the structural comparison (None: environment FORMULA_STRUCTURAL, default off).
"""
if not a or not b:
return None
if canonical(a, bold) == canonical(b, bold):
return "normalform"
if structural is None:
structural = os.environ.get("FORMULA_STRUCTURAL", "0") == "1"
if structural:
from .structure import structural_equal
if structural_equal(a, b, bold):
return "strukturell"
if render_equal(strip_delimiters(a), strip_delimiters(b), display, preset):
return "render"
return None

View file

@ -75,6 +75,10 @@ CONSENSUS_DEFAULTS: dict[str, float] = {
# alarms on Gehrke, never measured to catch anything)
"bold_relevant": 0, # 1: bold vs. normal counts as a different symbol
"require_third": 1, # a third reading (cloud, other model family) must agree as well
"arbiter": 0, # 1: Qwen = Gemma against pix2text is accepted if the image clearly shows their
"arbiter_margin": 5.0, # reading: evidence (compare.contrast) at least this large (rule p2t)
"structural": 0, # 1: readings with the same unevaluated sympy tree agree (structure.py). Off:
# after the multi-line normal form it found nothing more (Göllmann, 0.4.0)
}
@ -165,9 +169,14 @@ def _checks(agreeing: list[dict[str, Any]], veto: dict[str, Any] | None, pandoc_
TIEBREAK_MISSING = "Stichwahl fehlt"
def _bold_marks(r: dict[str, Any] | None) -> int:
tex = (r or {}).get("tex") or ""
return sum(tex.count(c) for c in ("\\boldsymbol", "\\mathbf", "\\bm{", "\\pmb"))
def decide_pair_tiebreak(readings: list[dict[str, Any] | None], agree: list[tuple[int, int, str]],
veto: dict[str, Any] | None, pandoc_ok: bool, book_px_per_pt: float | None,
th: dict[str, float]) -> tuple[str, str, int | None]:
th: dict[str, float], arbiter: float | None = None) -> tuple[str, str, int | None]:
"""Rule "p2t": pix2text reads first, Qwen confirms, Gemma only where the two disagree.
The reading used is always pix2text's (spaces tidied; it reads bold type best). *readings*:
@ -177,7 +186,9 @@ def decide_pair_tiebreak(readings: list[dict[str, Any] | None], agree: list[tupl
Gemma may only confirm pix2text. Qwen and Gemma together against pix2text go to a human: in the
measurement exactly these were the errors both VLMs share (\\mathcal{E}_r for \\varepsilon_r, an
invented \\tilde{z}).
invented \\tilde{z}). Exception (th["arbiter"], off by default): *arbiter*, the image evidence for
Qwen's reading against pix2text's (verify.image_arbiter), reaches th["arbiter_margin"]; then the
one of the two agreeing VLM readings with more bold markup is used.
"""
q, p = readings[0], readings[1]
g = readings[2] if len(readings) > 2 else None
@ -195,6 +206,14 @@ def decide_pair_tiebreak(readings: list[dict[str, Any] | None], agree: list[tupl
elif frozenset((1, 2)) in pairs:
how, who, confirm = pairs[frozenset((1, 2))], "Stichwahl: Gemma bestätigt pix2text", g
elif frozenset((0, 2)) in pairs:
if th.get("arbiter") and arbiter is not None and arbiter >= th.get("arbiter_margin", 5.0) and q.get("tex"):
use = 2 if _bold_marks(g) > _bold_marks(q) else 0
who = (f"Qwen und Gemma einig ({pairs[frozenset((0, 2))]}), Bild spricht gegen pix2text "
f"(Beleg {arbiter:.1f})")
problems = _checks([readings[use]], veto, pandoc_ok, book_px_per_pt, th)
if problems:
return "needs_review", f"{who}; " + "; ".join(problems), use
return "auto_accepted", f"{who}, Bild-Veto bestanden", use
return ("needs_review", "Qwen und Gemma einig gegen pix2text – Mensch entscheidet: "
f"Qwen: {q.get('tex') or '–'}", first)
else:

View file

@ -30,12 +30,14 @@ def tidy_p2t(tex: str) -> str:
The spaces are removed by post_process of pix2tex (LaTeX-OCR), which keeps a space between two
letters (`a x`, `\\cdot x`) and the control space `\\ `; TeX ignores the removed spaces in math
mode, so the typeset formula does not change. Before that, Pix2Text's bold spellings become
\\boldsymbol / \\mathbf.
\\boldsymbol / \\mathbf, and its \\cfrac becomes \\dfrac (same look for ordinary fractions; Pandoc has
no \\cfrac and would leave the formula as TeX text).
"""
from pix2tex.utils.utils import post_process # imports torch: only when needed
tex = _BOLDMATH.sub(lambda m: "\\boldsymbol{" + re.sub(r"\s+", "", m.group(1)) + "}", tex)
tex = _BF.sub(lambda m: "\\mathbf{" + re.sub(r"\s+", "", m.group(2)) + "}", tex)
tex = re.sub(r"\\cfrac(?![A-Za-z])", r"\\dfrac", tex)
return post_process(tex).strip()

View file

@ -15,9 +15,11 @@ from PIL import Image
from ..errors import EXIT_TOOL, FormulaError
from ..imageio import gray_to_ink, load_gray
from ..tex import fix_literal_newlines
MIN_HEIGHT = 96 # small formula images are enlarged to at least this height for the model
MAX_UPSCALE = 8
MAX_SIDE = 1600 # larger crops (600-dpi boxes, figures) are reduced: they overflow the model's context
TIMEOUT_S = 120
MAX_TOKENS = 800 # long multi-line formulas need ~400; runaway repetitions are cut off (they are useless anyway)
@ -63,13 +65,16 @@ class Recognition:
def prepare_image(data: bytes, name: str = "") -> bytes:
"""Flatten transparency onto white, enlarge tiny formulas, add a margin; PNG bytes."""
"""Flatten transparency onto white, enlarge tiny formulas, shrink huge ones, add a margin; PNG bytes."""
ink = gray_to_ink(load_gray(data, name))
gray = (255 - np.clip(ink * 255, 0, 255)).astype(np.uint8)
im = Image.fromarray(gray)
if im.height < MIN_HEIGHT:
f = min(MAX_UPSCALE, max(1, int(np.ceil(MIN_HEIGHT / max(im.height, 1)))))
im = im.resize((im.width * f, im.height * f), Image.LANCZOS)
if max(im.size) > MAX_SIDE:
s = MAX_SIDE / max(im.size)
im = im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))), Image.LANCZOS)
pad = max(8, im.height // 6)
canvas = Image.new("L", (im.width + 2 * pad, im.height + 2 * pad), 255)
canvas.paste(im, (pad, pad))
@ -240,6 +245,7 @@ class VisionEngine:
text = content.strip()
usage = js.get("usage") or {}
tex, isf = parse_answer(text)
tex = fix_literal_newlines(tex) if tex else tex
p_min, p_mean = latex_token_probs(content, ((choice.get("logprobs") or {}).get("content") or []))
return Recognition(self.name, js.get("model", self.model), tex, isf, text[:2000], None,
time.time() - t0, int(usage.get("prompt_tokens") or 0),

View file

@ -136,9 +136,9 @@ def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None
continue
if only_needing_tiebreak and not needs_tiebreak(f):
continue
if any(c.get("engine") == engine.name and c.get("model") == engine.model
if any(c.get("engine") == engine.name and c.get("model") == engine.model and not c.get("error")
and c.get("prompt") == PROMPT_VERSION and c.get("thinking") == engine.thinking for c in f.candidates):
continue # cached: never ask the same model the same question twice
continue # cached: never ask the same model the same question twice (a failed request is no answer)
todo.append(f)
if limit:
todo = todo[:limit]
@ -420,7 +420,7 @@ def decide_all_consensus(ws: Workspace, quiet: bool, preset: str, only_ids: set[
# pix2text's reading is the one used, so its final punctuation must be confirmed too
if rule != "p2t" and j >= 2:
a, b = _strip_final_punct(a), _strip_final_punct(b)
how = same_reading(a, b, f.display, preset, bold)
how = same_reading(a, b, f.display, preset, bold, bool(th.get("structural")))
if how:
agree.append((i, j, how))
plan.append((f, rs, agree))

View file

@ -0,0 +1,299 @@
"""Do two LaTeX readings have the same mathematical structure? (rule 3 of consensus.same_reading)
Two readings count as structurally equal if they parse to the same *unevaluated* sympy expression
tree. Spelling variants vanish (\\frac{a}{b} vs. a/b, \\cdot vs. juxtaposition, redundant
parentheses), but nothing is computed or reordered: a+b != b+a, 2x=4 != x=2, 1+1 != 2. The reading
that is used must still be faithful to the book, so an equivalence that needs a transformation is
never accepted.
Safeguards (each one measured to be necessary, see README):
* Relations are never handed to sympy (it evaluates v = (3,4)^T and \\nu = (3,4)^T both to False).
The formula is split at top-level relation and separator symbols; their sequence must match
exactly, only the operands go through sympy.
* Every symbol ("atom": letter, Greek letter, font-styled or accented letter, with index and primes)
is replaced by a placeholder from a table shared by both readings, because sympy merges glyphs
the project keeps apart (\\varepsilon and \\epsilon become one symbol).
* Products are made explicit with \\cdot (sympy's lark grammar rejects a^{b}c and x(y) is ambiguous).
* Visible brackets count: sympy drops them, so a-b and a+(-b) would be equal (a step in a textbook
derivation). The number of parentheses and absolute bars per operand is part of the key; only
the parentheses of a function argument are free (\\sin(x) = \\sin x).
* The tree is serialized in stored argument order (srepr sorts a+b and b+a alike).
* Every placeholder sent to sympy must come back in its tree (nothing silently dropped).
* Anything not covered (matrices, \\pm, \\times, intervals, sets, ...) gives "not comparable", never
a partial match; the caller then falls back to the render comparison.
Model output is untrusted: it only goes through sympy's LaTeX parser (lark backend, no eval), never
sympify().
"""
from __future__ import annotations
from collections import Counter
from functools import lru_cache
from typing import Any
from .tex import split_tag, strip_delimiters
MAX_OPERAND = 300 # characters of one operand handed to the parser (Earley parsing is not linear)
# top-level symbols that separate operands; their sequence is compared as is
_RELATIONS = {"=", "\\ne", "<", ">", "\\le", "\\ge", "\\ll", "\\gg", "\\approx", "\\equiv", "\\sim", "\\simeq",
"\\cong", "\\propto", "\\in", "\\notin", "\\ni", "\\subset", "\\subseteq", "\\supset", "\\supseteq",
"\\to", "\\mapsto", "\\Rightarrow", "\\Leftarrow", "\\Leftrightarrow", "\\leftarrow",
"\\leftrightarrow", "\\perp", "\\parallel", ":", ",", ";", "."}
_BINOPS = {"+", "-", "\\cdot", "/", "\\div"}
# functions sympy's lark grammar knows; an argument follows without \cdot
_FUNCS = {"\\sin", "\\cos", "\\tan", "\\cot", "\\sec", "\\csc", "\\arcsin", "\\arccos", "\\arctan", "\\sinh",
"\\cosh", "\\tanh", "\\log", "\\ln", "\\exp"}
_BIGOPS = {"\\lim", "\\sum", "\\prod", "\\int"}
_GREEK = {"\\" + g for g in (
"alpha beta gamma delta epsilon varepsilon zeta eta theta vartheta iota kappa lambda mu nu xi pi varpi rho "
"varrho sigma varsigma tau upsilon phi varphi chi psi omega Gamma Delta Theta Lambda Xi Pi Sigma Upsilon Phi "
"Psi Omega partial nabla hbar ell emptyset dots aleph").split()}
_STYLED = {"\\mathbb", "\\mathcal", "\\mathfrak", "\\mathscr", "\\mathsf", "\\mathtt", "\\boldsymbol", "\\vec", "\\bar",
"\\hat", "\\tilde", "\\dot", "\\ddot", "\\check", "\\breve", "\\underline", "\\overleftarrow"}
class _Unsupported(Exception):
pass
def structural_equal(a: str, b: str, bold: bool = False) -> bool:
"""True if both readings parse to the same unevaluated expression tree (see module docstring).
*bold*: whether bold vs. normal counts (as in consensus.canonical).
"""
if not a or not b:
return False
table: dict[str, str] = {}
try:
ka, atoms_a = structural_key(a, bold, table)
kb, atoms_b = structural_key(b, bold, table)
except Exception: # not covered, or the parser failed: not comparable
return False
return ka == kb and atoms_a == atoms_b
def structural_key(tex: str, bold: bool = False, table: dict[str, str] | None = None) -> tuple[tuple, Counter]:
"""(key, atom counts) of one reading; raises if the formula is not covered.
Placeholders are numbered in order of first appearance in *table*. Keys are only comparable
between readings converted with the same table.
"""
from .consensus import canonical
table = {} if table is None else table
body, tag = split_tag(strip_delimiters(tex))
toks = [t for t in canonical(body, bold).split(" ") if t]
conv = _Converter(table)
ops, operands = _split_top(toks)
key = (tuple(ops), tuple(conv.operand(o) for o in operands), canonical(tag, bold) if tag else None)
return key, conv.atoms
def _split_top(toks: list[str]) -> tuple[list[str], list[list[str]]]:
"""Split at relations and separators outside of any group; a dot between digits stays."""
ops: list[str] = []
parts: list[list[str]] = [[]]
depth = 0
for k, t in enumerate(toks):
if t in ("{", "(", "["):
depth += 1
elif t in ("}", ")", "]"):
depth -= 1
decimal = t == "." and 0 < k < len(toks) - 1 and toks[k - 1].isdigit() and toks[k + 1].isdigit()
if depth == 0 and t in _RELATIONS and not decimal:
ops.append(t)
parts.append([])
else:
parts[-1].append(t)
return ops, parts
class _Converter:
"""Canonical tokens of one operand -> LaTeX for sympy's lark grammar, atoms as placeholders."""
def __init__(self, table: dict[str, str]) -> None:
self.table = table
self.atoms: Counter = Counter()
self.sent: set[str] = set() # placeholders of the current operand
self.brackets = 0 # visible ( ) and | | of the current operand
def operand(self, toks: list[str]) -> tuple:
if not toks:
return ("leer",)
self.sent, self.brackets = set(), 0
s, i = self.expr(toks, 0, set())
if i != len(toks):
raise _Unsupported(toks[i])
if len(s) > MAX_OPERAND:
raise _Unsupported("zu lang")
key, names = _parse(s)
if {n for n in names if n.startswith("z_{")} != self.sent: # Limit also holds a direction symbol
raise _Unsupported("Parser hat Symbole verschluckt")
return key, self.brackets
def expr(self, t: list[str], i: int, stop: set[str], rel_ok: bool = False) -> tuple[str, int]:
out: list[str] = []
factor = False # the last emitted item is a complete factor (a following one needs \cdot)
func = False # the last emitted item is a function name: its parentheses are free
while i < len(t) and t[i] not in stop:
x = t[i]
if x in _BINOPS:
out.append(x)
factor, i = False, i + 1
elif rel_ok and x in ("=", "\\to"):
out.append(x)
factor, i = False, i + 1
elif x == "^" and factor:
s, i = self.arg(t, i + 1)
out.append(f"^{{{s}}}")
elif x == "!" and factor:
out.append("!")
i += 1
else:
if x in ("(", "|") and not func:
self.brackets += 1
func = x in _FUNCS
s, i, is_factor = self.primary(t, i)
if factor:
out.append("\\cdot")
out.append(s)
factor = is_factor
continue
func = False
return " ".join(out), i
def arg(self, t: list[str], i: int, rel_ok: bool = False) -> tuple[str, int]:
"""A macro argument: a {group} or a single token."""
if i >= len(t):
raise _Unsupported("Argument fehlt")
if t[i] == "{":
s, j = self.expr(t, i + 1, {"}"}, rel_ok)
return s, _expect(t, j, "}")
s, j, _f = self.primary(t[i:i + 1], 0)
if j != 1:
raise _Unsupported(t[i])
return s, i + 1
def primary(self, t: list[str], i: int) -> tuple[str, int, bool]:
x = t[i]
if x.isdigit():
j = i
while j < len(t) and t[j].isdigit():
j += 1
if j + 1 < len(t) and t[j] == "." and t[j + 1].isdigit():
j += 1
while j < len(t) and t[j].isdigit():
j += 1
return "".join(t[i:j]), j, True
if x in ("(", "|", "{"):
close = {"(": ")", "|": "|", "{": "}"}[x]
s, j = self.expr(t, i + 1, {close})
j = _expect(t, j, close)
return (f"|{s}|" if x == "|" else f"({s})"), j, True
if x == "\\frac" or x == "\\binom":
a, j = self.arg(t, i + 1)
b, j = self.arg(t, j)
return f"{x}{{{a}}}{{{b}}}", j, True
if x == "\\sqrt":
j, idx = i + 1, ""
if j < len(t) and t[j] == "[":
n, j = self.expr(t, j + 1, {"]"})
j = _expect(t, j, "]")
idx = f"[{n}]"
a, j = self.arg(t, j)
return f"\\sqrt{idx}{{{a}}}", j, True
if x in _FUNCS or x in _BIGOPS:
out, j = [x], i + 1
while j < len(t) and t[j] in ("^", "_"):
s, j2 = self.arg(t, j + 1, rel_ok=x in _BIGOPS)
out.append(f"{t[j]}{{{s}}}")
j = j2
return " ".join(out), j, False
if x == "\\infty":
return x, i + 1, True
return self.atom(t, i)
def atom(self, t: list[str], i: int) -> tuple[str, int, bool]:
"""Letter (with style or accent, index and primes) -> placeholder symbol."""
x = t[i]
if x in _STYLED:
parts, j = [x, *_raw_arg(t, i + 1)[0]], _raw_arg(t, i + 1)[1]
elif (len(x) == 1 and x.isalpha()) or x in _GREEK:
parts, j = [x], i + 1
else:
raise _Unsupported(x)
while j < len(t) and t[j] in ("'", "_"):
if t[j] == "'":
parts.append("'")
j += 1
else:
sub, j = _raw_arg(t, j + 1)
parts += ["_", "{", *sub, "}"]
key = " ".join(parts)
name = self.table.setdefault(key, f"z_{{{len(self.table)}}}")
self.atoms[key] += 1
self.sent.add(name)
return name, j, True
def _expect(t: list[str], j: int, tok: str) -> int:
if j >= len(t) or t[j] != tok:
raise _Unsupported(f"{tok} fehlt")
return j + 1
def _raw_arg(t: list[str], i: int) -> tuple[list[str], int]:
"""Tokens of a macro argument without conversion (index of a symbol, argument of \\vec)."""
if i >= len(t):
raise _Unsupported("Argument fehlt")
if t[i] != "{":
return [t[i]], i + 1
depth, j = 0, i
while j < len(t):
depth += {"{": 1, "}": -1}.get(t[j], 0)
if depth == 0:
return t[i + 1:j], j + 1
j += 1
raise _Unsupported("} fehlt")
@lru_cache(maxsize=1)
def _sympy() -> tuple[Any, Any]:
import sympy
# sympy's lark transformer imports sympy.physics lazily at the first juxtaposition; inside
# evaluate(False) that import fails and every later implicit product fails with it
import sympy.physics.quantum # noqa: F401
from sympy.parsing.latex import parse_latex
return sympy, parse_latex
@lru_cache(maxsize=8192)
def _parse(s: str) -> tuple[tuple, frozenset[str]]:
"""Unevaluated sympy tree of *s*, serialized; plus the names of all symbols in it."""
sympy, parse_latex = _sympy()
with sympy.evaluate(False):
e = parse_latex(s, backend="lark")
names: set[str] = set()
return _ser(e, names, sympy), frozenset(names)
def _ser(e: Any, names: set[str], sympy: Any) -> tuple:
"""Tree as nested tuples in stored argument order (srepr would sort commutative arguments)."""
from lark import Token, Tree
if isinstance(e, Tree): # ambiguous parse: deterministic for the same input
return ("Tree", str(e.data), tuple(_ser(c, names, sympy) for c in e.children))
if isinstance(e, Token):
return ("Token", str(e))
if isinstance(e, (tuple, list)):
return ("tuple", tuple(_ser(c, names, sympy) for c in e))
if isinstance(e, (sympy.logic.boolalg.Boolean, sympy.core.relational.Relational)) and not e.is_Symbol:
raise _Unsupported("Wahrheitswert")
if not isinstance(e, sympy.Basic):
raise _Unsupported(type(e).__name__)
if e.is_Symbol:
names.add(str(e.name))
return ("Symbol", str(e.name))
if e.is_Number:
return (type(e).__name__, str(e))
return (type(e).__name__, tuple(_ser(a, names, sympy) for a in e.args))

View file

@ -92,6 +92,26 @@ def unsafe_reason(tex: str) -> str | None:
return None
# commands that start with \n; any other "\n..." in a model reading is a JSON newline escape gone wrong
_N_COMMANDS = frozenset(
"nu nabla ne neq ni neg not notin nexists nmid nleq ngeq nless ngtr nsubseteq nsupseteq nparallel natural "
"nearrow nwarrow newline nleftarrow nrightarrow nLeftarrow nRightarrow nleftrightarrow nLeftrightarrow nprec "
"nsucc ncong nsim nvdash nVdash nolimits normalsize nonumber notag".split())
_NEWLINE_RE = re.compile(r"\\\\|\\[^A-Za-z]|\\n[A-Za-z]*")
def fix_literal_newlines(tex: str) -> str:
"""Models write line breaks of multi-line formulas as the two characters \\n (\\begin{aligned}\\n\\lim,
\\\\\\nk_2). TeX reads that as an unknown command \\n or \\nk; turn it into a space. Real commands
(\\nu, \\neq, ...) and a row break \\\\ followed by n stay."""
def repl(m: re.Match[str]) -> str:
t = m.group(0)
if t.startswith("\\n") and t[1:] not in _N_COMMANDS:
return " " + t[2:]
return t
return _NEWLINE_RE.sub(repl, tex)
_TAG_RE = re.compile(r"\\tag\*?\s*\{([^{}]*)\}")

View file

@ -19,6 +19,8 @@ from .render import render_many
SWITCH_MARGIN = 2.0 # evidence (see compare.contrast) by which a variant must win to replace the candidate
MAX_CLIMB = 3
MIN_NCC_FOR_VARIANTS = 0.6 # hopeless candidates are not worth the variant test
MAX_ORIG_PIXELS = 4_000_000 # larger crops are figures or text boxes (largest accepted formula: 1.7 MP);
# the elastic comparison of a full-page figure killed its worker process
@dataclass
@ -73,9 +75,27 @@ def _check_variants(orig: np.ndarray, cand: Comparison, tex: str, display: bool,
return min_c, worst, worst_kind, n, eq, rival
def image_arbiter(orig: np.ndarray, a: str, b: str, display: bool, preset: str) -> float | None:
"""Evidence that the original shows reading *a* rather than *b* (compare.contrast: positive = a).
Both renders share *a*'s scale and elastic warp, so only the differing symbols count. 0.0 if the
two render identically, None if *a* cannot be compared at all.
"""
if orig.size > MAX_ORIG_PIXELS:
return None
ra, rb = render_many([(a, display), (b, display)], preset)
ca = compare(orig, ra)
if not ca.ok:
return None
c = contrast(ca, compare(orig, rb, warp_from=ca))
return 0.0 if c is None else float(c)
def verify(orig: np.ndarray, tex: str, display: bool, preset: str, max_climb: int = MAX_CLIMB,
check_variants: bool = True) -> Verification:
"""Verify *tex* against the trimmed ink image *orig*."""
if orig.size > MAX_ORIG_PIXELS:
return Verification(tex, False, f"Ausschnitt zu groß für den Bildvergleich ({orig.size / 1e6:.1f} MP)")
history: list[str] = []
current = tex
while True:

View file

@ -5,3 +5,4 @@ scikit-image>=0.22
opencv-python-headless>=4.8
Pillow>=10.0
lxml>=5.0
# optional, only for the structural comparison (FORMULA_STRUCTURAL=1): sympy>=1.13, lark>=1.1

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

161
tools/buch_bericht.py Normal file
View file

@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Result page of a whole book: every formula with original, set LaTeX, status and reason.
python3 tools/buch_bericht.py AUSGABE [--pdf BUCH.pdf] [--out ORDNER]
Reads the workspace of a finished `run` (nothing is recomputed, no model is asked) and writes
<workspace>/bericht/index.html with images in bericht/img/ (default). The page lives next to the
book because it shows excerpts of it. Filter buttons: all, automatically replaced, review, image.
"""
from __future__ import annotations
import argparse
import html
import io
import re
import statistics
import sys
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from formula_img2latex.pipeline import _ref_for, latest_by_engine, open_all # noqa: E402
from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.tex import strip_delimiters # noqa: E402
LABEL = {"local": "Qwen", "p2t": "pix2text", "local2": "Gemma", "cloud": "GLM"}
STATUS = {"auto_accepted": ("automatisch ersetzt", "good"), "accepted_by_human": ("von Hand übernommen", "good"),
"needs_review": ("Review", "rev"), "not_formula": ("keine Formel", "img"),
"rejected_keep_image": ("bleibt Bild", "img"), "kept_by_human": ("bleibt Bild (Hand)", "img"),
"render_failed": ("nicht setzbar", "img"), "pending": ("nicht geprüft", "img")}
def page_key(f) -> tuple[int, int]:
m = re.search(r"Seite (\d+), Box (\d+)", f.source_name or "")
return (int(m.group(1)), int(m.group(2))) if m else (10**6, 0)
def save_png(img: Image.Image, path: Path) -> None:
img.save(path, "PNG", optimize=True)
def original_png(data: bytes, max_h: int = 110, max_w: int = 900) -> Image.Image:
im = Image.open(io.BytesIO(data))
if im.mode in ("P", "LA", "RGBA") or "transparency" in im.info:
im = Image.alpha_composite(Image.new("RGBA", im.size, "white"), im.convert("RGBA"))
im = im.convert("L")
f = min(1.0, max_h / max(im.height, 1), max_w / max(im.width, 1))
if f < 1.0:
im = im.resize((max(1, round(im.width * f)), max(1, round(im.height * f))), Image.LANCZOS)
return im
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("source", type=Path)
ap.add_argument("--pdf", type=Path, default=None)
ap.add_argument("--out", type=Path, default=None)
ap.add_argument("--titel", default=None)
args = ap.parse_args()
source, ws = open_all(args.source, None, pdf=args.pdf)
out = args.out or ws.root / "bericht"
(out / "img").mkdir(parents=True, exist_ok=True)
formulas = sorted(ws.all(), key=page_key)
preset = ws.settings.get("preset") or "cm"
# the LaTeX shown: the replacement if there is one, else pix2text's reading (rule p2t)
shown = []
for f in formulas:
tex = f.final_tex
if not tex:
p = latest_by_engine(f).get("p2t")
tex = p.get("tex") if p else None
shown.append(strip_delimiters(tex) if tex else None)
rendered = render_many([(t or "\\text{–}", f.display) for t, f in zip(shown, formulas)], preset)
counts: dict[str, int] = {}
secs: dict[str, list[float]] = {}
cards = []
for n, (f, tex, rr) in enumerate(zip(formulas, shown, rendered), 1):
counts[f.status] = counts.get(f.status, 0) + 1
latest = latest_by_engine(f)
for e, r in latest.items():
if r.get("seconds"):
secs.setdefault(e, []).append(float(r["seconds"]))
try:
o = out / "img" / f"{f.id}-o.png"
if not o.exists(): # the original never changes; cutting it from the PDF is the slow part
save_png(original_png(source.original_bytes(_ref_for(source, f))), o)
orig = f"<img loading=lazy src='img/{f.id}-o.png'>"
except Exception: # noqa: BLE001
orig = "<i>Original nicht lesbar</i>"
if tex and rr.ok:
g = (rr.rasterize(1.6) * 255).astype(np.uint8)
save_png(Image.fromarray(255 - g), out / "img" / f"{f.id}-l.png")
latex = f"<img loading=lazy src='img/{f.id}-l.png'>"
else:
latex = "<i>–</i>"
label, cls = STATUS.get(f.status, (f.status, "img"))
used = "ersetzt durch" if f.final_tex else "Lesart pix2text"
reads = "".join(f"<tr><th>{LABEL.get(e, e)}</th><td><code>{html.escape(r.get('tex') or '–')}</code></td></tr>"
for e, r in sorted(latest.items(), key=lambda kv: ["p2t", "local", "local2", "cloud"].index(kv[0])
if kv[0] in ("p2t", "local", "local2", "cloud") else 9))
occ = f.occurrences[0] if f.occurrences else {}
cards.append(
f"<section class='{cls}' data-s='{cls}'><div class=h><b>{n}</b> · {html.escape(f.source_name)} · "
f"Zeile {occ.get('line', '?')} · <span class=b>{label}</span></div>"
f"<div class=why>{html.escape(f.reason or '')}</div>"
f"<div class=row><div><small>Original (PDF)</small>{orig}</div><div><small>{used}</small>{latex}</div></div>"
f"<code class=tex>{html.escape(tex or '–')}</code>"
f"<details><summary>alle Lesarten</summary><table>{reads}</table></details></section>")
total = len(formulas)
good = counts.get("auto_accepted", 0) + counts.get("accepted_by_human", 0)
rev = counts.get("needs_review", 0)
img = total - good - rev
time_rows = "".join(f"<tr><th>Ø {LABEL.get(e, e)}</th><td>{statistics.mean(v):.2f} s ({len(v)} Formeln)</td></tr>"
for e, v in sorted(secs.items()))
status_rows = "".join(f"<tr><th>{STATUS.get(k, (k,))[0]}</th><td>{v} ({v / max(total, 1):.0%})</td></tr>"
for k, v in sorted(counts.items(), key=lambda kv: -kv[1]))
titel = args.titel or f"Formeln: {source.path.name}"
page = (
f"<!doctype html><meta charset=utf-8><title>{html.escape(titel)}</title><style>"
":root{--bg:#f6f6f3;--card:#fff;--ink:#222;--mut:#666;--line:#ddd}"
"body{font:14px system-ui,sans-serif;margin:0;padding:16px;background:var(--bg);color:var(--ink)}"
"h1{font-size:20px}table.sum{border-collapse:collapse;background:var(--card);margin:8px 0}"
"table.sum th,table.sum td{border:1px solid var(--line);padding:4px 10px;text-align:left}"
".bar{position:sticky;top:0;background:var(--bg);padding:8px 0;z-index:1}"
"button{font:inherit;padding:4px 10px;margin-right:6px;border:1px solid #bbb;border-radius:4px;background:#fff;cursor:pointer}"
"button.on{background:#222;color:#fff}"
"section{background:var(--card);border:1px solid var(--line);border-left:6px solid #999;border-radius:6px;"
"padding:6px 10px;margin:8px 0}section.good{border-left-color:#1f7a3a}section.rev{border-left-color:#c88a00}"
"section.img{border-left-color:#888}.h{font-size:13px}.b{font-weight:600}.good .b{color:#1f7a3a}"
".rev .b{color:#a15c00}.why{font-size:12px;color:var(--mut);margin:2px 0 4px}"
".row{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start}.row>div{display:flex;flex-direction:column;gap:2px}"
".row img{max-width:min(900px,100%);height:auto}code{font-size:12px}code.tex{display:block;margin:4px 0;"
"overflow-x:auto;white-space:pre-wrap;word-break:break-all}details{font-size:12px}"
"details th{text-align:left;font-weight:400;color:var(--mut);padding-right:8px;vertical-align:top}</style>"
f"<h1>{html.escape(titel)}</h1>"
f"<p>{total} Formelbilder (abgesetzte Formeln aus der PDF). Regel <code>p2t</code>: pix2text liest, Qwen "
"bestätigt, Gemma nur bei Uneinigkeit, dazu Pandoc und Bild-Veto. Links das Original aus der PDF, rechts das "
"gesetzte LaTeX.</p>"
f"<table class=sum>{status_rows}{time_rows}</table>"
"<div class=bar><button class=on data-f=all>alle</button>"
f"<button data-f=good>ersetzt ({good})</button><button data-f=rev>Review ({rev})</button>"
f"<button data-f=img>Bild/keine Formel ({img})</button></div>"
+ "".join(cards)
+ "<script>document.querySelectorAll('button[data-f]').forEach(function(b){b.onclick=function(){"
"document.querySelectorAll('button[data-f]').forEach(function(x){x.classList.toggle('on',x===b);});"
"var f=b.dataset.f;document.querySelectorAll('section').forEach(function(s){"
"s.hidden=!(f==='all'||s.dataset.s===f);});};});</script>")
(out / "index.html").write_text(page, encoding="utf-8")
print(out / "index.html")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -68,6 +68,11 @@ def main() -> int:
parts.append(f"<h2>{html.escape(s['label'])}</h2><p>{len(res)} Formeln · A richtig {s['a_correct'] / n:.0%} · "
f"B richtig {s['b_correct'] / n:.0%} · einig {s['agree'] / n:.0%} · "
f"<b>einig, aber falsch: {s['agree_wrong']}</b></p>")
st = s.get("structural_examples", [])
if st: # matches found only by the structural comparison: the truth comparison uses it too
parts.append(f"<p><b>Nur strukturell gleich ({len(st)}, bitte prüfen):</b></p><ul>" + "".join(
f"<li>{html.escape(ex['kind'])}: <code>{html.escape(ex['reading'] or '–')}</code> ≡ "
f"<code>{html.escape(ex['other'] or '–')}</code></li>" for ex in st) + "</ul>")
order = sorted(res, key=lambda r: (0 if r["agree"] and not r["a_ok"] else 1 if not r["agree"] else 2))
texes = []
for r in order:

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

205
tools/vergleich_bericht.py Normal file
View file

@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Visual before/after report of two runs on the same readings (--reuse), e.g. a rule change.
python3 tools/vergleich_bericht.py --epub1 BAND1.epub --epub2 BAND2.epub \\
--regeltest regeltest1-ohne:regeltest1-struktur --regeltest regeltest2-ohne:regeltest2-struktur \\
--kalibrierung VORHER.json:NACHHER.json [--name struktur] [--titel "…"]
Each pair is VORHER:NACHHER (regeltest: names under vergleich/, calibration: report files). Writes
vergleich/<name>/index.html (not versioned): summary table, every changed decision or truth verdict,
and every match found only by the structural comparison, with original image and rendered readings.
"""
from __future__ import annotations
import argparse
import html
import json
import sys
import zipfile
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from konsens_bericht import orig_img, png_uri # noqa: E402
from formula_img2latex.calibrate import _dvipng_jpeg, _rule_outcome # noqa: E402
from formula_img2latex.decide import consensus_thresholds, decide_pair_tiebreak # noqa: E402
from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.tex import strip_delimiters # noqa: E402
ROOT = Path(__file__).resolve().parents[1] / "vergleich"
LABEL = {"local": "Qwen", "p2t": "pix2text", "local2": "Gemma", "cloud": "GLM"}
class Page:
"""Collects cards; renders all TeX in one pdflatex batch at the end."""
def __init__(self) -> None:
self.parts: list[str] = []
self.texes: list[tuple[str, bool, str]] = [] # (tex, display, preset)
def img(self, tex: str | None, display: bool, preset: str) -> str:
self.texes.append((strip_delimiters(tex or "") or "\\text{–}", display, preset))
return f"@@IMG{len(self.texes) - 1}@@"
def html(self) -> str:
out = "".join(self.parts)
for preset in sorted({p for _t, _d, p in self.texes}):
idx = [k for k, (_t, _d, p) in enumerate(self.texes) if p == preset]
for k, rr in zip(idx, render_many([self.texes[k][:2] for k in idx], preset)):
if rr.ok:
g = (rr.rasterize(2.0) * 255).astype(np.uint8)
tag = f"<img src='{png_uri(Image.fromarray(255 - g))}'>"
else:
tag = "<i>nicht setzbar</i>"
out = out.replace(f"@@IMG{k}@@", tag)
return out
def original(z: zipfile.ZipFile | None, name: str, truth: str, display: bool) -> str:
try:
if name.endswith("#synth.jpg"):
data = _dvipng_jpeg(strip_delimiters(truth), display, 102) or b""
else:
data = z.read(name) if z else b""
return f"<img src='{png_uri(orig_img(data, 2))}'>"
except Exception: # noqa: BLE001
return "<i>–</i>"
def card(page: Page, cls: str, head: str, orig: str, truth: str, display: bool, preset: str,
rows: list[tuple[str, str | None]]) -> None:
trs = [f"<tr><th>Original</th><td>{orig}</td><td></td></tr>",
f"<tr><th>richtig</th><td>{page.img(truth, display, preset)}</td><td><code>{html.escape(truth)}</code></td></tr>"]
for label, tex in rows:
trs.append(f"<tr><th>{label}</th><td>{page.img(tex, display, preset) if tex else '<i>–</i>'}</td>"
f"<td><code>{html.escape(tex or '–')}</code></td></tr>")
page.parts.append(f"<section class={cls}><div class=v>{head}</div><table>{''.join(trs)}</table></section>")
def mark(ok: bool) -> str:
return "<b class=ok>✓</b>" if ok else "<b class=no>✗</b>"
def regeltest(page: Page, summary: list[str], pair: str, z: zipfile.ZipFile | None) -> None:
a, b = pair.split(":")
ra, rb = (json.loads((ROOT / n / "ergebnis.json").read_text(encoding="utf-8")) for n in (a, b))
sa, sb = ra["summary"], rb["summary"]
st = sb.get("structural", {})
summary.append(f"<tr><td><a href='../{b}/index.html'>{html.escape(b)}</a> "
f"<small>(<a href='../{a}/index.html'>vorher</a>)</small></td><td>{sa['formulas']}</td>"
f"<td>{sa['accepted']} / {sa['wrong']}</td><td><b>{sb['accepted']} / {sb['wrong']}</b></td>"
f"<td>{sa['review']} → {sb['review']}</td><td>{st.get('agree', 0)} / {st.get('truth', 0)}</td></tr>")
old = {c["name"]: c for c in ra["cases"]}
preset = rb.get("preset", "cm")
tools = ["local", "p2t", "local2"]
for c in rb["cases"]:
o = old[c["name"]]
changed = c["status"] != o["status"]
struct = [i for i, j, how in c["agree"] if how == "strukturell"] or [
k for k, h in enumerate(c.get("ok_how", [])) if h == "strukturell"]
verdict = [p for p in range(3) if c["readings"][p] and o["readings"][p] and c["ok"][p] != o["ok"][p]]
if not (changed or struct or verdict):
continue
right = c["status"] == "auto_accepted" and c["right"]
cls = "bad" if c["status"] == "auto_accepted" and not c["right"] else "good" if right else "rev"
head = (f"{html.escape(b)} · {'Entscheidung geändert: ' + o['status'] + ' → ' if changed else ''}"
f"{c['status']} · {html.escape(c['reason'])}"
+ (" · Urteil gegen Wahrheit geändert: " + ", ".join(LABEL[tools[p]] for p in verdict) if verdict else ""))
rows = [(f"{mark(c['ok'][p])} {LABEL[t]}" + (" (" + c["ok_how"][p] + ")" if c.get("ok_how") and c["ok_how"][p] else ""),
(c["readings"][p] or {}).get("tex")) for p, t in enumerate(tools) if c["readings"][p]]
card(page, cls, head, original(z, c["name"], c["truth"], c["display"]), c["truth"], c["display"], preset, rows)
def kalibrierung(page: Page, summary: list[str], pair: str, z: zipfile.ZipFile | None) -> None:
a, b = pair.split(":")
ka, kb = (json.loads(Path(p).read_text(encoding="utf-8")) for p in (a, b))
th = consensus_thresholds()
for sa, sb in zip(ka["sets"], kb["sets"]):
names = sb["engines"]
idx = [names.index(e) for e in ("local", "p2t", "local2")]
old = {r["name"]: r for r in sa["results"]}
acc = {"ohne": [0, 0], "mit": [0, 0]}
synth = sb["label"].endswith("Gehrke-Stil]")
preset = "cm" if synth else "txfonts"
for r in sb["results"]:
o = old[r["name"]]
use_o = _rule_outcome(o, idx, th, decide_pair_tiebreak, True)
use_n = _rule_outcome(r, idx, th, decide_pair_tiebreak, True)
for key, rr, u in (("ohne", o, use_o), ("mit", r, use_n)):
if u is not None:
acc[key][0] += 1
acc[key][1] += not rr["ok"][u]
struct = [(i, j) for i, j, how in r["agree_all"] if how == "strukturell"]
struct_t = [k for k, h in enumerate(r.get("ok_how", [])) if h == "strukturell"]
verdict_ch = [k for k in range(len(names)) if r["ok"][k] != o["ok"][k]]
if (use_o is None) == (use_n is None) and not struct and not struct_t and not verdict_ch:
continue
verdict = ("übernommen" if use_n is not None else "nicht übernommen")
if (use_o is None) != (use_n is None):
verdict = f"Entscheidung geändert: {'übernommen' if use_o is not None else 'nicht übernommen'} → {verdict}"
wrong = use_n is not None and not r["ok"][use_n]
cls = "bad" if wrong else "good" if use_n is not None else "rev"
how = "; ".join([f"{LABEL.get(names[i], names[i])} = {LABEL.get(names[j], names[j])} strukturell" for i, j in struct]
+ [f"{LABEL.get(names[k], names[k])} = Wahrheit strukturell" for k in struct_t]
+ ["Urteil gegen Wahrheit geändert: " + ", ".join(LABEL.get(names[k], names[k]) for k in verdict_ch)]
* bool(verdict_ch))
rows = [(f"{mark(r['ok'][k])} {LABEL.get(e, e)}" + (" ← verwendet" if k == use_n else ""),
r["readings"][k].get("tex")) for k, e in enumerate(names)]
card(page, cls, f"Kalibrierung {html.escape(sb['label'])} · {verdict} · {html.escape(how)}",
original(z, r["name"], r["truth"], r["display"]), r["truth"], r["display"], preset, rows)
n = sb["formulas"]
summary.append(f"<tr><td>Kalibrierung Band 1 {html.escape(sb['label'].split('[')[-1].rstrip(']'))}</td><td>{n}</td>"
f"<td>{acc['ohne'][0]} / {acc['ohne'][1]}</td><td><b>{acc['mit'][0]} / {acc['mit'][1]}</b></td>"
f"<td>–</td><td>{len(sb.get('structural_examples', []))}</td></tr>")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--epub1", type=Path, default=None, help="Göllmann Band 1 (Kalibrierung)")
ap.add_argument("--epub2", type=Path, default=None, help="Göllmann Band 2 (Regeltests)")
ap.add_argument("--regeltest", action="append", default=[], help="OHNE:MIT (Namen unter vergleich/)")
ap.add_argument("--kalibrierung", action="append", default=[], help="OHNE.json:MIT.json")
ap.add_argument("--name", default="struktur")
ap.add_argument("--titel", default="Vergleich vorher / nachher")
args = ap.parse_args()
z1 = zipfile.ZipFile(args.epub1) if args.epub1 else None
z2 = zipfile.ZipFile(args.epub2) if args.epub2 else None
page = Page()
summary: list[str] = []
for pair in args.regeltest:
regeltest(page, summary, pair, z2)
for pair in args.kalibrierung:
kalibrierung(page, summary, pair, z1)
head = (f"<h1>{html.escape(args.titel)}</h1>"
"<p>Dieselben gespeicherten Lesarten, zweimal entschieden: vorher und nachher. "
"Regel <code>p2t</code>, mit Pandoc und Bild-Veto. ✓/✗ = gleiche Formel wie die Wahrheit.</p>"
"<table class=sum><tr><th>Messung</th><th>Formeln</th><th>vorher: automatisch / falsch</th>"
"<th>nachher: automatisch / falsch</th><th>Review</th><th>nur strukturell gleich (Paare / Wahrheit)</th></tr>"
+ "".join(summary) + "</table>"
"<p>Unten: jede geänderte Entscheidung, jedes geänderte Urteil gegen die Wahrheit und jede "
"Übereinstimmung, die <b>nur</b> die Strukturstufe gefunden hat. Grün = übernommen und richtig, <b style='color:#a32020'>Rot = übernommen, aber falsch</b>, "
"Gelb = Review. Bitte prüfen: Sind die als gleich gewerteten Lesarten wirklich dieselbe Formel?</p>")
page.parts.insert(0, head)
out = ROOT / args.name
out.mkdir(parents=True, exist_ok=True)
(out / "index.html").write_text(
"<!doctype html><meta charset=utf-8><title>Vergleich</title><style>"
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
"border-left:6px solid #999;border-radius:6px;padding:6px 10px;margin:10px 0}section.good{border-left-color:#1f7a3a}"
"section.bad{border-left-color:#a32020;background:#fff5f5}section.rev{border-left-color:#c88a00}"
".v{font-weight:600;font-size:13px}table.sum{border-collapse:collapse;background:#fff}"
"table.sum td,table.sum th{border:1px solid #ccc;padding:4px 10px;width:auto}"
"th{text-align:left;font-weight:400;color:#555;width:190px;vertical-align:middle}td{padding:3px 8px;vertical-align:middle}"
".ok{color:#1f7a3a}.no{color:#a32020}code{font-size:12px}img{max-width:640px}</style>" + page.html(),
encoding="utf-8")
print(out / "index.html")
return 0
if __name__ == "__main__":
sys.exit(main())