Skip to content

Measure dollar typing against committee reports, with a resolver that agrees on 87.5% of them - #845

Merged
willhea merged 13 commits into
civictechdc:developfrom
banjtheman:research/committee-agreement
Oct 16, 2026
Merged

willhea merged 13 commits into
civictechdc:developfrom
banjtheman:research/committee-agreement

Conversation

@banjtheman

@banjtheman banjtheman commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Related issue

Refs #147 (epic: financial semantics research: type each dollar amount before money returns to the report). This adds evidence, not a closing change.
Refs #115 (split each account's money into typed sub-amounts). It covers itemised lines and multi-piece accounts. Each figure's role and effect aren't typed yet.
Refs #796 (proposed ADR 0025). This measures the bar it proposes: the figure typed as an account's appropriation must equal the committee report's figure for that account.

What does this change?

Research only. No product code changes, money stays out of the report, and classify_bill.py's rules are untouched (#736 copies them into the package and checks parity).

At Civic Tech DC night the organizers asked how good the regex classifier's dollar typing is. This answers it, and improves it.

  • committee_agreement.py scores the figure typed as each account's appropriation against the answer key's figure, for every account the answer keys (tests/data/validation_*.json) map to a bill node: 1,163 of 1,267, all committed fixtures, under a second to run. It reports each evidence source separately:

    • Committee reports: thirteen keys, each built from a Senate committee report (FY2024 and FY2025 reported bills), 752 mapped accounts.
    • The Legislative Branch spreadsheet: validation_leg_branch.json comes from House_and_Senate_Appropriations_FY_1994_to_present.xlsx, not a committee report, and is checked against the FY2014–FY2020 enrolled bills. 411 mapped accounts.
  • appropriation_resolver.py reads a paragraph the way appropriations are drafted:

    • nothing after the first "Provided, That" counts (an "in all" there caps one use of the money);
    • an "in all" total before it wins;
    • an explicit "is hereby appropriated" names the figure;
    • otherwise it takes the figure that closes the purpose clause, skipping allowances inside it, loan levels and carve-outs;
    • it abstains rather than guess.

    It also does two more things:

    • Itemised lines: ledger types each line with its own name ("including: Office of the Speaker, $6,645,417; ..."). The answer key's name is used only to find a line, never to choose a figure, and a figure selected for a line is scored against that line's own figure.
    • Multi-piece accounts: resolve_total adds the pieces an account appropriates in: "together with $X from the trust fund", ", and in addition $X", a second purpose, and an advance appropriation available this year.
  • Review fixes from @willhea (Review fixes for civictechdc/DeltaTrack#845: line scope, proviso boundary, evidence source banjtheman/DeltaTrack#1, merged into this branch):

    • An itemised line is scored by the scope its selection carries. Before, a wrong line figure could be excused as "finer than the paragraph" because an unrelated resolver happened to type the same number.
    • "In all" is read only before the first proviso. Before, "…Provided, That travel shall not exceed, in all, $500,000" replaced a $10,000,000 appropriation.
    • Agreement is reported per evidence source.

    No count moves, because neither bug fires on the committed answer keys. Tests fail if either comes back.

  • rollup_agreement.py adds typed figures up per agency and compares them with the report's "Total, " lines. A total is attributed by the report's own arithmetic, so every attribution is checked by construction. Totals are compared net and gross (report totals net out fee offsets and rescissions).

Results (all reproduced by the commands below):

Typed figure equals the answer key's classify_bill today this PR
Committee reports (752 accounts) 636 (84.6%) 658 (87.5%)
Legislative Branch spreadsheet (411 accounts) 284 (69.1%) 403 (98.1%)
Combined (1,163 accounts) 920 (79.1%) 1,061 (91.2%)
Errors and roll-ups classify_bill today this PR
Typed a different figure while the answer key's is right there in the text 37 1 (gross versus net, TSA)
Figures typed that the source contradicts or does not confirm 83 37, all on committee-report accounts
Agency roll-ups reproducing the committee report's total (of the complete ones) 116 of 159 124 of 154

Against committee reports alone, this PR agrees on 87.5%. The rise from 82.1% to 90.4% combined is entirely the spreadsheet's itemised office lines, so committee-report agreement doesn't change with them (648 of 752 either way).

The roll-up caught one thing no account check could: a liquidating appropriation typed as new money (FTA: "For payment of obligations incurred …, $14,279,000,000, to be derived from the Mass Transit Account", which the report keeps out of new budget authority).

How far to trust the numbers (the README says this at length):

  • The first rules were written from 6 answer-key files and frozen at d394669 before the other 8 were scored. On those 8 held-out files, wrong figures went 3 → 0.
  • Later steps were written after reading the held-out files, so their held-out number (85.5%) is no longer an unbiased estimate.
  • The combined 91.2% includes the Legislative Branch spreadsheet, which is not a committee report. Its 411 accounts are over a third of the answer keys and carry all 96 itemised-line gains. The committee-report figure is 87.5%.
  • A fresh answer key (another fiscal year) is the next honest test.

Deliberately left out:

How to test

uv run python docs/research/financial-semantics/committee_agreement.py                    # every resolver, all answer keys
uv run python docs/research/financial-semantics/committee_agreement.py --split held-out
uv run python docs/research/financial-semantics/committee_agreement.py --resolver summed --details wrong_figure
uv run python docs/research/financial-semantics/rollup_agreement.py --resolver summed --details mismatch
uv run pytest tests/test_appropriation_resolver.py tests/test_rollup_agreement.py

What I ran and checked:

  • Hand-traced disagreements:
    • EEOC: the regex took "up to $32,500,000 for payments to State agencies" over the $488,221,000 appropriation.
    • Legislative Branch: "$7,110 each…; in all, $28,440".
    • Promoting Safe and Stable Families: $345,000,000 + $72,515,000.
    • TSA: gross versus net.
    • NASA: the report's $3,044,440,000 against the bill's $3,044,400,000, which looks like a report typo.
  • New tests fail when broken: every unit test was checked by reverting its rule and watching it go red (for example, the loose "in addition" rule wrongly adds the Fiscal Service's $235,000; removing the subtotal jump double-counts).
  • Review fixes:
    • Both bugs reproduce on the pre-fix branch: the proviso example typed $500,000, and a wrong line was scored finer_than_node once another resolver typed its figure.
    • Both are fixed after the merge.
    • Each new test goes red with its fix reverted.
    • The measurement output is unchanged apart from the per-source lines, and the roll-up output is byte-identical.
  • Slow gates:
    • They pin the wrong-figure sets: structured: two known; ledger and summed: TSA only.
    • They pin minimum agreement counts (for summed, per source: at least 658 committee-report, at least 403 spreadsheet) and the roll-up outcome counts, so the README's numbers can't drift unnoticed.
    • The two new modules are added to CI's remaining-slow shard 2.
  • CI gates, locally:
    • ruff check and ruff format --check: clean.
    • Fast: 2309 passed.
    • Browser: 30 passed.
    • Slow (-m slow --deselect tests/test_govinfo_corpus_parity.py): 2192 passed.

@willhea @mattzamora, this measures #796's bar against #736's rules. Your read on whether it's the right shape matters most.

Checklist

AI assistance

Written with Claude Code (Claude Opus 5.5) at Civic Tech DC night. I reviewed the change and the evidence above.

🤖 Generated with Claude Code

banjtheman and others added 12 commits October 14, 2026 20:11
…lver for it

ADR 0025 (proposed, civictechdc#796) asks that the figure typed as an account's
appropriation equal the committee report's figure for that account.
ADR 0009's recall check asks only whether the figure appears under the
agency. `committee_agreement.py` measures the strong claim over every
account the committee-report answer keys map to a bill node (1,163 of
1,267, all committed fixtures).

`classify_bill` as `build_financial_df` uses it agrees on 920 (79.1%); it
types 138 wrong figures where the report's figure is in the text, and 67
the report does not confirm.

`appropriation_resolver.py` picks the figure the way a paragraph is
drafted (an "in all" total; before the first proviso; an explicit
"is hereby appropriated"; the figure that closes the purpose clause; loan
levels skipped) and otherwise abstains. Its rules were written from six
answer-key files only; this commit freezes them before scoring the other
eight. Held out (484 accounts): wrong figures 3 -> 0, agreements 401 ->
389, abstentions 13 -> 28. Dev (679): wrong figures 34 -> 2, agreements
519 -> 549.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rite up the result

The frozen resolver (d394669) typed no wrong figures on the eight held-out
answer keys but abstained where the baseline was right. Reading those
abstentions: a figure followed by "shall", "(", "plus" or "together with"
can close the purpose clause; a ceiling straight after the purposes
(", not to exceed $X, to remain available") is the appropriation stated as a
cap; "there is hereby appropriated to the Secretary $X" is an explicit
appropriation; and "under this heading" before a figure is part of the
purposes, not a limit (the first proviso already bounds the search).

All 1,163 mapped accounts: agreements 920 -> 955, wrong figures 37 -> 2,
with the same 21 abstentions and 46 figures no single report figure
confirms. Held out: 404 agreements against the baseline's 401, 0 wrong
figures, now no longer an unbiased estimate, which the README says.

tests/test_appropriation_resolver.py pins each drafting rule on a
constructed paragraph (each proved red with its rule removed) and, as a
slow gate run in CI's remaining-slow shard 2, fails on any wrong figure
beyond the two listed with their reasons (gross versus net; one office of
an itemised paragraph). The README records the measurement, how far to
trust it, and the questions it leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ee reports

The account figures exist to be added up: account, agency, department.
rollup_agreement.py checks that against each "Total, <agency>" line in the
thirteen Senate reports' comparative statements. A total is attributed to
the rows above it by the report's own arithmetic (a subtotal contributes
its value and is jumped over; the span is the first run that adds up), so
every attribution is checked by construction; 273 of 406 totals are, and
190 of those name exactly one bill heading. Report totals are net of fee
offsets and rescissions, typed figures gross, so both are compared.

With appropriation_resolver, 121 of 154 complete roll-ups reproduce the
report's total (113 net, 8 gross); classify_bill's first figure does 116 of
159. Hand-traced, the 31 that do not are mostly accounts appropriating more
than one figure ("$345,000,000 and, for carrying out ... $72,515,000"),
money classify_bill leaves untyped, one obligation limitation typed as an
appropriation (FTA, +$14.28 billion, caught only by the roll-up), and report
groupings the bill heads differently.

The README also breaks down the account-level 82%: 185 accounts cannot
agree with any one-figure-per-account method; where one figure can, the
resolver agrees on 97.6%; typing every figure raises the ceiling to 91.7%.
tests/test_rollup_agreement.py pins the attribution (each test proved red
against its mutation) and the counts, and runs in CI's remaining-slow shard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 answer-key accounts were report lines finer than the bill paragraph
they map to, mostly Legislative Branch paragraphs that itemise ("$22,278,891,
including: Office of the Speaker, $6,645,417, ...; ...") or carve out a named
line ("and of which $6,000,000 shall be allocated solely for ... Senate
interns"). One figure per paragraph cannot match them.

`ledger` types each itemised and "of which" line with its own text and its
closing figure, by the same rule as the top line; `resolve_named` looks a
report line up in it by name, only when the name is not the paragraph's
heading. The name finds a line and never chooses a figure, so a wrong match
still scores as wrong. A line's text stops at the next "of which", and a
report name's parenthetical ("(part of allowances and expenses)") is dropped
before matching.

All accounts: 955 -> 1,051 agree (82.1% -> 90.4%), wrong figures 2 -> 1 (the
gross-versus-net account). All 96 new agreements are Legislative Branch, in
DEV; HELD_OUT is unchanged at 83.5%, which the README says along with what
limits it (report-versus-bill differences, answer-key mapping errors in
scripts/build_validation.py, multi-figure accounts).

Tests pin each ledger rule (each proved red with its fix reverted) and a slow
gate holds the ledger to the one known wrong figure and >= 1,051 agreements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ten accounts' committee-report figure was a sum of figures in their
paragraph, each joined by its own phrase: a trust-fund amount "together
with" the top line (five), ", and in addition not to exceed $X" (one), a
second purpose ("$345,000,000 and, for carrying out ... section 437 ...,
$72,515,000", two), and an advance appropriation from last year's act made
available this year ("(in addition to the $4,000,000,000 previously
appropriated under this heading", two). resolve_total adds those pieces to
the top line; all ten agree.

The first attempt lost one account: the Fiscal Service's separate sentence
"In addition, $235,000, to be derived from the Oil Spill Liability Trust
Fund to reimburse ..." is not counted by its report, so "in addition" now
joins an account only within its own sentence (one example each way, which
the README says).

All accounts: 1,051 -> 1,061 agree (91.2%), still one wrong figure (gross
versus net); figures the report contradicts or does not confirm 47 -> 37.
Roll-ups with summed accounts: 124 of 154 agency totals (was 121). Tests
pin each phrase (proved red against the loose "in addition" and without
"together with") and slow gates hold the counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not an obligation limitation

Read from the bill text: "For payment of obligations incurred ..., $14,279,000,000, to be
derived from the Mass Transit Account" is cash to pay obligations made under contract
authority, which the report keeps out of new budget authority.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r resolvers' figures

measure() decided a typed figure was the paragraph's own (and so excusable
as finer_than_node on a multiply mapped paragraph) when it equalled
resolve(), resolve_total() or baseline(). An unchanged wrong line figure
could then flip from wrong_figure to finer_than_node when one of those
other resolvers changed.

select_named and select_named_total now return a Selection carrying the
figure's scope (the paragraph's own, or one itemised line found by name),
and judge() reads it. Counts are unchanged on the committed answer keys:
no line selection coincided with another resolver's figure.

The new test holds a wrong line selection fixed and makes every other
resolver type the same figure; it stays wrong_figure (red with the
equality inference restored).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
resolve() searched the whole paragraph for an "in all" total before
cutting at the first proviso, so a proviso cap replaced the account's
appropriation: "For necessary expenses, $10,000,000: Provided, That
payments for travel shall not exceed, in all, $500,000." typed
$500,000 from both resolve() and resolve_total(). Both now search only
the operative text, which also stops a proviso cap from switching off
resolve_total's added pieces.

No committed answer-key paragraph has an "in all" only after a proviso,
so the account and roll-up counts are unchanged. The new test is red with
either function's unrestricted search restored; the pre-proviso in-all
test now carries a trailing proviso so the boundary cannot drop it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lative Branch spreadsheet

validation_leg_branch.json comes from House_and_Senate_Appropriations_FY_1994_to_present.xlsx
(checked against the FY2014-FY2020 enrolled bills), not a committee report,
yet the write-up counted its 411 accounts as committee-report agreement.
They carry all 96 itemised-line gains.

Each result now records its answer key's source, and the summary reports
each source on its own line. With summed: committee reports 658/752
(87.5%), the spreadsheet 403/411 (98.1%), combined 1,061/1,163 (91.2%).
Committee-report agreement does not move from structured to ledger (648);
all 37 figures summed types that its source does not confirm are on
committee-report accounts. The README says so, keeping the DEV
concentration and the read-holdout disclosures.

The summed gate now holds each source's floor in place of the combined
one (red when the spreadsheet is attributed to committee reports).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… boundary sits

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The control called judge() directly, so restoring the original
equality-based override inside measure() left it green. It now scores a
synthetic answer key through measure(): three report lines mapped to one
itemised paragraph, the Speaker's given a wrong line, the other two the
paragraph's own figure, with and without every other resolver typing the
wrong line's figure. Red with the override restored in measure(), and with
the inference moved into judge(). Replaces the judge()-level test.

The module docstring now names both answer-key sources.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…re its lines are discussed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@willhea

willhea commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this, @banjtheman. Scoring the typed figure against an independent source is the check ADR 0025 needs, and the README is candid about where the numbers come from and how far to trust them.

Review turned up three things worth fixing before this merges. Rather than go back and forth here, I put working fixes in banjtheman#1, opened against your branch so you can merge, change or decline them:

  1. Scoring scope depended on the other resolvers. measure() treated a figure as paragraph-level when it equalled resolve(), resolve_total() or baseline(), so a wrong named-line figure could become finer_than_node just because an unrelated resolver changed. The fix carries the scope with the selection itself.
  2. A proviso's "in all" could replace the appropriation. resolve() searched the whole paragraph before cutting at the first proviso, so "For necessary expenses, $10,000,000: Provided, That payments for travel shall not exceed, in all, $500,000." typed $500,000.
  3. The Legislative Branch evidence comes from a spreadsheet, not a committee report. validation_leg_branch.json is built from House_and_Senate_Appropriations_FY_1994_to_present.xlsx, and its 411 accounts carry all 96 itemised-line gains. Against committee reports alone, summed agrees on 658 of 752 (87.5%). The spreadsheet is 403 of 411, and the 91.2% combines the two.

No count moves with the fixes, because neither bug fires on the committed answer keys. What changes is how the results are attributed, plus tests that fail if either bug comes back.

Feedback welcome, on the fixes themselves or on whether the per-source framing reads right to you.

Review fixes for civictechdc#845: line scope, proviso boundary, evidence source
@banjtheman banjtheman changed the title Measure dollar typing against committee reports, and a resolver that reaches 91% agreement Measure dollar typing against committee reports, with a resolver that agrees on 87.5% of them Oct 16, 2026
@banjtheman

Copy link
Copy Markdown
Contributor Author

Thanks @willhea, this was a really useful review, and I've merged banjtheman#1 into this branch.

Before merging I checked the fixes locally:

  • Both bugs reproduce on the pre-fix branch. The proviso example typed $500,000, and the Speaker's wrong line was scored finer_than_node once another resolver typed the same figure.
  • Both are fixed after the merge. Each new test goes red when its fix is reverted.
  • No counts move. The measurement output only gains the per-source lines, and the roll-up output is identical. The README's per-source numbers reproduce: 658/752 (87.5%) against committee reports, 403/411 (98.1%) against the spreadsheet.
  • Gates pass: ruff clean, fast 2309, slow 2192, browser 30.

On the framing: yes, per source reads right to me. Agreement with the Legislative Branch spreadsheet shouldn't be presented as committee-report agreement. I've updated the PR title and description to lead with 87.5% against committee reports, with 98.1% and the combined 91.2% beside it.

(Checked with Claude Code.)

@willhea
willhea enabled auto-merge October 16, 2026 22:20
@willhea
willhea added this pull request to the merge queue Oct 16, 2026
Merged via the queue into civictechdc:develop with commit 6ce6f2c Oct 16, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants