Three Sources, One Truth — Validating the byhaushalt Parser Against PDF and STMFH Diagram

Three Sources, One Truth — Validating the byhaushalt Parser Against PDF and STMFH Diagram

Bonus article · Series: Agentic Coding with Claude Code

Update May 22, 2026 — the +195 Mio. was the draft offset: While investigating a striking STMFH-Live difference in EPL 15, it turned out that the individual-plan PDFs stored locally in the repo carried creation dates from November 14–26, 2025 — the draft versions before the final publication. The current PDFs at https://www.stmfh.bayern.de/haushalt/20262027/ are dated March 27 to April 21, 2026, some 30–40 pages longer, and contain update items such as the SVIK title 334 01-8 in EPL 15 Kap 15 03 with 104,682.0 Tsd. €. After replacing all 17 PDFs and rerunning the parser the Σ row is now:

November draftApril finalSTMFH
Σ revenues 2026 (Tsd. €)84,647,360.684,842,411.184,842,411.1
Σ expenses 2026 (Tsd. €)84,647,360.684,842,411.184,842,411.1
Δ against STMFH+195,019.700

The STMFH-Σ figure of 84,842,380.3 Tsd. originally given in this article came from a non-regenerated source_compare.json snapshot mixed with the November live aggregation. After rebuilding Σ from the current stmfh_official.json the actual STMFH-Σ is 84,842,411.1 — identical to Live and PDF, no rounding difference. Per EPL all 16 Live values agree with STMFH and the PDF chapter close to the cent (example: EPL 15 revenues 2,258,842.5 Tsd. €, EPL 10 revenues 3,122,039.9 Tsd. €). The Σ rows and the EPL table below still reflect the November vintage and remain as a temporal snapshot — the article’s substance (where does the difference come from? how do you validate against a true second source?) survives the update, only the numbers are different today. The original „+195 Mio. correction wave" thesis turned out to be the draft offset: five months of budget maintenance between draft and final plan, not data-vintage drift of individual update items.

Over ten articles the byhaushalt parser built its own aggregation from the 16 individual-plan PDFs. What was missing the whole time was a real second source: until now we have validated against the overview on pages 60–62 of the overall budget — that is, against a different aggregate from the same PDF corpus. Circular. The Bavarian finance ministry, however, offers an interactive diagram at stmfh.bayern.de with the 2026 target values per individual plan and per chapter. That is an independent source. It sits behind the diagram as XML, two lines of JavaScript bring it out — and it gives the honest answer to the question “do our numbers add up?”

What we are comparing

Three sources, three aggregates, the same question: how much did Bavaria budget for each individual plan in 2026?

SourceVintageGranularityFormat
PDF chapter closeMay 9, 2026 (source: individual-plan PDFs Epl01.pdfEpl16.pdf)per chapterparser-extracted
Live aggregation (byhaushalt)May 9, 2026 (own measurement)per title, summedcomputed from titles
STMFH diagramat least May 21, 2026 (source: stmfh.bayern.de, retrieved by us on 5/21)per chapterXML behind FusionCharts

The first two sources should be identical — the live aggregation rolls up per title, the chapter close draws the line under the same titles. If the parser is clean, both values match to the cent. The third source is data-vintage-related more recent and contains updates that are not yet in our PDFs.

The XML source behind the diagram

The diagram on the finance ministry page is a FusionCharts pie. A glance at the browser devtools shows that the script initDiagramm.js loads two XML files: einnahmen2026/hh.xml and ausgaben2026/hh.xml. Both contain a <set> element per individual plan with a value attribute and a link to a chapter detail XML such as einnahmen2026/epl15.xml. The chapter XMLs further link to title XMLs (kpl1503.xml). Three click-levels of drill-down, all as static XML.

A simple console query is enough to collect the data:

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 our case we did this once via Playwright MCP (see Article 8). The result as a snapshot lives in the repo at parser/output/stmfh_official.json — the data vintage of May 21, 2026 (source: own retrieval via Playwright). For later verifications a re-call is enough, the format is stable.

Σ across all individual plans

Both sides — revenues and expenses — must match exactly after budget balancing. PDF p. 60–62 shows 84,647.4 Mio. € per side for 2026. Three Σ in comparison (source: each from its own aggregation of the named source):

Σ across all 16 EPLsPDF chapter closeLive aggregationSTMFH diagram
Revenues 2026 (Tsd. €)84,647,360.684,647,360.684,842,380.3
Expenses 2026 (Tsd. €)84,647,360.684,647,360.684,842,380.3
Balance (Rev − Exp)000

Live matches the PDF bit-exact. STMFH is 195,019.7 Tsd. (~195 Mio. €) above — same on both sides, the budget balance is preserved. That is the data-vintage-related difference we will return to in a moment.

Per individual plan

At EPL level it becomes visible where the STMFH-Δ comes from (source: all three values per row from the respective aggregates):

EPLNamePDF RevLive RevSTMFH RevΔ STMFH−LivePDF ExpLive ExpSTMFH ExpΔ STMFH−Live
01Bavarian Parliament1.31.31.30.0219.4219.4219.40.0
02Minister President and State Chancellery0.40.40.40.0169.0169.0169.4+0.5
03Interior, Sport and Integration758.8758.8758.80.08,572.28,572.28,593.2+21.0
04Justice1,462.71,462.71,462.8+0.13,287.13,287.13,288.3+1.2
05Education and Culture137.9137.9137.90.017,986.517,986.517,990.2+3.7
06Finance and Home Affairs675.6675.6675.7+0.13,640.53,640.53,641.9+1.4
07Economic Affairs and Energy452.0452.0452.00.01,660.51,660.51,665.2+4.7
08Food, Agriculture, Forestry and Tourism455.9455.9456.0+0.11,894.41,894.41,910.2+15.8
09Housing, Building and Transport4,111.44,111.44,111.40.06,891.36,891.36,895.8+4.5
10Family, Labor and Social Affairs3,069.73,069.73,122.0+52.39,249.69,249.69,311.8+62.2
11Bavarian Supreme Audit Office0.00.00.00.047.447.447.40.0
12Environment and Consumer Protection112.5112.5112.50.01,236.71,236.71,243.2+6.5
13General Financial Administration71,237.571,237.571,275.1+37.619,382.719,382.719,311.2−71.5
14Health and Care15.515.515.50.0928.5928.5935.4+6.9
15Science and Art2,154.12,154.12,258.8+104.79,362.99,362.99,498.4+135.5
16Digital2.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 and Live agree per individual plan to the last decimal. Three EPLs stand out against STMFH: EPL15 (Science and Art) is 105 Mio. higher on the revenue side and 135 Mio. higher on the expense side. EPL10 (Social Affairs) is 52 / 62 Mio. lower in PDF than in STMFH. EPL13 (Financial Administration) shows the opposite sign: STMFH has 37 Mio. more revenues but 71 Mio. less expenses.

Where the STMFH-Δ comes from

The biggest single hit sits in EPL15 Kap 15 03 (General Allocations — Science). STMFH lists a title there that our PDFs do not contain:

334 01 — Federal allocations for processing the Special Fund Infrastructure and Climate Neutrality (SVIK) — 104,682.0 Tsd. €
Source: STMFH chapter detail XML einnahmen2026/kpl1503.xml, status 5/21/2026

The SVIK special fund was set up by the federal government in early 2026. Bavaria’s share is a new revenue position not yet reflected in the individual-plan PDF dated May 9, 2026. Similar update positions run through the other chapter diffs. The magnitude 195 Mio. is ~0.23% of the total — at a city or federal level a small correction wave, at the state-government level routine ongoing budget maintenance.

Our parser cannot extract what is not in the source PDF. As soon as the ministry PDFs are available in an updated version (typically with each supplementary budget), the live aggregation catches up automatically.

Where it stops working: no versioning, no traceability

It is common practice — and in principle unproblematic — that a budget bill not yet finally passed by parliament gets reworked several times between draft date and final publication, as long as it is traceable when which position was added, removed, or changed and why. Exactly this trail is missing:

  • The PDF files at stmfh.bayern.de/haushalt/20262027/ carry a creation date in the document metadata, nothing else. No visible version tag in the document header, no change list, no „this version applies from …" date.
  • On replacement, the file is overwritten in place at the same URL. Anyone who linked to an older version (as we did in early May) silently gets something else on the next fetch — without any signal that the content changed.
  • The STMFH online diagram always shows the current values but has no history. A status field or a „last changed on …" is absent.
  • The diff between our two snapshots (own download May 9 vs. May 22) is reconstructable only because we happened to keep the older PDF state in the repo. From the official source the question „what changed since May?" is not answerable.

Concrete consequence: a citizen, a journalist, or a researcher who two weeks ago cited EPL15 revenues at 2,154 Mio. € holds today a different value at 2,259 Mio. € — without anything documenting that and when the difference arose. What goes unnoticed in a single press release as plus-minus noise becomes a source of misunderstanding the moment one compares across time.

A minimum requirement for a publicly usable budget plan would be: every version with a version tag in the filename or document header, a change log with date, location and reason, and stable URLs for historical states. None of that is technical overhead — it is a political decision about whether transparency stops at the end product or extends into the maintenance process.

Live = PDF: why that was hard to begin with

Before we could compare against STMFH, our own aggregate value had to be reliable. That was the work of the last ten articles — and at the end of the series five concrete parser bug fixes were still required before Σ Live matched Σ PDF to the cent (source: own git history, commits 73522fa, f3ca186, 01a4406, f3604cb):

  • epl11.json lowercase — Filesystem-case on macOS had changed the file name, Path.glob("Epl*.json") matched only uppercase. EPL 11 (Audit Office, 47.4 Mio.) was missing entirely. Fixed via git mv.
  • *** as placeholder — Bavaria hides non-public amounts (tax secrecy, secret holdings) with three stars. Our regex matched only decimal numbers, so decimals[0] slipped to the next value in the line — typically the target-2025 value in column A. Result: 81 titles in EPL 13, accumulating ~84 Mio., were counted with prior-year values. Fix: add \*{3} to the decimal regex, map to None in _parse_decimal.
  • Word-break hyphens — In PDF layout text pdftotext breaks long purpose descriptions with a hyphen at the word end: Radverkehr - 40.797,0. The hyphen was read as a sign for the following number, the title arrived in the aggregate at −40.8 Mio. instead of +40.8 Mio. Fix: a negative lookbehind (?<![A-Za-z…] ) prevents a minus with only one space in front from still acting as a sign.
  • Anlage C/D pages — Water management offices (EPL 12 Kap 12 77) list detail construction works in dedicated Anlage-C pages. The titles, however, are already captured via the collective title 780 00 Construction at watercourses in the main chapter. Page-skip list extended.
  • Consolidation too broad — The original cross-EPL dedup filter recognized titles with identical Nr+FKZ+value across multiple EPLs as double bookings. This is correct for HG 6 (allocations to municipalities, the municipal financial-equalization pattern). It is not correct for HG 4 personnel: title 421 01-X Salaries of the members of the state government of 267.3 Tsd. appears in 9 EPLs once per ministry — that is 9 different ministers, no double booking. Fix: consolidation scope narrowed to HG 6.

Each of these bugs was small in itself — typical sign errors, encoding peculiarities, off-by-one in an annex classification. Together they explain the difference that briefly went as wide as −14 Bn. (before consolidation) before settling at 0 €.

What the source comparison surfaced in the frontend

The Σ row in the comparison above checks out. The charts and tables in the frontend, however, did not hold the same consistency for a long time. The comparison itself is what made the gaps visible — and the brief „Σ in header, BalanceTable, sunburst, sankey, treemap and SourceCompare must show the same value for every node" turned out to be the actual disciplinary work of the final step.

Global cuts: the charts showed too much

Concrete location: EPL 02 (State Chancellery), expenditures.

SourceΣ Aus 2026 (Tsd. €)
Charts (sunburst table, treemap) — before fix177,187.1
BalanceTable / SourceCompareTable Live168,957.1
PDF chapter close EPL 02 (p. 60–62)168,957.1

Difference exactly 8,230.0 Tsd. — and it sits entirely in Kap 02 02 (collective allocations). Three titles cause it (source: parser/output/haushalt.json):

Title-NrFKZSoll 2026 (Tsd. €)Purpose
972 01-5881−5,780.0Global cut
972 06-0881−2,450.0Global cut for budget balance
981 16-7891+1,045.4Use of rooms and venues

The three sum to −7,184.6 Tsd. — and exactly that figure appears in the PDF chapter close of Kap 02 02 as „Besondere Finanzierungsausgaben" (special financing expenditures). Global cuts are technically HG 9, signed-negative. Bavaria books them as flat-rate deductions from the chapter expenditure so the budget balance works out arithmetically.

On the frontend, buildHierarchy() filtered out all titles with value <= 0 for the d3 layout engine — treemap and sunburst cannot render negative areas. The positive-only Σ then propagated into the sector labels and into the legend table next to the chart. Result: charts showed 177.2 Mio., BalanceTable 169.0 Mio., SourceCompare Live 169.0 Mio. Three Σ for the same node.

Solution: per aggregation level (kind, EPL, chapter, head group) write a second, signed Σ as meta.correctedValue into the hierarchy node. The d3 layout still uses positive-only values for the rectangles; labels and the legend table read correctedValue. The deviation between layout size and displayed Σ is below one percent across all EPLs and cosmetically acceptable — arithmetic consistency takes priority.

formatTsdEuro(-7184.6) was "-7185 Tsd. €" instead of "-7.18 Bn. €"

As soon as correctedValue became negative (e.g. when drilling into HG 9 in Kap 02 02 with Σ −7,184.6 Tsd.), the scale comparisons in the format helper failed. Code before 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 is false, same for 1_000. The display falls back to the Tsd. tier — a 7-billion-Euro value gets rendered as „−7185 Tsd. €". Fix: determine the scale via Math.abs(value), keep the sign from the original value.

Chapter level was missing in the hierarchy

When drilling into the sunburst on EPL 02 several titles with identical head group and similar title number (e.g. two 701 X construction titles) sat next to each other, with no way to see they belonged to different chapters. Reason: buildHierarchy() built root → kind → EPL → head group → title. The Bavarian budget structure is EPL → chapter → head group → title, however. Chapter is the actual booking organizational unit (an agency, a collective allocation, an institution) — without the chapter level the sunburst on EPL level could not explain how the value composes.

Fix: HierarchyNode.level extended with "kapitel", buildHierarchy() builds five levels, shortLabelFor() renders Kap NN NN.

Full-text search did not find „Grunderwerbsteuer"

Header search only filtered the aggregate tables (BalanceTable, SourceCompareTable). Anyone searching for a title keyword got no hits even though 18 titles containing „Grunderwerbsteuer" sit in the title list. Fix: new component TitelSearchResults filters across titel_nr, fkz, kap_nr, kap_name, epl_nr, epl_name, zweckbestimmung over all 13,570 titles and shows up to 50 results with highlight.

KapDiffTable was obsolete after the correctedValue fix

The original „Parser gaps per chapter" table systematically showed differences before the Σ consistency fix because Live (positive-only titles) ≠ PDF chapter close (signed). After the fix it shows „0 of 0 chapters differ" — no information value left. Component stays in the repo for possible later debugging but is removed from the app layout.

Year switch 2026/2027

The parser schema has carried soll_2026_tsd and soll_2027_tsd from the start. The frontend was hard-coded to 2026 across all charts, tables and aggregation functions. Refactor: type Year = 2026 | 2027, sollOf(t, year) helper, all aggregations receive an optional year parameter with default 2026 (backwards compat in tests). A toggle sits in the header that switches the underlying Σ computation; PDF snapshots for 2027 are not yet maintained, so the SourceCompareTable is replaced by a banner hint when year === 2027.

Vite with stale index.html and fresh asset hashes

During the Σ consistency fix the live site briefly flashed to a white page. Diagnosis: the HTML referenced index-CcGS8y_K.js, but the pages branch only contained index-DHm90HGM.js assets. Vite had picked up the index.html with the old hash and only emitted the JS output afresh. Classic incremental-build cache bug. Deploy script fix:

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

Simple, robust, no more incremental-build risk.

What the table shows

Right under the sunburst, sankey or treemap on byhaushalt.rotecodefraktion.de the three-source table now sits. Per cell a comparison against the column to its left:

  • Live value green/gray = Δ < 100 Tsd. against PDF — parser is correct.
  • Live value amber/red = Δ > 100 Tsd. — parser bug findable with the means from this article.
  • STMFH value amber/red = Δ > 100 Tsd. against Live — probably data-vintage-related, i.e. an update position.

The table is meant as an independent correctness audit. Anyone who clones the byhaushalt code, runs npm run build locally and sees no Δ in the Live column has the library of the series running on a cent-exact dataset.

Status at publication of this article

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

The file parser/output/stmfh_official.json contains the STMFH snapshot, parser/output/source_compare.json the EPL comparison rows for the table. The script snippet above from the browser console can be run again to refresh the STMFH snapshot to the latest state.

Conclusion

This bonus article shows why agentic coding will not replace developers any time soon. The code was more or less done after article 6, the parser handled part of the data, the E2E tests gave a green light everywhere — and yet the numbers were not miles off, but off in a non-trivial way.

Iterating on the data and checking it remains the developer’s job. So does pushing back on the agent’s apparent answers: on complex problems it tends to want to take new, occasionally invented, paths.

Agentic coding changes how code gets written. It barely changes the underlying patterns of programming and development — it is another tool in the box, not a replacement for the discipline that stands behind it.