Drei Quellen, eine Wahrheit — Validierung des byhaushalt-Parsers gegen PDF und STMFH-Diagramm

Drei Quellen, eine Wahrheit — Validierung des byhaushalt-Parsers gegen PDF und STMFH-Diagramm

Bonus-Artikel · Serie: Agentic Coding mit Claude Code

Der byhaushalt-Parser hat über zehn Artikel hinweg eine eigene Aggregation aus den 16 Einzelplan-PDFs aufgebaut. Was bisher fehlte, war eine echte zweite Quelle: bisher haben wir gegen die Übersicht auf Seite 60–62 des Gesamthaushaltsplans validiert — also gegen ein anderes Aggregat aus demselben PDF-Korpus. Zirkulär. Das bayerische Finanzministerium bietet aber auf stmfh.bayern.de ein “interaktives Diagramm” mit den Soll-Werten 2026 pro Einzelplan und pro Kapitel. Das ist eine unabhängige Quelle.

Was wir vergleichen

Drei Quellen, drei Aggregate, dieselbe Frage: wie viel hat Bayern 2026 für jeden Einzelplan veranschlagt?

QuelleStandGranularitätFormat
PDF-Kap-Abschluss9. Mai 2026 (Quelle: Einzelplan-PDFs Epl01.pdfEpl16.pdf)pro Kapitelparser-extrahiert
Live-Aggregation (byhaushalt)9. Mai 2026 (Eigenmessung)pro Titel, summiertberechnet aus Titeln
STMFH-Diagrammmindestens 21. Mai 2026 (Quelle: stmfh.bayern.de, von uns am 21.5. abgerufen)pro KapitelXML hinter FusionCharts

Die ersten beiden Quellen sollten identisch sein — die Live-Aggregation rechnet pro Titel hoch, der Kap-Abschluss zieht den Strich unter dieselben Titel. Wenn der Parser sauber arbeitet, decken sich beide Werte auf den Cent genau. Die dritte Quelle ist datenstandsbedingt jünger und enthält Aktualisierungen, die in unseren PDFs noch nicht stehen.

Die XML-Quelle hinter dem Diagramm

Das Diagramm auf der Finanzministeriums-Seite ist eine FusionCharts-Pie. Ein Blick in die Browser-Devtools zeigt, dass das Skript initDiagramm.js zwei XML-Dateien lädt: einnahmen2026/hh.xml und ausgaben2026/hh.xml. Beide enthalten pro Einzelplan ein <set>-Element mit value-Attribut und einem link auf eine Kapitel-Detail-XML wie einnahmen2026/epl15.xml. Die Kap-XMLs verlinken weiter auf Titel-XMLs (kpl1503.xml). Drei Klick-Ebenen Drill-Down, alle als statisches XML.

Eine eigene Abfrage in der Konsole reicht, um die Daten einmal komplett einzusammeln:

const data = { einnahmen: {}, ausgaben: {} };
for (const side of ['einnahmen', 'ausgaben']) {
  const top = await (await fetch(`${side}2026/hh.xml`)).text();
  const doc = new DOMParser().parseFromString(top, 'application/xml');
  for (const s of doc.querySelectorAll('set')) {
    const link = s.getAttribute('link') || '';
    const m = link.match(/epl(\d+)\.xml/);
    if (m) data[side][m[1]] = s.getAttribute('value');
  }
}
console.log(data);

In unserem Fall haben wir das einmal über Playwright-MCP gemacht (siehe Artikel 8). Das Ergebnis als Snapshot liegt im Repo unter parser/output/stmfh_official.json — der Datenstand vom 21. Mai 2026 (Quelle: Eigen-Abruf via Playwright). Bei späteren Verifikationen reicht ein erneuter Aufruf, das Format ist stabil.

Σ über alle Einzelpläne

Beide Seiten — Einnahmen und Ausgaben — müssen sich nach Haushaltsausgleich exakt entsprechen. PDF S. 60–62 zeigt für 2026 den Wert 84.647,4 Mio. € je Seite. Drei Σ im Vergleich (Quelle: jeweils eigene Aggregation aus der genannten Quelle):

Σ über alle 16 EPLsPDF-Kap-AbschlussLive-AggregationSTMFH-Diagramm
Einnahmen 2026 (Tsd. €)84.647.360,684.647.360,684.842.380,3
Ausgaben 2026 (Tsd. €)84.647.360,684.647.360,684.842.380,3
Bilanz (Einn − Aus)000

Live trifft das PDF bit-exakt. STMFH liegt 195.019,7 Tsd. (~195 Mio. €) darüber — auf beiden Seiten gleich, der Haushaltsausgleich bleibt. Das ist die datenstandsbedingte Differenz, auf die wir gleich zurückkommen.

Pro Einzelplan

Auf EPL-Ebene wird sichtbar, woher der STMFH-Δ kommt (Quelle: alle drei Werte pro Zeile aus den jeweiligen Aggregaten):

EPLNamePDF-EinnLive-EinnSTMFH-EinnΔ STMFH−LivePDF-AusLive-AusSTMFH-AusΔ STMFH−Live
01Bayerischer Landtag1,31,31,30,0219,4219,4219,40,0
02Bayerischer Ministerpräsident und Staatskanzlei0,40,40,40,0169,0169,0169,4+0,5
03Inneres, für Sport und Integration758,8758,8758,80,08.572,28.572,28.593,2+21,0
04Justiz1.462,71.462,71.462,8+0,13.287,13.287,13.288,3+1,2
05Unterricht und Kultus137,9137,9137,90,017.986,517.986,517.990,2+3,7
06Finanzen und für Heimat675,6675,6675,7+0,13.640,53.640,53.641,9+1,4
07Wirtschaft, Landesentwicklung und Energie452,0452,0452,00,01.660,51.660,51.665,2+4,7
08Ernährung, Landwirtschaft, Forsten und Tourismus455,9455,9456,0+0,11.894,41.894,41.910,2+15,8
09Wohnen, Bau und Verkehr4.111,44.111,44.111,40,06.891,36.891,36.895,8+4,5
10Familie, Arbeit und Soziales3.069,73.069,73.122,0+52,39.249,69.249,69.311,8+62,2
11Bayerischer Oberster Rechnungshof0,00,00,00,047,447,447,40,0
12Umwelt und Verbraucherschutz112,5112,5112,50,01.236,71.236,71.243,2+6,5
13Allgemeine Finanzverwaltung71.237,571.237,571.275,1+37,619.382,719.382,719.311,2−71,5
14Gesundheit und Pflege15,515,515,50,0928,5928,5935,4+6,9
15Wissenschaft und Kunst2.154,12.154,12.258,8+104,79.362,99.362,99.498,4+135,5
16Digitales2,12,12,10,0118,8118,8121,3+2,5
Σ84.647,484.647,484.842,4+195,084.647,484.647,484.842,4+195,0

PDF und Live stimmen pro Einzelplan auf die letzte Nachkommastelle überein. Drei EPLs heben sich gegen STMFH ab: EPL15 (Wissenschaft und Kunst) liegt 105 Mio. höher auf der Einnahmen-Seite und 135 Mio. höher auf der Ausgaben-Seite. EPL10 (Soziales) ist um 52 / 62 Mio. niedriger im PDF als im STMFH. EPL13 (Finanzverwaltung) zeigt das umgekehrte Vorzeichen: STMFH hat 37 Mio. mehr Einnahmen, aber 71 Mio. weniger Ausgaben.

Wo der STMFH-Δ herkommt

Der größte Einzeltreffer steckt in EPL15 Kap 15 03 (Allgemeine Bewilligungen — Wissenschaft). STMFH listet dort einen Titel, den unsere PDFs nicht enthalten:

334 01 — Zuweisungen des Bundes zur Abwicklung des Sondervermögens Infrastruktur und Klimaneutralität (SVIK) — 104.682,0 Tsd. €
Quelle: STMFH-Kapitel-Detail-XML einnahmen2026/kpl1503.xml, Stand 21.5.2026

Das SVIK-Sondervermögen wurde Anfang 2026 vom Bund eingerichtet. Bayerns Anteil ist eine neue Einnahme-Position, die im Einzelplan-PDF mit Download Stand 9. Mai 2026 noch nicht abgebildet ist. Ähnliche Update-Positionen ziehen sich durch die anderen Kap-Diffs. Die Größenordnung 195 Mio. ist ~0,23 % der Gesamtsumme — auf Stadt- oder Bundes-Niveau eine kleine Korrekturwelle, auf Bundesland-Niveau Tagesgeschäft der laufenden Haushaltspflege.

Unser Parser kann nichts extrahieren, was nicht im Quell-PDF steht. Sobald die Ministerien-PDFs in einer aktualisierten Version vorliegen (üblicherweise mit jedem Nachtragshaushalt), holt die Live-Aggregation den Δ automatisch ein.

Wo es haken bleibt: keine Versionierung, keine Nachvollziehbarkeit

Dass ein vom Parlament noch nicht endgültig verabschiedeter Haushaltsplan zwischen Entwurfsdatum und finaler Veröffentlichung mehrfach überarbeitet wird, ist gängige Praxis und im Grundsatz unproblematisch — solange nachvollziehbar ist, wann welche Position eingefügt, gestrichen oder geändert wurde und warum. Genau diese Spur fehlt:

  • Die PDF-Dateien unter stmfh.bayern.de/haushalt/20262027/ tragen ein Creation-Date im Dokumenten-Metadatensatz, sonst nichts. Kein sichtbares Versions-Tag im Dokumentkopf, keine Änderungsliste, kein Datum für „diese Fassung gilt ab …".
  • Beim Austausch wird die Datei am alten URL überschrieben. Wer eine ältere Fassung verlinkt hat (so wie wir Anfang Mai), bekommt beim erneuten Abruf stillschweigend etwas anderes geliefert — ohne Hinweis, dass sich der Inhalt geändert hat.
  • Das STMFH-Online-Diagramm zeigt zwar die jeweils aktuellen Werte, kennt aber keine Historie. Stand-Angaben oder ein „letzte Änderung am …" fehlen.
  • Die Diff zwischen unseren beiden Snapshots (Eigen-Download 9. Mai vs. 22. Mai) lässt sich nur rekonstruieren, weil wir den älteren PDF-Stand zufällig noch im Repo hatten. Aus der offiziellen Quelle heraus wäre die Frage „was hat sich seit Mai geändert?" nicht beantwortbar.

Konkrete Folge: ein Bürger, eine Journalistin oder ein Forscher, der vor zwei Wochen die EPL15-Einnahmen mit 2.154 Mio. € zitiert hat, hat heute mit 2.259 Mio. € einen anderen Wert in der Hand — ohne dass irgendetwas dokumentiert, dass und wann die Differenz entstanden ist. Was an einer Pressemitteilung als Plus-Minus-Rauschen niemandem auffällt, wird in einem Vergleich über Zeit zur Quelle von Missverständnissen.

Eine Mindest-Anforderung an einen öffentlich nutzbaren Haushaltsplan wäre: jede Fassung mit Versions-Tag im Dateinamen oder im Dokumentkopf, ein Änderungs-Log mit Datum, Stelle und Begründung, und stabile URLs für historische Stände. Das ist kein technischer Mehraufwand — es ist eine politische Entscheidung darüber, ob Transparenz beim Endprodukt aufhört oder bis in den Pflegeprozess hineinreicht.

Live = PDF: warum das überhaupt schwierig war

Bevor wir mit STMFH vergleichen konnten, musste der eigene Aggregat-Wert verlässlich sein. Das war die Arbeit der letzten zehn Artikel — und am Ende der Reihe brauchte es noch fünf konkrete Parser-Bugfixes, bevor Σ Live mit Σ PDF auf den Cent genau übereinstimmte (Quelle: eigene Git-History, Commits 73522fa, f3ca186, 01a4406, f3604cb):

  • epl11.json lowercase — Filesystem-Case auf macOS hatte den Datei-Namen geändert, Path.glob("Epl*.json") matched nur Uppercase. EPL 11 (Rechnungshof, 47,4 Mio.) fehlte komplett. Behoben via git mv.
  • *** als Platzhalter — Bayern verbirgt nicht-öffentliche Beträge (Steuergeheimnis, geheime Beteiligungen) mit drei Sternen. Unser Regex matched nur Dezimalzahlen, also rutschte decimals[0] auf den nächsten Wert in der Zeile — typischerweise den Soll-2025-Wert in Spalte A. Folge: 81 Titel in EPL 13, kumuliert ~84 Mio., wurden mit Vorjahres-Werten gezählt. Fix: \*{3} ins Decimal-Regex aufnehmen, in _parse_decimal zu None mappen.
  • Worttrennungs-Bindestriche — In PDF-Layout-Text trennt pdftotext lange Zweckbestimmungen mit Bindestrich am Wortende ab: Radverkehr - 40.797,0. Der Bindestrich wurde als Vorzeichen für die folgende Zahl gelesen, der Titel kam mit −40,8 Mio. statt +40,8 Mio. ins Aggregat. Fix: Negative Lookbehind (?<![A-Za-z…] ) verhindert, dass ein Minus mit nur einem Leerzeichen davor noch als Vorzeichen gilt.
  • Anlage C/D-Pages — Wasserwirtschaftsämter (EPL 12 Kap 12 77) haben Detail-Bauwerke in eigenen Anlage-C-Pages aufgelistet. Die Titel sind aber bereits über den Sammeltitel 780 00 Baumaßnahmen an Gewässern im Hauptkap erfasst. Page-Skip-Liste erweitert.
  • Konsolidierung zu breit — Der ursprüngliche Cross-EPL-Dedup-Filter erkannte Titel mit identischer Nr+FKZ+Wert in mehreren EPLs als Doppel-Buchungen. Das stimmt für HG 6 (Zuweisungen an Gemeinden, der kommunale Finanzausgleich-Pattern). Es stimmt aber nicht für HG 4 Personal: Titel 421 01-X Bezüge der Mitglieder der Staatsregierung mit 267,3 Tsd. taucht in 9 EPLs einmal pro Ministerium auf — das sind 9 verschiedene Minister, keine Doppelbuchung. Fix: Konsolidierungs-Scope auf HG 6 eingeengt.

Jeder dieser Bugs war für sich klein — typische Vorzeichenfehler, Encoding-Eigenheiten, Off-by-One in einer Anlage-Klassifizierung. Zusammen erklären sie die Differenz von zwischenzeitlich −14 Mrd. (vor Konsolidierung) bis schließlich 0 €.

Was der Quellenvergleich im Frontend ans Tageslicht brachte

Die Σ-Zeile im obigen Vergleich passt. Die Charts und Tabellen im Frontend haben aber dieselbe Konsistenz lange Zeit nicht eingehalten. Der Vergleich war daran schuld, dass die Lücken überhaupt sichtbar wurden — und der Auftrag „die Σ in Header, BalanceTable, Sunburst, Sankey, Treemap und SourceCompare müssen für jeden Knoten denselben Wert zeigen" hat sich im letzten Schritt der Reihe als die eigentliche Disziplinarbeit erwiesen.

Globale Minderausgaben: Charts zeigten zu viel

Konkrete Stelle: EPL 02 (Staatskanzlei), Ausgaben.

QuelleΣ Aus 2026 (Tsd. €)
Charts (Sunburst-Tabelle, Treemap) — vor Fix177.187,1
BalanceTable / SourceCompareTable Live168.957,1
PDF-Kap-Abschluss EPL 02 (S. 60–62)168.957,1

Differenz exakt 8.230,0 Tsd. — und die liegt komplett in Kap 02 02 (Sammelansätze). Drei Titel sind dafür verantwortlich (Quelle: parser/output/haushalt.json):

Titel-NrFKZSoll 2026 (Tsd. €)Zweckbestimmung
972 01-5881−5.780,0Globale Minderausgabe
972 06-0881−2.450,0Globale Minderausgabe zum Haushaltsabgleich
981 16-7891+1.045,4Nutzung von Räumen und Plätzen

Die drei summieren sich zu −7.184,6 Tsd. — und genau dieser Wert steht im PDF-Kap-Abschluss von Kap 02 02 als „Besondere Finanzierungsausgaben". Globale Minderausgaben sind buchungstechnisch HG 9, vorzeichenrichtig negativ. Bayern bucht sie als pauschalen Abzug auf den Kap-Ausgabewert, damit der Haushaltsausgleich rechnerisch aufgeht.

Frontend-seitig hat buildHierarchy() für die d3-Layout-Engine alle Titel mit value <= 0 herausgefiltert — Treemap und Sunburst können keine negativen Areas darstellen. Die positive-only-Σ wurde dann aber auch in die Sektor-Beschriftungen und in die Tabelle daneben übernommen. Ergebnis: Charts zeigten 177,2 Mio., BalanceTable 169,0 Mio., SourceCompare-Live 169,0 Mio. Drei Σ für denselben Knoten.

Lösung: pro Aggregations-Ebene (kind, EPL, Kapitel, Hauptgruppe) eine zweite, vorzeichenrichtige Σ als meta.correctedValue in den Hierarchie-Knoten schreiben. Das d3-Layout nutzt weiterhin positive-only Werte für die Rechtecke; die Beschriftung und die Legend-Tabelle lesen correctedValue. Die Abweichung zwischen Layout-Größe und angezeigter Σ ist kleiner als ein Prozent über alle EPLs und kosmetisch hinnehmbar — die rechnerische Konsistenz hat Vorrang.

formatTsdEuro(-7184.6) war "-7185 Tsd. €" statt "-7,18 Mrd. €"

Sobald correctedValue negativ wurde (z.B. beim Drill auf HG 9 in Kap 02 02 mit Σ −7.184,6 Tsd.), versagten die Skalen-Vergleiche im Format-Helper. Code vor Fix:

if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)} Mrd. €`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)} Mio. €`;
return `${value.toFixed(0)} Tsd. €`;

−7184,6 >= 1_000_000 ist false, ebenso für 1_000. Anzeige fällt auf die Tsd.-Stufe zurück — ein 7-Mrd.-Wert wird als „−7185 Tsd. €" gerendert. Fix: Skala über Math.abs(value) bestimmen, Vorzeichen aus dem Originalwert behalten.

Kapitel-Ebene fehlte in der Hierarchie

Beim Drill in den Sunburst auf EPL 02 sah man mehrere Titel mit identischer Hauptgruppe und ähnlicher Titel-Nr (z.B. zwei 701 X-Bau-Titel) nebeneinander, ohne dass erkennbar wurde, dass sie zu unterschiedlichen Kapiteln gehören. Grund: buildHierarchy() baute root → kind → EPL → Hauptgruppe → Titel. Die Bayerische Haushaltsstruktur ist aber EPL → Kapitel → Hauptgruppe → Titel. Kapitel ist die eigentliche Buchungs-Organisationseinheit (eine Behörde, ein Sammelansatz, eine Anstalt) — ohne Kap-Ebene konnte der Sunburst auf EPL-Ebene nicht erklären, woraus sich der Wert zusammensetzt.

Fix: HierarchyNode.level um "kapitel" erweitert, buildHierarchy() baut fünf Ebenen, shortLabelFor() rendert Kap NN NN.

Volltextsuche fand „Grunderwerbsteuer" nicht

Suche im Header durchsuchte nur die Aggregat-Tabellen (BalanceTable, SourceCompareTable). Wer nach einem Titel-Stichwort suchte, bekam keine Treffer, obwohl 18 Titel mit „Grunderwerbsteuer" in der Titel-Liste stehen. Fix: neue Komponente TitelSearchResults filtert über titel_nr, fkz, kap_nr, kap_name, epl_nr, epl_name, zweckbestimmung aller 13.570 Titel und zeigt bis zu 50 Treffer mit Highlight.

KapDiffTable war nach dem correctedValue-Fix obsolet

Die ursprüngliche „Parser-Lücken pro Kapitel"-Tabelle zeigte vor dem Σ-Konsistenz-Fix systematisch Abweichungen, weil Live (positive-only Titel) ≠ PDF-Kap-Abschluss (vorzeichenrichtig). Nach dem Fix zeigt sie „0 von 0 Kapiteln weichen ab" — kein Informationswert mehr. Komponente bleibt im Repo für eventuelles späteres Debugging, ist aber aus dem App-Layout entfernt.

Year-Switch 2026/2027

Das Parser-Schema kennt soll_2026_tsd und soll_2027_tsd seit Beginn. Frontend war über alle Charts, Tabellen und Aggregations-Funktionen hinweg auf 2026 hartkodiert. Refactor: Type Year = 2026 | 2027, sollOf(t, year)-Helper, alle Aggregationen erhalten einen optionalen year-Parameter mit Default 2026 (Backwards-Compat in Tests). Im Header sitzt ein Toggle, der die zugrundeliegende Σ-Berechnung umlegt; PDF-Snapshots für 2027 sind noch nicht gepflegt, deshalb wird die SourceCompareTable bei year === 2027 durch einen Banner-Hinweis ersetzt.

Vite mit altem index.html und neuen Asset-Hashes

Während des Σ-Konsistenz-Fixes flog die Live-Seite kurzzeitig auf eine weiße Seite. Diagnose: HTML referenzierte index-CcGS8y_K.js, im pages-Branch lagen aber nur index-DHm90HGM.js-Assets. Vite hatte beim Build das index.html mit altem Hash übernommen und nur den JS-Output neu erzeugt. Klassischer Incremental-Build-Cache-Bug. Fix im Deploy-Skript:

rm -rf web/dist
(cd web && npm run build)

Einfach, robust, kein Incremental-Build-Risiko mehr.

Was die Tabelle zeigt

Direkt oberhalb des Sunburst, Sankey oder Treemap auf byhaushalt.rotecodefraktion.de liegt jetzt die Drei-Quellen-Tabelle. Pro Zelle ein Vergleich gegen die jeweils linke Spalte:

  • Live-Wert grün/grau = Δ < 100 Tsd. gegen PDF — Parser stimmt.
  • Live-Wert amber/rot = Δ > 100 Tsd. — Parser-Bug, der mit den Mitteln aus diesem Artikel auffindbar wäre.
  • STMFH-Wert amber/rot = Δ > 100 Tsd. gegen Live — vermutlich datenstandsbedingt, also Update-Posten.

Die Tabelle ist als unabhängiges Korrektheits-Audit gedacht. Wer den byhaushalt-Code clont, lokal npm run build ausführt und keinen Δ in der Live-Spalte sieht, hat die Bibliothek der Reihe selbst auf einem Cent-genauen Datensatz laufen.

Stand bei der Veröffentlichung dieses Artikels

git clone https://codeberg.org/rotecodefraktion/byhaushalt.git
cd byhaushalt
git checkout main
cat parser/output/source_compare.json | jq '.[].epl_nr' | wc -l
# 16

Die Datei parser/output/stmfh_official.json enthält den STMFH-Snapshot, parser/output/source_compare.json die EPL-Vergleichszeilen für die Tabelle. Der Skript-Schnipsel oben aus der Browser-Konsole lässt sich erneut ausführen, um den STMFH-Snapshot auf den jeweils aktuellen Stand zu bringen.

Fazit

Dieser Bonus-Artikel zeigt, warum Agentic Coding Entwicklerinnen und Entwickler auf absehbare Zeit nicht ersetzen wird. Der Code war nach Artikel 6 mehr oder minder fertig, der Parser konnte einen Teil der Daten verarbeiten, die E2E-Tests gaben überall grünes Licht — und trotzdem lagen die Zahlen nicht meilenweit, aber doch signifikant daneben.

Die Iteration und Prüfung der Daten bleibt Aufgabe der Entwicklerin. Genauso das Hinterfragen der vermeintlichen Antworten des Agenten: der neigt bei komplexen Problemen dazu, neue, aber gelegentlich erfundene Wege gehen zu wollen.

Agentic Coding verändert die Art, wie Code entsteht. An den grundlegenden Mustern von Programmierung und Entwicklung ändert es wenig — es ist ein weiteres Werkzeug im Werkzeugkasten, kein Ersatz für die Disziplin, die dahintersteht.