fix(diff): read amounts from display_text so the money diff and money tree agree (#365) - #421
Conversation
#365) The leveled money tree extracts each node's amounts from display_text, with an in-code note calling body_text the lossy rendering. The financial diff -- which produces the report's amount-change table -- extracted from body_text, so the two views could disagree. They do. bill_tree._extract_section_text's "simple lead-in" fast path returns only a section's first <text> when the payload sits in <list>/<continuation- text>/<paragraph>, dropping the rest of the section and any amounts in it. Issue #365 left the divergence unmeasured; measured on the committed corpus it is 267 nodes / 1662 amount-instances, and 83 real amount changes missing from the amount-change table. 118-hr-4366 v2->v4 sec. 128 splits $30M/$30M/$30M into $15M/$7.5M/$7.5M and was emitted as `modified` carrying no financial change. NodeDiff gains old_amount_text/new_amount_text, populated from the same `display_text or body_text` expression structure_tree already uses, and the financial diff reads those. old_text/new_text stay body_text: they feed matching, text_diff and the JSON payload, none of which this changes. Known limit, recorded on the dataclass: when the dropped payload is the only thing that changed, both versions' body_text are byte-identical, the node is classified `unchanged`, and diff_to_dict filters it out before the new fields are read. Only completing body_text fixes the classification. Verification: full suite green (1968 passed) and every slow gate green (556 passed). The rendered example regenerates with 6 entries gaining a financial flag and none losing one -- strictly additive at the consumed output. Closes #365 Refs #147 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orpus pair The safety property behind #365 is that reading amounts from the display rendering is strictly additive: it may surface an amount change, never hide one. That property was pinned on a single bill pair, so a regression that only appeared on another pair had nothing watching it. Adds the invariant to TestCorpusDiffSmoke, which is already parametrized over every adjacent manifest version pair and already carries the fail-closed completeness floor. 17 cases today, and a new corpus bill extends the guard rather than breaking it: the count of newly surfaced changes is deliberately not pinned here, since it varies legitimately per pair. Shown to fail: truncating the display rendering to its first 80 characters, so amounts body_text saw go missing, turns 13 of the 17 cases red. Restored afterwards, with the corpus A/B unchanged at 0 lost / 63 gained. Refs #365
Both money views computed `display_text or body_text` as their own inline expression, in diff_bill and in structure_tree, with a comment asserting the two could not drift apart. Nothing enforced that: the guarantee lived in prose, and two copies of a rule are two places to change. Moves the rule to bill_tree.amount_text, which both modules already depend on, so the invariant is structural rather than asserted. No behavior change: the committed example reports regenerate byte-identical, and the corpus A/B is unchanged at 0 lost / 63 gained across 17 pairs. Also points the residual-gap comment at #422, which tracks the sections whose only change sits in the payload body_text drops. Those are classified unchanged and filtered out before these fields are read, so the note now names where that is being carried rather than leaving it as "tracked separately". Refs #365, #422
Since #365, amounts are extracted from the readable rendering while a change's `text` carries the match-normalized body, which truncates some sections. A consumer that searches `text` for a figure listed in `amount_entries` can now legitimately fail to find it. Documents that, and points consumers at full_text_span into full_text for the text an amount came from, which is the correspondence the report itself renders. Refs #365
|
Review follow-ups, pushed as three commits on top of The residual gap is now tracked in #422 (a section whose only change is its dollar amounts can be dropped from the report entirely). Confirmed while reviewing that the limitation this PR documents is real rather than theoretical: for 118-hr-4366 v2 to v4, sec. 256 is classified
Gates on You may want to fold the #422 reference into the PR body's "Known limitations" section before merging. |
Resolve the import conflict in src/deltatrack/diff_bill.py by keeping both intents: develop's amount_text import (from AgoraDMV#421/AgoraDMV#437 work) alongside this branch's version_stems imports (local_versions, resolve_version_file) for slug/ordinal version addressing. Co-Authored-By: Kimi K3 <noreply@kimi.com>
Summary
display_text(the readable rendering). The amount-change table readbody_text, a rendering normalized for matching that silently drops part of a section's contents. This change points amount extraction atdisplay_texton both sides, so the two views read the same text.What was actually wrong
Issue #365 identified the split by reading the code but flagged the divergence as unverified: no one had checked whether any real bill produced different amounts from the two sources. It does.
bill_tree._extract_section_texthas a "simple lead-in" fast path that returns only a section's first<text>element when the rest of the section's contents sit in<list>,<continuation-text>or<paragraph>. Everything after the lead-in, amounts included, is dropped frombody_text.Measured on the committed test corpus:
display_textand absent frombody_textThe clearest instance is 118-hr-4366 (FY24 Consolidated Appropriations), House-passed to Senate-amended, Defense sec. 128: three
$30,000,000figures become$15,000,000/$7,500,000/$7,500,000. The report listed the section asmodifiedwith no financial change attached at all, so a $90M reallocation never reached the table.The issue offered two options and said option 1 decides whether option 2 is needed. Option 1 (prove the two sources equivalent) is answered here: they are not equivalent, so this is option 2.
Changes
src/deltatrack/diff_bill.py—NodeDiffgainsold_amount_text/new_amount_text, populated from the samedisplay_text or body_textexpressionstructure_treealready uses for the tree, so the two money views cannot drift apart again.bill_diff_to_dictandfilter_diffread those instead ofbody_text. All sevenNodeDiffconstruction sites populate them, including the moved-section path, so a section that relocates between versions keeps its amounts.old_text/new_textdeliberately staybody_text. They feed section matching, the text diff and the JSON payload, none of which this is trying to change; repointing them wholesale would churn rendered output for no gain. The two new fields default toNone, and the properties that read them fall back toold_text/new_text, so any caller that builds aNodeDiffby hand behaves exactly as before.tests/test_financial_diff.py— three unit tests for the source-selection and fallback behavior, plus a corpus regression class pinning the sec. 128 case and asserting the switch is strictly additive (it may surface an amount change, never hide one).tests/test_diff_validation.py— two existing checks now read the same source the report reads, so they are testing the shipped path.examples/hr4366_house_vs_senate_xml_diff.html— regenerated, since the change alters which entries carry financial data.Test plan
Everything below was run on this branch at
b919ce2.The bug fails without the fix. Reverting only the two property bodies so they return
old_text/new_textagain, and re-running the new tests:The two that still pass are the ones that do not depend on the source choice (the no-separate-source fallback, and the empty-
display_textguard), which is expected. The source was restored afterwards and the tree confirmed clean.Gates. Full suite: 1968 passed, 23 skipped, 15 xfailed, 0 failed. Every slow gate (
pytest -m slow --deselect tests/test_govinfo_corpus_parity.py, the deselection being the live-network one): 556 passed, 7 skipped.ruff check: clean.ruff format --check: clean. Skip counts match the pre-change baseline; no new skip.Effect on the rendered report, measured on the regenerated example rather than on an intermediate: 687 change entries before and after, 0 lost their financial flag, 6 gained one. Strictly additive at the output a reader actually sees.
Not run locally: the live-network corpus parity gate, which CI runs on its own schedule.
Breaking changes
None. The new fields are optional with a fallback to the previous behavior, and
old_text/new_textare untouched, so the JSON payload shape and the text diff are unchanged. The only output difference is that more entries now correctly carry financial data.Known limitations
This is a mitigation, not a cure, and the residual gap is recorded on the dataclass so the next reader does not have to rediscover it.
When the dropped payload is the only thing that changed, both versions'
body_textare byte-identical. The section is therefore classifiedunchangedand filtered out of the report before the new fields are ever read. Its amounts are now correct, but the entry never appears. 118-hr-4366 sec. 124 and sec. 256 are live instances, and sec. 256 moves billions.Fixing that requires completing
body_textitself, i.e. repairing_extract_section_text. That was kept out of this change deliberately:body_textis the matching key, so changing it affects section matching across every bill, a much wider blast radius than the amount table. It wants its own issue and its own evidence.Closes #365
Refs #147
Written with the help of Claude Code. I specified the change, reviewed the diff, ran the fault-injection repro and every gate reported above, and verified the rendered-output effect myself.