diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3229c3ee..bf5426fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,7 @@ jobs: tests/test_manifest_report_fixtures.py tests/test_canonical_baseline.py tests/test_pdf_canonical_baseline.py + tests/test_pdf_ledger_location.py tests/test_pdf_matching_boundary.py tests/test_pdf_observation_emission.py tests/test_pdf_observation_identity.py diff --git a/.gitignore b/.gitignore index ce47748b..519e49a1 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ docs/research/pdf-backend-bakeoff/validation/external-validity/results/x*.json dist/ build/ *.egg-info/ + +# Jupyter autosave copies, written next to any notebook that is opened (docs/research/). +.ipynb_checkpoints/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39a3077d..d8dad346 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -299,7 +299,7 @@ What to look at, roughly in priority order: - **Correctness of the diff itself.** This is the product. Passing tests are necessary, not sufficient: a diff can be green and still wrong. For any change that affects diff output, **run the tool on a real bill and eyeball the report** rather than trusting the suite alone. To check the PDF and XML pipelines against each other, render each to its own HTML file (see [TESTING.md](TESTING.md#comparing-the-two-pipelines-by-eye)). - **The risk hotspots**, where a bug does the most damage: - - **Parser accuracy** (`src/deltatrack/bill_tree.py`, `src/deltatrack/parsers/`) -- does the bill's structure come through intact? A missing or mis-nested section corrupts everything downstream. See [docs/parser-validation.md](docs/parser-validation.md). + - **Parser accuracy** (`src/deltatrack/bill_tree.py`, `src/deltatrack/parsers/`) -- does the bill's structure come through intact? A missing or mis-nested section corrupts everything downstream. See [docs/parser-validation.md](docs/parser-validation.md). For PDF headings, the ledger checks in [TESTING.md](TESTING.md#the-ledger-location-pin-and-when-you-may-regenerate-it) show where each amount moved. - **Financial diff** (`src/deltatrack/diff_bill.py` and its financial filtering) -- dollar amounts and their changes must be exact. - **The canonical schema contract** (`src/deltatrack/formatters/canonical.py`) -- both pipelines and the renderer depend on it, so a breaking change there ripples everywhere. - **Tests for the change.** New behavior should come with a test that would fail without the fix. Judge that by the red-green delta on your own machine rather than by the totals the author reported, and compare like-for-like selections — [TESTING.md](TESTING.md#reading-test-counts) explains what a count does and does not tell you, including which differences are a fail-open signal rather than an environment difference. diff --git a/TESTING.md b/TESTING.md index b2427e13..56493b3e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -19,7 +19,7 @@ comparison needs no key and no internet connection. ## How accuracy is checked -Accuracy is checked in six ways. Each one answers a different question, and +Accuracy is checked in seven ways. Each one answers a different question, and each has limits worth being honest about. There is no single accuracy percentage that would be truthful across all of appropriations, so we describe what each layer does and does not establish. @@ -172,6 +172,26 @@ position, so it confirms a passage is present somewhere in the version, not that it appears in the right place. It also cannot cover draft bills at all, which have no official version to compare against; that is check 4's job. +### 7. Cross-checking *where* the PDF files each dollar amount + +Check 5 confirms every dollar amount in the official version turns up somewhere in +the PDF. This one asks whether it turns up under the right heading. For every bill we +have in both forms, each amount in the official version is lined up with the same +amount in the PDF reading, in document order, and the two locations (the headings the +amount sits under) are compared. Each amount lands in one of a few grades: same place; +same place under a slightly different label; the right place but with a parent heading +missing; the right heading under the wrong parent; or filed under a different heading +entirely. The first three are acceptable, since a heading left out is visible, while a +wrong one is not. The count in each grade is frozen per bill version, so a change that +files money under the wrong heading more often fails, and one that improves it has to +be locked in on purpose. + +**Limit:** the official version's own reader is the answer key, and it leaves out some +headings the print shows (a heading with no text of its own, such as an umbrella agency +over its accounts). Where the PDF keeps such a heading the two disagree even though the +PDF is right, so the "wrong parent" grade overstates PDF errors. Draft bills have no +official version, so this check cannot cover them. + ## Known soft spots We keep these in the open rather than papering over them: @@ -324,6 +344,75 @@ Note what the sentinel cannot see. Two corpus-invisible behaviours move zero of pairs and are bound only by synthetic fixtures in `tests/test_round1_stages.py`, so "the corpus is still green" is not evidence about them. +### The ledger-location pin, and when you may regenerate it + +`tests/test_pdf_ledger_location.py` measures where the PDF pipeline files each dollar amount, +against the XML twin of the same version ([ADR 0022](docs/decisions/0022-pdf-heading-convergence.md)). +Every amount in the XML ledger is aligned with the same amount in the PDF ledger and its two +locations are compared level by level, the whole path, not just the account and its parent: + +| tier | the PDF path, against the XML's | counted as | +|---|---|---| +| `T0` | identical | true hit | +| `T1` | the same levels in the same order, a label differs (a joined, tail or near-variant name) | tolerated | +| `T2` | the XML's ancestors in order, with some left out | tolerated | +| `T3` | an ancestor that is wrong, extra or out of order | not tolerated | +| `T4` | a different account or section | not tolerated | +| `MISS` | no PDF amount to pair with | not tolerated | + +`tests/test_ledger_location_scorer.py` pins each case on hand-built paths. The tier counts for +every committed dual-format version (enrolled excluded) are pinned in +`tests/data/ledger_location_baseline.json`. + +The pin is exact in both directions. More not-tolerated amounts (`T3`, `T4`, `MISS`) or fewer +true hits (`T0`) is a regression. An improvement also fails, so it gets locked in: + +```bash +UPDATE_LEDGER_BASELINE=1 uv run pytest tests/test_pdf_ledger_location.py +``` + +#### Two readings: the tier totals, and the per-amount check + +The **tier totals** are the formal result. They are what the pin holds, what a heading change is +optimized toward, and what an ADR reports. The **per-amount check** is informal: it follows each +amount from one parser to another and lists every amount whose tier got worse, with the XML +path and the path before and after. It is not pinned and is not a gate. It exists because a +total hides a regression whenever another amount in the same version improves: one amount +moving `T2 → T3` and another `T3 → T2` leaves every count as it was. Use it to find and explain +regressions; report the totals as the result. + +Both come from `tests/ledger_location.py`, which prints the totals for whichever parser is +importable, so the "before" is the base branch's `src/` on `PYTHONPATH`. `--save` writes every +amount's locations; `--against` compares a later run with them. The comparison grades both runs +with the scorer that is checked out, so a scorer change never passes for a parser change. +`--extra bills` adds your locally fetched versions to both readings (they are not pinned): + +```bash +git archive origin/develop src | tar -x -C /tmp/before +PYTHONPATH=/tmp/before/src uv run python -m tests.ledger_location --save /tmp/before.json # before +uv run python -m tests.ledger_location --against /tmp/before.json # after +``` + +For a change to PDF headings or breadcrumbs, put both in the pull request: the before and after +totals, and the check's count of amounts better and worse, with each group of worse amounts +explained or fixed. + +#### What the answer key gets wrong + +The answer key is DeltaTrack's own XML reader, which leaves heading-only elements (an agency +heading with no text of its own) out of the breadcrumb, so where the PDF keeps such a heading +it is graded "wrong parent" although it matches the page. Read `T3` as an upper bound on PDF +errors. The reference for headings is the raw XML file's heading tags, not the reader +([ADR 0022](docs/decisions/0022-pdf-heading-convergence.md)): a change to the reader that +restores them shows up here as fewer `T3`. The same holds for two headings the XML sets on one +level where the PDF nests one under the other, and for a file whose own tags are misplaced +(in division G of 117-hr-4502 the `TITLE I` element is empty and its accounts sit in an unnamed +title after it, while the print nests them correctly). `T1` is lenient by design: a label that is a +near-variant of the XML's, or ends with the same words, counts as the same place. A fragment +passes too: a PDF heading read as just `ADMINISTRATION` grades `T1` against `FEDERAL HIGHWAY +ADMINISTRATION`, and reading the full printed `ADMINISTRATIVE PROVISIONS—FEDERAL HIGHWAY +ADMINISTRATION` instead (a heading the reader drops) shows in the per-amount check as `T1 → T3`. The XML is read only by these tests, never by the product. + ### The rest of the slow suite runs in CI too A further CI step runs the remaining slow modules (`CI_SLOW_MODULES` in diff --git a/docs/bill-structure.md b/docs/bill-structure.md index ccec2743..c7abe549 100644 --- a/docs/bill-structure.md +++ b/docs/bill-structure.md @@ -218,12 +218,15 @@ Closing the gap (DeltaTrack#54 and beyond — applies to both pipelines): Re-checked against the corpus 2026-06-27. Glyph size yields **two** bands, not three: a body band (~14pt) and a single heading band (~11.2pt small-caps) that holds -*both* `appropriations-intermediate` and `-small`. Casing does **not** rescue the -split — an agency header that is title-case in the XML `header` +*both* `appropriations-intermediate` and `-small`. The *text* does not rescue the +split: an agency header that is title-case in the XML `header` (`Management directorate`) extracts **ALL-CAPS** in heading position -(`MANAGEMENT DIRECTORATE`); small-caps flattens to caps in PDF text, so it is -lexically indistinguishable from an all-caps account header. `Line` carries no -x-position, so indentation/centering is not a usable signal either. +(`MANAGEMENT DIRECTORATE`), because small capitals flatten to capitals in PDF text. +The *letters* do. GPO sets an agency in title case in small caps, each word's first +letter printed larger (11.2 pt over 14.0 pt initials), and an account in even small +caps. The line's median glyph size hides that; `LineGeom.initial_caps` reads it per +word ([ADR 0022](decisions/0022-pdf-heading-convergence.md)). A department heading is +set in capitals at body size throughout (`LineGeom.size_min`/`size_max`). What that leaves recoverable from PDF-only input, keyed on *what follows* a heading-band line: @@ -233,18 +236,41 @@ heading-band line: | Major (department), `appropriations-major` | body-size + uppercase, sits above the band | Yes, with a dedicated detector | | Intermediate **carry-over** (one agency over ≥2 accounts) | another heading-band line | **Yes** — today silently dropped | | Intermediate **grouping header** (`ADMINISTRATIVE PROVISIONS`) | a `SEC.` line | **Yes** — today mislabeled `account`; the `SEC.` is detectable | -| Intermediate **prose-leading** (agency → "For necessary expenses…") | appropriation prose | **No** — identical to an account by size, case, and position | +| Intermediate **prose-leading** (agency → "For necessary expenses…") | appropriation prose | **Partly**: same size and position as an account, but printed agency-style. The case pattern ends the previous agency's reach; the heading itself is still emitted as an account | | Account, `appropriations-small` | appropriation prose | Yes | The unrecoverable row is not an edge case: prose-leading intermediates are the **majority** of the agency level in many bills (H.R. 8774 Defense: 59 of 67; H.R. 5895 Energy-Water: 37 of 44). Only H.R. 8752 and S. 2625 in the working corpus have none, which is why H.R. 8752 alone can look like full agency recovery. So on -PDF-only input the agency level is recoverable only in its carry-over and -grouping-header forms; the prose-leading form is not separable from an account by any -signal currently extracted. On the XML side all three levels are present in the path -tuples (`display_path` / `match_path`), even though they are dropped as standalone -nodes. +PDF-only input the agency level is recovered in its carry-over and grouping-header +forms; the prose-leading form is recognized only by its case pattern, which is used for +scope, not to re-label the heading. On the XML side all three levels are present in +the path tuples (`display_path` / `match_path`), even though they are dropped as +standalone nodes. + +### Reading heading structure across lines (ADR 0022) + +The **ledger** of a bill is every dollar amount it contains, each with its location: +the breadcrumb of headings it sits under. It is the same idea for an appropriations +account and a reconciliation section, and it is how PDF heading quality is measured: +for a published bill, each amount's PDF location is compared with its location in the +XML twin ([ADR 0022](decisions/0022-pdf-heading-convergence.md)). Forms that only show +across lines: + +| Form | Example | How the print shows it | +|---|---|---| +| Wrapped name | `SALARIES AND EXPENSES, FOREIGN CLAIMS` / `SETTLEMENT COMMISSION` | letters alike and the upper line full: one heading | +| Stacked headings | `BUREAU OF PRISONS` / `BUILDINGS AND FACILITIES` | letters differ, or the upper line broke early: two headings | +| Hanging-indent heading | first line at the margin, the rest at the paragraph indent | one heading, whatever the letters | +| Department mid-title | `UNITED STATES SECRET SERVICE` after an account's prose | capitals at body size, centered | +| Umbrella heading | `ATOMIC ENERGY DEFENSE ACTIVITIES` over `NATIONAL NUCLEAR SECURITY ADMINISTRATION` | same style as the heading below it, nothing between: **not separable** (read as one) | +| Quoted heading | `‘‘CHAPTER 16—…` inside an amendment to another law | inside an open quotation: not a heading of this bill | + +The XML reader leaves heading-only elements (an umbrella agency, `FOOD AND DRUG +ADMINISTRATION` over its accounts, a topical general-provisions heading) out of +`display_path`, so where the PDF keeps such a heading the two disagree even though the +PDF matches the print. ## Why not USLM? diff --git a/docs/decisions/0012-pdf-heading-levels.md b/docs/decisions/0012-pdf-heading-levels.md index 18c838e3..d0f8c955 100644 --- a/docs/decisions/0012-pdf-heading-levels.md +++ b/docs/decisions/0012-pdf-heading-levels.md @@ -48,18 +48,27 @@ discarded — so the probe added zero PDFium calls). The findings: (split line sums 250–315 pt, wrap sums 354–418 pt), robust to the small constants and to the one bill whose column measured 311 pt rather than 339. - **No geometric signal separates boundary 1.** A prose-leading agency is a single - body-size line followed by prose: there is no second heading line to test for - fullness, and x-centering and leading are null as above. + heading-band line followed by prose: there is no second heading line to test for + fullness, and x-centering and leading are null as above. The spike did not measure + the letters themselves: an agency prints in title case in small caps, an account in + even small caps ([0022](0022-pdf-heading-convergence.md)). ## Decision We will recover the PDF heading levels from **deterministic geometry and structural position only**, and accept the one boundary that no such signal separates. -- **Boundary 1 (prose-leading agency vs account): accept the gap.** A lone - body-size heading followed by prose is emitted as an account. We do not guess it is - an agency. The level is genuinely not present in the print as a distinguishable - signal, so inventing one would trade an honest gap for unauditable noise. +- **Boundary 1 (prose-leading agency vs account): accept the gap, narrowed.** A lone + heading followed by prose is emitted as an account; we do not guess it is an agency + from its print alone. Its case pattern ([0022](0022-pdf-heading-convergence.md)) ends + the reach of an earlier agency, and re-labels the heading only with one more fact: a + title-case heading whose text carries no dollar amount, followed by an account-style + heading, introduces accounts rather than holding money, and is an agency + (`BUREAU OF RECLAMATION`, `GREAT LAKES ST. LAWRENCE SEAWAY DEVELOPMENT CORPORATION`). + Measured on 118 PDFs: 183 amounts toward the XML, none away. Title case alone was + measured and rejected (265 amounts the wrong way against 8), because account names + print title case too. The dollar sign is read as a fact about the text, not as + appropriations wording ([0018](0018-text-triggers-are-financial-only.md)). - **Boundary 2 (stacked vs wrapped major): split on line-fullness.** The major detector splits a post-`TITLE` body-size run at each line-fullness hard break: `w_i + space + first_word_width(line_{i+1}) ≤ column_width − slack` means line *i* @@ -95,8 +104,8 @@ Alternatives considered: level fully separated (the 4/12 stacked residue from #105 is retired) and the carry-over agency level recovered where it is followed by another heading (#104). - Defense-class bills keep a known, documented agency gap: prose-leading agencies - surface as accounts. This is recorded as an accepted limitation, not a bug to chase - with a heuristic. Where the agency level matters for those bills, it comes from the + whose own text carries money surface as accounts. This is recorded as an accepted + limitation, not a bug to chase with a heuristic. Where the agency level matters for those bills, it comes from the XML pipeline (which the user already supplies for a published bill, [0010](0010-pdf-pipeline-pre-publication.md)). - The line-fullness rule carries one residual edge, absent from the FY2025 corpus: diff --git a/docs/decisions/0014-leveled-heading-tree-scope.md b/docs/decisions/0014-leveled-heading-tree-scope.md index 28da39e0..eda399d7 100644 --- a/docs/decisions/0014-leveled-heading-tree-scope.md +++ b/docs/decisions/0014-leveled-heading-tree-scope.md @@ -85,9 +85,9 @@ not an equality it would have to fudge: | bill (v1→v2) | XML | PDF | gap | attributed cause | |--------------|----:|----:|----:|------------------| -| 118-hr-8752 | 39 | 37 | −2 | clean; XML sees 2 bare subsections of added SEC.s the PDF folds into the section block (#188) | +| 118-hr-8752 | 39 | 38 | −1 | clean; XML sees 2 bare subsections of added SEC.s the PDF folds into the section block (#188); the PDF sees the added SPENDING REDUCTION ACCOUNT heading as its own block ([0022](0022-pdf-heading-convergence.md)) | | 118-hr-8774 | 31 | 33 | +2 | PDF over-segments a few blocks | -| 117-hr-4502 | 1415 | 1500 | +85 | PDF over-segments a large added block; XML +304 from subsection nodes (#188) | +| 117-hr-4502 | 1415 | 1456 | +41 | PDF over-segments a large added block; XML +304 from subsection nodes (#188) | | 115-hr-5895 | 300 | 336 | +36 | division-collapse + segmentation; XML +54 from subsection nodes (#188) | Snapshot 2026-06-29; the 117-hr-4502 and 115-hr-5895 PDF rows re-measured @@ -100,7 +100,10 @@ the #96 PDF recalibration), and the XML↔PDF gap narrows sharply (4502 +389→+ 5895 +90→+36) — much of what was attributed to PDF over-segmentation was subsection granularity the XML tree previously lacked. 118-hr-8752's exact parity gives way to a −2 by design: XML emits bare subsections the PDF cannot detect -(the all-subsections scope decision recorded on #188). The live numbers and the bands that gate them are in +(the all-subsections scope decision recorded on #188). Two PDF rows re-measured +2026-09-27 after the heading passes of [0022](0022-pdf-heading-convergence.md): 4502 +1500→1456 (wrapped account names now one block), 8752 37→38 (a heading the PDF +now reads). The live numbers and the bands that gate them are in `tests/test_pipeline_parity.py` (regenerate the table: `.venv/bin/python scripts/parity_table.py`). The Senate #89 residual `118-s-4795` recovers its heading hierarchy at a size-band ratio of 1.02 diff --git a/docs/decisions/0022-pdf-heading-convergence.md b/docs/decisions/0022-pdf-heading-convergence.md new file mode 100644 index 00000000..3b6b7d18 --- /dev/null +++ b/docs/decisions/0022-pdf-heading-convergence.md @@ -0,0 +1,293 @@ +# 22. Recover PDF heading structure with ordered, fail-closed passes over the reading order, measured by where each dollar amount lands against the XML twin + +- Status: Proposed +- Date: 2026-09-27 + +## Context + +The **ledger** of a bill is every dollar amount it contains, each with its location: the +breadcrumb of headings it sits under (`TITLE II > U.S. CUSTOMS AND BORDER PROTECTION > +OPERATIONS AND SUPPORT`). It is bill-type neutral (an appropriations account, a +reconciliation section) and is what the financial views ([#115](https://github.com/civictechdc/DeltaTrack/issues/115)) +read. On the PDF pipeline the location comes from the heading anchors in `pdf_anchors`, so +a misread heading files money under the wrong name. + +The anchor detectors judge each printed line largely on its own: its glyph-size band, its +casing, whether prose follows ([0012](0012-pdf-heading-levels.md), [0018](0018-text-triggers-are-financial-only.md)). +That misses anything only visible across lines ([#524](https://github.com/civictechdc/DeltaTrack/issues/524)): + +- a wrapped account name read as an agency fragment over an account fragment + (`SALARIES AND EXPENSES, FOREIGN CLAIMS` / `SETTLEMENT COMMISSION`); +- two stacked headings glued into one, or one glued onto the account below it; +- a heading-shaped line that is inside a quotation (a reconciliation bill quoting the law it + amends) or inside an unfinished sentence; +- a department-level heading in the middle of a title (`UNITED STATES SECRET SERVICE`), which + the major detector looks for only directly under `TITLE n`; +- an agency carried over to accounts that are not its own. + +**Measuring against the XML twin.** A published bill has both a PDF and an XML version of the +same text. The XML carries the heading levels as tags, so for every amount in the XML ledger we +can ask where the PDF ledger files the same amount. The two amount sequences are aligned in +document order and each matched amount is scored at account/section grain, comparing every level +of the two paths: + +| tier | the PDF path, against the XML's | counted as | +|---|---|---| +| T0 | identical | true hit | +| T1 | the same levels in the same order, a label differs (a joined, tail or near-variant name) | tolerated | +| T2 | true but shallower: the XML's ancestors in order, with some left out | tolerated | +| T3 | right heading, wrong parent: an ancestor that is wrong, extra or out of order | not tolerated | +| T4 | filed under a different heading | not tolerated | +| MISS | XML amount with no PDF counterpart | not tolerated | + +The tier totals are the result this record reports and later changes are held to. Totals can +hide a regression when another amount in the same version improves, so a change is also +checked amount by amount: each amount is followed from the parser before to the parser after, +both graded by the same scorer, and every amount whose tier got worse is listed and explained. +That check is a working tool, not a gate +([TESTING.md](../../TESTING.md#two-readings-the-tier-totals-and-the-per-amount-check)). + +T2 is tolerated for the reason [0018](0018-text-triggers-are-financial-only.md) gives: a +shallower breadcrumb that is true beats a deeper one that is invented. The XML is used only +by this test. Nothing reads it at runtime ([0005](0005-contained-two-version-tool.md), +[0010](0010-pdf-pipeline-pre-publication.md), [0011](0011-local-only-processing.md)). + +**The answer key is incomplete, and that is an assumption this record corrects.** Checks that +compare the PDF with its XML twin have taken DeltaTrack's XML reader (`bill_tree.normalize_bill`) +as the reference for headings. It is a sound reference for where money sits, not for every +heading above it: the reader leaves out a heading that carries no text of its own, while the raw +XML file tags it and the PDF prints it. In H.R. 4502 (117th Congress) the file tags +`Department of Health and Human Services`, `Food and Drug Administration` and `Salaries and +expenses` in turn, and the reader's breadcrumb keeps the first and last. The reference for +headings is therefore the raw XML file's heading tags; the reader is measured against it, +not the other way round. This record changes the PDF side only and leaves the reader as it is, +so a PDF heading the reader drops is graded "wrong parent" here although it matches the page. + +**A signal the earlier spike did not measure.** [0012](0012-pdf-heading-levels.md) found no +geometric signal between an agency and an account: same size band, same centering, same +leading. Both are small capitals, but GPO sets an agency in *title case* in small caps (each +word's first letter printed larger) and an account in *even* small caps. The line's median +glyph size, which is all `Line.glyph_size` kept, hides the difference. `pdf_text` now also +records, from the same glyph walk and with no new PDFium calls, `LineGeom.initial_caps` (the +case pattern) and `size_min`/`size_max` (a department heading is capitals at body size). The +case pattern is judged one word at a time, from the size of each letter. Sizes exist only in the +raw character data (each character the file draws, with its position and size), not in the +simplified page text PDFium also returns. The two differ on word spaces: the page text fills a +space in from the gap between words, while the raw data has only the spaces the file draws, and +in heading lines the file mostly draws none (65–92% of heading-line spaces are filled in, in two +sampled bills; about 11% in prose). So word breaks are found from the gaps between letters. Small +words (`and`, `of`, `the`, …) and words printed all in large capitals, such as acronyms, have no +case pattern of their own and are skipped. + +## Decision + +We will re-read the detected headings against the whole reading order in a fixed sequence of +passes (`parsers/pdf_heading_passes.py`, `converge_headings`), after detection and before +divisions are assigned. Each pass reads only what earlier passes produced, and each **fails +closed**: where its evidence is missing (no geometry, no case pattern), it leaves the detectors' +reading unchanged. + +1. **Stream.** One reading-order stream across page breaks, with page furniture removed. A + page break is never a heading boundary. +2. **Lines that cannot be headings.** Quoted text (a quote is open from an opening mark to the + next closing mark; an unquoted `SEC.`/`TITLE`/`DIVISION` line closes it), a line inside an + unfinished sentence, and the wrapped tail of a section or subsection title. Heading anchors + on such lines are dropped. Missing punctuation alone does not put a line inside a sentence: a + run of centered lines followed by a new, indented paragraph (or by nothing that is prose) is + set apart from the text around it and ends the sentence carried down to it. Without that, a + paragraph that ends without a period (`(Public Law 111–241)`) or an enacting clause that + `TITLE I` interrupts (`… assembled, That`) hides every heading printed after it. Heading-shaped + text inside prose runs the full column, or is followed by its sentence's continuation at the + left margin, and stays excluded. +3. **Department headings anywhere in a title.** A centered line set entirely at body size in + capitals is a department-level heading, not only directly under `TITLE n`. A line opening + with a structural token (`TITLE`, `SUBTITLE`, `CHAPTER`, `PART`, …), or directly under one, + is that level or its wrapped name, not a department. +4. **Heading runs.** Every run of heading lines between prose is re-segmented at each line + break. The first decisive signal wins: a line-break hyphen joins; a line ending in a small + word (`AND`, `OR`, `OF`, `FOR`, `TO`, `THE`, …) joins, since a heading cannot end there; + letters printed differently (title case over even) split; letters printed alike join unless + a veto applies. The vetoes: the lower line stands alone as a heading at least twice elsewhere + in the bill, or the next word would have fitted on the upper line (line fullness, + [0012](0012-pdf-heading-levels.md), measured against the widest centered heading rather than + the body column, with the lower line's first word ended at a space or a letter gap as for the + case pattern). A hanging-indent block (first line at the margin running full width, the rest + at the paragraph indent) is one heading. The department line directly under a bare `TITLE n` + is left as the major detector read it, and lines that run on, uncentered, from a numbered + level's own line (`TITLE VIII—COUNTERING THE MALIGN INFLU-` / `ENCE OF THE PEOPLE'S …`) + finish that line's name and are not headings. Each piece of a re-segmented run takes a level: + the last keeps the detectors' level, a piece printed like the last one (same case pattern and + size) takes the same level, and any other piece is an agency. GPO stacks a name over its own + account at one level (`FOOD AND DRUG ADMINISTRATION` / `SALARIES AND EXPENSES`); read as an + agency, the upper line would end the reach of the real agency above it. The run's last heading + is then judged whole, after joining: printed title case, with no dollar amount in the text + under it and an account-style heading next, it introduces accounts and is an agency + ([0012](0012-pdf-heading-levels.md), boundary 1). + +Scope, which heading an account inherits, is decided in `pdf_anchors._breadcrumb_core` from +the case pattern the passes record on each heading (`Anchor.caps`): a department ends the +agency above it; an agency carried past other accounts does not reach an account when it prints +like the heading right below it, when the account itself prints agency-style, or when an +agency-styled heading lies between them. An unknown case pattern never blocks. + +**No wording decides a line break.** Every signal above is format or grammar, as +[0018](0018-text-triggers-are-financial-only.md) requires. Where format cannot tell two stacked +headings from one wrapped name, the run stays joined: a missed split is accepted rather than +hierarchy inferred from what a line says (Known residuals, below). +`tests/test_pdf_heading_passes.py::TestNoWordingDecidesALineBreak` pins this on stacks whose +wording alone would have split them. + +Alternatives considered: + +- **Promote a joined title-case heading to agency on its print alone.** The detectors read + `GREAT LAKES ST. LAWRENCE SEAWAY DEVELOPMENT` as an agency over its wrapped tail `CORPORATION`; + keeping that first line's level for any title-case join was measured on 118 PDFs and moved 265 + amounts the wrong way against 8, because account names print title case too (`FAMILY HOUSING + OPERATION AND MAINTENANCE, ARMY`) and as agencies they took the accounts after them. The case + pattern ends an agency's reach reliably; making one also needs the text under the heading to + carry no money. +- **A repeat-words veto.** Split two lines printed alike when every word of the lower line + appears in the upper one, on the idea that a wrapped name continues with new words. Rejected + on measurement: over the same 118 PDFs it changed 9 headings and was wrong in all 9, because + names repeat words (`NATIONAL SECURITY COUNCIL AND HOMELAND` / `SECURITY COUNCIL`; `COUNCIL ON + ENVIRONMENTAL QUALITY AND OFFICE OF` / `ENVIRONMENTAL QUALITY`). +- **Named wording exceptions.** Split a stack when its lower line is exactly `SALARIES AND + EXPENSES`, or its upper line exactly one of the fifteen executive departments (5 U.S.C. 101). + Rejected in review: each entry encodes the answer to a known example rather than showing that + the print distinguishes the structures, and admitting them would amend 0018. Measured before + removal on 118 PDFs (the committed corpus plus 65 fetched versions of reconciliation, + continuing-resolution, supplemental, authorizing and holdout bills), comparing the heading + output with and without them: the departments rule changed nothing (54 firing sites, no + difference). The `SALARIES AND EXPENSES` rule changed 8 versions, which are 4 headings in 4 + bills, each an office name stacked over its own `SALARIES AND EXPENSES` (Office of Terrorism and + Financial Intelligence in 117-hr-4502 and 118-s-4928; Office of Congressional Accessibility + Services in 115-hr-5895 and 117-hr-4346): 8 of 9,129 organizing headings and 19 of 60,508 + amounts. Without the rule each reads as one heading; its amounts stay with their account, and + every version of a bill reads it the same way, so version diffs are unaffected. +- **Defer to the detectors' joins and only add splits.** Rejected: measured worse (56 more + not-tolerated headings, leaks 20 → 48 on the development corpus), because the detectors' + joins are where most glued stacks come from. +- **Attach the first heading after a bare `TITLE n` to the title's name.** Rejected: it + re-decides what the major detector already decided (#105), and title text is a key other + code relies on. +- **Label the PDF from the XML at runtime.** Rejected for the reasons in + [0012](0012-pdf-heading-levels.md): a network call and input automation the engine forbids. +- **A model to classify the ambiguous lines.** Rejected by [0008](0008-deterministic-engine.md). + +## Consequences + +Measured with the shipped parser before and after this change, both graded by the same +whole-path scorer, on every committed dual-format version whose XML carries appropriations +headings (33 versions, 13,422 amounts; `python -m tests.ledger_location` prints these totals +for whichever parser is importable), on an unseen holdout of four FY2024 House bills fetched +after the rules were fixed (Agriculture, Energy and Water, State and Foreign Operations, +Interior: 10 versions, 2,272 amounts), and on the fetched versions whose XML carries no +appropriations headings (reconciliation, authorizing and continuing-resolution text: 32 +versions, 11,736 amounts): + +| | corpus before | corpus after | holdout before | holdout after | +|---|---:|---:|---:|---:| +| same location (T0) | 58.3% | 78.7% | 29.2% | 53.8% | +| OK (T0–T2) | 66.7% | 91.8% | 45.1% | 83.0% | +| wrong parent (T3) | 4,373 | 1,055 | 1,202 | 375 | +| misfiled (T4) | 99 | 40 | 45 | 12 | + +Without appropriations headings: misfiled 368 → 4, OK 96.1% → 99.2%. No version's totals got +worse: none of the 42 committed versions and none of the 55 fetched ones. + +Amount by amount, across all 97 versions, 6,413 amounts are filed better and 105 worse. 103 +of the 105 are one case: +the PDF now reads the printed `ADMINISTRATIVE PROVISIONS—FEDERAL HIGHWAY ADMINISTRATION` (and +its FTA, FRA, FAA, NHTSA, GSA, SBA and NARA twins) where before it kept only the fragment +`ADMINISTRATION`, which T1 passes as a variant of the XML's `Federal Highway Administration`. +The XML file tags the full heading and the reader drops it, so the correct reading grades T3: +the answer-key gap below. The other two are the sec. 614 straggler listed under open issues. + +**The OK rate undercounts the PDF.** The answer key is DeltaTrack's existing XML reader, which +leaves heading-only elements out of the breadcrumb: in H.R. 4502 the XML file tags +`Department of Health and Human Services`, `Food and Drug Administration` and `Salaries and +expenses` in turn, the PDF reads all three, and the reader keeps only the first and last, so +the PDF is graded "wrong parent" where it matches the page. Of the 1,055 wrong-parent amounts +in the corpus, 905 have PDF ancestors that the XML path lacks and the XML file tags as headings +(334 of 375 on the holdout). That check only asks whether each heading is tagged somewhere in +the file, so it is an upper bound, and the reader is left as the answer key here: changing it +is a separate decision. + +- The same table is the yardstick for the XML side: a change to the reader counts as an + improvement when its breadcrumbs carry the file's heading tags, which shows here as the + corpus OK rate rising toward the roughly 98.5% the upper bound above allows. + +- Account names, measured the older way (is each PDF account heading a heading the XML twin + has, `scripts/heading_precision.py`): on the nine bills of + `tests/test_pdf_anchor_golden.py::TestCorpusAccountPrecision` the lowest recall and precision + rise from 0.744 / 0.750 to 0.967 / 0.967, and seven of the nine score 1.000 / 1.000. Its floors + are re-derived to 0.95 in the same change. On 118-hr-4820 every one of the fourteen wrapped + names listed on [#524](https://github.com/civictechdc/DeltaTrack/issues/524) is read as one + account, and its unmatched account headings fall from 17 to 0. #524's governing case, + `NATIONAL OCEANIC AND ATMOSPHERIC ADMINISTRATION` on 114-hr-2578 (not in the corpus, fetched + to check it), stays an agency over its own accounts in all four versions. + +- The per-version tiers are the **baseline** for later heading changes. + `tests/test_pdf_ledger_location.py` pins them on the committed corpus, exactly: a version + whose not-tolerated count rises or whose true hits fall fails, and an improvement fails until + it is locked in, so every later change reports its before and after from the same scorer. + `tests/test_pdf_heading_passes.py` pins each rule on synthetic lines, each proved by + removing the rule. +- The passes change the parser's output, so the parser revision changes + ([0019](0019-observation-identity.md)), and the canonical baselines and the round-1 pairing + sentinel are regenerated in the same change. This record's measurement is the independent + precision/recall evidence [0020](0020-matching-stages.md) asks for. +- **Known residuals.** Two same-styled headings stacked with no prose between them are glued + into one (`ATOMIC ENERGY DEFENSE ACTIVITIES` over `NATIONAL NUCLEAR SECURITY ADMINISTRATION`, + [#501](https://github.com/civictechdc/DeltaTrack/issues/501); `STATE AND LOCAL LAW + ENFORCEMENT ACTIVITIES` over `OFFICE ON VIOLENCE AGAINST WOMEN`). The XML marks the upper one + as a heading with no text of its own; the print gives no format signal for it. An office name + over its own `SALARIES AND EXPENSES`, printed alike, is glued the same way (`OFFICE OF + TERRORISM AND FINANCIAL INTELLIGENCE SALARIES AND EXPENSES`; see the rejected named + exceptions above). A department + name glued by the major detector itself (`OVERSEAS CONTINGENCY OPERATIONS DEPARTMENT OF + DEFENSE`) is left as detected. A wrapped account name that opens with the word `TITLE` + (`TITLE 17 INNOVATIVE TECHNOLOGY LOAN GUARANTEE` / `PROGRAM`, 115-hr-5895) is read as the + structural token and stays split. A name broken early for balance after a complete word + would still be split by the line-fullness veto; the one balanced break in the corpus ends in + `OF` and is joined. +- **A name over its own account can still be read one level too high.** A piece the passes + split out takes its level from the print, but where the detectors already made the upper line + an agency it stays one: `NORTH ATLANTIC TREATY ORGANIZATION` over `SECURITY INVESTMENT + PROGRAM`, which the XML sets as two headings on one level. +- **A title's name is displaced from breadcrumbs.** The heading under a bare `TITLE n` and the + major-level heading below it compete for one breadcrumb level, and the nearer wins: + `TITLE I › LEGISLATIVE BRANCH › HOUSE OF REPRESENTATIVES` reaches its accounts as + `TITLE I › HOUSE OF REPRESENTATIVES`. True but one level shallower, and true of develop too. + The follow-up is to keep the title's name as a level of its own. +- **Reconciliation bills stay shallower.** Their prints carry no subtitle or part level the + passes can read, so their amounts land T2 at best. Recovering those levels, and measuring + other bill vehicles (continuing resolutions, supplementals, authorizing bills with direct + appropriations), is follow-up work. +- The prose-leading agency gap in [0012](0012-pdf-heading-levels.md) narrows but stays: the + case pattern ends an agency's scope at an agency-styled account, and an agency-styled heading + that introduces accounts (no dollar amount under it, an account-style heading next) is now an + agency, but one whose own text carries money is still emitted as an account. + +**Open issues** found by the measurement and left for later changes: + +- **A department repeated as its own parent.** `DEPARTMENT OF DEFENSE › DEPARTMENT OF DEFENSE` + over `FAMILY HOUSING IMPROVEMENT FUND` and `MILITARY UNACCOMPANIED HOUSING IMPROVEMENT FUND`: + 16 amounts in 8 versions, graded T3. Not yet traced. +- **An account carried over a general provision.** In division J of 117-hr-3684 (v5), two + amounts in sec. 614 are filed under `ENVIRONMENTAL PROGRAMS AND MANAGEMENT`, where the XML + files them under the EPA's general provisions. Before this change they sat directly under the + title (T2); now they are T3. +- **T1 passes a fragment.** A label that ends with the same word as the XML's counts as the + same place, so `ADMINISTRATION` grades T1 against `Federal Highway Administration` (the 103 + amounts above). Tightening it regrades the parser before this change, not after, and is a + scorer change to measure on its own. +- **The answer key.** Restoring the heading-only elements the reader drops is the separate + decision above. A change to the answer key (#739) moves these grades too: under whole-path + T0 a heading the reader carries too far shows as T3 against a correct PDF path, so its + effect should be read amount by amount before the totals are re-pinned. + + diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e2975bda..3249b186 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -103,3 +103,4 @@ presents itself as the architecture in force. | [0019](0019-observation-identity.md) | Accepted | Identify a parsed observation by its source, its parser revision and its ordinal; never by its text | | [0020](0020-matching-stages.md) | Accepted | Separate retrieval, correspondence evidence, correspondence assignment and change classification | | [0021](0021-naming-authority-and-boundaries.md) | Accepted | Name things in the vocabulary an outside reader already speaks, scoped to the boundary being named | +| [0022](0022-pdf-heading-convergence.md) | Proposed | Recover PDF heading structure with ordered, fail-closed passes over the reading order, measured by where each dollar amount lands against the XML twin | diff --git a/docs/source-signal-inventory.md b/docs/source-signal-inventory.md index 2abd5e75..e9c3b755 100644 --- a/docs/source-signal-inventory.md +++ b/docs/source-signal-inventory.md @@ -24,7 +24,11 @@ in-file signal read from the input the user already supplied. - **PDF** (`src/deltatrack/parsers/pdf_text.py`, `src/deltatrack/parsers/pdf_anchors.py`): codepoints; per-glyph char boxes → baseline clustering (visual lines), x-gaps (word spacing), line extent (justified column width, line-fullness split); text-matrix scale → glyph **size** - (body/heading/major size bands). That is the complete set of PDFium signals we read. + (body/heading/major size bands), and per line the **letter size range** and the + **case pattern** (whether each word's first letter prints larger than the rest: title + case in small caps, the agency style, vs even small caps, the account style; + `LineGeom.initial_caps`, [ADR 0022](decisions/0022-pdf-heading-convergence.md)). That + is the complete set of PDFium signals we read. - **XML** (`src/deltatrack/bill_tree.py`, `src/deltatrack/structure_tree.py`, `src/deltatrack/diff_bill.py`): the structural tag hierarchy + text, the `form` metadata block, and three attributes — `@id` (stored on `BillNode.element_id`, **not** used for matching), `@display-inline`, diff --git a/examples/hr8752_pdf_diff.html b/examples/hr8752_pdf_diff.html index 1ec03259..cdcc3cab 100644 --- a/examples/hr8752_pdf_diff.html +++ b/examples/hr8752_pdf_diff.html @@ -317,15 +317,15 @@ - + - +

H.R. 8752 — Making appropriations for the Department of Homeland Security for the fiscal year ending September 30, 2025, and for other purposes.

v1: reported-in-house → v2: engrossed-in-house · 118th Congress
-
modified 18added 16removed 2moved 1
+
modified 18added 17removed 2moved 1
@@ -571,21 +571,33 @@

TITLE IV > RESEARCH, DEVELOPMENT, TRAINING, AND SERVICES > ADMINISTRAT
TITLE V
GENERAL PROVISIONS -
SEC. 552 +
SPENDING REDUCTION ACCOUNT
added -

TITLE V > GENERAL PROVISIONS > SEC. 552

+

TITLE V > GENERAL PROVISIONS > SPENDING REDUCTION ACCOUNT

+
+
— (new in v2)p.97 L13
+
SPENDING REDUCTION ACCOUNT
+
+
+
+
SPENDING REDUCTION ACCOUNT +
SEC. 552 +
+
+added +

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 552

— (new in v2)p.97 L14
SEC. 552. $0 (increased by $10,000,000).
SEC. 553 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 553

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 553

— (new in v2)p.97 L15 – p.100 L6
SEC. 553. (a) None of the funds made available by @@ -652,10 +664,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 553

SEC. 554 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 554

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 554

— (new in v2)p.100 L7 – p.100 L12
SEC. 554. None of the funds made available by this @@ -666,10 +678,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 554

SEC. 555 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 555

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 555

— (new in v2)p.100 L13 – p.100 L16
SEC. 555. None of the funds appropriated or otherwise made available by this Act may be used to implement, @@ -678,10 +690,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 555

SEC. 556 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 556

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 556

— (new in v2)p.100 L17 – p.100 L20
SEC. 556. None of the funds made available in this @@ -691,10 +703,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 556

SEC. 557 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 557

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 557

— (new in v2)p.100 L21 – p.100 L22
SEC. 557. None of the funds made available by this @@ -702,10 +714,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 557

SEC. 558 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 558

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 558

— (new in v2)p.100 L23 – p.100 L25
SEC. 558. None of the funds made available by this @@ -714,10 +726,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 558

SEC. 559 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 559

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 559

— (new in v2)p.101 L1 – p.101 L2
SEC. 559. None of the funds made available by this @@ -725,10 +737,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 559

SEC. 560 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 560

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 560

— (new in v2)p.101 L4 – p.101 L5
SEC. 560. None of the funds made available under @@ -736,10 +748,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 560

SEC. 561 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 561

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 561

— (new in v2)p.101 L8 – p.101 L11
SEC. 561. None of the funds appropriated or otherwise made available by this Act may be made available @@ -748,10 +760,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 561

SEC. 562 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 562

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 562

— (new in v2)p.101 L12 – p.102 L9
SEC. 562. None of the funds made available by this @@ -771,10 +783,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 562

SEC. 563 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 563

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 563

— (new in v2)p.102 L10 – p.102 L12
SEC. 563. The salary of Nejwa Ali, U.S. Citizenship @@ -783,10 +795,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 563

SEC. 564 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 564

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 564

— (new in v2)p.102 L13 – p.102 L16
SEC. 564. None of the funds made available by this @@ -796,10 +808,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 564

SEC. 565 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 565

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 565

— (new in v2)p.102 L17 – p.102 L20
SEC. 565. None of the funds made available by this @@ -809,10 +821,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 565

SEC. 566 -
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 566

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 566

— (new in v2)p.102 L21 – p.102 L25
SEC. 566. None of the funds made available by this @@ -856,10 +868,10 @@

TITLE V > GENERAL PROVISIONS > SEC. 552

Committed to the Committee of the Whole House on the State of the Union and ordered to be printed
-
+
added -

TITLE V > GENERAL PROVISIONS > SEC. 567

+

TITLE V > SPENDING REDUCTION ACCOUNT > SEC. 567

— (new in v2)p.103 L1 – p.104
SEC. 567. None of the funds appropriated or otherwise made available by this Act may be used to make payments to the Department of Veterans Affairs for medical @@ -876,7 +888,7 @@

TITLE V > GENERAL PROVISIONS > SEC. 567

Homeland Security for the fiscal year ending
-

- + +